@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.
- package/dist/cached-snapshot-storage.d.ts +79 -0
- package/dist/cached-snapshot-storage.js +176 -0
- package/dist/encode.d.ts +19 -0
- package/dist/encode.js +56 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +44 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/neutralize-oklch.d.ts +43 -0
- package/dist/neutralize-oklch.js +96 -0
- package/dist/snapshot-nav-list.d.ts +45 -8
- package/dist/snapshot-nav-list.js +105 -44
- package/dist/snapshot-service.d.ts +99 -12
- package/dist/snapshot-service.js +217 -39
- package/dist/snapshot-storage.d.ts +46 -7
- package/dist/snapshot-storage.js +54 -18
- package/dist/wait-for-canvases-to-paint.d.ts +20 -0
- package/dist/wait-for-canvases-to-paint.js +53 -0
- package/package.json +10 -5
package/dist/snapshot-service.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
/**
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
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
|
-
|
|
87
|
-
this.
|
|
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 () =>
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
}
|
package/dist/snapshot-storage.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
return this.mintObjectUrl(id, blob);
|
|
16
|
+
attach(service) {
|
|
17
|
+
this.service = service;
|
|
18
18
|
}
|
|
19
|
-
async
|
|
20
|
-
|
|
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(
|
|
24
|
-
return blob ? this.mintObjectUrl(
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
31
|
-
this.revoke(
|
|
66
|
+
mintObjectUrl(key, blob) {
|
|
67
|
+
this.revoke(key);
|
|
32
68
|
const url = URL.createObjectURL(blob);
|
|
33
|
-
this.objectUrls.set(
|
|
69
|
+
this.objectUrls.set(key, url);
|
|
34
70
|
return url;
|
|
35
71
|
}
|
|
36
|
-
revoke(
|
|
37
|
-
const existing = this.objectUrls.get(
|
|
72
|
+
revoke(key) {
|
|
73
|
+
const existing = this.objectUrls.get(key);
|
|
38
74
|
if (existing) {
|
|
39
75
|
URL.revokeObjectURL(existing);
|
|
40
|
-
this.objectUrls.delete(
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
33
|
+
"jsdom": "^28.0.0",
|
|
34
|
+
"typescript": "^5.5.0",
|
|
35
|
+
"vitest": "^4.0.8"
|
|
31
36
|
}
|
|
32
37
|
}
|