@anton-gustafsson/snapshot-core 0.4.2 → 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/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/neutralize-oklch.d.ts +11 -7
- package/dist/neutralize-oklch.js +11 -7
- package/dist/snapshot-nav-list.d.ts +4 -23
- package/dist/snapshot-nav-list.js +49 -269
- package/dist/snapshot-service.d.ts +63 -5
- package/dist/snapshot-service.js +79 -3
- package/package.json +1 -1
- package/dist/wait-for-canvases-to-paint.d.ts +0 -20
- package/dist/wait-for-canvases-to-paint.js +0 -53
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
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
|
+
*
|
|
2
7
|
* html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
|
|
3
8
|
* `getComputedStyle` resolves a growing share of real-world CSS to —
|
|
4
9
|
* Tailwind v4's default palette among others — independent of how the color
|
|
@@ -9,13 +14,12 @@
|
|
|
9
14
|
* in that space), so anything animating a color at capture time needs the
|
|
10
15
|
* same treatment.
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* correct ancestor stacking/background),
|
|
15
|
-
* descendant
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* real inline styles on the live page, visibly if left in place.
|
|
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.
|
|
19
23
|
*
|
|
20
24
|
* Walks the subtree, rewrites every computed property whose value contains
|
|
21
25
|
* `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
|
package/dist/neutralize-oklch.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
const OKLCH_PATTERN = /okl(?:ch|ab)\([^)]*\)/gi;
|
|
2
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
|
+
*
|
|
3
8
|
* html2canvas can't parse the CSS `oklch()`/`oklab()` color functions that
|
|
4
9
|
* `getComputedStyle` resolves a growing share of real-world CSS to —
|
|
5
10
|
* Tailwind v4's default palette among others — independent of how the color
|
|
@@ -10,13 +15,12 @@ const OKLCH_PATTERN = /okl(?:ch|ab)\([^)]*\)/gi;
|
|
|
10
15
|
* in that space), so anything animating a color at capture time needs the
|
|
11
16
|
* same treatment.
|
|
12
17
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* correct ancestor stacking/background),
|
|
16
|
-
* descendant
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* real inline styles on the live page, visibly if left in place.
|
|
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.
|
|
20
24
|
*
|
|
21
25
|
* Walks the subtree, rewrites every computed property whose value contains
|
|
22
26
|
* `oklch(...)`/`oklab(...)` to an inline `hsl()` equivalent (custom
|
|
@@ -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);
|
|
@@ -40,14 +40,72 @@ export interface CaptureOptions extends VariantOptions {
|
|
|
40
40
|
* (and the clone of `el`) it's about to render, before it renders it. The
|
|
41
41
|
* escape hatch for anything that needs to touch the clone specifically. A
|
|
42
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
43
|
*/
|
|
49
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;
|
|
50
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
|
+
};
|
|
51
109
|
export declare class SnapshotService {
|
|
52
110
|
private storage;
|
|
53
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,
|
|
@@ -152,6 +224,10 @@ export class SnapshotService {
|
|
|
152
224
|
// errors.ts documents every rejection from this library as a SnapshotError.
|
|
153
225
|
throw new SnapshotRenderError(key.id, err);
|
|
154
226
|
}
|
|
227
|
+
finally {
|
|
228
|
+
restoreColors?.();
|
|
229
|
+
fitFrame?.cleanup();
|
|
230
|
+
}
|
|
155
231
|
const raw = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new SnapshotTaintedCanvasError(key.id))), 'image/png'));
|
|
156
232
|
const encode = opts.encode ?? this.encode;
|
|
157
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": {
|
|
@@ -1,20 +0,0 @@
|
|
|
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>;
|
|
@@ -1,53 +0,0 @@
|
|
|
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
|
-
}
|