@anton-gustafsson/snapshot-core 0.0.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,8 +23,11 @@ export class SnapshotNavList extends LitElement {
23
23
  constructor() {
24
24
  super(...arguments);
25
25
  this.items = [];
26
- this.variant = 'icon-only';
27
- /** icon-only tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
26
+ /** `card` by default — a framed preview with title/description underneath. `tile` is the compact contact-sheet grid, `list` a sidebar row. */
27
+ this.variant = 'card';
28
+ /** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
29
+ this.scrollable = false;
30
+ /** tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
28
31
  this.overlayTint = 'none';
29
32
  /** caption background tint strength, 0-1 */
30
33
  this.textOverlayOpacity = 0.35;
@@ -32,11 +35,11 @@ export class SnapshotNavList extends LitElement {
32
35
  this.imageOverlayOpacity = 0;
33
36
  /** backdrop blur behind the title, in px */
34
37
  this.overlayBlur = 0;
35
- /** icon-only only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
38
+ /** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
36
39
  this.labelPosition = 'bottom';
37
40
  /** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
38
41
  this.snapshotService = defaultSnapshotService;
39
- /** Shows an edit button per card. Off by default — clicking it fires `nav-edit` instead of `nav-select`; the host decides what "edit" means (e.g. open its own dialog component). */
42
+ /** Shows an edit button per card. Off by default — clicking it fires `nav-edit` instead of `nav-select`; the host decides what "edit" means (e.g. open its own dialog component). Overridable per row via `NavItem.editable`. */
40
43
  this.editable = false;
41
44
  /** Where the edit button sits: `overlay` (default) floats it over the thumbnail; `meta` pins it to the right edge of the title row, with the description below. Ignored by the icon-only variant, whose caption is itself an overlay. */
42
45
  this.editButtonPosition = 'overlay';
@@ -44,7 +47,14 @@ export class SnapshotNavList extends LitElement {
44
47
  this.editIcon = DEFAULT_EDIT_ICON;
45
48
  this.thumbs = new Map();
46
49
  this.loadingIds = new Set();
47
- /** In-flight dedup guard, separate from `loadingIds` (which is only for spinner display) so a repeat `loadThumb` call for an id already being fetched is a no-op. */
50
+ /**
51
+ * In-flight dedup guard, separate from `loadingIds` (which is only for
52
+ * spinner display) so a repeat `loadThumb` call for an id already being
53
+ * fetched is a no-op. Keyed by `${variant}\0${id}` — not just `id` — so a
54
+ * `variantKey` switch mid-flight doesn't have the new variant's fetch
55
+ * silently dropped because the *previous* variant's id is still marked
56
+ * in-flight.
57
+ */
48
58
  this.fetchingIds = new Set();
49
59
  }
50
60
  static { this.styles = css `
@@ -52,6 +62,16 @@ export class SnapshotNavList extends LitElement {
52
62
  display: block;
53
63
  font-family: var(--snapshot-nav-list-font, inherit);
54
64
  --frame-accent: var(--snapshot-nav-list-accent, #ff5a1f);
65
+ max-height: var(--snapshot-nav-list-max-height, none);
66
+ }
67
+ /* scrollable: the host owns the scroll, so a long list in a flex sidebar
68
+ doesn't push its siblings off-screen and the consumer doesn't have to
69
+ reach in with its own overflow/min-height CSS. */
70
+ :host([scrollable]) {
71
+ flex: 1 1 auto;
72
+ min-height: 0;
73
+ overflow-y: auto;
74
+ overscroll-behavior: contain;
55
75
  }
56
76
  ul {
57
77
  list-style: none;
@@ -187,10 +207,10 @@ export class SnapshotNavList extends LitElement {
187
207
  -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
188
208
  pointer-events: none;
189
209
  }
190
- /* the overlay exists so icon-only's overlaid title stays legible — list
210
+ /* the overlay exists so a tile's overlaid title stays legible — list
191
211
  variant shows the label beside the thumb, not on top of it, so the
192
212
  tint has nothing to do there. */
193
- :host(:not([variant='icon-only'])) .image-overlay {
213
+ :host(:not([variant='tile'])) .image-overlay {
194
214
  display: none;
195
215
  }
196
216
 
@@ -299,25 +319,25 @@ export class SnapshotNavList extends LitElement {
299
319
  height: 68px;
300
320
  }
301
321
 
302
- /* icon-only: contact-sheet grid, caption strip pinned to the bottom of each frame */
303
- :host([variant='icon-only']) ul {
322
+ /* tile: contact-sheet grid, caption strip pinned to the bottom of each frame */
323
+ :host([variant='tile']) ul {
304
324
  flex-direction: row;
305
325
  flex-wrap: wrap;
306
326
  gap: 0.6rem;
307
327
  }
308
- :host([variant='icon-only']) li {
328
+ :host([variant='tile']) li {
309
329
  position: relative;
310
330
  width: var(--snapshot-nav-list-tile-width, 160px);
311
331
  height: var(--snapshot-nav-list-tile-height, 100px);
312
332
  padding: 0;
313
333
  overflow: hidden;
314
334
  }
315
- :host([variant='icon-only']) .thumb-wrap {
335
+ :host([variant='tile']) .thumb-wrap {
316
336
  width: 100%;
317
337
  height: 100%;
318
338
  border-radius: var(--snapshot-nav-list-radius, 10px);
319
339
  }
320
- :host([variant='icon-only']) .meta {
340
+ :host([variant='tile']) .meta {
321
341
  position: absolute;
322
342
  inset: auto 0 0 0;
323
343
  margin: var(--snapshot-nav-list-overlay-margin, 0);
@@ -330,28 +350,28 @@ export class SnapshotNavList extends LitElement {
330
350
  backdrop-filter: blur(var(--overlay-blur, 0px));
331
351
  -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
332
352
  }
333
- :host([variant='icon-only']) .label {
353
+ :host([variant='tile']) .label {
334
354
  white-space: normal;
335
355
  }
336
- :host([variant='icon-only']) .description {
356
+ :host([variant='tile']) .description {
337
357
  color: color-mix(in srgb, var(--overlay-text, #fff) 75%, transparent);
338
358
  }
339
359
 
340
360
  /* label-position="center": title big and centered */
341
- :host([variant='icon-only'][label-position='center']) .meta {
361
+ :host([variant='tile'][label-position='center']) .meta {
342
362
  inset: 0;
343
363
  align-items: center;
344
364
  justify-content: center;
345
365
  padding: 0.6rem;
346
366
  }
347
- :host([variant='icon-only'][label-position='center']) .label {
367
+ :host([variant='tile'][label-position='center']) .label {
348
368
  font-size: 1.15rem;
349
369
  font-weight: 600;
350
370
  text-align: center;
351
371
  }
352
372
 
353
373
  /* card: a contained (never cropped) preview above real body text below it —
354
- text never sits on top of the image, so unlike icon-only it needs no
374
+ text never sits on top of the image, so unlike a tile it needs no
355
375
  overlay tint to stay legible. Modeled on a typical "preview card"
356
376
  pattern: framed shot, title + description underneath, shadow on hover. */
357
377
  :host([variant='card']) ul {
@@ -416,15 +436,25 @@ export class SnapshotNavList extends LitElement {
416
436
  font-size: 0.8125rem;
417
437
  }
418
438
  `; }
439
+ fetchKey(variant, id) {
440
+ return `${variant ?? ''}\0${id}`;
441
+ }
419
442
  subscribeToService() {
420
443
  this.unsubscribe?.();
421
- this.unsubscribe = this.snapshotService.subscribe((id, url) => {
444
+ this.unsubscribe = this.snapshotService.subscribe((id, url, variant) => {
422
445
  // Ignore captures for ids this list isn't showing — the service is
423
446
  // often a shared singleton, so without this guard `thumbs` would grow
424
447
  // forever with urls for every snapshot captured anywhere on the page,
425
448
  // not just this list's own items.
426
449
  if (!this.items.some((item) => item.id === id))
427
450
  return;
451
+ // Same reason, one dimension over: a dark-theme capture must not
452
+ // overwrite the light-theme tile this list is currently showing. Folds
453
+ // '' and undefined together on both sides — SnapshotService.keyOf()
454
+ // does the same, so a caller that sets variantKey to '' instead of
455
+ // leaving it undefined must still match a plain (no-variant) capture.
456
+ if ((variant || undefined) !== (this.variantKey || undefined))
457
+ return;
428
458
  if (url === null) {
429
459
  this.thumbs.delete(id);
430
460
  }
@@ -449,14 +479,23 @@ export class SnapshotNavList extends LitElement {
449
479
  // update instead of triggering Lit's "update scheduled from updated()"
450
480
  // warning that came from doing this same flip inside updated().
451
481
  willUpdate(changed) {
452
- if (changed.has('items')) {
482
+ // 'icon-only' is the pre-0.3 name for 'tile'. Normalising here (rather
483
+ // than in a setter) keeps the reflected attribute — and therefore every
484
+ // CSS selector — on the one canonical value.
485
+ if (this.variant === 'icon-only')
486
+ this.variant = 'tile';
487
+ // A variant-key switch (e.g. light -> dark) invalidates every thumbnail:
488
+ // they're separate snapshots under separate keys.
489
+ if (changed.has('variantKey'))
490
+ this.thumbs.clear();
491
+ if (changed.has('items') || changed.has('variantKey')) {
453
492
  const ids = new Set(this.items.map((item) => item.id));
454
493
  for (const id of this.thumbs.keys()) {
455
494
  if (!ids.has(id))
456
495
  this.thumbs.delete(id);
457
496
  }
458
497
  for (const item of this.items) {
459
- if (!this.thumbs.has(item.id) && !this.fetchingIds.has(item.id)) {
498
+ if (!this.thumbs.has(item.id) && !this.fetchingIds.has(this.fetchKey(this.variantKey, item.id))) {
460
499
  this.loadingIds.add(item.id);
461
500
  }
462
501
  }
@@ -466,25 +505,42 @@ export class SnapshotNavList extends LitElement {
466
505
  if (changed.has('snapshotService')) {
467
506
  this.subscribeToService();
468
507
  }
469
- if (changed.has('items')) {
470
- this.items.forEach((item) => this.loadThumb(item.id));
508
+ if (changed.has('items') || changed.has('variantKey')) {
509
+ void this.loadThumbs();
471
510
  }
472
511
  }
473
- async loadThumb(id) {
474
- if (this.thumbs.has(id) || this.fetchingIds.has(id))
512
+ /**
513
+ * One batched read for the whole list — `getMany()` collapses to a single
514
+ * query/round-trip on a storage that implements `loadMany`, instead of one
515
+ * per row. `fetchingIds` still guards per id, so an `items` reassignment
516
+ * mid-flight doesn't re-request what's already coming.
517
+ */
518
+ async loadThumbs() {
519
+ const variant = this.variantKey;
520
+ const wanted = this.items.filter((item) => !this.thumbs.has(item.id) && !this.fetchingIds.has(this.fetchKey(variant, item.id)));
521
+ if (wanted.length === 0)
475
522
  return;
476
- this.fetchingIds.add(id);
523
+ const ids = wanted.map((item) => item.id);
524
+ ids.forEach((id) => this.fetchingIds.add(this.fetchKey(variant, id)));
477
525
  try {
478
- const url = await this.snapshotService.get(id);
479
- if (url)
480
- this.thumbs.set(id, url);
526
+ const urls = await this.snapshotService.getMany(ids, { variant });
527
+ // Dropped if the variant changed while the read was in flight — those
528
+ // urls belong to the previous theme.
529
+ if (variant !== this.variantKey)
530
+ return;
531
+ for (const [id, url] of urls) {
532
+ if (url)
533
+ this.thumbs.set(id, url);
534
+ }
481
535
  }
482
536
  catch (err) {
483
- console.error(`snapshot-nav-list: failed to load thumbnail for "${id}"`, err);
537
+ console.error('snapshot-nav-list: failed to load thumbnails', err);
484
538
  }
485
539
  finally {
486
- this.fetchingIds.delete(id);
487
- this.loadingIds.delete(id);
540
+ ids.forEach((id) => {
541
+ this.fetchingIds.delete(this.fetchKey(variant, id));
542
+ this.loadingIds.delete(id);
543
+ });
488
544
  this.requestUpdate();
489
545
  }
490
546
  }
@@ -509,20 +565,17 @@ export class SnapshotNavList extends LitElement {
509
565
  const text = this.overlayTint === 'light' ? '#111' : '#fff';
510
566
  return { '--overlay-bg': bg, '--overlay-text': text, '--overlay-blur': blur };
511
567
  }
568
+ // Both events carry the whole item — including `data` — so a handler never
569
+ // has to look the item back up by id.
512
570
  select(item) {
513
- this.dispatchEvent(new CustomEvent('nav-select', {
514
- detail: { id: item.id, route: item.route },
515
- bubbles: true,
516
- composed: true,
517
- }));
571
+ this.dispatchEvent(new CustomEvent('nav-select', { detail: item, bubbles: true, composed: true }));
518
572
  }
519
573
  edit(e, item) {
520
574
  e.stopPropagation();
521
- this.dispatchEvent(new CustomEvent('nav-edit', {
522
- detail: { id: item.id, route: item.route },
523
- bubbles: true,
524
- composed: true,
525
- }));
575
+ this.dispatchEvent(new CustomEvent('nav-edit', { detail: item, bubbles: true, composed: true }));
576
+ }
577
+ isEditable(item) {
578
+ return item.editable ?? this.editable;
526
579
  }
527
580
  renderEditButton(item) {
528
581
  const icon = this.editIcon || DEFAULT_EDIT_ICON;
@@ -565,12 +618,12 @@ export class SnapshotNavList extends LitElement {
565
618
  >
566
619
  </div>`}
567
620
  <div class="image-overlay" part="overlay" style=${styleMap(imageOverlayStyle)}></div>
568
- ${this.editable && !editInMeta ? this.renderEditButton(item) : ''}
621
+ ${this.isEditable(item) && !editInMeta ? this.renderEditButton(item) : ''}
569
622
  </div>
570
623
  <div class="meta" part="meta" style=${styleMap(metaStyle)}>
571
624
  <div class="label-row" part="label-row">
572
625
  <span class="label" part="label">${item.label}</span>
573
- ${this.editable && editInMeta ? this.renderEditButton(item) : ''}
626
+ ${this.isEditable(item) && editInMeta ? this.renderEditButton(item) : ''}
574
627
  </div>
575
628
  ${item.description ? html `<span class="description" part="description">${item.description}</span>` : ''}
576
629
  </div>
@@ -586,6 +639,12 @@ __decorate([
586
639
  __decorate([
587
640
  property({ reflect: true })
588
641
  ], SnapshotNavList.prototype, "variant", void 0);
642
+ __decorate([
643
+ property({ attribute: 'variant-key' })
644
+ ], SnapshotNavList.prototype, "variantKey", void 0);
645
+ __decorate([
646
+ property({ type: Boolean, reflect: true })
647
+ ], SnapshotNavList.prototype, "scrollable", void 0);
589
648
  __decorate([
590
649
  property({ attribute: 'overlay-tint' })
591
650
  ], SnapshotNavList.prototype, "overlayTint", void 0);
@@ -619,6 +678,8 @@ __decorate([
619
678
  __decorate([
620
679
  state()
621
680
  ], SnapshotNavList.prototype, "loadingIds", void 0);
622
- if (!customElements.get('snapshot-nav-list')) {
681
+ // Guarded so importing this package in a DOM-less process (SSR, Node, a test
682
+ // runner without jsdom) is a no-op instead of a crash.
683
+ if (typeof customElements !== 'undefined' && !customElements.get('snapshot-nav-list')) {
623
684
  customElements.define('snapshot-nav-list', SnapshotNavList);
624
685
  }
@@ -1,4 +1,5 @@
1
- import type { SnapshotStorage } from './snapshot-storage';
1
+ import type { EncodeOptions } from './encode';
2
+ import type { SnapshotKey, SnapshotStorage } from './snapshot-storage';
2
3
  export interface SnapshotServiceConfig {
3
4
  /** Provide your own backend-backed implementation to persist across devices. */
4
5
  storage?: SnapshotStorage;
@@ -11,21 +12,85 @@ export interface SnapshotServiceConfig {
11
12
  * their keys and live-update notifications can't collide.
12
13
  */
13
14
  keyPrefix?: string;
15
+ /** Re-encode applied to every capture before it's stored. Defaults to none (the raw html2canvas PNG). */
16
+ encode?: EncodeOptions;
17
+ /**
18
+ * Escape hatch for an existing store whose keys don't follow
19
+ * `keyPrefix + id [+ '@' + variant]` — return the key to use. Note that
20
+ * `parseKey()` (and therefore `prune()`) can't reverse a custom shape.
21
+ */
22
+ keyFor?: (key: SnapshotKey) => string;
23
+ }
24
+ /** `url` is null when the id was removed (or invalidated) rather than (re)captured. */
25
+ type Listener = (id: string, url: string | null, variant?: string) => void;
26
+ export interface VariantOptions {
27
+ /**
28
+ * A second dimension on the id — a theme, a density, a locale. Stored under
29
+ * its own key, so `get(id, { variant: 'dark' })` never returns the light one.
30
+ */
31
+ variant?: string;
32
+ }
33
+ export interface CaptureOptions extends VariantOptions {
34
+ /** Per-call override of the instance `scale`. */
35
+ scale?: number;
36
+ /** Per-call override of the instance `encode`. */
37
+ encode?: EncodeOptions;
14
38
  }
15
- /** `url` is null when the id was removed rather than (re)captured. */
16
- type Listener = (id: string, url: string | null) => void;
17
39
  export declare class SnapshotService {
18
40
  private storage;
19
41
  private scale;
20
42
  private keyPrefix;
21
- private channel;
43
+ private encode?;
44
+ private keyFor?;
45
+ /** Absent under Node/SSR (or any environment without BroadcastChannel) — same-tab notification still works. */
46
+ private channel?;
22
47
  private listeners;
48
+ /** In-flight captures, keyed by storage key, so two callers racing on the same view do one render. */
49
+ private capturing;
23
50
  constructor(config?: SnapshotServiceConfig);
24
- /** Capture works on any element — a snapshot-nav-list item is one convention, not a requirement. */
25
- capture(el: HTMLElement, id: string): Promise<string>;
26
- get(id: string): Promise<string | null>;
51
+ /**
52
+ * The fully-qualified storage key for an id (+ variant) under this instance.
53
+ * `id`/`variant` are `encodeURIComponent`-escaped before joining, so a `@`
54
+ * (or any other character) inside either can never be mistaken for the
55
+ * separator itself — without escaping, `id: 'a@b'` and `id: 'a', variant: 'b'`
56
+ * would otherwise land on the exact same key.
57
+ */
58
+ keyOf(id: string, opts?: VariantOptions): SnapshotKey;
59
+ /**
60
+ * Inverse of `keyOf()` for the default key shape — splits a stored key back
61
+ * into `{ id, variant }`. Returns `null` for a key belonging to a different
62
+ * `keyPrefix`, or for any key when a custom `keyFor` is configured (a custom
63
+ * shape isn't reversible).
64
+ */
65
+ parseKey(key: string): SnapshotKey | null;
66
+ /**
67
+ * Capture works on any element — a snapshot-nav-list item is one convention,
68
+ * not a requirement. Concurrent calls for the same id/variant share one
69
+ * render instead of racing two.
70
+ */
71
+ capture(el: HTMLElement, id: string, opts?: CaptureOptions): Promise<string>;
72
+ private runCapture;
73
+ get(id: string, opts?: VariantOptions): Promise<string | null>;
74
+ /**
75
+ * Batch read, keyed by bare id. Uses the storage's `loadMany()` when it has
76
+ * one (a single query/round-trip for a whole list) and falls back to parallel
77
+ * `load()`s when it doesn't.
78
+ */
79
+ getMany(ids: string[], opts?: VariantOptions): Promise<Map<string, string | null>>;
27
80
  /** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
28
- remove(id: string): Promise<void>;
81
+ remove(id: string, opts?: VariantOptions): Promise<void>;
82
+ /**
83
+ * Deletes every stored snapshot (all variants) whose id isn't in `keepIds`,
84
+ * so thumbnails don't outlive the entities they belong to. Requires a storage
85
+ * that implements `keys()`; returns the number of snapshots removed.
86
+ */
87
+ prune(keepIds: Iterable<string>): Promise<number>;
88
+ /**
89
+ * Warms the storage for a list of ids ahead of render (one `loadMany()` where
90
+ * the storage supports it) and publishes whatever it finds, so any mounted
91
+ * `<snapshot-nav-list>` paints from the first frame instead of spinning.
92
+ */
93
+ prefetch(ids: string[], opts?: VariantOptions): Promise<void>;
29
94
  /**
30
95
  * Announces a URL for `id` to subscribers (this tab and others) without
31
96
  * touching storage. For a storage layer that resolves a fresher value
@@ -33,7 +98,7 @@ export declare class SnapshotService {
33
98
  * local cache in front of a slower authoritative database — call this once
34
99
  * the slow read settles so any mounted <snapshot-nav-list> updates live.
35
100
  */
36
- publish(id: string, url: string): void;
101
+ publish(id: string, url: string, opts?: VariantOptions): void;
37
102
  /**
38
103
  * Tells subscribers to drop their locally cached thumbnail for `id` and
39
104
  * treat it as not-yet-loaded — without deleting anything from storage.
@@ -41,12 +106,22 @@ export declare class SnapshotService {
41
106
  * it again. Useful when the underlying data changed out from under the
42
107
  * cache (or, for a demo, to replay a loading state on demand).
43
108
  */
44
- invalidate(id: string): void;
45
- subscribe(cb: Listener): () => boolean;
109
+ invalidate(id: string, opts?: VariantOptions): void;
110
+ subscribe(cb: Listener): () => void;
46
111
  /** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
47
112
  close(): void;
48
113
  private notify;
49
114
  }
50
- /** Default singleton (IndexedDB-backed). Replace with your own SnapshotService({ storage }) if you need a real backend. */
115
+ /**
116
+ * The shared IndexedDB-backed instance, created on first call — so importing
117
+ * this package never opens a BroadcastChannel or an IndexedDB connection an
118
+ * app that brings its own `SnapshotService` would never use.
119
+ */
120
+ export declare function getDefaultSnapshotService(): SnapshotService;
121
+ /**
122
+ * @deprecated Use `getDefaultSnapshotService()`. A lazy stand-in for the
123
+ * default instance: it forwards every access to the real service, constructing
124
+ * it on first touch rather than at import time.
125
+ */
51
126
  export declare const snapshotService: SnapshotService;
52
127
  export {};