@anton-gustafsson/snapshot-core 0.4.2 → 0.4.4
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 +17 -24
- package/dist/snapshot-nav-list.js +145 -286
- 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
|
|
@@ -4,8 +4,10 @@ export interface NavItem<T = unknown> {
|
|
|
4
4
|
/** The snapshot id — a plain domain id. Anything else the consumer needs belongs in `data`. */
|
|
5
5
|
id: string;
|
|
6
6
|
label: string;
|
|
7
|
-
/**
|
|
7
|
+
/** Markup — `<svg>`, `<img>`, or any element — rendered as raw HTML in the placeholder frame shown before a card's first capture. Must start with `<`: a bare glyph or emoji is ignored (it read as an icon but rendered as a stray character at whatever the frame's font happened to be); use `placeholderText` for words. */
|
|
8
8
|
icon?: string;
|
|
9
|
+
/** Per-item override of the component-level `placeholderText` — a caption in the frame shown before this card's first capture (e.g. "not visited yet"). Plain text only; pass `''` to show none where the component sets one. */
|
|
10
|
+
placeholderText?: string;
|
|
9
11
|
description?: string;
|
|
10
12
|
/** Arbitrary consumer payload — echoed back verbatim on `nav-select` / `nav-edit`, so no lookup-by-id is needed in the handler. */
|
|
11
13
|
data?: T;
|
|
@@ -14,21 +16,16 @@ export interface NavItem<T = unknown> {
|
|
|
14
16
|
/** @deprecated Put the route in `data` and read it off the emitted item. Kept for one release. */
|
|
15
17
|
route?: string;
|
|
16
18
|
}
|
|
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
19
|
/** `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
20
|
export type SnapshotNavListEditButtonPosition = 'overlay' | 'meta';
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `currentColor`, so an unstyled host still looks intentional.
|
|
22
|
+
* A grid of preview cards: a contained (never-cropped) screenshot above real
|
|
23
|
+
* title/description text below it — the text never sits on top of the
|
|
24
|
+
* image, so it needs no overlay tint to stay legible.
|
|
26
25
|
*/
|
|
27
26
|
export declare class SnapshotNavList extends LitElement {
|
|
28
27
|
static styles: import("lit").CSSResult;
|
|
29
28
|
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
29
|
/**
|
|
33
30
|
* Second dimension on every id — typically the active theme, so a light and a
|
|
34
31
|
* dark capture of the same view are stored (and read) separately. Passed
|
|
@@ -37,24 +34,22 @@ export declare class SnapshotNavList extends LitElement {
|
|
|
37
34
|
variantKey?: string;
|
|
38
35
|
/** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
|
|
39
36
|
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
37
|
/** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
|
|
51
38
|
snapshotService: SnapshotService;
|
|
52
39
|
/** 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
40
|
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.
|
|
41
|
+
/** 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
42
|
editButtonPosition: SnapshotNavListEditButtonPosition;
|
|
56
43
|
/** 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
44
|
editIcon: string;
|
|
45
|
+
/**
|
|
46
|
+
* Caption shown in the frame of a card with no capture yet (e.g. "no snapshot
|
|
47
|
+
* yet"), for every card at once; `NavItem.placeholderText` overrides it per
|
|
48
|
+
* row. Plain text, never markup — it's rendered as text, not HTML. Empty by
|
|
49
|
+
* default, which keeps the icon-and-hatch placeholder; set it and the frame
|
|
50
|
+
* goes see-through with a dashed edge instead.
|
|
51
|
+
*/
|
|
52
|
+
placeholderText: string;
|
|
58
53
|
private thumbs;
|
|
59
54
|
private loadingIds;
|
|
60
55
|
/**
|
|
@@ -80,14 +75,12 @@ export declare class SnapshotNavList extends LitElement {
|
|
|
80
75
|
* mid-flight doesn't re-request what's already coming.
|
|
81
76
|
*/
|
|
82
77
|
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
78
|
private select;
|
|
88
79
|
private edit;
|
|
89
80
|
private isEditable;
|
|
90
81
|
private renderEditButton;
|
|
82
|
+
/** The frame before a card's first capture: an icon, a caption, or both. */
|
|
83
|
+
private renderPlaceholder;
|
|
91
84
|
render(): import("lit-html").TemplateResult<1>;
|
|
92
85
|
}
|
|
93
86
|
/** Detail type of both `nav-select` and `nav-edit` — the clicked item itself. */
|
|
@@ -6,45 +6,55 @@ 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) {
|
|
13
12
|
return icon.trimStart().startsWith('<');
|
|
14
13
|
}
|
|
14
|
+
const warnedTextIcons = new Set();
|
|
15
|
+
/**
|
|
16
|
+
* `NavItem.icon` is markup-only, so a plain-text glyph renders as nothing —
|
|
17
|
+
* silently, without this, since the old behavior was to paint it as text.
|
|
18
|
+
* Warns once per distinct value: `items` re-renders on every thumbnail that
|
|
19
|
+
* lands, and a whole list of glyph icons would otherwise flood the console.
|
|
20
|
+
*/
|
|
21
|
+
function markupIconOrWarn(icon) {
|
|
22
|
+
if (isMarkupIcon(icon))
|
|
23
|
+
return icon;
|
|
24
|
+
if (!warnedTextIcons.has(icon)) {
|
|
25
|
+
warnedTextIcons.add(icon);
|
|
26
|
+
console.warn(`<snapshot-nav-list>: NavItem.icon takes markup (e.g. '<svg>...</svg>'), not text — ignoring ${JSON.stringify(icon)}. Use placeholderText for a caption.`);
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
15
30
|
const DEFAULT_EDIT_ICON = '✎';
|
|
16
31
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `currentColor`, so an unstyled host still looks intentional.
|
|
32
|
+
* A grid of preview cards: a contained (never-cropped) screenshot above real
|
|
33
|
+
* title/description text below it — the text never sits on top of the
|
|
34
|
+
* image, so it needs no overlay tint to stay legible.
|
|
21
35
|
*/
|
|
22
36
|
export class SnapshotNavList extends LitElement {
|
|
23
37
|
constructor() {
|
|
24
38
|
super(...arguments);
|
|
25
39
|
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
40
|
/** Lets the host itself scroll (see `--snapshot-nav-list-max-height`) instead of growing unbounded. */
|
|
29
41
|
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
42
|
/** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
|
|
41
43
|
this.snapshotService = defaultSnapshotService;
|
|
42
44
|
/** 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
45
|
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.
|
|
46
|
+
/** 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
47
|
this.editButtonPosition = 'overlay';
|
|
46
48
|
/** 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
49
|
this.editIcon = DEFAULT_EDIT_ICON;
|
|
50
|
+
/**
|
|
51
|
+
* Caption shown in the frame of a card with no capture yet (e.g. "no snapshot
|
|
52
|
+
* yet"), for every card at once; `NavItem.placeholderText` overrides it per
|
|
53
|
+
* row. Plain text, never markup — it's rendered as text, not HTML. Empty by
|
|
54
|
+
* default, which keeps the icon-and-hatch placeholder; set it and the frame
|
|
55
|
+
* goes see-through with a dashed edge instead.
|
|
56
|
+
*/
|
|
57
|
+
this.placeholderText = '';
|
|
48
58
|
this.thumbs = new Map();
|
|
49
59
|
this.loadingIds = new Set();
|
|
50
60
|
/**
|
|
@@ -77,66 +87,48 @@ export class SnapshotNavList extends LitElement {
|
|
|
77
87
|
list-style: none;
|
|
78
88
|
margin: 0;
|
|
79
89
|
padding: 0;
|
|
80
|
-
display:
|
|
81
|
-
|
|
82
|
-
gap: var(--snapshot-nav-list-gap,
|
|
90
|
+
display: grid;
|
|
91
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--snapshot-nav-list-card-min-width, 220px), 1fr));
|
|
92
|
+
gap: var(--snapshot-nav-list-card-gap, 1.25rem);
|
|
83
93
|
}
|
|
84
94
|
li {
|
|
85
95
|
display: flex;
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
flex-direction: column;
|
|
97
|
+
align-items: stretch;
|
|
98
|
+
gap: 0;
|
|
88
99
|
cursor: pointer;
|
|
89
|
-
padding:
|
|
100
|
+
padding: var(--snapshot-nav-list-card-padding, 0.5rem);
|
|
90
101
|
border-radius: var(--snapshot-nav-list-radius, 10px);
|
|
102
|
+
background: var(--snapshot-nav-list-card-bg, transparent);
|
|
103
|
+
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
|
104
|
+
box-shadow: none;
|
|
105
|
+
transition:
|
|
106
|
+
box-shadow var(--snapshot-nav-list-card-transition-dur, 0.15s) ease,
|
|
107
|
+
border-color var(--snapshot-nav-list-card-transition-dur, 0.15s) ease;
|
|
91
108
|
}
|
|
92
109
|
li:hover,
|
|
93
110
|
li:focus-visible {
|
|
94
|
-
background:
|
|
111
|
+
background: var(--snapshot-nav-list-card-bg, transparent);
|
|
112
|
+
box-shadow: var(--snapshot-nav-list-card-shadow, 0 2px 8px color-mix(in srgb, currentColor 18%, transparent));
|
|
113
|
+
border-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
95
114
|
outline: none;
|
|
96
115
|
}
|
|
97
|
-
li:focus-visible
|
|
98
|
-
outline:
|
|
99
|
-
|
|
116
|
+
li:focus-visible {
|
|
117
|
+
outline: none;
|
|
118
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--frame-accent) 55%, transparent);
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
.thumb-wrap {
|
|
103
122
|
position: relative;
|
|
104
|
-
width:
|
|
105
|
-
height:
|
|
123
|
+
width: 100%;
|
|
124
|
+
height: auto;
|
|
125
|
+
aspect-ratio: 2 / 1;
|
|
106
126
|
border-radius: var(--snapshot-nav-list-radius-sm, 7px);
|
|
107
|
-
|
|
127
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent);
|
|
128
|
+
background: color-mix(in srgb, currentColor 4%, transparent);
|
|
129
|
+
display: grid;
|
|
130
|
+
place-items: center;
|
|
108
131
|
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
132
|
}
|
|
141
133
|
|
|
142
134
|
.thumb {
|
|
@@ -145,34 +137,82 @@ export class SnapshotNavList extends LitElement {
|
|
|
145
137
|
display: block;
|
|
146
138
|
}
|
|
147
139
|
img.thumb {
|
|
148
|
-
|
|
140
|
+
/* contain, not cover — the whole preview stays readable, nothing cropped */
|
|
141
|
+
object-fit: contain;
|
|
149
142
|
object-position: center;
|
|
150
143
|
background: transparent;
|
|
151
144
|
}
|
|
152
145
|
/* unexposed frame: fine diagonal hatch instead of a generic gradient blob */
|
|
153
146
|
.thumb-placeholder {
|
|
147
|
+
box-sizing: border-box;
|
|
154
148
|
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
155
150
|
align-items: center;
|
|
156
151
|
justify-content: center;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
152
|
+
gap: 0.4rem;
|
|
153
|
+
padding: 0.45rem 0;
|
|
154
|
+
background-color: var(--snapshot-nav-list-placeholder-bg, color-mix(in srgb, currentColor 7%, transparent));
|
|
155
|
+
/* var-driven so a plain 'none' turns the hatch off on its own — a card
|
|
156
|
+
showing real fallback art wants the frame plain behind it. */
|
|
157
|
+
background-image: var(
|
|
158
|
+
--snapshot-nav-list-placeholder-hatch,
|
|
159
|
+
repeating-linear-gradient(
|
|
160
|
+
135deg,
|
|
161
|
+
color-mix(in srgb, currentColor 16%, transparent) 0px,
|
|
162
|
+
color-mix(in srgb, currentColor 16%, transparent) 1.5px,
|
|
163
|
+
transparent 1.5px,
|
|
164
|
+
transparent 7px
|
|
165
|
+
)
|
|
164
166
|
);
|
|
165
167
|
}
|
|
168
|
+
/* A caption carries the "nothing here yet" meaning on its own, so the hatch
|
|
169
|
+
(and the tint under it) would only fight it — the frame goes see-through,
|
|
170
|
+
marked out by a dashed edge instead. */
|
|
171
|
+
.thumb-placeholder.has-text {
|
|
172
|
+
background-color: var(--snapshot-nav-list-placeholder-bg, transparent);
|
|
173
|
+
background-image: none;
|
|
174
|
+
border: 1px dashed var(--snapshot-nav-list-placeholder-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
175
|
+
border-radius: inherit;
|
|
176
|
+
}
|
|
177
|
+
.placeholder-text {
|
|
178
|
+
font-family: var(--snapshot-nav-list-placeholder-font, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
179
|
+
font-size: var(--snapshot-nav-list-placeholder-font-size, 0.75rem);
|
|
180
|
+
letter-spacing: var(--snapshot-nav-list-placeholder-letter-spacing, 0.02em);
|
|
181
|
+
color: var(--snapshot-nav-list-placeholder-color, color-mix(in srgb, currentColor 55%, transparent));
|
|
182
|
+
max-width: 100%;
|
|
183
|
+
padding: 0 0.5rem;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
text-overflow: ellipsis;
|
|
186
|
+
white-space: nowrap;
|
|
187
|
+
text-align: center;
|
|
188
|
+
}
|
|
189
|
+
/* Spans the frame (minus the caption, if any) rather than shrinking to the
|
|
190
|
+
glyph: a percentage width/height on a fallback <img> then has a real box
|
|
191
|
+
to resolve against, while a default-sized icon still sits centered. */
|
|
166
192
|
.icon-lg {
|
|
167
|
-
|
|
168
|
-
|
|
193
|
+
flex: 1 1 auto;
|
|
194
|
+
min-height: 0;
|
|
195
|
+
width: 100%;
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
justify-content: center;
|
|
199
|
+
opacity: var(--snapshot-nav-list-placeholder-icon-opacity, 0.4);
|
|
200
|
+
}
|
|
201
|
+
/* Sized off one var so a card using a real fallback image (rather than a
|
|
202
|
+
small glyph-sized icon) can grow it to fill the frame from outside the
|
|
203
|
+
shadow root. */
|
|
204
|
+
.icon-lg svg,
|
|
205
|
+
.icon-lg img {
|
|
206
|
+
width: var(--snapshot-nav-list-placeholder-icon-size, 1.6rem);
|
|
207
|
+
height: var(--snapshot-nav-list-placeholder-icon-size, 1.6rem);
|
|
208
|
+
display: block;
|
|
169
209
|
}
|
|
170
210
|
.icon-lg svg {
|
|
171
|
-
width: 1.6rem;
|
|
172
|
-
height: 1.6rem;
|
|
173
|
-
display: block;
|
|
174
211
|
fill: currentColor;
|
|
175
212
|
}
|
|
213
|
+
.icon-lg img {
|
|
214
|
+
object-fit: var(--snapshot-nav-list-placeholder-icon-fit, contain);
|
|
215
|
+
}
|
|
176
216
|
.thumb-loading {
|
|
177
217
|
display: flex;
|
|
178
218
|
align-items: center;
|
|
@@ -198,41 +238,27 @@ export class SnapshotNavList extends LitElement {
|
|
|
198
238
|
}
|
|
199
239
|
}
|
|
200
240
|
|
|
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
241
|
.meta {
|
|
218
242
|
display: flex;
|
|
219
243
|
flex-direction: column;
|
|
220
|
-
gap: 0.
|
|
244
|
+
gap: 0.25rem;
|
|
221
245
|
min-width: 0;
|
|
246
|
+
padding: 0.75rem 0.5rem 0.5rem;
|
|
222
247
|
}
|
|
223
248
|
.label {
|
|
224
249
|
overflow: hidden;
|
|
225
250
|
text-overflow: ellipsis;
|
|
226
|
-
white-space:
|
|
251
|
+
white-space: normal;
|
|
227
252
|
color: inherit;
|
|
228
|
-
font-weight:
|
|
253
|
+
font-weight: 600;
|
|
254
|
+
font-size: 1rem;
|
|
229
255
|
}
|
|
230
256
|
.description {
|
|
231
257
|
overflow: hidden;
|
|
232
258
|
text-overflow: ellipsis;
|
|
233
|
-
white-space:
|
|
259
|
+
white-space: normal;
|
|
234
260
|
color: color-mix(in srgb, currentColor 60%, transparent);
|
|
235
|
-
font-size: 0.
|
|
261
|
+
font-size: 0.8125rem;
|
|
236
262
|
}
|
|
237
263
|
|
|
238
264
|
.edit-button {
|
|
@@ -275,18 +301,15 @@ export class SnapshotNavList extends LitElement {
|
|
|
275
301
|
fill: currentColor;
|
|
276
302
|
}
|
|
277
303
|
|
|
278
|
-
/* Transparent by default (display: contents) so
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
button moves in beside the title. */
|
|
304
|
+
/* Transparent by default (display: contents) so .label/.meta keep applying
|
|
305
|
+
unchanged; it only becomes a real row when the edit button moves in
|
|
306
|
+
beside the title. */
|
|
282
307
|
.label-row {
|
|
283
308
|
display: contents;
|
|
284
309
|
}
|
|
285
310
|
/* 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 {
|
|
311
|
+
still on its own line underneath. */
|
|
312
|
+
:host([edit-button-position='meta']) .label-row {
|
|
290
313
|
display: flex;
|
|
291
314
|
align-items: center;
|
|
292
315
|
gap: 0.5rem;
|
|
@@ -295,11 +318,11 @@ export class SnapshotNavList extends LitElement {
|
|
|
295
318
|
/* the title takes the whole row so the button lands on the card's right
|
|
296
319
|
edge, still on the title's own line (the description sits below it);
|
|
297
320
|
min-width: 0 keeps a long title ellipsising instead of pushing out. */
|
|
298
|
-
:host([edit-button-position='meta']
|
|
321
|
+
:host([edit-button-position='meta']) .label {
|
|
299
322
|
flex: 1;
|
|
300
323
|
min-width: 0;
|
|
301
324
|
}
|
|
302
|
-
:host([edit-button-position='meta']
|
|
325
|
+
:host([edit-button-position='meta']) .edit-button {
|
|
303
326
|
position: static;
|
|
304
327
|
flex-shrink: 0;
|
|
305
328
|
/* in-flow, over the host's own background: currentColor-derived instead
|
|
@@ -309,132 +332,9 @@ export class SnapshotNavList extends LitElement {
|
|
|
309
332
|
color: inherit;
|
|
310
333
|
opacity: 1;
|
|
311
334
|
}
|
|
312
|
-
:host([edit-button-position='meta']
|
|
335
|
+
:host([edit-button-position='meta']) .edit-button:hover {
|
|
313
336
|
background: color-mix(in srgb, currentColor 20%, transparent);
|
|
314
337
|
}
|
|
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
338
|
`; }
|
|
439
339
|
fetchKey(variant, id) {
|
|
440
340
|
return `${variant ?? ''}\0${id}`;
|
|
@@ -479,11 +379,6 @@ export class SnapshotNavList extends LitElement {
|
|
|
479
379
|
// update instead of triggering Lit's "update scheduled from updated()"
|
|
480
380
|
// warning that came from doing this same flip inside updated().
|
|
481
381
|
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
382
|
// A variant-key switch (e.g. light -> dark) invalidates every thumbnail:
|
|
488
383
|
// they're separate snapshots under separate keys.
|
|
489
384
|
if (changed.has('variantKey'))
|
|
@@ -544,27 +439,6 @@ export class SnapshotNavList extends LitElement {
|
|
|
544
439
|
this.requestUpdate();
|
|
545
440
|
}
|
|
546
441
|
}
|
|
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
442
|
// Both events carry the whole item — including `data` — so a handler never
|
|
569
443
|
// has to look the item back up by id.
|
|
570
444
|
select(item) {
|
|
@@ -589,12 +463,17 @@ export class SnapshotNavList extends LitElement {
|
|
|
589
463
|
${isMarkupIcon(icon) ? unsafeHTML(icon) : icon}
|
|
590
464
|
</button>`;
|
|
591
465
|
}
|
|
466
|
+
/** The frame before a card's first capture: an icon, a caption, or both. */
|
|
467
|
+
renderPlaceholder(item) {
|
|
468
|
+
const text = item.placeholderText ?? this.placeholderText;
|
|
469
|
+
const icon = item.icon ? markupIconOrWarn(item.icon) : undefined;
|
|
470
|
+
return html `<div class="thumb thumb-placeholder ${text ? 'has-text' : ''}" part="thumb" aria-hidden="true">
|
|
471
|
+
${icon ? html `<span class="icon-lg" part="placeholder-icon">${unsafeHTML(icon)}</span>` : ''}
|
|
472
|
+
${text ? html `<span class="placeholder-text" part="placeholder-text">${text}</span>` : ''}
|
|
473
|
+
</div>`;
|
|
474
|
+
}
|
|
592
475
|
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';
|
|
476
|
+
const editInMeta = this.editButtonPosition === 'meta';
|
|
598
477
|
return html `
|
|
599
478
|
<ul role="listbox">
|
|
600
479
|
${this.items.map((item) => html `
|
|
@@ -612,15 +491,10 @@ export class SnapshotNavList extends LitElement {
|
|
|
612
491
|
? html `<div class="thumb thumb-loading" part="thumb" aria-hidden="true">
|
|
613
492
|
<span class="spinner" part="spinner"></span>
|
|
614
493
|
</div>`
|
|
615
|
-
:
|
|
616
|
-
<span class="icon-lg"
|
|
617
|
-
>${item.icon ? (isMarkupIcon(item.icon) ? unsafeHTML(item.icon) : item.icon) : ''}</span
|
|
618
|
-
>
|
|
619
|
-
</div>`}
|
|
620
|
-
<div class="image-overlay" part="overlay" style=${styleMap(imageOverlayStyle)}></div>
|
|
494
|
+
: this.renderPlaceholder(item)}
|
|
621
495
|
${this.isEditable(item) && !editInMeta ? this.renderEditButton(item) : ''}
|
|
622
496
|
</div>
|
|
623
|
-
<div class="meta" part="meta"
|
|
497
|
+
<div class="meta" part="meta">
|
|
624
498
|
<div class="label-row" part="label-row">
|
|
625
499
|
<span class="label" part="label">${item.label}</span>
|
|
626
500
|
${this.isEditable(item) && editInMeta ? this.renderEditButton(item) : ''}
|
|
@@ -636,30 +510,12 @@ export class SnapshotNavList extends LitElement {
|
|
|
636
510
|
__decorate([
|
|
637
511
|
property({ type: Array })
|
|
638
512
|
], SnapshotNavList.prototype, "items", void 0);
|
|
639
|
-
__decorate([
|
|
640
|
-
property({ reflect: true })
|
|
641
|
-
], SnapshotNavList.prototype, "variant", void 0);
|
|
642
513
|
__decorate([
|
|
643
514
|
property({ attribute: 'variant-key' })
|
|
644
515
|
], SnapshotNavList.prototype, "variantKey", void 0);
|
|
645
516
|
__decorate([
|
|
646
517
|
property({ type: Boolean, reflect: true })
|
|
647
518
|
], 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
519
|
__decorate([
|
|
664
520
|
property({ attribute: false })
|
|
665
521
|
], SnapshotNavList.prototype, "snapshotService", void 0);
|
|
@@ -672,6 +528,9 @@ __decorate([
|
|
|
672
528
|
__decorate([
|
|
673
529
|
property({ attribute: 'edit-icon' })
|
|
674
530
|
], SnapshotNavList.prototype, "editIcon", void 0);
|
|
531
|
+
__decorate([
|
|
532
|
+
property({ attribute: 'placeholder-text' })
|
|
533
|
+
], SnapshotNavList.prototype, "placeholderText", void 0);
|
|
675
534
|
__decorate([
|
|
676
535
|
state()
|
|
677
536
|
], SnapshotNavList.prototype, "thumbs", 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.4",
|
|
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
|
-
}
|