@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.
- 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 +3 -0
- package/dist/index.js +3 -0
- package/dist/snapshot-nav-list.d.ts +45 -8
- package/dist/snapshot-nav-list.js +105 -44
- package/dist/snapshot-service.d.ts +87 -12
- package/dist/snapshot-service.js +216 -39
- package/dist/snapshot-storage.d.ts +46 -7
- package/dist/snapshot-storage.js +54 -18
- package/package.json +7 -3
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,188 @@ 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
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// errors.ts documents every rejection from this library as a SnapshotError.
|
|
152
|
+
throw new SnapshotRenderError(key.id, err);
|
|
153
|
+
}
|
|
154
|
+
const raw = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new SnapshotTaintedCanvasError(key.id))), 'image/png'));
|
|
155
|
+
const encode = opts.encode ?? this.encode;
|
|
156
|
+
const blob = encode ? await encodeSnapshot(raw, encode) : raw;
|
|
157
|
+
const url = await this.storage.save(blob, key);
|
|
158
|
+
this.notify(key.id, url, key.variant);
|
|
79
159
|
return url;
|
|
80
160
|
}
|
|
81
|
-
get(id) {
|
|
82
|
-
return this.storage.load(this.
|
|
161
|
+
get(id, opts = {}) {
|
|
162
|
+
return this.storage.load(this.keyOf(id, opts));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Batch read, keyed by bare id. Uses the storage's `loadMany()` when it has
|
|
166
|
+
* one (a single query/round-trip for a whole list) and falls back to parallel
|
|
167
|
+
* `load()`s when it doesn't.
|
|
168
|
+
*/
|
|
169
|
+
async getMany(ids, opts = {}) {
|
|
170
|
+
const keys = ids.map((id) => this.keyOf(id, opts));
|
|
171
|
+
const byId = new Map();
|
|
172
|
+
if (this.storage.loadMany) {
|
|
173
|
+
const byKey = await this.storage.loadMany(keys);
|
|
174
|
+
for (const key of keys)
|
|
175
|
+
byId.set(key.id, byKey.get(key.key) ?? null);
|
|
176
|
+
return byId;
|
|
177
|
+
}
|
|
178
|
+
// allSettled, not all: one item's rejection (e.g. a flaky read for one row
|
|
179
|
+
// of a ten-row list) shouldn't fail every other id's already-successful load.
|
|
180
|
+
const results = await Promise.allSettled(keys.map((key) => this.storage.load(key)));
|
|
181
|
+
keys.forEach((key, i) => {
|
|
182
|
+
const result = results[i];
|
|
183
|
+
byId.set(key.id, result.status === 'fulfilled' ? result.value : null);
|
|
184
|
+
});
|
|
185
|
+
return byId;
|
|
83
186
|
}
|
|
84
187
|
/** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
|
|
85
|
-
async remove(id) {
|
|
86
|
-
|
|
87
|
-
this.
|
|
188
|
+
async remove(id, opts = {}) {
|
|
189
|
+
const key = this.keyOf(id, opts);
|
|
190
|
+
await this.storage.remove(key);
|
|
191
|
+
this.notify(id, null, key.variant);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Deletes every stored snapshot (all variants) whose id isn't in `keepIds`,
|
|
195
|
+
* so thumbnails don't outlive the entities they belong to. Requires a storage
|
|
196
|
+
* that implements `keys()`; returns the number of snapshots removed.
|
|
197
|
+
*/
|
|
198
|
+
async prune(keepIds) {
|
|
199
|
+
if (!this.storage.keys) {
|
|
200
|
+
console.warn('SnapshotService: prune() needs a storage that implements keys() — nothing was removed.');
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
if (this.keyFor) {
|
|
204
|
+
console.warn('SnapshotService: prune() can\'t reverse a custom keyFor — parseKey() has no way to recover each ' +
|
|
205
|
+
'entry\'s id, so every stored key is skipped. Nothing was removed.');
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
const keep = new Set(keepIds);
|
|
209
|
+
const stored = await this.storage.keys();
|
|
210
|
+
let removed = 0;
|
|
211
|
+
for (const entry of stored) {
|
|
212
|
+
const parsed = this.parseKey(entry.key);
|
|
213
|
+
// Not ours (different keyPrefix, or an unreversible custom shape) — leave it alone.
|
|
214
|
+
if (!parsed || keep.has(parsed.id))
|
|
215
|
+
continue;
|
|
216
|
+
await this.storage.remove(parsed);
|
|
217
|
+
this.notify(parsed.id, null, parsed.variant);
|
|
218
|
+
removed++;
|
|
219
|
+
}
|
|
220
|
+
return removed;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Warms the storage for a list of ids ahead of render (one `loadMany()` where
|
|
224
|
+
* the storage supports it) and publishes whatever it finds, so any mounted
|
|
225
|
+
* `<snapshot-nav-list>` paints from the first frame instead of spinning.
|
|
226
|
+
*/
|
|
227
|
+
async prefetch(ids, opts = {}) {
|
|
228
|
+
const urls = await this.getMany(ids, opts);
|
|
229
|
+
for (const [id, url] of urls) {
|
|
230
|
+
if (url)
|
|
231
|
+
this.notify(id, url, opts.variant);
|
|
232
|
+
}
|
|
88
233
|
}
|
|
89
234
|
/**
|
|
90
235
|
* Announces a URL for `id` to subscribers (this tab and others) without
|
|
@@ -93,8 +238,8 @@ export class SnapshotService {
|
|
|
93
238
|
* local cache in front of a slower authoritative database — call this once
|
|
94
239
|
* the slow read settles so any mounted <snapshot-nav-list> updates live.
|
|
95
240
|
*/
|
|
96
|
-
publish(id, url) {
|
|
97
|
-
this.notify(id, url);
|
|
241
|
+
publish(id, url, opts = {}) {
|
|
242
|
+
this.notify(id, url, opts.variant);
|
|
98
243
|
}
|
|
99
244
|
/**
|
|
100
245
|
* Tells subscribers to drop their locally cached thumbnail for `id` and
|
|
@@ -103,26 +248,58 @@ export class SnapshotService {
|
|
|
103
248
|
* it again. Useful when the underlying data changed out from under the
|
|
104
249
|
* cache (or, for a demo, to replay a loading state on demand).
|
|
105
250
|
*/
|
|
106
|
-
invalidate(id) {
|
|
107
|
-
this.notify(id, null);
|
|
251
|
+
invalidate(id, opts = {}) {
|
|
252
|
+
this.notify(id, null, opts.variant);
|
|
108
253
|
}
|
|
109
254
|
subscribe(cb) {
|
|
110
255
|
this.listeners.add(cb);
|
|
111
|
-
return () =>
|
|
256
|
+
return () => {
|
|
257
|
+
this.listeners.delete(cb);
|
|
258
|
+
};
|
|
112
259
|
}
|
|
113
260
|
/** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
|
|
114
261
|
close() {
|
|
115
|
-
this.channel
|
|
262
|
+
this.channel?.close();
|
|
116
263
|
this.listeners.clear();
|
|
117
264
|
activeKeyPrefixes.delete(this.keyPrefix);
|
|
118
265
|
}
|
|
119
266
|
// BroadcastChannel never delivers a message back to its own sender, so
|
|
120
267
|
// same-tab listeners have to be notified directly alongside the cross-tab
|
|
121
268
|
// 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));
|
|
269
|
+
notify(id, url, variant) {
|
|
270
|
+
this.channel?.postMessage({ id, url, variant });
|
|
271
|
+
this.listeners.forEach((l) => l(id, url, variant));
|
|
125
272
|
}
|
|
126
273
|
}
|
|
127
|
-
|
|
128
|
-
|
|
274
|
+
let defaultService;
|
|
275
|
+
/**
|
|
276
|
+
* The shared IndexedDB-backed instance, created on first call — so importing
|
|
277
|
+
* this package never opens a BroadcastChannel or an IndexedDB connection an
|
|
278
|
+
* app that brings its own `SnapshotService` would never use.
|
|
279
|
+
*/
|
|
280
|
+
export function getDefaultSnapshotService() {
|
|
281
|
+
return (defaultService ??= new SnapshotService());
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* @deprecated Use `getDefaultSnapshotService()`. A lazy stand-in for the
|
|
285
|
+
* default instance: it forwards every access to the real service, constructing
|
|
286
|
+
* it on first touch rather than at import time.
|
|
287
|
+
*/
|
|
288
|
+
export const snapshotService = new Proxy({}, {
|
|
289
|
+
get(_target, prop) {
|
|
290
|
+
const service = getDefaultSnapshotService();
|
|
291
|
+
// No `receiver` on purpose — forwarding the proxy as `this` to an accessor
|
|
292
|
+
// would loop straight back through this handler.
|
|
293
|
+
const value = Reflect.get(service, prop);
|
|
294
|
+
return typeof value === 'function' ? value.bind(service) : value;
|
|
295
|
+
},
|
|
296
|
+
set(_target, prop, value) {
|
|
297
|
+
return Reflect.set(getDefaultSnapshotService(), prop, value);
|
|
298
|
+
},
|
|
299
|
+
has(_target, prop) {
|
|
300
|
+
return prop in getDefaultSnapshotService();
|
|
301
|
+
},
|
|
302
|
+
getPrototypeOf() {
|
|
303
|
+
return SnapshotService.prototype;
|
|
304
|
+
},
|
|
305
|
+
});
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anton-gustafsson/snapshot-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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,7 +19,9 @@
|
|
|
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
|
"lit": "^3.2.0",
|
|
@@ -27,6 +29,8 @@
|
|
|
27
29
|
"idb-keyval": "^6.2.1"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
30
|
-
"
|
|
32
|
+
"jsdom": "^28.0.0",
|
|
33
|
+
"typescript": "^5.5.0",
|
|
34
|
+
"vitest": "^4.0.8"
|
|
31
35
|
}
|
|
32
36
|
}
|