@anton-gustafsson/snapshot-core 0.0.7 → 0.4.2

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.
@@ -1,10 +1,13 @@
1
- import html2canvas from 'html2canvas';
1
+ import { encodeSnapshot } from './encode';
2
+ import { SnapshotDetachedElementError, SnapshotRenderError, SnapshotTaintedCanvasError } from './errors';
2
3
  import { IndexedDbSnapshotStorage } from './snapshot-storage';
3
4
  const CONTENT_PADDING = 16;
5
+ const VARIANT_SEPARATOR = '@';
4
6
  // Tracks keyPrefixes already claimed by a live SnapshotService instance, so
5
7
  // two instances that both forget to set one (or pick the same one) get a
6
8
  // loud warning instead of silently colliding on the same broadcast channel
7
- // and storage keys.
9
+ // and storage keys. Entries are dropped on close(), so a hot-reload that
10
+ // re-instantiates a closed service doesn't warn.
8
11
  const activeKeyPrefixes = new Set();
9
12
  /** So a container much bigger than its content (e.g. a full-page canvas with one small widget) doesn't capture as mostly empty space — crop to the actual children's bounding box, padded, instead of the whole element. */
10
13
  function getContentBounds(el) {
@@ -45,46 +48,189 @@ function getContentBounds(el) {
45
48
  export class SnapshotService {
46
49
  constructor(config = {}) {
47
50
  this.listeners = new Set();
51
+ /** In-flight captures, keyed by storage key, so two callers racing on the same view do one render. */
52
+ this.capturing = new Map();
48
53
  this.storage = config.storage ?? new IndexedDbSnapshotStorage();
49
54
  this.scale = config.scale ?? 0.4;
50
55
  this.keyPrefix = config.keyPrefix ?? '';
56
+ this.encode = config.encode;
57
+ this.keyFor = config.keyFor;
51
58
  if (activeKeyPrefixes.has(this.keyPrefix)) {
52
59
  console.warn(`SnapshotService: another instance already uses keyPrefix "${this.keyPrefix}" — ` +
53
60
  'their storage keys and cross-tab notifications will collide. Give each instance its own keyPrefix.');
54
61
  }
55
62
  activeKeyPrefixes.add(this.keyPrefix);
56
- this.channel = new BroadcastChannel(`nav-snapshots:${this.keyPrefix}`);
57
- this.channel.onmessage = (e) => {
58
- const { id, url } = e.data;
59
- this.listeners.forEach((l) => l(id, url));
60
- };
63
+ if (typeof BroadcastChannel === 'function') {
64
+ this.channel = new BroadcastChannel(`nav-snapshots:${this.keyPrefix}`);
65
+ this.channel.onmessage = (e) => {
66
+ const { id, url, variant } = e.data;
67
+ this.listeners.forEach((l) => l(id, url, variant));
68
+ };
69
+ }
70
+ this.storage.attach?.(this);
71
+ }
72
+ /**
73
+ * The fully-qualified storage key for an id (+ variant) under this instance.
74
+ * `id`/`variant` are `encodeURIComponent`-escaped before joining, so a `@`
75
+ * (or any other character) inside either can never be mistaken for the
76
+ * separator itself — without escaping, `id: 'a@b'` and `id: 'a', variant: 'b'`
77
+ * would otherwise land on the exact same key.
78
+ */
79
+ keyOf(id, opts = {}) {
80
+ const base = opts.variant === undefined || opts.variant === ''
81
+ ? `${this.keyPrefix}${encodeURIComponent(id)}`
82
+ : `${this.keyPrefix}${encodeURIComponent(id)}${VARIANT_SEPARATOR}${encodeURIComponent(opts.variant)}`;
83
+ const key = { id, key: base };
84
+ if (opts.variant)
85
+ key.variant = opts.variant;
86
+ if (this.keyFor)
87
+ key.key = this.keyFor(key);
88
+ return key;
89
+ }
90
+ /**
91
+ * Inverse of `keyOf()` for the default key shape — splits a stored key back
92
+ * into `{ id, variant }`. Returns `null` for a key belonging to a different
93
+ * `keyPrefix`, or for any key when a custom `keyFor` is configured (a custom
94
+ * shape isn't reversible).
95
+ */
96
+ parseKey(key) {
97
+ if (this.keyFor)
98
+ return null;
99
+ if (!key.startsWith(this.keyPrefix))
100
+ return null;
101
+ const rest = key.slice(this.keyPrefix.length);
102
+ // Safe to split on the first raw '@': encodeURIComponent never emits one,
103
+ // so a '@' here can only be the separator this class itself inserted.
104
+ const at = rest.indexOf(VARIANT_SEPARATOR);
105
+ if (at < 0)
106
+ return { id: decodeURIComponent(rest), key };
107
+ return { id: decodeURIComponent(rest.slice(0, at)), variant: decodeURIComponent(rest.slice(at + 1)), key };
61
108
  }
62
- /** Capture works on any element — a snapshot-nav-list item is one convention, not a requirement. */
63
- async capture(el, id) {
109
+ /**
110
+ * Capture works on any element — a snapshot-nav-list item is one convention,
111
+ * not a requirement. Concurrent calls for the same id/variant share one
112
+ * render instead of racing two.
113
+ */
114
+ async capture(el, id, opts = {}) {
115
+ // Declared `async` on purpose: `keyOf()` can throw synchronously (a
116
+ // caller-supplied `keyFor` is free to), and without `async` that throw
117
+ // would escape as a synchronous exception instead of a rejected promise,
118
+ // bypassing a caller's chained `.catch()`.
119
+ const key = this.keyOf(id, opts);
120
+ const inFlight = this.capturing.get(key.key);
121
+ if (inFlight)
122
+ return inFlight;
123
+ const run = this.runCapture(el, key, opts).finally(() => this.capturing.delete(key.key));
124
+ this.capturing.set(key.key, run);
125
+ return run;
126
+ }
127
+ async runCapture(el, key, opts) {
128
+ // A detached element renders as an empty (or, under html2canvas, a failed)
129
+ // clone — reject with something the caller can recognise instead of
130
+ // storing a blank thumbnail over a good one.
131
+ if (!el.isConnected)
132
+ throw new SnapshotDetachedElementError(key.id);
64
133
  const crop = getContentBounds(el);
65
- const canvas = await html2canvas(el, {
66
- scale: this.scale,
67
- logging: false,
68
- useCORS: true,
69
- x: crop.x,
70
- y: crop.y,
71
- width: crop.width,
72
- height: crop.height,
73
- });
74
- const blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b
75
- ? resolve(b)
76
- : reject(new Error('SnapshotService: canvas.toBlob() returned null — the captured element likely contains tainted (cross-origin, no CORS headers) content, or has zero size.')), 'image/png'));
77
- const url = await this.storage.save(this.keyPrefix + id, blob);
78
- this.notify(id, url);
134
+ // Imported on demand so `import '@anton-gustafsson/snapshot-core'` doesn't
135
+ // pull a DOM-only dependency into a Node/SSR/Jest process that only wants
136
+ // the types or a storage.
137
+ const { default: html2canvas } = await import('html2canvas');
138
+ let canvas;
139
+ try {
140
+ canvas = await html2canvas(el, {
141
+ scale: opts.scale ?? this.scale,
142
+ logging: false,
143
+ useCORS: true,
144
+ x: crop.x,
145
+ y: crop.y,
146
+ width: crop.width,
147
+ height: crop.height,
148
+ onclone: opts.onclone,
149
+ });
150
+ }
151
+ catch (err) {
152
+ // errors.ts documents every rejection from this library as a SnapshotError.
153
+ throw new SnapshotRenderError(key.id, err);
154
+ }
155
+ const raw = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new SnapshotTaintedCanvasError(key.id))), 'image/png'));
156
+ const encode = opts.encode ?? this.encode;
157
+ const blob = encode ? await encodeSnapshot(raw, encode) : raw;
158
+ const url = await this.storage.save(blob, key);
159
+ this.notify(key.id, url, key.variant);
79
160
  return url;
80
161
  }
81
- get(id) {
82
- return this.storage.load(this.keyPrefix + id);
162
+ get(id, opts = {}) {
163
+ return this.storage.load(this.keyOf(id, opts));
164
+ }
165
+ /**
166
+ * Batch read, keyed by bare id. Uses the storage's `loadMany()` when it has
167
+ * one (a single query/round-trip for a whole list) and falls back to parallel
168
+ * `load()`s when it doesn't.
169
+ */
170
+ async getMany(ids, opts = {}) {
171
+ const keys = ids.map((id) => this.keyOf(id, opts));
172
+ const byId = new Map();
173
+ if (this.storage.loadMany) {
174
+ const byKey = await this.storage.loadMany(keys);
175
+ for (const key of keys)
176
+ byId.set(key.id, byKey.get(key.key) ?? null);
177
+ return byId;
178
+ }
179
+ // allSettled, not all: one item's rejection (e.g. a flaky read for one row
180
+ // of a ten-row list) shouldn't fail every other id's already-successful load.
181
+ const results = await Promise.allSettled(keys.map((key) => this.storage.load(key)));
182
+ keys.forEach((key, i) => {
183
+ const result = results[i];
184
+ byId.set(key.id, result.status === 'fulfilled' ? result.value : null);
185
+ });
186
+ return byId;
83
187
  }
84
188
  /** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
85
- async remove(id) {
86
- await this.storage.remove?.(this.keyPrefix + id);
87
- this.notify(id, null);
189
+ async remove(id, opts = {}) {
190
+ const key = this.keyOf(id, opts);
191
+ await this.storage.remove(key);
192
+ this.notify(id, null, key.variant);
193
+ }
194
+ /**
195
+ * Deletes every stored snapshot (all variants) whose id isn't in `keepIds`,
196
+ * so thumbnails don't outlive the entities they belong to. Requires a storage
197
+ * that implements `keys()`; returns the number of snapshots removed.
198
+ */
199
+ async prune(keepIds) {
200
+ if (!this.storage.keys) {
201
+ console.warn('SnapshotService: prune() needs a storage that implements keys() — nothing was removed.');
202
+ return 0;
203
+ }
204
+ if (this.keyFor) {
205
+ console.warn('SnapshotService: prune() can\'t reverse a custom keyFor — parseKey() has no way to recover each ' +
206
+ 'entry\'s id, so every stored key is skipped. Nothing was removed.');
207
+ return 0;
208
+ }
209
+ const keep = new Set(keepIds);
210
+ const stored = await this.storage.keys();
211
+ let removed = 0;
212
+ for (const entry of stored) {
213
+ const parsed = this.parseKey(entry.key);
214
+ // Not ours (different keyPrefix, or an unreversible custom shape) — leave it alone.
215
+ if (!parsed || keep.has(parsed.id))
216
+ continue;
217
+ await this.storage.remove(parsed);
218
+ this.notify(parsed.id, null, parsed.variant);
219
+ removed++;
220
+ }
221
+ return removed;
222
+ }
223
+ /**
224
+ * Warms the storage for a list of ids ahead of render (one `loadMany()` where
225
+ * the storage supports it) and publishes whatever it finds, so any mounted
226
+ * `<snapshot-nav-list>` paints from the first frame instead of spinning.
227
+ */
228
+ async prefetch(ids, opts = {}) {
229
+ const urls = await this.getMany(ids, opts);
230
+ for (const [id, url] of urls) {
231
+ if (url)
232
+ this.notify(id, url, opts.variant);
233
+ }
88
234
  }
89
235
  /**
90
236
  * Announces a URL for `id` to subscribers (this tab and others) without
@@ -93,8 +239,8 @@ export class SnapshotService {
93
239
  * local cache in front of a slower authoritative database — call this once
94
240
  * the slow read settles so any mounted <snapshot-nav-list> updates live.
95
241
  */
96
- publish(id, url) {
97
- this.notify(id, url);
242
+ publish(id, url, opts = {}) {
243
+ this.notify(id, url, opts.variant);
98
244
  }
99
245
  /**
100
246
  * Tells subscribers to drop their locally cached thumbnail for `id` and
@@ -103,26 +249,58 @@ export class SnapshotService {
103
249
  * it again. Useful when the underlying data changed out from under the
104
250
  * cache (or, for a demo, to replay a loading state on demand).
105
251
  */
106
- invalidate(id) {
107
- this.notify(id, null);
252
+ invalidate(id, opts = {}) {
253
+ this.notify(id, null, opts.variant);
108
254
  }
109
255
  subscribe(cb) {
110
256
  this.listeners.add(cb);
111
- return () => this.listeners.delete(cb);
257
+ return () => {
258
+ this.listeners.delete(cb);
259
+ };
112
260
  }
113
261
  /** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
114
262
  close() {
115
- this.channel.close();
263
+ this.channel?.close();
116
264
  this.listeners.clear();
117
265
  activeKeyPrefixes.delete(this.keyPrefix);
118
266
  }
119
267
  // BroadcastChannel never delivers a message back to its own sender, so
120
268
  // same-tab listeners have to be notified directly alongside the cross-tab
121
269
  // post — kept as one method so `capture()` and `remove()` can't drift.
122
- notify(id, url) {
123
- this.channel.postMessage({ id, url });
124
- this.listeners.forEach((l) => l(id, url));
270
+ notify(id, url, variant) {
271
+ this.channel?.postMessage({ id, url, variant });
272
+ this.listeners.forEach((l) => l(id, url, variant));
125
273
  }
126
274
  }
127
- /** Default singleton (IndexedDB-backed). Replace with your own SnapshotService({ storage }) if you need a real backend. */
128
- export const snapshotService = new SnapshotService();
275
+ let defaultService;
276
+ /**
277
+ * The shared IndexedDB-backed instance, created on first call — so importing
278
+ * this package never opens a BroadcastChannel or an IndexedDB connection an
279
+ * app that brings its own `SnapshotService` would never use.
280
+ */
281
+ export function getDefaultSnapshotService() {
282
+ return (defaultService ??= new SnapshotService());
283
+ }
284
+ /**
285
+ * @deprecated Use `getDefaultSnapshotService()`. A lazy stand-in for the
286
+ * default instance: it forwards every access to the real service, constructing
287
+ * it on first touch rather than at import time.
288
+ */
289
+ export const snapshotService = new Proxy({}, {
290
+ get(_target, prop) {
291
+ const service = getDefaultSnapshotService();
292
+ // No `receiver` on purpose — forwarding the proxy as `this` to an accessor
293
+ // would loop straight back through this handler.
294
+ const value = Reflect.get(service, prop);
295
+ return typeof value === 'function' ? value.bind(service) : value;
296
+ },
297
+ set(_target, prop, value) {
298
+ return Reflect.set(getDefaultSnapshotService(), prop, value);
299
+ },
300
+ has(_target, prop) {
301
+ return prop in getDefaultSnapshotService();
302
+ },
303
+ getPrototypeOf() {
304
+ return SnapshotService.prototype;
305
+ },
306
+ });
@@ -1,7 +1,41 @@
1
+ import type { SnapshotService } from './snapshot-service';
2
+ /**
3
+ * Everything a storage needs to key a snapshot, handed over as one object so
4
+ * no storage ever has to re-derive (or strip) a prefix the service already
5
+ * knows.
6
+ */
7
+ export interface SnapshotKey {
8
+ /** Bare id, exactly as the caller passed it to `capture()`/`get()`. */
9
+ id: string;
10
+ /** Variant, if the call carried one (e.g. a theme). */
11
+ variant?: string;
12
+ /** Fully-qualified storage key: `keyPrefix + id [+ '@' + variant]`. Stable, safe to use verbatim. */
13
+ key: string;
14
+ }
1
15
  export interface SnapshotStorage {
2
- save(id: string, blob: Blob): Promise<string>;
3
- load(id: string): Promise<string | null>;
4
- remove?(id: string): Promise<void>;
16
+ /** Persists `blob` and returns a URL/dataURL that can be displayed right away. */
17
+ save(blob: Blob, key: SnapshotKey): Promise<string>;
18
+ load(key: SnapshotKey): Promise<string | null>;
19
+ remove(key: SnapshotKey): Promise<void>;
20
+ /**
21
+ * Optional batch read. When present, a `<snapshot-nav-list>` uses it once per
22
+ * `items` change instead of one `load()` per row. Resolve a `Map` keyed by
23
+ * `SnapshotKey.key` (the fully-qualified key), with `null` for a miss.
24
+ */
25
+ loadMany?(keys: SnapshotKey[]): Promise<Map<string, string | null>>;
26
+ /**
27
+ * Every key this storage holds — enables `SnapshotService.prune()`. `id` and
28
+ * `variant` are best-effort: a storage that can't tell a `keyPrefix` from an
29
+ * id may leave them as the raw remainder, since `prune()` re-derives them
30
+ * from `key` itself.
31
+ */
32
+ keys?(): Promise<SnapshotKey[]>;
33
+ /**
34
+ * Called once from the `SnapshotService` constructor, so a storage that
35
+ * resolves fresher data asynchronously can `publish()` it without the
36
+ * consumer having to wire the two together by hand.
37
+ */
38
+ attach?(service: SnapshotService): void;
5
39
  }
6
40
  /**
7
41
  * Default storage: browser-local IndexedDB. Does NOT sync across devices.
@@ -9,14 +43,19 @@ export interface SnapshotStorage {
9
43
  * Blobs are stored natively (IndexedDB supports them directly) instead of
10
44
  * base64-encoding into a data URL — that would cost ~33% extra storage and
11
45
  * an encode/decode pass on every save/render. Displayable URLs are minted
12
- * via `URL.createObjectURL`, cached per id so a re-capture of the same id
46
+ * via `URL.createObjectURL`, cached per key so a re-capture of the same key
13
47
  * revokes its old URL instead of leaking one per capture.
14
48
  */
15
49
  export declare class IndexedDbSnapshotStorage implements SnapshotStorage {
16
50
  private objectUrls;
17
- save(id: string, blob: Blob): Promise<string>;
18
- load(id: string): Promise<string | null>;
19
- remove(id: string): Promise<void>;
51
+ private service?;
52
+ attach(service: SnapshotService): void;
53
+ save(blob: Blob, key: SnapshotKey): Promise<string>;
54
+ load(key: SnapshotKey): Promise<string | null>;
55
+ remove(key: SnapshotKey): Promise<void>;
56
+ /** One IndexedDB transaction for the whole list instead of one per row. */
57
+ loadMany(requested: SnapshotKey[]): Promise<Map<string, string | null>>;
58
+ keys(): Promise<SnapshotKey[]>;
20
59
  private mintObjectUrl;
21
60
  private revoke;
22
61
  }
@@ -1,43 +1,79 @@
1
- import { get, set, del } from 'idb-keyval';
1
+ import { get, set, del, keys as idbKeys, getMany } from 'idb-keyval';
2
+ const IDB_PREFIX = 'snapshot:';
2
3
  /**
3
4
  * Default storage: browser-local IndexedDB. Does NOT sync across devices.
4
5
  *
5
6
  * Blobs are stored natively (IndexedDB supports them directly) instead of
6
7
  * base64-encoding into a data URL — that would cost ~33% extra storage and
7
8
  * an encode/decode pass on every save/render. Displayable URLs are minted
8
- * via `URL.createObjectURL`, cached per id so a re-capture of the same id
9
+ * via `URL.createObjectURL`, cached per key so a re-capture of the same key
9
10
  * revokes its old URL instead of leaking one per capture.
10
11
  */
11
12
  export class IndexedDbSnapshotStorage {
12
13
  constructor() {
13
14
  this.objectUrls = new Map();
14
15
  }
15
- async save(id, blob) {
16
- await set(`snapshot:${id}`, blob);
17
- return this.mintObjectUrl(id, blob);
16
+ attach(service) {
17
+ this.service = service;
18
18
  }
19
- async load(id) {
20
- const cached = this.objectUrls.get(id);
19
+ async save(blob, key) {
20
+ await set(IDB_PREFIX + key.key, blob);
21
+ return this.mintObjectUrl(key.key, blob);
22
+ }
23
+ async load(key) {
24
+ const cached = this.objectUrls.get(key.key);
21
25
  if (cached)
22
26
  return cached;
23
- const blob = await get(`snapshot:${id}`);
24
- return blob ? this.mintObjectUrl(id, blob) : null;
27
+ const blob = await get(IDB_PREFIX + key.key);
28
+ return blob ? this.mintObjectUrl(key.key, blob) : null;
29
+ }
30
+ async remove(key) {
31
+ this.revoke(key.key);
32
+ await del(IDB_PREFIX + key.key);
33
+ }
34
+ /** One IndexedDB transaction for the whole list instead of one per row. */
35
+ async loadMany(requested) {
36
+ const result = new Map();
37
+ const missing = [];
38
+ for (const key of requested) {
39
+ const cached = this.objectUrls.get(key.key);
40
+ if (cached)
41
+ result.set(key.key, cached);
42
+ else
43
+ missing.push(key);
44
+ }
45
+ if (missing.length > 0) {
46
+ const blobs = await getMany(missing.map((key) => IDB_PREFIX + key.key));
47
+ missing.forEach((key, i) => {
48
+ const blob = blobs[i];
49
+ result.set(key.key, blob ? this.mintObjectUrl(key.key, blob) : null);
50
+ });
51
+ }
52
+ return result;
25
53
  }
26
- async remove(id) {
27
- this.revoke(id);
28
- await del(`snapshot:${id}`);
54
+ async keys() {
55
+ const stored = await idbKeys();
56
+ return stored
57
+ .filter((k) => typeof k === 'string' && k.startsWith(IDB_PREFIX))
58
+ .map((k) => {
59
+ const key = k.slice(IDB_PREFIX.length);
60
+ // Once attached, the service can split the key properly (it owns both
61
+ // the keyPrefix and the '@variant' convention); standalone, the raw
62
+ // remainder is the honest answer.
63
+ return this.service?.parseKey(key) ?? { id: key, key };
64
+ });
29
65
  }
30
- mintObjectUrl(id, blob) {
31
- this.revoke(id);
66
+ mintObjectUrl(key, blob) {
67
+ this.revoke(key);
32
68
  const url = URL.createObjectURL(blob);
33
- this.objectUrls.set(id, url);
69
+ this.objectUrls.set(key, url);
34
70
  return url;
35
71
  }
36
- revoke(id) {
37
- const existing = this.objectUrls.get(id);
72
+ revoke(key) {
73
+ const existing = this.objectUrls.get(key);
38
74
  if (existing) {
39
75
  URL.revokeObjectURL(existing);
40
- this.objectUrls.delete(id);
76
+ this.objectUrls.delete(key);
41
77
  }
42
78
  }
43
79
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A `<canvas>`-based chart (Chart.js, and most others) typically paints
3
+ * through its own `ResizeObserver` + `requestAnimationFrame` cycle, entirely
4
+ * decoupled from the framework's own change detection — so the tick-plus-
5
+ * one-frame `injectSnapshotCapture()` waits for can still race a chart's own
6
+ * pending redraw. html2canvas only ever copies whatever is currently in a
7
+ * canvas's pixel buffer, so losing that race produces a capture with a fully
8
+ * blank chart: no error, nothing logged, just an empty rectangle where the
9
+ * chart should be.
10
+ *
11
+ * Polls every `<canvas>` under `root` (one requestAnimationFrame per
12
+ * attempt) until each either has non-transparent pixel data or `maxFrames`
13
+ * is exhausted, whichever comes first. Best-effort: a canvas that's still
14
+ * blank after `maxFrames` is left as-is rather than blocking the capture
15
+ * indefinitely — a bad thumbnail isn't worth stalling navigation over. Call
16
+ * this before `capture()`, on the live element (unlike `neutralizeOklchColors`,
17
+ * which needs the clone — this needs the canvas that's actually still
18
+ * painting).
19
+ */
20
+ export declare function waitForCanvasesToPaint(root: HTMLElement, maxFrames?: number): Promise<void>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A `<canvas>`-based chart (Chart.js, and most others) typically paints
3
+ * through its own `ResizeObserver` + `requestAnimationFrame` cycle, entirely
4
+ * decoupled from the framework's own change detection — so the tick-plus-
5
+ * one-frame `injectSnapshotCapture()` waits for can still race a chart's own
6
+ * pending redraw. html2canvas only ever copies whatever is currently in a
7
+ * canvas's pixel buffer, so losing that race produces a capture with a fully
8
+ * blank chart: no error, nothing logged, just an empty rectangle where the
9
+ * chart should be.
10
+ *
11
+ * Polls every `<canvas>` under `root` (one requestAnimationFrame per
12
+ * attempt) until each either has non-transparent pixel data or `maxFrames`
13
+ * is exhausted, whichever comes first. Best-effort: a canvas that's still
14
+ * blank after `maxFrames` is left as-is rather than blocking the capture
15
+ * indefinitely — a bad thumbnail isn't worth stalling navigation over. Call
16
+ * this before `capture()`, on the live element (unlike `neutralizeOklchColors`,
17
+ * which needs the clone — this needs the canvas that's actually still
18
+ * painting).
19
+ */
20
+ export async function waitForCanvasesToPaint(root, maxFrames = 6) {
21
+ const canvases = Array.from(root.querySelectorAll('canvas'));
22
+ if (canvases.length === 0)
23
+ return;
24
+ for (let frame = 0; frame < maxFrames; frame++) {
25
+ if (canvases.every((canvas) => !isBlank(canvas)))
26
+ return;
27
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
28
+ }
29
+ }
30
+ function isBlank(canvas) {
31
+ if (canvas.width === 0 || canvas.height === 0)
32
+ return false;
33
+ try {
34
+ // Only 2D canvases can be cheaply inspected this way — a WebGL canvas
35
+ // with `preserveDrawingBuffer: false` reads back as empty regardless of
36
+ // what's on screen, so treat anything non-2D as "can't tell, assume it's
37
+ // fine" rather than waiting forever.
38
+ const ctx = canvas.getContext('2d');
39
+ if (!ctx)
40
+ return false;
41
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
42
+ for (let i = 0; i < data.length; i++) {
43
+ if (data[i] !== 0)
44
+ return false;
45
+ }
46
+ return true;
47
+ }
48
+ catch {
49
+ // A tainted canvas throws on getImageData — not something this check
50
+ // can resolve either way, so don't block the capture over it.
51
+ return false;
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anton-gustafsson/snapshot-core",
3
- "version": "0.0.7",
3
+ "version": "0.4.2",
4
4
  "description": "A pluggable snapshot service that turns any DOM element into a stored, shareable image, plus an optional <snapshot-nav-list> web component to display them.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,14 +19,19 @@
19
19
  "access": "public"
20
20
  },
21
21
  "scripts": {
22
- "build": "tsc -p tsconfig.json"
22
+ "build": "tsc -p tsconfig.json",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc -p tsconfig.test.json"
23
25
  },
24
26
  "dependencies": {
25
- "lit": "^3.2.0",
27
+ "colorjs.io": "^0.7.1",
26
28
  "html2canvas": "^1.4.1",
27
- "idb-keyval": "^6.2.1"
29
+ "idb-keyval": "^6.2.1",
30
+ "lit": "^3.2.0"
28
31
  },
29
32
  "devDependencies": {
30
- "typescript": "^5.5.0"
33
+ "jsdom": "^28.0.0",
34
+ "typescript": "^5.5.0",
35
+ "vitest": "^4.0.8"
31
36
  }
32
37
  }