@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 CHANGED
@@ -4,5 +4,3 @@ export * from './snapshot-storage';
4
4
  export * from './cached-snapshot-storage';
5
5
  export * from './encode';
6
6
  export * from './errors';
7
- export * from './neutralize-oklch';
8
- export * from './wait-for-canvases-to-paint';
package/dist/index.js CHANGED
@@ -4,5 +4,3 @@ export * from './snapshot-storage';
4
4
  export * from './cached-snapshot-storage';
5
5
  export * from './encode';
6
6
  export * from './errors';
7
- export * from './neutralize-oklch';
8
- export * from './wait-for-canvases-to-paint';
@@ -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
- * Call this on the *live* document, before `capture()` not just on the
13
- * element being captured. html2canvas clones the whole document (for
14
- * correct ancestor stacking/background), not only the target element, so a
15
- * descendant can still inherit or otherwise resolve through an ancestor this
16
- * call never touched if `root` is scoped too narrowly; `document.documentElement`
17
- * is the safe default. Restore once the capture settles this rewrites
18
- * real inline styles on the live page, visibly if left in place.
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
@@ -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
- * Call this on the *live* document, before `capture()` not just on the
14
- * element being captured. html2canvas clones the whole document (for
15
- * correct ancestor stacking/background), not only the target element, so a
16
- * descendant can still inherit or otherwise resolve through an ancestor this
17
- * call never touched if `root` is scoped too narrowly; `document.documentElement`
18
- * is the safe default. Restore once the capture settles this rewrites
19
- * real inline styles on the live page, visibly if left in place.
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
- /** A plain-text glyph (e.g. an emoji), or markup a string starting with `<` renders as raw HTML/SVG instead of text, so a consumer can pass its own icon (e.g. `<svg>...</svg>`). Only the placeholder frame shown before a card's first capture. */
7
+ /** Markup `<svg>`, `<img>`, or any elementrendered 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
- * Visual identity: a contact sheet. Every tile is a "frame" numbered like a strip
23
- * of negatives because that's literally what a snapshot thumbnail is. All colors
24
- * come from CSS custom properties (themeable) with sensible fallbacks derived from
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. Ignored by the icon-only variant, whose caption is itself an overlay. */
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
- * Visual identity: a contact sheet. Every tile is a "frame" numbered like a strip
18
- * of negatives because that's literally what a snapshot thumbnail is. All colors
19
- * come from CSS custom properties (themeable) with sensible fallbacks derived from
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. Ignored by the icon-only variant, whose caption is itself an overlay. */
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: flex;
81
- flex-direction: column;
82
- gap: var(--snapshot-nav-list-gap, 0.3rem);
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
- align-items: center;
87
- gap: 0.7rem;
96
+ flex-direction: column;
97
+ align-items: stretch;
98
+ gap: 0;
88
99
  cursor: pointer;
89
- padding: 0.45rem 0.55rem;
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: color-mix(in srgb, currentColor 7%, transparent);
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 .thumb-wrap {
98
- outline: 2px solid var(--frame-accent);
99
- outline-offset: 2px;
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: 160px;
105
- height: 100px;
123
+ width: 100%;
124
+ height: auto;
125
+ aspect-ratio: 2 / 1;
106
126
  border-radius: var(--snapshot-nav-list-radius-sm, 7px);
107
- flex-shrink: 0;
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
- object-fit: cover;
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
- background-color: color-mix(in srgb, currentColor 7%, transparent);
158
- background-image: repeating-linear-gradient(
159
- 135deg,
160
- color-mix(in srgb, currentColor 16%, transparent) 0px,
161
- color-mix(in srgb, currentColor 16%, transparent) 1.5px,
162
- transparent 1.5px,
163
- transparent 7px
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
- font-size: 1.6rem;
168
- opacity: 0.4;
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.15rem;
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: nowrap;
251
+ white-space: normal;
227
252
  color: inherit;
228
- font-weight: 500;
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: nowrap;
259
+ white-space: normal;
234
260
  color: color-mix(in srgb, currentColor 60%, transparent);
235
- font-size: 0.75rem;
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 the existing per-variant
279
- .label/.meta rules including icon-only's absolute caption strip
280
- keep applying unchanged; it only becomes a real row when the edit
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. Not offered for icon-only, whose
287
- .meta is an absolutely positioned overlay strip — the overlay button is
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']:not([variant='icon-only'])) .label {
321
+ :host([edit-button-position='meta']) .label {
299
322
  flex: 1;
300
323
  min-width: 0;
301
324
  }
302
- :host([edit-button-position='meta']:not([variant='icon-only'])) .edit-button {
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']:not([variant='icon-only'])) .edit-button:hover {
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 imageOverlayStyle = this.imageOverlayStyle;
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
- : html `<div class="thumb thumb-placeholder" part="thumb" aria-hidden="true">
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" style=${styleMap(metaStyle)}>
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;
@@ -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
- const CONTENT_PADDING = 16;
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 crop = getContentBounds(el);
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(el, {
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.2",
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
- }