@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { EncodeOptions } from './encode';
|
|
2
|
+
import type { SnapshotService } from './snapshot-service';
|
|
3
|
+
import type { SnapshotKey, SnapshotStorage } from './snapshot-storage';
|
|
4
|
+
/**
|
|
5
|
+
* The half of a server-backed storage only the consumer can write: the HTTP
|
|
6
|
+
* calls themselves. Everything around them — the local cache, the
|
|
7
|
+
* stale-while-revalidate order, the "a 404 must not evict my capture" rule —
|
|
8
|
+
* is `CachedSnapshotStorage`'s job.
|
|
9
|
+
*/
|
|
10
|
+
export interface RemoteSnapshotStorage {
|
|
11
|
+
/**
|
|
12
|
+
* Resolve `null` for "nothing stored yet" — **including a 404**, which is the
|
|
13
|
+
* normal answer for an entity nobody has captured. Reject only for real
|
|
14
|
+
* failures (network down, 500, 403), which are reported through `onError`
|
|
15
|
+
* and leave the local copy standing.
|
|
16
|
+
*/
|
|
17
|
+
load(id: string, key: SnapshotKey): Promise<Blob | null>;
|
|
18
|
+
save(blob: Blob, id: string, key: SnapshotKey): Promise<void>;
|
|
19
|
+
remove?(id: string, key: SnapshotKey): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface CachedSnapshotStorageOptions {
|
|
22
|
+
remote: RemoteSnapshotStorage;
|
|
23
|
+
/** Defaults to a fresh `IndexedDbSnapshotStorage`. */
|
|
24
|
+
local?: SnapshotStorage;
|
|
25
|
+
/** Re-encode applied to the *uploaded* copy only; the local cache keeps the full capture. */
|
|
26
|
+
uploadEncode?: EncodeOptions;
|
|
27
|
+
/** Skip the upload (and report a `SnapshotTooLargeError`) above this size. Mirrors a server-side cap. */
|
|
28
|
+
maxBytes?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called for every swallowed failure instead of `console.warn`, so a consumer
|
|
31
|
+
* can filter the routine ones (a 403 for a read-only user) from the real ones.
|
|
32
|
+
*/
|
|
33
|
+
onError?(err: unknown, key: SnapshotKey, op: 'load' | 'save' | 'remove'): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A local cache in front of a remote store — the shape every server-backed
|
|
37
|
+
* consumer ends up needing.
|
|
38
|
+
*
|
|
39
|
+
* Load is stale-while-revalidate: the local hit paints immediately and the
|
|
40
|
+
* remote read happens in the background, `publish()`ing through the attached
|
|
41
|
+
* service if it turns up something new. Save writes locally first and fires the
|
|
42
|
+
* upload without awaiting it, so a slow PUT can never delay a navigation.
|
|
43
|
+
*
|
|
44
|
+
* Freshness is delegated to the HTTP cache (`ETag` + `Cache-Control: no-cache`)
|
|
45
|
+
* rather than any version bookkeeping here.
|
|
46
|
+
*/
|
|
47
|
+
export declare class CachedSnapshotStorage implements SnapshotStorage {
|
|
48
|
+
private readonly remote;
|
|
49
|
+
private readonly local;
|
|
50
|
+
private readonly options;
|
|
51
|
+
private service?;
|
|
52
|
+
/**
|
|
53
|
+
* Byte size of the copy currently cached per key. A revalidation that comes
|
|
54
|
+
* back the same size is treated as unchanged, so a warm list doesn't rewrite
|
|
55
|
+
* IndexedDB and swap every <img> src on every visit.
|
|
56
|
+
*/
|
|
57
|
+
private cachedSizes;
|
|
58
|
+
/** In-flight revalidations, so N rows of the same key don't fan out N reads. */
|
|
59
|
+
private revalidating;
|
|
60
|
+
/** Keys `remove()` has deleted, so a revalidation already in flight can't resurrect them when it resolves after. */
|
|
61
|
+
private removedKeys;
|
|
62
|
+
/** Only defined when the wrapped local storage has one — keeps `SnapshotService.prune()`'s capability check honest. */
|
|
63
|
+
readonly keys?: () => Promise<SnapshotKey[]>;
|
|
64
|
+
constructor(options: CachedSnapshotStorageOptions);
|
|
65
|
+
attach(service: SnapshotService): void;
|
|
66
|
+
/** Local write is awaited (the caller needs a displayable URL); the upload is not. */
|
|
67
|
+
save(blob: Blob, key: SnapshotKey): Promise<string>;
|
|
68
|
+
load(key: SnapshotKey): Promise<string | null>;
|
|
69
|
+
loadMany(keys: SnapshotKey[]): Promise<Map<string, string | null>>;
|
|
70
|
+
remove(key: SnapshotKey): Promise<void>;
|
|
71
|
+
/** Best-effort: reads the cached blob back through its own URL to learn its size, without any storage needing a new method. */
|
|
72
|
+
private primeCachedSize;
|
|
73
|
+
private upload;
|
|
74
|
+
/** Awaited path: resolve the remote copy, seed the cache, hand back the URL. */
|
|
75
|
+
private fetchRemote;
|
|
76
|
+
/** Background path: only touches anything when the remote copy actually differs. */
|
|
77
|
+
private revalidate;
|
|
78
|
+
private report;
|
|
79
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { encodeSnapshot } from './encode';
|
|
2
|
+
import { SnapshotTooLargeError } from './errors';
|
|
3
|
+
import { IndexedDbSnapshotStorage } from './snapshot-storage';
|
|
4
|
+
/**
|
|
5
|
+
* A local cache in front of a remote store — the shape every server-backed
|
|
6
|
+
* consumer ends up needing.
|
|
7
|
+
*
|
|
8
|
+
* Load is stale-while-revalidate: the local hit paints immediately and the
|
|
9
|
+
* remote read happens in the background, `publish()`ing through the attached
|
|
10
|
+
* service if it turns up something new. Save writes locally first and fires the
|
|
11
|
+
* upload without awaiting it, so a slow PUT can never delay a navigation.
|
|
12
|
+
*
|
|
13
|
+
* Freshness is delegated to the HTTP cache (`ETag` + `Cache-Control: no-cache`)
|
|
14
|
+
* rather than any version bookkeeping here.
|
|
15
|
+
*/
|
|
16
|
+
export class CachedSnapshotStorage {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
/**
|
|
19
|
+
* Byte size of the copy currently cached per key. A revalidation that comes
|
|
20
|
+
* back the same size is treated as unchanged, so a warm list doesn't rewrite
|
|
21
|
+
* IndexedDB and swap every <img> src on every visit.
|
|
22
|
+
*/
|
|
23
|
+
this.cachedSizes = new Map();
|
|
24
|
+
/** In-flight revalidations, so N rows of the same key don't fan out N reads. */
|
|
25
|
+
this.revalidating = new Set();
|
|
26
|
+
/** Keys `remove()` has deleted, so a revalidation already in flight can't resurrect them when it resolves after. */
|
|
27
|
+
this.removedKeys = new Set();
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.remote = options.remote;
|
|
30
|
+
this.local = options.local ?? new IndexedDbSnapshotStorage();
|
|
31
|
+
if (this.local.keys) {
|
|
32
|
+
const local = this.local;
|
|
33
|
+
this.keys = () => local.keys();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
attach(service) {
|
|
37
|
+
this.service = service;
|
|
38
|
+
this.local.attach?.(service);
|
|
39
|
+
}
|
|
40
|
+
/** Local write is awaited (the caller needs a displayable URL); the upload is not. */
|
|
41
|
+
async save(blob, key) {
|
|
42
|
+
const url = await this.local.save(blob, key);
|
|
43
|
+
this.cachedSizes.set(key.key, blob.size);
|
|
44
|
+
this.removedKeys.delete(key.key);
|
|
45
|
+
void this.upload(blob, key);
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
async load(key) {
|
|
49
|
+
const cached = await this.local.load(key);
|
|
50
|
+
if (cached) {
|
|
51
|
+
// A fresh instance (e.g. after a page reload) has an empty cachedSizes
|
|
52
|
+
// even though the local copy is already on disk — without this, the
|
|
53
|
+
// first revalidate() below has nothing to compare against and always
|
|
54
|
+
// treats an unchanged remote copy as "changed".
|
|
55
|
+
if (!this.cachedSizes.has(key.key))
|
|
56
|
+
await this.primeCachedSize(key, cached);
|
|
57
|
+
void this.revalidate(key, true);
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
// Cold miss: nothing to paint yet, so the remote read is worth awaiting —
|
|
61
|
+
// and its URL is returned directly rather than announced via publish().
|
|
62
|
+
return this.fetchRemote(key);
|
|
63
|
+
}
|
|
64
|
+
async loadMany(keys) {
|
|
65
|
+
const cached = this.local.loadMany
|
|
66
|
+
? await this.local.loadMany(keys)
|
|
67
|
+
: new Map(await Promise.all(keys.map(async (key) => [key.key, await this.local.load(key)])));
|
|
68
|
+
const misses = keys.filter((key) => !cached.get(key.key));
|
|
69
|
+
const fetched = await Promise.all(misses.map((key) => this.fetchRemote(key)));
|
|
70
|
+
misses.forEach((key, i) => cached.set(key.key, fetched[i]));
|
|
71
|
+
// Everything that came from the cache still gets revalidated, exactly as a
|
|
72
|
+
// single load() would — primed first, for the same reason as load() above.
|
|
73
|
+
const hits = keys.filter((key) => !misses.includes(key));
|
|
74
|
+
await Promise.all(hits.map(async (key) => {
|
|
75
|
+
const url = cached.get(key.key);
|
|
76
|
+
if (url && !this.cachedSizes.has(key.key))
|
|
77
|
+
await this.primeCachedSize(key, url);
|
|
78
|
+
}));
|
|
79
|
+
for (const key of hits)
|
|
80
|
+
void this.revalidate(key, true);
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
async remove(key) {
|
|
84
|
+
this.removedKeys.add(key.key);
|
|
85
|
+
this.cachedSizes.delete(key.key);
|
|
86
|
+
await this.local.remove(key);
|
|
87
|
+
if (!this.remote.remove)
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
await this.remote.remove(key.id, key);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
this.report(err, key, 'remove');
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Best-effort: reads the cached blob back through its own URL to learn its size, without any storage needing a new method. */
|
|
98
|
+
async primeCachedSize(key, url) {
|
|
99
|
+
if (typeof fetch !== 'function')
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
const blob = await fetch(url).then((r) => r.blob());
|
|
103
|
+
this.cachedSizes.set(key.key, blob.size);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// A miss here just costs one extra revalidate() write, same as before this fix.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async upload(blob, key) {
|
|
110
|
+
try {
|
|
111
|
+
const payload = this.options.uploadEncode ? await encodeSnapshot(blob, this.options.uploadEncode) : blob;
|
|
112
|
+
const { maxBytes } = this.options;
|
|
113
|
+
if (maxBytes !== undefined && payload.size > maxBytes) {
|
|
114
|
+
this.report(new SnapshotTooLargeError(payload.size, maxBytes), key, 'save');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await this.remote.save(payload, key.id, key);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this.report(err, key, 'save');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Awaited path: resolve the remote copy, seed the cache, hand back the URL. */
|
|
124
|
+
async fetchRemote(key) {
|
|
125
|
+
try {
|
|
126
|
+
const blob = await this.remote.load(key.id, key);
|
|
127
|
+
// null / empty is "nobody has captured this yet", not a failure.
|
|
128
|
+
if (!blob || blob.size === 0)
|
|
129
|
+
return null;
|
|
130
|
+
const url = await this.local.save(blob, key);
|
|
131
|
+
this.cachedSizes.set(key.key, blob.size);
|
|
132
|
+
this.removedKeys.delete(key.key);
|
|
133
|
+
return url;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
this.report(err, key, 'load');
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Background path: only touches anything when the remote copy actually differs. */
|
|
141
|
+
async revalidate(key, publish) {
|
|
142
|
+
if (this.revalidating.has(key.key))
|
|
143
|
+
return;
|
|
144
|
+
this.revalidating.add(key.key);
|
|
145
|
+
try {
|
|
146
|
+
const blob = await this.remote.load(key.id, key);
|
|
147
|
+
// The load-bearing rule: a remote miss must NEVER evict a local capture.
|
|
148
|
+
// Offline, 404, and "not synced yet" all land here.
|
|
149
|
+
if (!blob || blob.size === 0)
|
|
150
|
+
return;
|
|
151
|
+
// remove() ran while this read was in flight — don't resurrect what the
|
|
152
|
+
// caller just deleted.
|
|
153
|
+
if (this.removedKeys.has(key.key))
|
|
154
|
+
return;
|
|
155
|
+
if (this.cachedSizes.get(key.key) === blob.size)
|
|
156
|
+
return;
|
|
157
|
+
const url = await this.local.save(blob, key);
|
|
158
|
+
this.cachedSizes.set(key.key, blob.size);
|
|
159
|
+
if (publish)
|
|
160
|
+
this.service?.publish(key.id, url, { variant: key.variant });
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
// A rejected read leaves the local copy in place — offline still works.
|
|
164
|
+
this.report(err, key, 'load');
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
this.revalidating.delete(key.key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
report(err, key, op) {
|
|
171
|
+
if (this.options.onError)
|
|
172
|
+
this.options.onError(err, key, op);
|
|
173
|
+
else
|
|
174
|
+
console.warn(`CachedSnapshotStorage: ${op} failed for "${key.key}"`, err);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/encode.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type SnapshotImageType = `image/${'png' | 'webp' | 'jpeg'}`;
|
|
2
|
+
export interface EncodeOptions {
|
|
3
|
+
/** Output format. Defaults to `image/webp`, falling back to `image/png` where WebP encoding isn't available. */
|
|
4
|
+
type?: SnapshotImageType;
|
|
5
|
+
/** Lossy quality, 0-1. Ignored for `image/png`. Default 0.8. */
|
|
6
|
+
quality?: number;
|
|
7
|
+
/** Longest edge in px — downscales to fit, never upscales. */
|
|
8
|
+
maxEdge?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Re-encodes (and optionally downscales) a snapshot blob off the layout path,
|
|
12
|
+
* via `createImageBitmap` + `OffscreenCanvas` — so a full-resolution PNG from
|
|
13
|
+
* html2canvas becomes a small WebP suitable for a thumbnail or an upload.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately never throws: a browser missing either API, a decode failure, or
|
|
16
|
+
* an encoder that doesn't know the requested type all resolve with the *input*
|
|
17
|
+
* blob. A thumbnail that's bigger than intended beats no thumbnail at all.
|
|
18
|
+
*/
|
|
19
|
+
export declare function encodeSnapshot(blob: Blob, opts?: EncodeOptions): Promise<Blob>;
|
package/dist/encode.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const DEFAULT_TYPE = 'image/webp';
|
|
2
|
+
const DEFAULT_QUALITY = 0.8;
|
|
3
|
+
function targetSize(width, height, maxEdge) {
|
|
4
|
+
if (!maxEdge || maxEdge <= 0)
|
|
5
|
+
return { width, height };
|
|
6
|
+
const longest = Math.max(width, height);
|
|
7
|
+
if (longest <= maxEdge)
|
|
8
|
+
return { width, height };
|
|
9
|
+
const ratio = maxEdge / longest;
|
|
10
|
+
return { width: Math.max(1, Math.round(width * ratio)), height: Math.max(1, Math.round(height * ratio)) };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Re-encodes (and optionally downscales) a snapshot blob off the layout path,
|
|
14
|
+
* via `createImageBitmap` + `OffscreenCanvas` — so a full-resolution PNG from
|
|
15
|
+
* html2canvas becomes a small WebP suitable for a thumbnail or an upload.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately never throws: a browser missing either API, a decode failure, or
|
|
18
|
+
* an encoder that doesn't know the requested type all resolve with the *input*
|
|
19
|
+
* blob. A thumbnail that's bigger than intended beats no thumbnail at all.
|
|
20
|
+
*/
|
|
21
|
+
export async function encodeSnapshot(blob, opts = {}) {
|
|
22
|
+
const type = opts.type ?? DEFAULT_TYPE;
|
|
23
|
+
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
24
|
+
if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function')
|
|
25
|
+
return blob;
|
|
26
|
+
let bitmap;
|
|
27
|
+
try {
|
|
28
|
+
bitmap = await createImageBitmap(blob);
|
|
29
|
+
const { width, height } = targetSize(bitmap.width, bitmap.height, opts.maxEdge);
|
|
30
|
+
// Nothing to do: same format already, no downscale needed, and either quality
|
|
31
|
+
// doesn't apply (png) or the caller didn't ask for a specific one — we can't
|
|
32
|
+
// tell what quality the existing blob was actually encoded at, so a caller
|
|
33
|
+
// who *did* specify one always gets a fresh encode at that quality.
|
|
34
|
+
const sameDimsAndType = width === bitmap.width && height === bitmap.height && blob.type === type;
|
|
35
|
+
if (sameDimsAndType && (type === 'image/png' || opts.quality === undefined))
|
|
36
|
+
return blob;
|
|
37
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
38
|
+
const ctx = canvas.getContext('2d');
|
|
39
|
+
if (!ctx)
|
|
40
|
+
return blob;
|
|
41
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
42
|
+
let encoded = await canvas.convertToBlob({ type, quality });
|
|
43
|
+
// Chrome/Safari silently fall back to image/png for a type they can't
|
|
44
|
+
// encode, so trust the *result's* type rather than a feature probe.
|
|
45
|
+
if (encoded.type !== type && type !== 'image/png') {
|
|
46
|
+
encoded = await canvas.convertToBlob({ type: 'image/png' });
|
|
47
|
+
}
|
|
48
|
+
return encoded;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return blob;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
bitmap?.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors, so a caller can branch on *why* a capture failed instead of
|
|
3
|
+
* string-matching a prose message. Every rejection thrown by the library
|
|
4
|
+
* extends `SnapshotError`.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SnapshotError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/** The element passed to `capture()` was removed from the document before the render started. */
|
|
10
|
+
export declare class SnapshotDetachedElementError extends SnapshotError {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
constructor(id: string);
|
|
13
|
+
}
|
|
14
|
+
/** html2canvas rejected while rendering the element (e.g. "Unable to find element in cloned iframe"). */
|
|
15
|
+
export declare class SnapshotRenderError extends SnapshotError {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly cause: unknown;
|
|
18
|
+
constructor(id: string, cause: unknown);
|
|
19
|
+
}
|
|
20
|
+
/** `canvas.toBlob()` resolved null — tainted (cross-origin) content, or a zero-size element. */
|
|
21
|
+
export declare class SnapshotTaintedCanvasError extends SnapshotError {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
constructor(id: string);
|
|
24
|
+
}
|
|
25
|
+
/** A blob exceeded a configured `maxBytes` cap (see `CachedSnapshotStorage`). */
|
|
26
|
+
export declare class SnapshotTooLargeError extends SnapshotError {
|
|
27
|
+
readonly size: number;
|
|
28
|
+
readonly maxBytes: number;
|
|
29
|
+
constructor(size: number, maxBytes: number);
|
|
30
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors, so a caller can branch on *why* a capture failed instead of
|
|
3
|
+
* string-matching a prose message. Every rejection thrown by the library
|
|
4
|
+
* extends `SnapshotError`.
|
|
5
|
+
*/
|
|
6
|
+
export class SnapshotError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** The element passed to `capture()` was removed from the document before the render started. */
|
|
13
|
+
export class SnapshotDetachedElementError extends SnapshotError {
|
|
14
|
+
constructor(id) {
|
|
15
|
+
super(`SnapshotService: the element for "${id}" is not connected to the document — ` +
|
|
16
|
+
'it was removed (e.g. the view was destroyed) before the capture could run.');
|
|
17
|
+
this.id = id;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** html2canvas rejected while rendering the element (e.g. "Unable to find element in cloned iframe"). */
|
|
21
|
+
export class SnapshotRenderError extends SnapshotError {
|
|
22
|
+
constructor(id, cause) {
|
|
23
|
+
super(`SnapshotService: html2canvas failed while capturing "${id}": ` +
|
|
24
|
+
(cause instanceof Error ? cause.message : String(cause)));
|
|
25
|
+
this.id = id;
|
|
26
|
+
this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** `canvas.toBlob()` resolved null — tainted (cross-origin) content, or a zero-size element. */
|
|
30
|
+
export class SnapshotTaintedCanvasError extends SnapshotError {
|
|
31
|
+
constructor(id) {
|
|
32
|
+
super(`SnapshotService: canvas.toBlob() returned null while capturing "${id}" — the element likely ` +
|
|
33
|
+
'contains tainted (cross-origin, no CORS headers) content, or has zero size.');
|
|
34
|
+
this.id = id;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** A blob exceeded a configured `maxBytes` cap (see `CachedSnapshotStorage`). */
|
|
38
|
+
export class SnapshotTooLargeError extends SnapshotError {
|
|
39
|
+
constructor(size, maxBytes) {
|
|
40
|
+
super(`Snapshot is ${size} bytes, above the configured maxBytes of ${maxBytes} — not uploaded.`);
|
|
41
|
+
this.size = size;
|
|
42
|
+
this.maxBytes = maxBytes;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export * from './snapshot-nav-list';
|
|
2
2
|
export * from './snapshot-service';
|
|
3
3
|
export * from './snapshot-storage';
|
|
4
|
+
export * from './cached-snapshot-storage';
|
|
5
|
+
export * from './encode';
|
|
6
|
+
export * from './errors';
|
|
7
|
+
export * from './neutralize-oklch';
|
|
8
|
+
export * from './wait-for-canvases-to-paint';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export * from './snapshot-nav-list';
|
|
2
2
|
export * from './snapshot-service';
|
|
3
3
|
export * from './snapshot-storage';
|
|
4
|
+
export * from './cached-snapshot-storage';
|
|
5
|
+
export * from './encode';
|
|
6
|
+
export * from './errors';
|
|
7
|
+
export * from './neutralize-oklch';
|
|
8
|
+
export * from './wait-for-canvases-to-paint';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
|
|
3
|
+
* `getComputedStyle` resolves a growing share of real-world CSS to —
|
|
4
|
+
* Tailwind v4's default palette among others — independent of how the color
|
|
5
|
+
* was originally authored. A base/reset rule commonly inherits onto
|
|
6
|
+
* virtually every element too, so this can show up on dozens of computed
|
|
7
|
+
* properties per node, not just background/text. A color mid-CSS-transition
|
|
8
|
+
* also computes to a literal `oklab(...)` (the browser interpolates colors
|
|
9
|
+
* in that space), so anything animating a color at capture time needs the
|
|
10
|
+
* same treatment.
|
|
11
|
+
*
|
|
12
|
+
* Call this on the *live* document, before `capture()` — not just on the
|
|
13
|
+
* element being captured. html2canvas clones the whole document (for
|
|
14
|
+
* correct ancestor stacking/background), not only the target element, so a
|
|
15
|
+
* descendant can still inherit or otherwise resolve through an ancestor this
|
|
16
|
+
* call never touched if `root` is scoped too narrowly; `document.documentElement`
|
|
17
|
+
* is the safe default. Restore once the capture settles — this rewrites
|
|
18
|
+
* real inline styles on the live page, visibly if left in place.
|
|
19
|
+
*
|
|
20
|
+
* Walks the subtree, rewrites every computed property whose value contains
|
|
21
|
+
* `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
|
|
22
|
+
* properties are skipped — they're inert until something resolves them with
|
|
23
|
+
* `var()`), set `!important` so it wins over an `!important` rule in the
|
|
24
|
+
* page's own stylesheets too, and returns a callback that restores the
|
|
25
|
+
* original inline styles.
|
|
26
|
+
*
|
|
27
|
+
* Also suppresses `transition`/`animation` on every element first. Writing
|
|
28
|
+
* a new color below is itself a style change — on an element with e.g.
|
|
29
|
+
* `transition: color 150ms`, that starts a transition, and a read of the
|
|
30
|
+
* computed value straight afterward (by html2canvas, or by any other code
|
|
31
|
+
* running after this returns) lands mid-transition rather than on the value
|
|
32
|
+
* just set. Chrome interpolates color transitions in oklab by default, so
|
|
33
|
+
* the symptom is indistinguishable from this function having done nothing
|
|
34
|
+
* at all: the computed color comes back as an oklab() this can't parse
|
|
35
|
+
* either, on a value that was never authored as oklab anywhere.
|
|
36
|
+
*
|
|
37
|
+
* `colorjs.io` is imported on demand (like html2canvas in `capture()`) so
|
|
38
|
+
* a consumer that never calls this doesn't pay for it in their initial
|
|
39
|
+
* bundle. `await`s once, up front — the DOM walk and every write below it
|
|
40
|
+
* is still one synchronous pass, which the transition/animation
|
|
41
|
+
* suppression above depends on.
|
|
42
|
+
*/
|
|
43
|
+
export declare function neutralizeOklchColors(root: HTMLElement): Promise<() => void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const OKLCH_PATTERN = /okl(?:ch|ab)\([^)]*\)/gi;
|
|
2
|
+
/**
|
|
3
|
+
* html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
|
|
4
|
+
* `getComputedStyle` resolves a growing share of real-world CSS to —
|
|
5
|
+
* Tailwind v4's default palette among others — independent of how the color
|
|
6
|
+
* was originally authored. A base/reset rule commonly inherits onto
|
|
7
|
+
* virtually every element too, so this can show up on dozens of computed
|
|
8
|
+
* properties per node, not just background/text. A color mid-CSS-transition
|
|
9
|
+
* also computes to a literal `oklab(...)` (the browser interpolates colors
|
|
10
|
+
* in that space), so anything animating a color at capture time needs the
|
|
11
|
+
* same treatment.
|
|
12
|
+
*
|
|
13
|
+
* Call this on the *live* document, before `capture()` — not just on the
|
|
14
|
+
* element being captured. html2canvas clones the whole document (for
|
|
15
|
+
* correct ancestor stacking/background), not only the target element, so a
|
|
16
|
+
* descendant can still inherit or otherwise resolve through an ancestor this
|
|
17
|
+
* call never touched if `root` is scoped too narrowly; `document.documentElement`
|
|
18
|
+
* is the safe default. Restore once the capture settles — this rewrites
|
|
19
|
+
* real inline styles on the live page, visibly if left in place.
|
|
20
|
+
*
|
|
21
|
+
* Walks the subtree, rewrites every computed property whose value contains
|
|
22
|
+
* `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
|
|
23
|
+
* properties are skipped — they're inert until something resolves them with
|
|
24
|
+
* `var()`), set `!important` so it wins over an `!important` rule in the
|
|
25
|
+
* page's own stylesheets too, and returns a callback that restores the
|
|
26
|
+
* original inline styles.
|
|
27
|
+
*
|
|
28
|
+
* Also suppresses `transition`/`animation` on every element first. Writing
|
|
29
|
+
* a new color below is itself a style change — on an element with e.g.
|
|
30
|
+
* `transition: color 150ms`, that starts a transition, and a read of the
|
|
31
|
+
* computed value straight afterward (by html2canvas, or by any other code
|
|
32
|
+
* running after this returns) lands mid-transition rather than on the value
|
|
33
|
+
* just set. Chrome interpolates color transitions in oklab by default, so
|
|
34
|
+
* the symptom is indistinguishable from this function having done nothing
|
|
35
|
+
* at all: the computed color comes back as an oklab() this can't parse
|
|
36
|
+
* either, on a value that was never authored as oklab anywhere.
|
|
37
|
+
*
|
|
38
|
+
* `colorjs.io` is imported on demand (like html2canvas in `capture()`) so
|
|
39
|
+
* a consumer that never calls this doesn't pay for it in their initial
|
|
40
|
+
* bundle. `await`s once, up front — the DOM walk and every write below it
|
|
41
|
+
* is still one synchronous pass, which the transition/animation
|
|
42
|
+
* suppression above depends on.
|
|
43
|
+
*/
|
|
44
|
+
export async function neutralizeOklchColors(root) {
|
|
45
|
+
const { default: Color } = await import('colorjs.io');
|
|
46
|
+
const elements = [root, ...Array.from(root.querySelectorAll('*'))];
|
|
47
|
+
const restores = [];
|
|
48
|
+
for (const element of elements) {
|
|
49
|
+
for (const prop of ['transition', 'animation']) {
|
|
50
|
+
const previousValue = element.style.getPropertyValue(prop);
|
|
51
|
+
const previousPriority = element.style.getPropertyPriority(prop);
|
|
52
|
+
element.style.setProperty(prop, 'none', 'important');
|
|
53
|
+
restores.push(() => {
|
|
54
|
+
if (previousValue)
|
|
55
|
+
element.style.setProperty(prop, previousValue, previousPriority);
|
|
56
|
+
else
|
|
57
|
+
element.style.removeProperty(prop);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const computed = getComputedStyle(element);
|
|
61
|
+
for (let i = 0; i < computed.length; i++) {
|
|
62
|
+
const property = computed[i];
|
|
63
|
+
if (property.startsWith('--'))
|
|
64
|
+
continue;
|
|
65
|
+
const value = computed.getPropertyValue(property);
|
|
66
|
+
if (!value.includes('oklch(') && !value.includes('oklab('))
|
|
67
|
+
continue;
|
|
68
|
+
const replaced = value.replace(OKLCH_PATTERN, (match) => toHslString(Color, match) ?? match);
|
|
69
|
+
if (replaced === value)
|
|
70
|
+
continue;
|
|
71
|
+
const previousValue = element.style.getPropertyValue(property);
|
|
72
|
+
const previousPriority = element.style.getPropertyPriority(property);
|
|
73
|
+
element.style.setProperty(property, replaced, 'important');
|
|
74
|
+
restores.push(() => {
|
|
75
|
+
if (previousValue) {
|
|
76
|
+
element.style.setProperty(property, previousValue, previousPriority);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
element.style.removeProperty(property);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return () => restores.forEach((restore) => restore());
|
|
85
|
+
}
|
|
86
|
+
function toHslString(ColorCtor, cssColor) {
|
|
87
|
+
try {
|
|
88
|
+
const color = new ColorCtor(cssColor);
|
|
89
|
+
const [h, s, l] = color.hsl;
|
|
90
|
+
const alpha = color.alpha ?? 1;
|
|
91
|
+
return alpha < 1 ? `hsla(${h}, ${s}%, ${l}%, ${alpha})` : `hsl(${h}, ${s}%, ${l}%)`;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|