@anton-gustafsson/snapshot-core 0.4.1 → 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/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/neutralize-oklch.d.ts +43 -0
- package/dist/neutralize-oklch.js +96 -0
- package/dist/snapshot-service.d.ts +12 -0
- package/dist/snapshot-service.js +1 -0
- package/dist/wait-for-canvases-to-paint.d.ts +20 -0
- package/dist/wait-for-canvases-to-paint.js +53 -0
- package/package.json +4 -3
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -35,6 +35,18 @@ export interface CaptureOptions extends VariantOptions {
|
|
|
35
35
|
scale?: number;
|
|
36
36
|
/** Per-call override of the instance `encode`. */
|
|
37
37
|
encode?: EncodeOptions;
|
|
38
|
+
/**
|
|
39
|
+
* Passed straight through to html2canvas: called with the cloned document
|
|
40
|
+
* (and the clone of `el`) it's about to render, before it renders it. The
|
|
41
|
+
* escape hatch for anything that needs to touch the clone specifically. A
|
|
42
|
+
* returned promise is awaited.
|
|
43
|
+
*
|
|
44
|
+
* For colors specifically, prefer `neutralizeOklchColors` called on the
|
|
45
|
+
* *live* document before `capture()` (see its own docs) — html2canvas
|
|
46
|
+
* clones the whole document, not just `el`, so a fix scoped to `element`
|
|
47
|
+
* here can still miss a color a descendant inherits from outside it.
|
|
48
|
+
*/
|
|
49
|
+
onclone?: (document: Document, element: HTMLElement) => void | Promise<void>;
|
|
38
50
|
}
|
|
39
51
|
export declare class SnapshotService {
|
|
40
52
|
private storage;
|
package/dist/snapshot-service.js
CHANGED
|
@@ -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.4.
|
|
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": {
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
"typecheck": "tsc -p tsconfig.test.json"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"
|
|
27
|
+
"colorjs.io": "^0.7.1",
|
|
28
28
|
"html2canvas": "^1.4.1",
|
|
29
|
-
"idb-keyval": "^6.2.1"
|
|
29
|
+
"idb-keyval": "^6.2.1",
|
|
30
|
+
"lit": "^3.2.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"jsdom": "^28.0.0",
|