@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 CHANGED
@@ -4,3 +4,5 @@ export * from './snapshot-storage';
4
4
  export * from './cached-snapshot-storage';
5
5
  export * from './encode';
6
6
  export * from './errors';
7
+ export * from './neutralize-oklch';
8
+ export * from './wait-for-canvases-to-paint';
package/dist/index.js CHANGED
@@ -4,3 +4,5 @@ export * from './snapshot-storage';
4
4
  export * from './cached-snapshot-storage';
5
5
  export * from './encode';
6
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
+ }
@@ -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;
@@ -145,6 +145,7 @@ export class SnapshotService {
145
145
  y: crop.y,
146
146
  width: crop.width,
147
147
  height: crop.height,
148
+ onclone: opts.onclone,
148
149
  });
149
150
  }
150
151
  catch (err) {
@@ -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.1",
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
- "lit": "^3.2.0",
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",