@anton-gustafsson/snapshot-core 0.4.1 → 0.4.3
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/neutralize-oklch.d.ts +47 -0
- package/dist/neutralize-oklch.js +100 -0
- package/dist/snapshot-nav-list.d.ts +4 -23
- package/dist/snapshot-nav-list.js +49 -269
- package/dist/snapshot-service.d.ts +70 -0
- package/dist/snapshot-service.js +80 -3
- package/package.json +4 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal to `SnapshotService.capture()` — wired up via
|
|
3
|
+
* `CaptureOptions.neutralizeColors`, not meant to be called directly. Kept
|
|
4
|
+
* in its own module (rather than inlined) so it stays independently unit
|
|
5
|
+
* testable and `colorjs.io` stays an on-demand import.
|
|
6
|
+
*
|
|
7
|
+
* html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
|
|
8
|
+
* `getComputedStyle` resolves a growing share of real-world CSS to —
|
|
9
|
+
* Tailwind v4's default palette among others — independent of how the color
|
|
10
|
+
* was originally authored. A base/reset rule commonly inherits onto
|
|
11
|
+
* virtually every element too, so this can show up on dozens of computed
|
|
12
|
+
* properties per node, not just background/text. A color mid-CSS-transition
|
|
13
|
+
* also computes to a literal `oklab(...)` (the browser interpolates colors
|
|
14
|
+
* in that space), so anything animating a color at capture time needs the
|
|
15
|
+
* same treatment.
|
|
16
|
+
*
|
|
17
|
+
* Runs on the whole document (`capture()` always passes
|
|
18
|
+
* `document.documentElement`), not just the captured element — html2canvas
|
|
19
|
+
* clones the whole document (for correct ancestor stacking/background), so
|
|
20
|
+
* a descendant could otherwise still inherit an un-neutralized color from
|
|
21
|
+
* outside the captured element. Restored once the capture settles — this
|
|
22
|
+
* rewrites real inline styles on the live page, visibly if left in place.
|
|
23
|
+
*
|
|
24
|
+
* Walks the subtree, rewrites every computed property whose value contains
|
|
25
|
+
* `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
|
|
26
|
+
* properties are skipped — they're inert until something resolves them with
|
|
27
|
+
* `var()`), set `!important` so it wins over an `!important` rule in the
|
|
28
|
+
* page's own stylesheets too, and returns a callback that restores the
|
|
29
|
+
* original inline styles.
|
|
30
|
+
*
|
|
31
|
+
* Also suppresses `transition`/`animation` on every element first. Writing
|
|
32
|
+
* a new color below is itself a style change — on an element with e.g.
|
|
33
|
+
* `transition: color 150ms`, that starts a transition, and a read of the
|
|
34
|
+
* computed value straight afterward (by html2canvas, or by any other code
|
|
35
|
+
* running after this returns) lands mid-transition rather than on the value
|
|
36
|
+
* just set. Chrome interpolates color transitions in oklab by default, so
|
|
37
|
+
* the symptom is indistinguishable from this function having done nothing
|
|
38
|
+
* at all: the computed color comes back as an oklab() this can't parse
|
|
39
|
+
* either, on a value that was never authored as oklab anywhere.
|
|
40
|
+
*
|
|
41
|
+
* `colorjs.io` is imported on demand (like html2canvas in `capture()`) so
|
|
42
|
+
* a consumer that never calls this doesn't pay for it in their initial
|
|
43
|
+
* bundle. `await`s once, up front — the DOM walk and every write below it
|
|
44
|
+
* is still one synchronous pass, which the transition/animation
|
|
45
|
+
* suppression above depends on.
|
|
46
|
+
*/
|
|
47
|
+
export declare function neutralizeOklchColors(root: HTMLElement): Promise<() => void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const OKLCH_PATTERN = /okl(?:ch|ab)\([^)]*\)/gi;
|
|
2
|
+
/**
|
|
3
|
+
* Internal to `SnapshotService.capture()` — wired up via
|
|
4
|
+
* `CaptureOptions.neutralizeColors`, not meant to be called directly. Kept
|
|
5
|
+
* in its own module (rather than inlined) so it stays independently unit
|
|
6
|
+
* testable and `colorjs.io` stays an on-demand import.
|
|
7
|
+
*
|
|
8
|
+
* html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
|
|
9
|
+
* `getComputedStyle` resolves a growing share of real-world CSS to —
|
|
10
|
+
* Tailwind v4's default palette among others — independent of how the color
|
|
11
|
+
* was originally authored. A base/reset rule commonly inherits onto
|
|
12
|
+
* virtually every element too, so this can show up on dozens of computed
|
|
13
|
+
* properties per node, not just background/text. A color mid-CSS-transition
|
|
14
|
+
* also computes to a literal `oklab(...)` (the browser interpolates colors
|
|
15
|
+
* in that space), so anything animating a color at capture time needs the
|
|
16
|
+
* same treatment.
|
|
17
|
+
*
|
|
18
|
+
* Runs on the whole document (`capture()` always passes
|
|
19
|
+
* `document.documentElement`), not just the captured element — html2canvas
|
|
20
|
+
* clones the whole document (for correct ancestor stacking/background), so
|
|
21
|
+
* a descendant could otherwise still inherit an un-neutralized color from
|
|
22
|
+
* outside the captured element. Restored once the capture settles — this
|
|
23
|
+
* rewrites real inline styles on the live page, visibly if left in place.
|
|
24
|
+
*
|
|
25
|
+
* Walks the subtree, rewrites every computed property whose value contains
|
|
26
|
+
* `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
|
|
27
|
+
* properties are skipped — they're inert until something resolves them with
|
|
28
|
+
* `var()`), set `!important` so it wins over an `!important` rule in the
|
|
29
|
+
* page's own stylesheets too, and returns a callback that restores the
|
|
30
|
+
* original inline styles.
|
|
31
|
+
*
|
|
32
|
+
* Also suppresses `transition`/`animation` on every element first. Writing
|
|
33
|
+
* a new color below is itself a style change — on an element with e.g.
|
|
34
|
+
* `transition: color 150ms`, that starts a transition, and a read of the
|
|
35
|
+
* computed value straight afterward (by html2canvas, or by any other code
|
|
36
|
+
* running after this returns) lands mid-transition rather than on the value
|
|
37
|
+
* just set. Chrome interpolates color transitions in oklab by default, so
|
|
38
|
+
* the symptom is indistinguishable from this function having done nothing
|
|
39
|
+
* at all: the computed color comes back as an oklab() this can't parse
|
|
40
|
+
* either, on a value that was never authored as oklab anywhere.
|
|
41
|
+
*
|
|
42
|
+
* `colorjs.io` is imported on demand (like html2canvas in `capture()`) so
|
|
43
|
+
* a consumer that never calls this doesn't pay for it in their initial
|
|
44
|
+
* bundle. `await`s once, up front — the DOM walk and every write below it
|
|
45
|
+
* is still one synchronous pass, which the transition/animation
|
|
46
|
+
* suppression above depends on.
|
|
47
|
+
*/
|
|
48
|
+
export async function neutralizeOklchColors(root) {
|
|
49
|
+
const { default: Color } = await import('colorjs.io');
|
|
50
|
+
const elements = [root, ...Array.from(root.querySelectorAll('*'))];
|
|
51
|
+
const restores = [];
|
|
52
|
+
for (const element of elements) {
|
|
53
|
+
for (const prop of ['transition', 'animation']) {
|
|
54
|
+
const previousValue = element.style.getPropertyValue(prop);
|
|
55
|
+
const previousPriority = element.style.getPropertyPriority(prop);
|
|
56
|
+
element.style.setProperty(prop, 'none', 'important');
|
|
57
|
+
restores.push(() => {
|
|
58
|
+
if (previousValue)
|
|
59
|
+
element.style.setProperty(prop, previousValue, previousPriority);
|
|
60
|
+
else
|
|
61
|
+
element.style.removeProperty(prop);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const computed = getComputedStyle(element);
|
|
65
|
+
for (let i = 0; i < computed.length; i++) {
|
|
66
|
+
const property = computed[i];
|
|
67
|
+
if (property.startsWith('--'))
|
|
68
|
+
continue;
|
|
69
|
+
const value = computed.getPropertyValue(property);
|
|
70
|
+
if (!value.includes('oklch(') && !value.includes('oklab('))
|
|
71
|
+
continue;
|
|
72
|
+
const replaced = value.replace(OKLCH_PATTERN, (match) => toHslString(Color, match) ?? match);
|
|
73
|
+
if (replaced === value)
|
|
74
|
+
continue;
|
|
75
|
+
const previousValue = element.style.getPropertyValue(property);
|
|
76
|
+
const previousPriority = element.style.getPropertyPriority(property);
|
|
77
|
+
element.style.setProperty(property, replaced, 'important');
|
|
78
|
+
restores.push(() => {
|
|
79
|
+
if (previousValue) {
|
|
80
|
+
element.style.setProperty(property, previousValue, previousPriority);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
element.style.removeProperty(property);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return () => restores.forEach((restore) => restore());
|
|
89
|
+
}
|
|
90
|
+
function toHslString(ColorCtor, cssColor) {
|
|
91
|
+
try {
|
|
92
|
+
const color = new ColorCtor(cssColor);
|
|
93
|
+
const [h, s, l] = color.hsl;
|
|
94
|
+
const alpha = color.alpha ?? 1;
|
|
95
|
+
return alpha < 1 ? `hsla(${h}, ${s}%, ${l}%, ${alpha})` : `hsl(${h}, ${s}%, ${l}%)`;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -14,21 +14,16 @@ export interface NavItem<T = unknown> {
|
|
|
14
14
|
/** @deprecated Put the route in `data` and read it off the emitted item. Kept for one release. */
|
|
15
15
|
route?: string;
|
|
16
16
|
}
|
|
17
|
-
/** `'icon-only'` is the old name for `'tile'`; it still works and normalises to `'tile'`. */
|
|
18
|
-
export type SnapshotNavListVariant = 'list' | 'tile' | 'card' | 'icon-only';
|
|
19
17
|
/** `overlay` floats the edit button over the thumbnail (top-right, reveals on hover); `meta` pins it to the right edge of the title's line (description below), always visible. */
|
|
20
18
|
export type SnapshotNavListEditButtonPosition = 'overlay' | 'meta';
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `currentColor`, so an unstyled host still looks intentional.
|
|
20
|
+
* A grid of preview cards: a contained (never-cropped) screenshot above real
|
|
21
|
+
* title/description text below it — the text never sits on top of the
|
|
22
|
+
* image, so it needs no overlay tint to stay legible.
|
|
26
23
|
*/
|
|
27
24
|
export declare class SnapshotNavList extends LitElement {
|
|
28
25
|
static styles: import("lit").CSSResult;
|
|
29
26
|
items: NavItem[];
|
|
30
|
-
/** `card` by default — a framed preview with title/description underneath. `tile` is the compact contact-sheet grid, `list` a sidebar row. */
|
|
31
|
-
variant: SnapshotNavListVariant;
|
|
32
27
|
/**
|
|
33
28
|
* Second dimension on every id — typically the active theme, so a light and a
|
|
34
29
|
* dark capture of the same view are stored (and read) separately. Passed
|
|
@@ -37,21 +32,11 @@ export declare class SnapshotNavList extends LitElement {
|
|
|
37
32
|
variantKey?: string;
|
|
38
33
|
/** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
|
|
39
34
|
scrollable: boolean;
|
|
40
|
-
/** tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
|
|
41
|
-
overlayTint: 'dark' | 'light' | 'none';
|
|
42
|
-
/** caption background tint strength, 0-1 */
|
|
43
|
-
textOverlayOpacity: number;
|
|
44
|
-
/** image scrim tint strength, 0-1 — 0 keeps the image clear (blur only) */
|
|
45
|
-
imageOverlayOpacity: number;
|
|
46
|
-
/** backdrop blur behind the title, in px */
|
|
47
|
-
overlayBlur: number;
|
|
48
|
-
/** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
|
|
49
|
-
labelPosition: 'bottom' | 'center';
|
|
50
35
|
/** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
|
|
51
36
|
snapshotService: SnapshotService;
|
|
52
37
|
/** Shows an edit button per card. Off by default — clicking it fires `nav-edit` instead of `nav-select`; the host decides what "edit" means (e.g. open its own dialog component). Overridable per row via `NavItem.editable`. */
|
|
53
38
|
editable: boolean;
|
|
54
|
-
/** Where the edit button sits: `overlay` (default) floats it over the thumbnail; `meta` pins it to the right edge of the title row, with the description below.
|
|
39
|
+
/** Where the edit button sits: `overlay` (default) floats it over the thumbnail; `meta` pins it to the right edge of the title row, with the description below. */
|
|
55
40
|
editButtonPosition: SnapshotNavListEditButtonPosition;
|
|
56
41
|
/** Edit button glyph. Same convention as `NavItem.icon`: a plain-text glyph (e.g. an emoji), or markup — a string starting with `<` renders as raw HTML/SVG, so a consumer can pass its own icon (e.g. `<svg>...</svg>`). */
|
|
57
42
|
editIcon: string;
|
|
@@ -80,10 +65,6 @@ export declare class SnapshotNavList extends LitElement {
|
|
|
80
65
|
* mid-flight doesn't re-request what's already coming.
|
|
81
66
|
*/
|
|
82
67
|
private loadThumbs;
|
|
83
|
-
/** image scrim: blur + its own (usually 0) tint strength — independent of the caption's. */
|
|
84
|
-
private get imageOverlayStyle();
|
|
85
|
-
/** caption background: tint (to pop the text) + the same blur. */
|
|
86
|
-
private get metaStyle();
|
|
87
68
|
private select;
|
|
88
69
|
private edit;
|
|
89
70
|
private isEditable;
|
|
@@ -6,7 +6,6 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
6
6
|
};
|
|
7
7
|
import { LitElement, html, css } from 'lit';
|
|
8
8
|
import { property, state } from 'lit/decorators.js';
|
|
9
|
-
import { styleMap } from 'lit/directives/style-map.js';
|
|
10
9
|
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
|
11
10
|
import { snapshotService as defaultSnapshotService } from './snapshot-service';
|
|
12
11
|
function isMarkupIcon(icon) {
|
|
@@ -14,34 +13,21 @@ function isMarkupIcon(icon) {
|
|
|
14
13
|
}
|
|
15
14
|
const DEFAULT_EDIT_ICON = '✎';
|
|
16
15
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `currentColor`, so an unstyled host still looks intentional.
|
|
16
|
+
* A grid of preview cards: a contained (never-cropped) screenshot above real
|
|
17
|
+
* title/description text below it — the text never sits on top of the
|
|
18
|
+
* image, so it needs no overlay tint to stay legible.
|
|
21
19
|
*/
|
|
22
20
|
export class SnapshotNavList extends LitElement {
|
|
23
21
|
constructor() {
|
|
24
22
|
super(...arguments);
|
|
25
23
|
this.items = [];
|
|
26
|
-
/** `card` by default — a framed preview with title/description underneath. `tile` is the compact contact-sheet grid, `list` a sidebar row. */
|
|
27
|
-
this.variant = 'card';
|
|
28
24
|
/** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
|
|
29
25
|
this.scrollable = false;
|
|
30
|
-
/** tile overlay: tint behind the title so it stays legible over any image. Transparent by default — opt into a scrim explicitly. */
|
|
31
|
-
this.overlayTint = 'none';
|
|
32
|
-
/** caption background tint strength, 0-1 */
|
|
33
|
-
this.textOverlayOpacity = 0.35;
|
|
34
|
-
/** image scrim tint strength, 0-1 — 0 keeps the image clear (blur only) */
|
|
35
|
-
this.imageOverlayOpacity = 0;
|
|
36
|
-
/** backdrop blur behind the title, in px */
|
|
37
|
-
this.overlayBlur = 0;
|
|
38
|
-
/** tile only: 'bottom' is the caption strip (default), 'center' centers a larger title. */
|
|
39
|
-
this.labelPosition = 'bottom';
|
|
40
26
|
/** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
|
|
41
27
|
this.snapshotService = defaultSnapshotService;
|
|
42
28
|
/** Shows an edit button per card. Off by default — clicking it fires `nav-edit` instead of `nav-select`; the host decides what "edit" means (e.g. open its own dialog component). Overridable per row via `NavItem.editable`. */
|
|
43
29
|
this.editable = false;
|
|
44
|
-
/** Where the edit button sits: `overlay` (default) floats it over the thumbnail; `meta` pins it to the right edge of the title row, with the description below.
|
|
30
|
+
/** Where the edit button sits: `overlay` (default) floats it over the thumbnail; `meta` pins it to the right edge of the title row, with the description below. */
|
|
45
31
|
this.editButtonPosition = 'overlay';
|
|
46
32
|
/** Edit button glyph. Same convention as `NavItem.icon`: a plain-text glyph (e.g. an emoji), or markup — a string starting with `<` renders as raw HTML/SVG, so a consumer can pass its own icon (e.g. `<svg>...</svg>`). */
|
|
47
33
|
this.editIcon = DEFAULT_EDIT_ICON;
|
|
@@ -77,66 +63,48 @@ export class SnapshotNavList extends LitElement {
|
|
|
77
63
|
list-style: none;
|
|
78
64
|
margin: 0;
|
|
79
65
|
padding: 0;
|
|
80
|
-
display:
|
|
81
|
-
|
|
82
|
-
gap: var(--snapshot-nav-list-gap,
|
|
66
|
+
display: grid;
|
|
67
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--snapshot-nav-list-card-min-width, 220px), 1fr));
|
|
68
|
+
gap: var(--snapshot-nav-list-card-gap, 1.25rem);
|
|
83
69
|
}
|
|
84
70
|
li {
|
|
85
71
|
display: flex;
|
|
86
|
-
|
|
87
|
-
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
align-items: stretch;
|
|
74
|
+
gap: 0;
|
|
88
75
|
cursor: pointer;
|
|
89
|
-
padding:
|
|
76
|
+
padding: var(--snapshot-nav-list-card-padding, 0.5rem);
|
|
90
77
|
border-radius: var(--snapshot-nav-list-radius, 10px);
|
|
78
|
+
background: var(--snapshot-nav-list-card-bg, transparent);
|
|
79
|
+
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
|
80
|
+
box-shadow: none;
|
|
81
|
+
transition:
|
|
82
|
+
box-shadow var(--snapshot-nav-list-card-transition-dur, 0.15s) ease,
|
|
83
|
+
border-color var(--snapshot-nav-list-card-transition-dur, 0.15s) ease;
|
|
91
84
|
}
|
|
92
85
|
li:hover,
|
|
93
86
|
li:focus-visible {
|
|
94
|
-
background:
|
|
87
|
+
background: var(--snapshot-nav-list-card-bg, transparent);
|
|
88
|
+
box-shadow: var(--snapshot-nav-list-card-shadow, 0 2px 8px color-mix(in srgb, currentColor 18%, transparent));
|
|
89
|
+
border-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
95
90
|
outline: none;
|
|
96
91
|
}
|
|
97
|
-
li:focus-visible
|
|
98
|
-
outline:
|
|
99
|
-
|
|
92
|
+
li:focus-visible {
|
|
93
|
+
outline: none;
|
|
94
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--frame-accent) 55%, transparent);
|
|
100
95
|
}
|
|
101
96
|
|
|
102
97
|
.thumb-wrap {
|
|
103
98
|
position: relative;
|
|
104
|
-
width:
|
|
105
|
-
height:
|
|
99
|
+
width: 100%;
|
|
100
|
+
height: auto;
|
|
101
|
+
aspect-ratio: 2 / 1;
|
|
106
102
|
border-radius: var(--snapshot-nav-list-radius-sm, 7px);
|
|
107
|
-
|
|
103
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent);
|
|
104
|
+
background: color-mix(in srgb, currentColor 4%, transparent);
|
|
105
|
+
display: grid;
|
|
106
|
+
place-items: center;
|
|
108
107
|
overflow: hidden;
|
|
109
|
-
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 16%, transparent);
|
|
110
|
-
}
|
|
111
|
-
/* signature: registration-mark corners, like a photo mount */
|
|
112
|
-
.thumb-wrap::before,
|
|
113
|
-
.thumb-wrap::after {
|
|
114
|
-
content: '';
|
|
115
|
-
position: absolute;
|
|
116
|
-
width: 9px;
|
|
117
|
-
height: 9px;
|
|
118
|
-
pointer-events: none;
|
|
119
|
-
opacity: 0;
|
|
120
|
-
transition: opacity 0.15s ease;
|
|
121
|
-
z-index: 2;
|
|
122
|
-
}
|
|
123
|
-
.thumb-wrap::before {
|
|
124
|
-
top: 4px;
|
|
125
|
-
left: 4px;
|
|
126
|
-
border-top: 2px solid var(--frame-accent);
|
|
127
|
-
border-left: 2px solid var(--frame-accent);
|
|
128
|
-
}
|
|
129
|
-
.thumb-wrap::after {
|
|
130
|
-
bottom: 4px;
|
|
131
|
-
right: 4px;
|
|
132
|
-
border-bottom: 2px solid var(--frame-accent);
|
|
133
|
-
border-right: 2px solid var(--frame-accent);
|
|
134
|
-
}
|
|
135
|
-
li:hover .thumb-wrap::before,
|
|
136
|
-
li:hover .thumb-wrap::after,
|
|
137
|
-
li:focus-visible .thumb-wrap::before,
|
|
138
|
-
li:focus-visible .thumb-wrap::after {
|
|
139
|
-
opacity: 1;
|
|
140
108
|
}
|
|
141
109
|
|
|
142
110
|
.thumb {
|
|
@@ -145,7 +113,8 @@ export class SnapshotNavList extends LitElement {
|
|
|
145
113
|
display: block;
|
|
146
114
|
}
|
|
147
115
|
img.thumb {
|
|
148
|
-
|
|
116
|
+
/* contain, not cover — the whole preview stays readable, nothing cropped */
|
|
117
|
+
object-fit: contain;
|
|
149
118
|
object-position: center;
|
|
150
119
|
background: transparent;
|
|
151
120
|
}
|
|
@@ -198,41 +167,27 @@ export class SnapshotNavList extends LitElement {
|
|
|
198
167
|
}
|
|
199
168
|
}
|
|
200
169
|
|
|
201
|
-
/* independent from .meta's tint — image-overlay-opacity defaults to 0 so the image stays clear (blur only) */
|
|
202
|
-
.image-overlay {
|
|
203
|
-
position: absolute;
|
|
204
|
-
inset: 0;
|
|
205
|
-
background: var(--overlay-bg, transparent);
|
|
206
|
-
backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
207
|
-
-webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
208
|
-
pointer-events: none;
|
|
209
|
-
}
|
|
210
|
-
/* the overlay exists so a tile's overlaid title stays legible — list
|
|
211
|
-
variant shows the label beside the thumb, not on top of it, so the
|
|
212
|
-
tint has nothing to do there. */
|
|
213
|
-
:host(:not([variant='tile'])) .image-overlay {
|
|
214
|
-
display: none;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
170
|
.meta {
|
|
218
171
|
display: flex;
|
|
219
172
|
flex-direction: column;
|
|
220
|
-
gap: 0.
|
|
173
|
+
gap: 0.25rem;
|
|
221
174
|
min-width: 0;
|
|
175
|
+
padding: 0.75rem 0.5rem 0.5rem;
|
|
222
176
|
}
|
|
223
177
|
.label {
|
|
224
178
|
overflow: hidden;
|
|
225
179
|
text-overflow: ellipsis;
|
|
226
|
-
white-space:
|
|
180
|
+
white-space: normal;
|
|
227
181
|
color: inherit;
|
|
228
|
-
font-weight:
|
|
182
|
+
font-weight: 600;
|
|
183
|
+
font-size: 1rem;
|
|
229
184
|
}
|
|
230
185
|
.description {
|
|
231
186
|
overflow: hidden;
|
|
232
187
|
text-overflow: ellipsis;
|
|
233
|
-
white-space:
|
|
188
|
+
white-space: normal;
|
|
234
189
|
color: color-mix(in srgb, currentColor 60%, transparent);
|
|
235
|
-
font-size: 0.
|
|
190
|
+
font-size: 0.8125rem;
|
|
236
191
|
}
|
|
237
192
|
|
|
238
193
|
.edit-button {
|
|
@@ -275,18 +230,15 @@ export class SnapshotNavList extends LitElement {
|
|
|
275
230
|
fill: currentColor;
|
|
276
231
|
}
|
|
277
232
|
|
|
278
|
-
/* Transparent by default (display: contents) so
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
button moves in beside the title. */
|
|
233
|
+
/* Transparent by default (display: contents) so .label/.meta keep applying
|
|
234
|
+
unchanged; it only becomes a real row when the edit button moves in
|
|
235
|
+
beside the title. */
|
|
282
236
|
.label-row {
|
|
283
237
|
display: contents;
|
|
284
238
|
}
|
|
285
239
|
/* edit-button-position="meta": button on the title's line, description
|
|
286
|
-
still on its own line underneath.
|
|
287
|
-
|
|
288
|
-
already the right place there. */
|
|
289
|
-
:host([edit-button-position='meta']:not([variant='icon-only'])) .label-row {
|
|
240
|
+
still on its own line underneath. */
|
|
241
|
+
:host([edit-button-position='meta']) .label-row {
|
|
290
242
|
display: flex;
|
|
291
243
|
align-items: center;
|
|
292
244
|
gap: 0.5rem;
|
|
@@ -295,11 +247,11 @@ export class SnapshotNavList extends LitElement {
|
|
|
295
247
|
/* the title takes the whole row so the button lands on the card's right
|
|
296
248
|
edge, still on the title's own line (the description sits below it);
|
|
297
249
|
min-width: 0 keeps a long title ellipsising instead of pushing out. */
|
|
298
|
-
:host([edit-button-position='meta']
|
|
250
|
+
:host([edit-button-position='meta']) .label {
|
|
299
251
|
flex: 1;
|
|
300
252
|
min-width: 0;
|
|
301
253
|
}
|
|
302
|
-
:host([edit-button-position='meta']
|
|
254
|
+
:host([edit-button-position='meta']) .edit-button {
|
|
303
255
|
position: static;
|
|
304
256
|
flex-shrink: 0;
|
|
305
257
|
/* in-flow, over the host's own background: currentColor-derived instead
|
|
@@ -309,132 +261,9 @@ export class SnapshotNavList extends LitElement {
|
|
|
309
261
|
color: inherit;
|
|
310
262
|
opacity: 1;
|
|
311
263
|
}
|
|
312
|
-
:host([edit-button-position='meta']
|
|
264
|
+
:host([edit-button-position='meta']) .edit-button:hover {
|
|
313
265
|
background: color-mix(in srgb, currentColor 20%, transparent);
|
|
314
266
|
}
|
|
315
|
-
|
|
316
|
-
/* list: a compact thumb reads better in a narrow sidebar than the grid's 160x100 */
|
|
317
|
-
:host([variant='list']) .thumb-wrap {
|
|
318
|
-
width: 108px;
|
|
319
|
-
height: 68px;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/* tile: contact-sheet grid, caption strip pinned to the bottom of each frame */
|
|
323
|
-
:host([variant='tile']) ul {
|
|
324
|
-
flex-direction: row;
|
|
325
|
-
flex-wrap: wrap;
|
|
326
|
-
gap: 0.6rem;
|
|
327
|
-
}
|
|
328
|
-
:host([variant='tile']) li {
|
|
329
|
-
position: relative;
|
|
330
|
-
width: var(--snapshot-nav-list-tile-width, 160px);
|
|
331
|
-
height: var(--snapshot-nav-list-tile-height, 100px);
|
|
332
|
-
padding: 0;
|
|
333
|
-
overflow: hidden;
|
|
334
|
-
}
|
|
335
|
-
:host([variant='tile']) .thumb-wrap {
|
|
336
|
-
width: 100%;
|
|
337
|
-
height: 100%;
|
|
338
|
-
border-radius: var(--snapshot-nav-list-radius, 10px);
|
|
339
|
-
}
|
|
340
|
-
:host([variant='tile']) .meta {
|
|
341
|
-
position: absolute;
|
|
342
|
-
inset: auto 0 0 0;
|
|
343
|
-
margin: var(--snapshot-nav-list-overlay-margin, 0);
|
|
344
|
-
border-radius: var(--snapshot-nav-list-overlay-radius, 0);
|
|
345
|
-
align-items: flex-start;
|
|
346
|
-
gap: 0.15rem;
|
|
347
|
-
padding: 0.4rem 0.5rem;
|
|
348
|
-
color: var(--overlay-text, #fff);
|
|
349
|
-
background: var(--overlay-bg, transparent);
|
|
350
|
-
backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
351
|
-
-webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
352
|
-
}
|
|
353
|
-
:host([variant='tile']) .label {
|
|
354
|
-
white-space: normal;
|
|
355
|
-
}
|
|
356
|
-
:host([variant='tile']) .description {
|
|
357
|
-
color: color-mix(in srgb, var(--overlay-text, #fff) 75%, transparent);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/* label-position="center": title big and centered */
|
|
361
|
-
:host([variant='tile'][label-position='center']) .meta {
|
|
362
|
-
inset: 0;
|
|
363
|
-
align-items: center;
|
|
364
|
-
justify-content: center;
|
|
365
|
-
padding: 0.6rem;
|
|
366
|
-
}
|
|
367
|
-
:host([variant='tile'][label-position='center']) .label {
|
|
368
|
-
font-size: 1.15rem;
|
|
369
|
-
font-weight: 600;
|
|
370
|
-
text-align: center;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/* card: a contained (never cropped) preview above real body text below it —
|
|
374
|
-
text never sits on top of the image, so unlike a tile it needs no
|
|
375
|
-
overlay tint to stay legible. Modeled on a typical "preview card"
|
|
376
|
-
pattern: framed shot, title + description underneath, shadow on hover. */
|
|
377
|
-
:host([variant='card']) ul {
|
|
378
|
-
display: grid;
|
|
379
|
-
grid-template-columns: repeat(auto-fill, minmax(var(--snapshot-nav-list-card-min-width, 220px), 1fr));
|
|
380
|
-
gap: var(--snapshot-nav-list-card-gap, 1.25rem);
|
|
381
|
-
}
|
|
382
|
-
:host([variant='card']) li {
|
|
383
|
-
flex-direction: column;
|
|
384
|
-
align-items: stretch;
|
|
385
|
-
gap: 0;
|
|
386
|
-
padding: var(--snapshot-nav-list-card-padding, 0.5rem);
|
|
387
|
-
background: var(--snapshot-nav-list-card-bg, transparent);
|
|
388
|
-
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
|
389
|
-
box-shadow: none;
|
|
390
|
-
transition:
|
|
391
|
-
box-shadow var(--snapshot-nav-list-card-transition-dur, 0.15s) ease,
|
|
392
|
-
border-color var(--snapshot-nav-list-card-transition-dur, 0.15s) ease;
|
|
393
|
-
}
|
|
394
|
-
:host([variant='card']) li:hover,
|
|
395
|
-
:host([variant='card']) li:focus-visible {
|
|
396
|
-
background: var(--snapshot-nav-list-card-bg, transparent);
|
|
397
|
-
box-shadow: var(--snapshot-nav-list-card-shadow, 0 2px 8px color-mix(in srgb, currentColor 18%, transparent));
|
|
398
|
-
border-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
399
|
-
}
|
|
400
|
-
:host([variant='card']) li:focus-visible {
|
|
401
|
-
outline: none;
|
|
402
|
-
box-shadow: 0 0 0 3px color-mix(in srgb, var(--frame-accent) 55%, transparent);
|
|
403
|
-
}
|
|
404
|
-
:host([variant='card']) li:focus-visible .thumb-wrap {
|
|
405
|
-
outline: none;
|
|
406
|
-
}
|
|
407
|
-
:host([variant='card']) .thumb-wrap {
|
|
408
|
-
width: 100%;
|
|
409
|
-
height: auto;
|
|
410
|
-
aspect-ratio: 2 / 1;
|
|
411
|
-
border-radius: var(--snapshot-nav-list-radius-sm, 7px);
|
|
412
|
-
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent);
|
|
413
|
-
background: color-mix(in srgb, currentColor 4%, transparent);
|
|
414
|
-
display: grid;
|
|
415
|
-
place-items: center;
|
|
416
|
-
}
|
|
417
|
-
:host([variant='card']) .thumb-wrap::before,
|
|
418
|
-
:host([variant='card']) .thumb-wrap::after {
|
|
419
|
-
display: none;
|
|
420
|
-
}
|
|
421
|
-
:host([variant='card']) img.thumb {
|
|
422
|
-
/* contain, not cover — the whole dashboard stays readable, nothing cropped */
|
|
423
|
-
object-fit: contain;
|
|
424
|
-
}
|
|
425
|
-
:host([variant='card']) .meta {
|
|
426
|
-
padding: 0.75rem 0.5rem 0.5rem;
|
|
427
|
-
gap: 0.25rem;
|
|
428
|
-
}
|
|
429
|
-
:host([variant='card']) .label {
|
|
430
|
-
white-space: normal;
|
|
431
|
-
font-size: 1rem;
|
|
432
|
-
font-weight: 600;
|
|
433
|
-
}
|
|
434
|
-
:host([variant='card']) .description {
|
|
435
|
-
white-space: normal;
|
|
436
|
-
font-size: 0.8125rem;
|
|
437
|
-
}
|
|
438
267
|
`; }
|
|
439
268
|
fetchKey(variant, id) {
|
|
440
269
|
return `${variant ?? ''}\0${id}`;
|
|
@@ -479,11 +308,6 @@ export class SnapshotNavList extends LitElement {
|
|
|
479
308
|
// update instead of triggering Lit's "update scheduled from updated()"
|
|
480
309
|
// warning that came from doing this same flip inside updated().
|
|
481
310
|
willUpdate(changed) {
|
|
482
|
-
// 'icon-only' is the pre-0.3 name for 'tile'. Normalising here (rather
|
|
483
|
-
// than in a setter) keeps the reflected attribute — and therefore every
|
|
484
|
-
// CSS selector — on the one canonical value.
|
|
485
|
-
if (this.variant === 'icon-only')
|
|
486
|
-
this.variant = 'tile';
|
|
487
311
|
// A variant-key switch (e.g. light -> dark) invalidates every thumbnail:
|
|
488
312
|
// they're separate snapshots under separate keys.
|
|
489
313
|
if (changed.has('variantKey'))
|
|
@@ -544,27 +368,6 @@ export class SnapshotNavList extends LitElement {
|
|
|
544
368
|
this.requestUpdate();
|
|
545
369
|
}
|
|
546
370
|
}
|
|
547
|
-
/** image scrim: blur + its own (usually 0) tint strength — independent of the caption's. */
|
|
548
|
-
get imageOverlayStyle() {
|
|
549
|
-
const blur = `${this.overlayBlur}px`;
|
|
550
|
-
if (this.overlayTint === 'none' || this.imageOverlayOpacity === 0) {
|
|
551
|
-
return { '--overlay-blur': blur };
|
|
552
|
-
}
|
|
553
|
-
const tintColor = this.overlayTint === 'light' ? '#fff' : '#000';
|
|
554
|
-
const bg = `color-mix(in srgb, ${tintColor} ${Math.round(this.imageOverlayOpacity * 100)}%, transparent)`;
|
|
555
|
-
return { '--overlay-bg': bg, '--overlay-blur': blur };
|
|
556
|
-
}
|
|
557
|
-
/** caption background: tint (to pop the text) + the same blur. */
|
|
558
|
-
get metaStyle() {
|
|
559
|
-
const blur = `${this.overlayBlur}px`;
|
|
560
|
-
if (this.overlayTint === 'none') {
|
|
561
|
-
return { '--overlay-bg': 'transparent', '--overlay-text': 'inherit', '--overlay-blur': blur };
|
|
562
|
-
}
|
|
563
|
-
const tintColor = this.overlayTint === 'light' ? '#fff' : '#000';
|
|
564
|
-
const bg = `color-mix(in srgb, ${tintColor} ${Math.round(this.textOverlayOpacity * 100)}%, transparent)`;
|
|
565
|
-
const text = this.overlayTint === 'light' ? '#111' : '#fff';
|
|
566
|
-
return { '--overlay-bg': bg, '--overlay-text': text, '--overlay-blur': blur };
|
|
567
|
-
}
|
|
568
371
|
// Both events carry the whole item — including `data` — so a handler never
|
|
569
372
|
// has to look the item back up by id.
|
|
570
373
|
select(item) {
|
|
@@ -590,11 +393,7 @@ export class SnapshotNavList extends LitElement {
|
|
|
590
393
|
</button>`;
|
|
591
394
|
}
|
|
592
395
|
render() {
|
|
593
|
-
const
|
|
594
|
-
const metaStyle = this.metaStyle;
|
|
595
|
-
// icon-only's caption is itself an overlay strip on the image, so there's
|
|
596
|
-
// no in-flow text row to put the button in — fall back to the overlay.
|
|
597
|
-
const editInMeta = this.editButtonPosition === 'meta' && this.variant !== 'icon-only';
|
|
396
|
+
const editInMeta = this.editButtonPosition === 'meta';
|
|
598
397
|
return html `
|
|
599
398
|
<ul role="listbox">
|
|
600
399
|
${this.items.map((item) => html `
|
|
@@ -617,10 +416,9 @@ export class SnapshotNavList extends LitElement {
|
|
|
617
416
|
>${item.icon ? (isMarkupIcon(item.icon) ? unsafeHTML(item.icon) : item.icon) : ''}</span
|
|
618
417
|
>
|
|
619
418
|
</div>`}
|
|
620
|
-
<div class="image-overlay" part="overlay" style=${styleMap(imageOverlayStyle)}></div>
|
|
621
419
|
${this.isEditable(item) && !editInMeta ? this.renderEditButton(item) : ''}
|
|
622
420
|
</div>
|
|
623
|
-
<div class="meta" part="meta"
|
|
421
|
+
<div class="meta" part="meta">
|
|
624
422
|
<div class="label-row" part="label-row">
|
|
625
423
|
<span class="label" part="label">${item.label}</span>
|
|
626
424
|
${this.isEditable(item) && editInMeta ? this.renderEditButton(item) : ''}
|
|
@@ -636,30 +434,12 @@ export class SnapshotNavList extends LitElement {
|
|
|
636
434
|
__decorate([
|
|
637
435
|
property({ type: Array })
|
|
638
436
|
], SnapshotNavList.prototype, "items", void 0);
|
|
639
|
-
__decorate([
|
|
640
|
-
property({ reflect: true })
|
|
641
|
-
], SnapshotNavList.prototype, "variant", void 0);
|
|
642
437
|
__decorate([
|
|
643
438
|
property({ attribute: 'variant-key' })
|
|
644
439
|
], SnapshotNavList.prototype, "variantKey", void 0);
|
|
645
440
|
__decorate([
|
|
646
441
|
property({ type: Boolean, reflect: true })
|
|
647
442
|
], SnapshotNavList.prototype, "scrollable", void 0);
|
|
648
|
-
__decorate([
|
|
649
|
-
property({ attribute: 'overlay-tint' })
|
|
650
|
-
], SnapshotNavList.prototype, "overlayTint", void 0);
|
|
651
|
-
__decorate([
|
|
652
|
-
property({ type: Number, attribute: 'text-overlay-opacity' })
|
|
653
|
-
], SnapshotNavList.prototype, "textOverlayOpacity", void 0);
|
|
654
|
-
__decorate([
|
|
655
|
-
property({ type: Number, attribute: 'image-overlay-opacity' })
|
|
656
|
-
], SnapshotNavList.prototype, "imageOverlayOpacity", void 0);
|
|
657
|
-
__decorate([
|
|
658
|
-
property({ type: Number, attribute: 'overlay-blur' })
|
|
659
|
-
], SnapshotNavList.prototype, "overlayBlur", void 0);
|
|
660
|
-
__decorate([
|
|
661
|
-
property({ reflect: true, attribute: 'label-position' })
|
|
662
|
-
], SnapshotNavList.prototype, "labelPosition", void 0);
|
|
663
443
|
__decorate([
|
|
664
444
|
property({ attribute: false })
|
|
665
445
|
], SnapshotNavList.prototype, "snapshotService", void 0);
|
|
@@ -35,7 +35,77 @@ 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
|
+
onclone?: (document: Document, element: HTMLElement) => void | Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Target output size in CSS px. Required to use `fit`; ignored otherwise.
|
|
47
|
+
*/
|
|
48
|
+
width?: number;
|
|
49
|
+
height?: number;
|
|
50
|
+
/**
|
|
51
|
+
* How to fit `el`'s content into `width`×`height`:
|
|
52
|
+
* - `'contain'` — scale to fit entirely inside the box, letterboxed (filled
|
|
53
|
+
* with `background`) if the aspect ratio doesn't match.
|
|
54
|
+
* - `'cover'` — scale to fill the box, cropping the overflow, centered.
|
|
55
|
+
* Upscales content smaller than the target — this is a thumbnail, not a
|
|
56
|
+
* lossless copy.
|
|
57
|
+
*
|
|
58
|
+
* Implemented by cloning `el` off-screen into a `width`×`height` frame and
|
|
59
|
+
* capturing that instead, so it works the same for any caller — no
|
|
60
|
+
* framework-specific lifetime handling. `contentCrop` defaults to `false`
|
|
61
|
+
* whenever `fit` is set, since the frame is already the exact requested
|
|
62
|
+
* size.
|
|
63
|
+
*/
|
|
64
|
+
fit?: 'contain' | 'cover';
|
|
65
|
+
/** Fill color behind letterboxing or undersized content. Defaults to html2canvas's own default (white). */
|
|
66
|
+
background?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Every capture crops to the bounding box of `el`'s visible children (see
|
|
69
|
+
* `CONTENT_PADDING`), so a container much bigger than its content doesn't
|
|
70
|
+
* capture as mostly empty space. Pass `false` to capture `el` at its own
|
|
71
|
+
* full size instead — needed for exact, pre-sized output (`fit` does this
|
|
72
|
+
* automatically).
|
|
73
|
+
*/
|
|
74
|
+
contentCrop?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Rewrites resolved `oklch()`/`oklab()` colors (anywhere in the document,
|
|
77
|
+
* not just `el`) to `hsl()` for the duration of the capture, then restores
|
|
78
|
+
* them. html2canvas can't parse either function, and `getComputedStyle`
|
|
79
|
+
* resolves a growing share of ordinary CSS to one of them regardless of
|
|
80
|
+
* how the color was authored — Tailwind v4's default palette included.
|
|
81
|
+
* Off by default: it's a full-document style walk plus an on-demand import
|
|
82
|
+
* of `colorjs.io`, so only pay for it on a page that actually hits this.
|
|
83
|
+
*/
|
|
84
|
+
neutralizeColors?: boolean;
|
|
38
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The padding (in CSS px) applied around the bounding box of `el`'s visible
|
|
88
|
+
* children when the default content-crop runs — see `CaptureOptions.contentCrop`.
|
|
89
|
+
*/
|
|
90
|
+
export declare const CONTENT_PADDING = 16;
|
|
91
|
+
/**
|
|
92
|
+
* Scale + centered-crop-offset for fitting a `natural` box into a `target`
|
|
93
|
+
* box. `'cover'` uses `Math.max` (fills the target, overflow gets cropped,
|
|
94
|
+
* offsets can go negative to center that overflow); `'contain'` uses
|
|
95
|
+
* `Math.min` (fits entirely inside, offsets are never negative — the
|
|
96
|
+
* shortfall is left for the caller to fill as letterboxing).
|
|
97
|
+
*/
|
|
98
|
+
export declare function computeFit(natural: {
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
}, target: {
|
|
102
|
+
width: number;
|
|
103
|
+
height: number;
|
|
104
|
+
}, fit: 'contain' | 'cover'): {
|
|
105
|
+
scale: number;
|
|
106
|
+
offsetX: number;
|
|
107
|
+
offsetY: number;
|
|
108
|
+
};
|
|
39
109
|
export declare class SnapshotService {
|
|
40
110
|
private storage;
|
|
41
111
|
private scale;
|
package/dist/snapshot-service.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { encodeSnapshot } from './encode';
|
|
2
2
|
import { SnapshotDetachedElementError, SnapshotRenderError, SnapshotTaintedCanvasError } from './errors';
|
|
3
|
+
import { neutralizeOklchColors } from './neutralize-oklch';
|
|
3
4
|
import { IndexedDbSnapshotStorage } from './snapshot-storage';
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The padding (in CSS px) applied around the bounding box of `el`'s visible
|
|
7
|
+
* children when the default content-crop runs — see `CaptureOptions.contentCrop`.
|
|
8
|
+
*/
|
|
9
|
+
export const CONTENT_PADDING = 16;
|
|
5
10
|
const VARIANT_SEPARATOR = '@';
|
|
6
11
|
// Tracks keyPrefixes already claimed by a live SnapshotService instance, so
|
|
7
12
|
// two instances that both forget to set one (or pick the same one) get a
|
|
@@ -45,6 +50,57 @@ function getContentBounds(el) {
|
|
|
45
50
|
return full;
|
|
46
51
|
return { x, y, width, height };
|
|
47
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Scale + centered-crop-offset for fitting a `natural` box into a `target`
|
|
55
|
+
* box. `'cover'` uses `Math.max` (fills the target, overflow gets cropped,
|
|
56
|
+
* offsets can go negative to center that overflow); `'contain'` uses
|
|
57
|
+
* `Math.min` (fits entirely inside, offsets are never negative — the
|
|
58
|
+
* shortfall is left for the caller to fill as letterboxing).
|
|
59
|
+
*/
|
|
60
|
+
export function computeFit(natural, target, fit) {
|
|
61
|
+
const scale = fit === 'cover'
|
|
62
|
+
? Math.max(target.width / natural.width, target.height / natural.height)
|
|
63
|
+
: Math.min(target.width / natural.width, target.height / natural.height);
|
|
64
|
+
const scaledWidth = natural.width * scale;
|
|
65
|
+
const scaledHeight = natural.height * scale;
|
|
66
|
+
return {
|
|
67
|
+
scale,
|
|
68
|
+
offsetX: (scaledWidth - target.width) / 2,
|
|
69
|
+
offsetY: (scaledHeight - target.height) / 2,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Clones `el` into an off-screen `width`×`height` frame, scaled per `fit`
|
|
74
|
+
* and centered. Returns the frame (to capture instead of `el`) and a cleanup
|
|
75
|
+
* that removes it from the document — always call it, capture or not.
|
|
76
|
+
*/
|
|
77
|
+
function buildFitFrame(el, width, height, fit, background) {
|
|
78
|
+
const naturalWidth = el.scrollWidth;
|
|
79
|
+
const naturalHeight = el.scrollHeight;
|
|
80
|
+
const frame = document.createElement('div');
|
|
81
|
+
frame.style.position = 'fixed';
|
|
82
|
+
frame.style.top = '0';
|
|
83
|
+
frame.style.left = '-99999px';
|
|
84
|
+
frame.style.width = `${width}px`;
|
|
85
|
+
frame.style.height = `${height}px`;
|
|
86
|
+
frame.style.overflow = 'hidden';
|
|
87
|
+
if (background)
|
|
88
|
+
frame.style.background = background;
|
|
89
|
+
if (naturalWidth > 0 && naturalHeight > 0) {
|
|
90
|
+
const { scale, offsetX, offsetY } = computeFit({ width: naturalWidth, height: naturalHeight }, { width, height }, fit);
|
|
91
|
+
const clone = el.cloneNode(true);
|
|
92
|
+
clone.style.position = 'absolute';
|
|
93
|
+
clone.style.top = `${-offsetY}px`;
|
|
94
|
+
clone.style.left = `${-offsetX}px`;
|
|
95
|
+
clone.style.transformOrigin = 'top left';
|
|
96
|
+
clone.style.transform = `scale(${scale})`;
|
|
97
|
+
clone.style.width = `${naturalWidth}px`;
|
|
98
|
+
clone.style.height = `${naturalHeight}px`;
|
|
99
|
+
frame.appendChild(clone);
|
|
100
|
+
}
|
|
101
|
+
document.body.appendChild(frame);
|
|
102
|
+
return { frame, cleanup: () => frame.remove() };
|
|
103
|
+
}
|
|
48
104
|
export class SnapshotService {
|
|
49
105
|
constructor(config = {}) {
|
|
50
106
|
this.listeners = new Set();
|
|
@@ -130,14 +186,30 @@ export class SnapshotService {
|
|
|
130
186
|
// storing a blank thumbnail over a good one.
|
|
131
187
|
if (!el.isConnected)
|
|
132
188
|
throw new SnapshotDetachedElementError(key.id);
|
|
133
|
-
const
|
|
189
|
+
const useFit = opts.width !== undefined && opts.height !== undefined && opts.fit !== undefined;
|
|
190
|
+
const fitFrame = useFit
|
|
191
|
+
? buildFitFrame(el, opts.width, opts.height, opts.fit, opts.background)
|
|
192
|
+
: undefined;
|
|
193
|
+
const target = fitFrame?.frame ?? el;
|
|
194
|
+
// `fit` already produced an exactly-sized frame — the default content-crop
|
|
195
|
+
// would only fight that (see CaptureOptions.contentCrop), so it's off by
|
|
196
|
+
// default here unless the caller explicitly asks for it back.
|
|
197
|
+
const contentCrop = opts.contentCrop ?? !useFit;
|
|
198
|
+
const crop = contentCrop
|
|
199
|
+
? getContentBounds(target)
|
|
200
|
+
: useFit
|
|
201
|
+
// Known exactly — no need to round-trip through layout (`clientWidth`)
|
|
202
|
+
// for the one size `buildFitFrame` was already asked to produce.
|
|
203
|
+
? { x: 0, y: 0, width: opts.width, height: opts.height }
|
|
204
|
+
: { x: 0, y: 0, width: target.clientWidth, height: target.clientHeight };
|
|
205
|
+
const restoreColors = opts.neutralizeColors ? await neutralizeOklchColors(document.documentElement) : undefined;
|
|
134
206
|
// Imported on demand so `import '@anton-gustafsson/snapshot-core'` doesn't
|
|
135
207
|
// pull a DOM-only dependency into a Node/SSR/Jest process that only wants
|
|
136
208
|
// the types or a storage.
|
|
137
209
|
const { default: html2canvas } = await import('html2canvas');
|
|
138
210
|
let canvas;
|
|
139
211
|
try {
|
|
140
|
-
canvas = await html2canvas(
|
|
212
|
+
canvas = await html2canvas(target, {
|
|
141
213
|
scale: opts.scale ?? this.scale,
|
|
142
214
|
logging: false,
|
|
143
215
|
useCORS: true,
|
|
@@ -145,12 +217,17 @@ export class SnapshotService {
|
|
|
145
217
|
y: crop.y,
|
|
146
218
|
width: crop.width,
|
|
147
219
|
height: crop.height,
|
|
220
|
+
onclone: opts.onclone,
|
|
148
221
|
});
|
|
149
222
|
}
|
|
150
223
|
catch (err) {
|
|
151
224
|
// errors.ts documents every rejection from this library as a SnapshotError.
|
|
152
225
|
throw new SnapshotRenderError(key.id, err);
|
|
153
226
|
}
|
|
227
|
+
finally {
|
|
228
|
+
restoreColors?.();
|
|
229
|
+
fitFrame?.cleanup();
|
|
230
|
+
}
|
|
154
231
|
const raw = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new SnapshotTaintedCanvasError(key.id))), 'image/png'));
|
|
155
232
|
const encode = opts.encode ?? this.encode;
|
|
156
233
|
const blob = encode ? await encodeSnapshot(raw, encode) : raw;
|
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.3",
|
|
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",
|