@anton-gustafsson/snapshot-core 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/snapshot-nav-list.d.ts +49 -0
- package/dist/snapshot-nav-list.js +415 -0
- package/dist/snapshot-service.d.ts +52 -0
- package/dist/snapshot-service.js +128 -0
- package/dist/snapshot-storage.d.ts +22 -0
- package/dist/snapshot-storage.js +43 -0
- package/package.json +32 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { LitElement } from 'lit';
|
|
2
|
+
import type { SnapshotService } from './snapshot-service';
|
|
3
|
+
export interface NavItem {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: string;
|
|
7
|
+
route?: string;
|
|
8
|
+
}
|
|
9
|
+
export type SnapshotNavListVariant = 'list' | 'icon-only';
|
|
10
|
+
/**
|
|
11
|
+
* Visual identity: a contact sheet. Every tile is a "frame" — numbered like a strip
|
|
12
|
+
* of negatives — because that's literally what a snapshot thumbnail is. All colors
|
|
13
|
+
* come from CSS custom properties (themeable) with sensible fallbacks derived from
|
|
14
|
+
* `currentColor`, so an unstyled host still looks intentional.
|
|
15
|
+
*/
|
|
16
|
+
export declare class SnapshotNavList extends LitElement {
|
|
17
|
+
static styles: import("lit").CSSResult;
|
|
18
|
+
items: NavItem[];
|
|
19
|
+
variant: SnapshotNavListVariant;
|
|
20
|
+
/** icon-only tile overlay: tint behind the title so it stays legible over any image. */
|
|
21
|
+
overlayTint: 'dark' | 'light' | 'none';
|
|
22
|
+
/** overlay tint strength, 0-1 */
|
|
23
|
+
overlayOpacity: number;
|
|
24
|
+
/** backdrop blur behind the title, in px */
|
|
25
|
+
overlayBlur: number;
|
|
26
|
+
/** icon-only only: 'bottom' is the caption strip (default), 'center' pins the frame number to the corner and centers a larger title. */
|
|
27
|
+
labelPosition: 'bottom' | 'center';
|
|
28
|
+
/** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
|
|
29
|
+
snapshotService: SnapshotService;
|
|
30
|
+
private thumbs;
|
|
31
|
+
private loadingIds;
|
|
32
|
+
/** In-flight dedup guard, separate from `loadingIds` (which is only for spinner display) so a repeat `loadThumb` call for an id already being fetched is a no-op. */
|
|
33
|
+
private fetchingIds;
|
|
34
|
+
private unsubscribe?;
|
|
35
|
+
private subscribeToService;
|
|
36
|
+
connectedCallback(): void;
|
|
37
|
+
disconnectedCallback(): void;
|
|
38
|
+
willUpdate(changed: Map<string, unknown>): void;
|
|
39
|
+
updated(changed: Map<string, unknown>): void;
|
|
40
|
+
private loadThumb;
|
|
41
|
+
private get overlayStyle();
|
|
42
|
+
private select;
|
|
43
|
+
render(): import("lit-html").TemplateResult<1>;
|
|
44
|
+
}
|
|
45
|
+
declare global {
|
|
46
|
+
interface HTMLElementTagNameMap {
|
|
47
|
+
'snapshot-nav-list': SnapshotNavList;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { LitElement, html, css } from 'lit';
|
|
8
|
+
import { property, state } from 'lit/decorators.js';
|
|
9
|
+
import { styleMap } from 'lit/directives/style-map.js';
|
|
10
|
+
import { snapshotService as defaultSnapshotService } from './snapshot-service';
|
|
11
|
+
/**
|
|
12
|
+
* Visual identity: a contact sheet. Every tile is a "frame" — numbered like a strip
|
|
13
|
+
* of negatives — because that's literally what a snapshot thumbnail is. All colors
|
|
14
|
+
* come from CSS custom properties (themeable) with sensible fallbacks derived from
|
|
15
|
+
* `currentColor`, so an unstyled host still looks intentional.
|
|
16
|
+
*/
|
|
17
|
+
export class SnapshotNavList extends LitElement {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(...arguments);
|
|
20
|
+
this.items = [];
|
|
21
|
+
this.variant = 'icon-only';
|
|
22
|
+
/** icon-only tile overlay: tint behind the title so it stays legible over any image. */
|
|
23
|
+
this.overlayTint = 'dark';
|
|
24
|
+
/** overlay tint strength, 0-1 */
|
|
25
|
+
this.overlayOpacity = 0.35;
|
|
26
|
+
/** backdrop blur behind the title, in px */
|
|
27
|
+
this.overlayBlur = 0;
|
|
28
|
+
/** icon-only only: 'bottom' is the caption strip (default), 'center' pins the frame number to the corner and centers a larger title. */
|
|
29
|
+
this.labelPosition = 'bottom';
|
|
30
|
+
/** Defaults to the shared singleton — set your own instance (e.g. a namespaced or custom-storage SnapshotService) per <snapshot-nav-list> if needed. */
|
|
31
|
+
this.snapshotService = defaultSnapshotService;
|
|
32
|
+
this.thumbs = new Map();
|
|
33
|
+
this.loadingIds = new Set();
|
|
34
|
+
/** In-flight dedup guard, separate from `loadingIds` (which is only for spinner display) so a repeat `loadThumb` call for an id already being fetched is a no-op. */
|
|
35
|
+
this.fetchingIds = new Set();
|
|
36
|
+
}
|
|
37
|
+
static { this.styles = css `
|
|
38
|
+
:host {
|
|
39
|
+
display: block;
|
|
40
|
+
font-family: var(--snapshot-nav-list-font, inherit);
|
|
41
|
+
--frame-accent: var(--snapshot-nav-list-accent, #ff5a1f);
|
|
42
|
+
}
|
|
43
|
+
ul {
|
|
44
|
+
list-style: none;
|
|
45
|
+
margin: 0;
|
|
46
|
+
padding: 0;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: var(--snapshot-nav-list-gap, 0.3rem);
|
|
50
|
+
counter-reset: frame;
|
|
51
|
+
}
|
|
52
|
+
li {
|
|
53
|
+
counter-increment: frame;
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
gap: 0.7rem;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
padding: 0.45rem 0.55rem;
|
|
59
|
+
border-radius: var(--snapshot-nav-list-radius, 10px);
|
|
60
|
+
}
|
|
61
|
+
li:hover,
|
|
62
|
+
li:focus-visible {
|
|
63
|
+
background: color-mix(in srgb, currentColor 7%, transparent);
|
|
64
|
+
outline: none;
|
|
65
|
+
}
|
|
66
|
+
li:focus-visible .thumb-wrap {
|
|
67
|
+
outline: 2px solid var(--frame-accent);
|
|
68
|
+
outline-offset: 2px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.thumb-wrap {
|
|
72
|
+
position: relative;
|
|
73
|
+
width: 160px;
|
|
74
|
+
height: 100px;
|
|
75
|
+
border-radius: var(--snapshot-nav-list-radius-sm, 7px);
|
|
76
|
+
flex-shrink: 0;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 16%, transparent);
|
|
79
|
+
}
|
|
80
|
+
/* signature: registration-mark corners, like a photo mount */
|
|
81
|
+
.thumb-wrap::before,
|
|
82
|
+
.thumb-wrap::after {
|
|
83
|
+
content: '';
|
|
84
|
+
position: absolute;
|
|
85
|
+
width: 9px;
|
|
86
|
+
height: 9px;
|
|
87
|
+
pointer-events: none;
|
|
88
|
+
opacity: 0;
|
|
89
|
+
transition: opacity 0.15s ease;
|
|
90
|
+
z-index: 2;
|
|
91
|
+
}
|
|
92
|
+
.thumb-wrap::before {
|
|
93
|
+
top: 4px;
|
|
94
|
+
left: 4px;
|
|
95
|
+
border-top: 2px solid var(--frame-accent);
|
|
96
|
+
border-left: 2px solid var(--frame-accent);
|
|
97
|
+
}
|
|
98
|
+
.thumb-wrap::after {
|
|
99
|
+
bottom: 4px;
|
|
100
|
+
right: 4px;
|
|
101
|
+
border-bottom: 2px solid var(--frame-accent);
|
|
102
|
+
border-right: 2px solid var(--frame-accent);
|
|
103
|
+
}
|
|
104
|
+
li:hover .thumb-wrap::before,
|
|
105
|
+
li:hover .thumb-wrap::after,
|
|
106
|
+
li:focus-visible .thumb-wrap::before,
|
|
107
|
+
li:focus-visible .thumb-wrap::after {
|
|
108
|
+
opacity: 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.thumb {
|
|
112
|
+
width: 100%;
|
|
113
|
+
height: 100%;
|
|
114
|
+
display: block;
|
|
115
|
+
}
|
|
116
|
+
img.thumb {
|
|
117
|
+
object-fit: cover;
|
|
118
|
+
object-position: center;
|
|
119
|
+
background: transparent;
|
|
120
|
+
}
|
|
121
|
+
/* unexposed frame: fine diagonal hatch instead of a generic gradient blob */
|
|
122
|
+
.thumb-placeholder {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: center;
|
|
126
|
+
background-color: color-mix(in srgb, currentColor 7%, transparent);
|
|
127
|
+
background-image: repeating-linear-gradient(
|
|
128
|
+
135deg,
|
|
129
|
+
color-mix(in srgb, currentColor 16%, transparent) 0px,
|
|
130
|
+
color-mix(in srgb, currentColor 16%, transparent) 1.5px,
|
|
131
|
+
transparent 1.5px,
|
|
132
|
+
transparent 7px
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
.icon-lg {
|
|
136
|
+
font-size: 1.6rem;
|
|
137
|
+
opacity: 0.4;
|
|
138
|
+
}
|
|
139
|
+
.thumb-loading {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
justify-content: center;
|
|
143
|
+
background: color-mix(in srgb, currentColor 5%, transparent);
|
|
144
|
+
}
|
|
145
|
+
.spinner {
|
|
146
|
+
width: 22px;
|
|
147
|
+
height: 22px;
|
|
148
|
+
border-radius: 50%;
|
|
149
|
+
border: 2px solid color-mix(in srgb, currentColor 18%, transparent);
|
|
150
|
+
border-top-color: var(--frame-accent);
|
|
151
|
+
animation: snapshot-nav-list-spin 0.8s linear infinite;
|
|
152
|
+
}
|
|
153
|
+
@keyframes snapshot-nav-list-spin {
|
|
154
|
+
to {
|
|
155
|
+
transform: rotate(360deg);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
@media (prefers-reduced-motion: reduce) {
|
|
159
|
+
.spinner {
|
|
160
|
+
animation-duration: 2.4s;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* tint/blur controls apply only to the image, via this scrim layered on top of it */
|
|
165
|
+
.image-overlay {
|
|
166
|
+
position: absolute;
|
|
167
|
+
inset: 0;
|
|
168
|
+
background: var(--overlay-bg, transparent);
|
|
169
|
+
backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
170
|
+
-webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
171
|
+
pointer-events: none;
|
|
172
|
+
}
|
|
173
|
+
/* the overlay exists so icon-only's overlaid title stays legible — list
|
|
174
|
+
variant shows the label beside the thumb, not on top of it, so the
|
|
175
|
+
tint has nothing to do there. */
|
|
176
|
+
:host(:not([variant='icon-only'])) .image-overlay {
|
|
177
|
+
display: none;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.meta {
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: 0.15rem;
|
|
184
|
+
min-width: 0;
|
|
185
|
+
}
|
|
186
|
+
.frame-number {
|
|
187
|
+
font-family: var(--snapshot-nav-list-mono-font, ui-monospace, 'SF Mono', Menlo, monospace);
|
|
188
|
+
font-size: 0.65rem;
|
|
189
|
+
letter-spacing: 0.04em;
|
|
190
|
+
color: color-mix(in srgb, currentColor 55%, transparent);
|
|
191
|
+
}
|
|
192
|
+
.frame-number::before {
|
|
193
|
+
content: 'F' counter(frame, decimal-leading-zero);
|
|
194
|
+
}
|
|
195
|
+
.label {
|
|
196
|
+
overflow: hidden;
|
|
197
|
+
text-overflow: ellipsis;
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
color: inherit;
|
|
200
|
+
font-weight: 500;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* list: a compact thumb reads better in a narrow sidebar than the grid's 160x100 */
|
|
204
|
+
:host([variant='list']) .thumb-wrap {
|
|
205
|
+
width: 108px;
|
|
206
|
+
height: 68px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* icon-only: contact-sheet grid, caption strip pinned to the bottom of each frame */
|
|
210
|
+
:host([variant='icon-only']) ul {
|
|
211
|
+
flex-direction: row;
|
|
212
|
+
flex-wrap: wrap;
|
|
213
|
+
gap: 0.6rem;
|
|
214
|
+
}
|
|
215
|
+
:host([variant='icon-only']) li {
|
|
216
|
+
position: relative;
|
|
217
|
+
width: var(--snapshot-nav-list-tile-width, 160px);
|
|
218
|
+
height: var(--snapshot-nav-list-tile-height, 100px);
|
|
219
|
+
padding: 0;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
}
|
|
222
|
+
:host([variant='icon-only']) .thumb-wrap {
|
|
223
|
+
width: 100%;
|
|
224
|
+
height: 100%;
|
|
225
|
+
border-radius: var(--snapshot-nav-list-radius, 10px);
|
|
226
|
+
}
|
|
227
|
+
:host([variant='icon-only']) .meta {
|
|
228
|
+
position: absolute;
|
|
229
|
+
inset: auto 0 0 0;
|
|
230
|
+
flex-direction: row;
|
|
231
|
+
align-items: baseline;
|
|
232
|
+
justify-content: space-between;
|
|
233
|
+
gap: 0.4rem;
|
|
234
|
+
padding: 0.4rem 0.5rem;
|
|
235
|
+
color: var(--overlay-text, #fff);
|
|
236
|
+
}
|
|
237
|
+
:host([variant='icon-only']) .label {
|
|
238
|
+
white-space: normal;
|
|
239
|
+
}
|
|
240
|
+
:host([variant='icon-only']) .frame-number {
|
|
241
|
+
color: color-mix(in srgb, currentColor 70%, transparent);
|
|
242
|
+
flex-shrink: 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* label-position="center": frame number pinned to the corner, title big and centered */
|
|
246
|
+
:host([variant='icon-only'][label-position='center']) .meta {
|
|
247
|
+
inset: 0;
|
|
248
|
+
align-items: center;
|
|
249
|
+
justify-content: center;
|
|
250
|
+
padding: 0.6rem;
|
|
251
|
+
}
|
|
252
|
+
:host([variant='icon-only'][label-position='center']) .frame-number {
|
|
253
|
+
position: absolute;
|
|
254
|
+
top: 0.6rem;
|
|
255
|
+
left: 0.6rem;
|
|
256
|
+
}
|
|
257
|
+
:host([variant='icon-only'][label-position='center']) .label {
|
|
258
|
+
font-size: 1.15rem;
|
|
259
|
+
font-weight: 600;
|
|
260
|
+
text-align: center;
|
|
261
|
+
}
|
|
262
|
+
`; }
|
|
263
|
+
subscribeToService() {
|
|
264
|
+
this.unsubscribe?.();
|
|
265
|
+
this.unsubscribe = this.snapshotService.subscribe((id, url) => {
|
|
266
|
+
// Ignore captures for ids this list isn't showing — the service is
|
|
267
|
+
// often a shared singleton, so without this guard `thumbs` would grow
|
|
268
|
+
// forever with urls for every snapshot captured anywhere on the page,
|
|
269
|
+
// not just this list's own items.
|
|
270
|
+
if (!this.items.some((item) => item.id === id))
|
|
271
|
+
return;
|
|
272
|
+
if (url === null) {
|
|
273
|
+
this.thumbs.delete(id);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
this.thumbs.set(id, url);
|
|
277
|
+
}
|
|
278
|
+
this.requestUpdate();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
connectedCallback() {
|
|
282
|
+
super.connectedCallback();
|
|
283
|
+
// No initial loadThumb() loop needed here — Lit always calls updated()
|
|
284
|
+
// with every reactive property (including `items`) marked changed after
|
|
285
|
+
// the first render, so the branch below covers it.
|
|
286
|
+
this.subscribeToService();
|
|
287
|
+
}
|
|
288
|
+
disconnectedCallback() {
|
|
289
|
+
super.disconnectedCallback();
|
|
290
|
+
this.unsubscribe?.();
|
|
291
|
+
}
|
|
292
|
+
// Runs before render, so mutating `loadingIds` here lands in the *current*
|
|
293
|
+
// update instead of triggering Lit's "update scheduled from updated()"
|
|
294
|
+
// warning that came from doing this same flip inside updated().
|
|
295
|
+
willUpdate(changed) {
|
|
296
|
+
if (changed.has('items')) {
|
|
297
|
+
const ids = new Set(this.items.map((item) => item.id));
|
|
298
|
+
for (const id of this.thumbs.keys()) {
|
|
299
|
+
if (!ids.has(id))
|
|
300
|
+
this.thumbs.delete(id);
|
|
301
|
+
}
|
|
302
|
+
for (const item of this.items) {
|
|
303
|
+
if (!this.thumbs.has(item.id) && !this.fetchingIds.has(item.id)) {
|
|
304
|
+
this.loadingIds.add(item.id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
updated(changed) {
|
|
310
|
+
if (changed.has('snapshotService')) {
|
|
311
|
+
this.subscribeToService();
|
|
312
|
+
}
|
|
313
|
+
if (changed.has('items')) {
|
|
314
|
+
this.items.forEach((item) => this.loadThumb(item.id));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async loadThumb(id) {
|
|
318
|
+
if (this.thumbs.has(id) || this.fetchingIds.has(id))
|
|
319
|
+
return;
|
|
320
|
+
this.fetchingIds.add(id);
|
|
321
|
+
try {
|
|
322
|
+
const url = await this.snapshotService.get(id);
|
|
323
|
+
if (url)
|
|
324
|
+
this.thumbs.set(id, url);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
console.error(`snapshot-nav-list: failed to load thumbnail for "${id}"`, err);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
this.fetchingIds.delete(id);
|
|
331
|
+
this.loadingIds.delete(id);
|
|
332
|
+
this.requestUpdate();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
get overlayStyle() {
|
|
336
|
+
const blur = `${this.overlayBlur}px`;
|
|
337
|
+
if (this.overlayTint === 'none') {
|
|
338
|
+
return { '--overlay-bg': 'transparent', '--overlay-text': 'inherit', '--overlay-blur': blur };
|
|
339
|
+
}
|
|
340
|
+
const tintColor = this.overlayTint === 'light' ? '#fff' : '#000';
|
|
341
|
+
const bg = `color-mix(in srgb, ${tintColor} ${Math.round(this.overlayOpacity * 100)}%, transparent)`;
|
|
342
|
+
const text = this.overlayTint === 'light' ? '#111' : '#fff';
|
|
343
|
+
return { '--overlay-bg': bg, '--overlay-text': text, '--overlay-blur': blur };
|
|
344
|
+
}
|
|
345
|
+
select(item) {
|
|
346
|
+
this.dispatchEvent(new CustomEvent('nav-select', {
|
|
347
|
+
detail: { id: item.id, route: item.route },
|
|
348
|
+
bubbles: true,
|
|
349
|
+
composed: true,
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
render() {
|
|
353
|
+
const overlayStyle = this.overlayStyle;
|
|
354
|
+
return html `
|
|
355
|
+
<ul role="listbox">
|
|
356
|
+
${this.items.map((item) => html `
|
|
357
|
+
<li
|
|
358
|
+
role="option"
|
|
359
|
+
aria-label=${item.label}
|
|
360
|
+
tabindex="0"
|
|
361
|
+
@click=${() => this.select(item)}
|
|
362
|
+
@keydown=${(e) => e.key === 'Enter' && this.select(item)}
|
|
363
|
+
>
|
|
364
|
+
<div class="thumb-wrap" part="frame">
|
|
365
|
+
${this.thumbs.has(item.id)
|
|
366
|
+
? html `<img class="thumb" part="thumb" src=${this.thumbs.get(item.id)} alt="" />`
|
|
367
|
+
: this.loadingIds.has(item.id)
|
|
368
|
+
? html `<div class="thumb thumb-loading" part="thumb" aria-hidden="true">
|
|
369
|
+
<span class="spinner" part="spinner"></span>
|
|
370
|
+
</div>`
|
|
371
|
+
: html `<div class="thumb thumb-placeholder" part="thumb" aria-hidden="true">
|
|
372
|
+
<span class="icon-lg">${item.icon ?? ''}</span>
|
|
373
|
+
</div>`}
|
|
374
|
+
<div class="image-overlay" part="overlay" style=${styleMap(overlayStyle)}></div>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="meta" part="meta">
|
|
377
|
+
<span class="frame-number" part="frame-number" aria-hidden="true"></span>
|
|
378
|
+
<span class="label" part="label">${item.label}</span>
|
|
379
|
+
</div>
|
|
380
|
+
</li>
|
|
381
|
+
`)}
|
|
382
|
+
</ul>
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
__decorate([
|
|
387
|
+
property({ type: Array })
|
|
388
|
+
], SnapshotNavList.prototype, "items", void 0);
|
|
389
|
+
__decorate([
|
|
390
|
+
property({ reflect: true })
|
|
391
|
+
], SnapshotNavList.prototype, "variant", void 0);
|
|
392
|
+
__decorate([
|
|
393
|
+
property({ attribute: 'overlay-tint' })
|
|
394
|
+
], SnapshotNavList.prototype, "overlayTint", void 0);
|
|
395
|
+
__decorate([
|
|
396
|
+
property({ type: Number, attribute: 'overlay-opacity' })
|
|
397
|
+
], SnapshotNavList.prototype, "overlayOpacity", void 0);
|
|
398
|
+
__decorate([
|
|
399
|
+
property({ type: Number, attribute: 'overlay-blur' })
|
|
400
|
+
], SnapshotNavList.prototype, "overlayBlur", void 0);
|
|
401
|
+
__decorate([
|
|
402
|
+
property({ reflect: true, attribute: 'label-position' })
|
|
403
|
+
], SnapshotNavList.prototype, "labelPosition", void 0);
|
|
404
|
+
__decorate([
|
|
405
|
+
property({ attribute: false })
|
|
406
|
+
], SnapshotNavList.prototype, "snapshotService", void 0);
|
|
407
|
+
__decorate([
|
|
408
|
+
state()
|
|
409
|
+
], SnapshotNavList.prototype, "thumbs", void 0);
|
|
410
|
+
__decorate([
|
|
411
|
+
state()
|
|
412
|
+
], SnapshotNavList.prototype, "loadingIds", void 0);
|
|
413
|
+
if (!customElements.get('snapshot-nav-list')) {
|
|
414
|
+
customElements.define('snapshot-nav-list', SnapshotNavList);
|
|
415
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SnapshotStorage } from './snapshot-storage';
|
|
2
|
+
export interface SnapshotServiceConfig {
|
|
3
|
+
/** Provide your own backend-backed implementation to persist across devices. */
|
|
4
|
+
storage?: SnapshotStorage;
|
|
5
|
+
/** html2canvas render scale. Lower = smaller/faster thumbnails. */
|
|
6
|
+
scale?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Prepended to every id before it reaches storage (and to the cross-tab
|
|
9
|
+
* broadcast channel name). Namespaces multiple SnapshotService instances —
|
|
10
|
+
* or multiple apps on the same origin — sharing one storage backend so
|
|
11
|
+
* their keys and live-update notifications can't collide.
|
|
12
|
+
*/
|
|
13
|
+
keyPrefix?: string;
|
|
14
|
+
}
|
|
15
|
+
/** `url` is null when the id was removed rather than (re)captured. */
|
|
16
|
+
type Listener = (id: string, url: string | null) => void;
|
|
17
|
+
export declare class SnapshotService {
|
|
18
|
+
private storage;
|
|
19
|
+
private scale;
|
|
20
|
+
private keyPrefix;
|
|
21
|
+
private channel;
|
|
22
|
+
private listeners;
|
|
23
|
+
constructor(config?: SnapshotServiceConfig);
|
|
24
|
+
/** Capture works on any element — a snapshot-nav-list item is one convention, not a requirement. */
|
|
25
|
+
capture(el: HTMLElement, id: string): Promise<string>;
|
|
26
|
+
get(id: string): Promise<string | null>;
|
|
27
|
+
/** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
|
|
28
|
+
remove(id: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Announces a URL for `id` to subscribers (this tab and others) without
|
|
31
|
+
* touching storage. For a storage layer that resolves a fresher value
|
|
32
|
+
* asynchronously after `get()` already returned a cached one — e.g. a fast
|
|
33
|
+
* local cache in front of a slower authoritative database — call this once
|
|
34
|
+
* the slow read settles so any mounted <snapshot-nav-list> updates live.
|
|
35
|
+
*/
|
|
36
|
+
publish(id: string, url: string): void;
|
|
37
|
+
/**
|
|
38
|
+
* Tells subscribers to drop their locally cached thumbnail for `id` and
|
|
39
|
+
* treat it as not-yet-loaded — without deleting anything from storage.
|
|
40
|
+
* Unlike `remove()`, the data is still there; the next `get()` will fetch
|
|
41
|
+
* it again. Useful when the underlying data changed out from under the
|
|
42
|
+
* cache (or, for a demo, to replay a loading state on demand).
|
|
43
|
+
*/
|
|
44
|
+
invalidate(id: string): void;
|
|
45
|
+
subscribe(cb: Listener): () => boolean;
|
|
46
|
+
/** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
|
|
47
|
+
close(): void;
|
|
48
|
+
private notify;
|
|
49
|
+
}
|
|
50
|
+
/** Default singleton (IndexedDB-backed). Replace with your own SnapshotService({ storage }) if you need a real backend. */
|
|
51
|
+
export declare const snapshotService: SnapshotService;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import html2canvas from 'html2canvas';
|
|
2
|
+
import { IndexedDbSnapshotStorage } from './snapshot-storage';
|
|
3
|
+
const CONTENT_PADDING = 16;
|
|
4
|
+
// Tracks keyPrefixes already claimed by a live SnapshotService instance, so
|
|
5
|
+
// two instances that both forget to set one (or pick the same one) get a
|
|
6
|
+
// loud warning instead of silently colliding on the same broadcast channel
|
|
7
|
+
// and storage keys.
|
|
8
|
+
const activeKeyPrefixes = new Set();
|
|
9
|
+
/** So a container much bigger than its content (e.g. a full-page canvas with one small widget) doesn't capture as mostly empty space — crop to the actual children's bounding box, padded, instead of the whole element. */
|
|
10
|
+
function getContentBounds(el) {
|
|
11
|
+
const full = { x: 0, y: 0, width: el.clientWidth, height: el.clientHeight };
|
|
12
|
+
// Ignore children that don't actually occupy visible space (display:none, or
|
|
13
|
+
// otherwise zero-size) — otherwise a single hidden sibling collapses the
|
|
14
|
+
// whole crop back to `full`, defeating the point of this function.
|
|
15
|
+
const children = Array.from(el.children).filter((child) => (child.offsetWidth > 0 || child.offsetHeight > 0) &&
|
|
16
|
+
getComputedStyle(child).display !== 'none');
|
|
17
|
+
if (children.length === 0)
|
|
18
|
+
return full;
|
|
19
|
+
// el.clientWidth/clientHeight are content-box (border excluded), but
|
|
20
|
+
// getBoundingClientRect() is border-box — offset by clientLeft/clientTop
|
|
21
|
+
// (the border width) so child positions line up with `full`'s coordinate
|
|
22
|
+
// space instead of drifting by the border width on a bordered container.
|
|
23
|
+
const elRect = el.getBoundingClientRect();
|
|
24
|
+
const originX = elRect.left + el.clientLeft;
|
|
25
|
+
const originY = elRect.top + el.clientTop;
|
|
26
|
+
let minX = Infinity;
|
|
27
|
+
let minY = Infinity;
|
|
28
|
+
let maxX = -Infinity;
|
|
29
|
+
let maxY = -Infinity;
|
|
30
|
+
for (const child of children) {
|
|
31
|
+
const r = child.getBoundingClientRect();
|
|
32
|
+
minX = Math.min(minX, r.left - originX);
|
|
33
|
+
minY = Math.min(minY, r.top - originY);
|
|
34
|
+
maxX = Math.max(maxX, r.right - originX);
|
|
35
|
+
maxY = Math.max(maxY, r.bottom - originY);
|
|
36
|
+
}
|
|
37
|
+
const x = Math.max(0, minX - CONTENT_PADDING);
|
|
38
|
+
const y = Math.max(0, minY - CONTENT_PADDING);
|
|
39
|
+
const width = Math.min(full.width - x, maxX - minX + CONTENT_PADDING * 2);
|
|
40
|
+
const height = Math.min(full.height - y, maxY - minY + CONTENT_PADDING * 2);
|
|
41
|
+
if (width <= 0 || height <= 0)
|
|
42
|
+
return full;
|
|
43
|
+
return { x, y, width, height };
|
|
44
|
+
}
|
|
45
|
+
export class SnapshotService {
|
|
46
|
+
constructor(config = {}) {
|
|
47
|
+
this.listeners = new Set();
|
|
48
|
+
this.storage = config.storage ?? new IndexedDbSnapshotStorage();
|
|
49
|
+
this.scale = config.scale ?? 0.4;
|
|
50
|
+
this.keyPrefix = config.keyPrefix ?? '';
|
|
51
|
+
if (activeKeyPrefixes.has(this.keyPrefix)) {
|
|
52
|
+
console.warn(`SnapshotService: another instance already uses keyPrefix "${this.keyPrefix}" — ` +
|
|
53
|
+
'their storage keys and cross-tab notifications will collide. Give each instance its own keyPrefix.');
|
|
54
|
+
}
|
|
55
|
+
activeKeyPrefixes.add(this.keyPrefix);
|
|
56
|
+
this.channel = new BroadcastChannel(`nav-snapshots:${this.keyPrefix}`);
|
|
57
|
+
this.channel.onmessage = (e) => {
|
|
58
|
+
const { id, url } = e.data;
|
|
59
|
+
this.listeners.forEach((l) => l(id, url));
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Capture works on any element — a snapshot-nav-list item is one convention, not a requirement. */
|
|
63
|
+
async capture(el, id) {
|
|
64
|
+
const crop = getContentBounds(el);
|
|
65
|
+
const canvas = await html2canvas(el, {
|
|
66
|
+
scale: this.scale,
|
|
67
|
+
logging: false,
|
|
68
|
+
useCORS: true,
|
|
69
|
+
x: crop.x,
|
|
70
|
+
y: crop.y,
|
|
71
|
+
width: crop.width,
|
|
72
|
+
height: crop.height,
|
|
73
|
+
});
|
|
74
|
+
const blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b
|
|
75
|
+
? resolve(b)
|
|
76
|
+
: reject(new Error('SnapshotService: canvas.toBlob() returned null — the captured element likely contains tainted (cross-origin, no CORS headers) content, or has zero size.')), 'image/png'));
|
|
77
|
+
const url = await this.storage.save(this.keyPrefix + id, blob);
|
|
78
|
+
this.notify(id, url);
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
get(id) {
|
|
82
|
+
return this.storage.load(this.keyPrefix + id);
|
|
83
|
+
}
|
|
84
|
+
/** Deletes a stored snapshot and notifies subscribers (this tab and others) that `id` is gone. */
|
|
85
|
+
async remove(id) {
|
|
86
|
+
await this.storage.remove?.(this.keyPrefix + id);
|
|
87
|
+
this.notify(id, null);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Announces a URL for `id` to subscribers (this tab and others) without
|
|
91
|
+
* touching storage. For a storage layer that resolves a fresher value
|
|
92
|
+
* asynchronously after `get()` already returned a cached one — e.g. a fast
|
|
93
|
+
* local cache in front of a slower authoritative database — call this once
|
|
94
|
+
* the slow read settles so any mounted <snapshot-nav-list> updates live.
|
|
95
|
+
*/
|
|
96
|
+
publish(id, url) {
|
|
97
|
+
this.notify(id, url);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Tells subscribers to drop their locally cached thumbnail for `id` and
|
|
101
|
+
* treat it as not-yet-loaded — without deleting anything from storage.
|
|
102
|
+
* Unlike `remove()`, the data is still there; the next `get()` will fetch
|
|
103
|
+
* it again. Useful when the underlying data changed out from under the
|
|
104
|
+
* cache (or, for a demo, to replay a loading state on demand).
|
|
105
|
+
*/
|
|
106
|
+
invalidate(id) {
|
|
107
|
+
this.notify(id, null);
|
|
108
|
+
}
|
|
109
|
+
subscribe(cb) {
|
|
110
|
+
this.listeners.add(cb);
|
|
111
|
+
return () => this.listeners.delete(cb);
|
|
112
|
+
}
|
|
113
|
+
/** Releases the cross-tab BroadcastChannel. Call when this instance (a non-default, namespaced one) is no longer needed. */
|
|
114
|
+
close() {
|
|
115
|
+
this.channel.close();
|
|
116
|
+
this.listeners.clear();
|
|
117
|
+
activeKeyPrefixes.delete(this.keyPrefix);
|
|
118
|
+
}
|
|
119
|
+
// BroadcastChannel never delivers a message back to its own sender, so
|
|
120
|
+
// same-tab listeners have to be notified directly alongside the cross-tab
|
|
121
|
+
// post — kept as one method so `capture()` and `remove()` can't drift.
|
|
122
|
+
notify(id, url) {
|
|
123
|
+
this.channel.postMessage({ id, url });
|
|
124
|
+
this.listeners.forEach((l) => l(id, url));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Default singleton (IndexedDB-backed). Replace with your own SnapshotService({ storage }) if you need a real backend. */
|
|
128
|
+
export const snapshotService = new SnapshotService();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SnapshotStorage {
|
|
2
|
+
save(id: string, blob: Blob): Promise<string>;
|
|
3
|
+
load(id: string): Promise<string | null>;
|
|
4
|
+
remove?(id: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Default storage: browser-local IndexedDB. Does NOT sync across devices.
|
|
8
|
+
*
|
|
9
|
+
* Blobs are stored natively (IndexedDB supports them directly) instead of
|
|
10
|
+
* base64-encoding into a data URL — that would cost ~33% extra storage and
|
|
11
|
+
* an encode/decode pass on every save/render. Displayable URLs are minted
|
|
12
|
+
* via `URL.createObjectURL`, cached per id so a re-capture of the same id
|
|
13
|
+
* revokes its old URL instead of leaking one per capture.
|
|
14
|
+
*/
|
|
15
|
+
export declare class IndexedDbSnapshotStorage implements SnapshotStorage {
|
|
16
|
+
private objectUrls;
|
|
17
|
+
save(id: string, blob: Blob): Promise<string>;
|
|
18
|
+
load(id: string): Promise<string | null>;
|
|
19
|
+
remove(id: string): Promise<void>;
|
|
20
|
+
private mintObjectUrl;
|
|
21
|
+
private revoke;
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { get, set, del } from 'idb-keyval';
|
|
2
|
+
/**
|
|
3
|
+
* Default storage: browser-local IndexedDB. Does NOT sync across devices.
|
|
4
|
+
*
|
|
5
|
+
* Blobs are stored natively (IndexedDB supports them directly) instead of
|
|
6
|
+
* base64-encoding into a data URL — that would cost ~33% extra storage and
|
|
7
|
+
* an encode/decode pass on every save/render. Displayable URLs are minted
|
|
8
|
+
* via `URL.createObjectURL`, cached per id so a re-capture of the same id
|
|
9
|
+
* revokes its old URL instead of leaking one per capture.
|
|
10
|
+
*/
|
|
11
|
+
export class IndexedDbSnapshotStorage {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.objectUrls = new Map();
|
|
14
|
+
}
|
|
15
|
+
async save(id, blob) {
|
|
16
|
+
await set(`snapshot:${id}`, blob);
|
|
17
|
+
return this.mintObjectUrl(id, blob);
|
|
18
|
+
}
|
|
19
|
+
async load(id) {
|
|
20
|
+
const cached = this.objectUrls.get(id);
|
|
21
|
+
if (cached)
|
|
22
|
+
return cached;
|
|
23
|
+
const blob = await get(`snapshot:${id}`);
|
|
24
|
+
return blob ? this.mintObjectUrl(id, blob) : null;
|
|
25
|
+
}
|
|
26
|
+
async remove(id) {
|
|
27
|
+
this.revoke(id);
|
|
28
|
+
await del(`snapshot:${id}`);
|
|
29
|
+
}
|
|
30
|
+
mintObjectUrl(id, blob) {
|
|
31
|
+
this.revoke(id);
|
|
32
|
+
const url = URL.createObjectURL(blob);
|
|
33
|
+
this.objectUrls.set(id, url);
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
revoke(id) {
|
|
37
|
+
const existing = this.objectUrls.get(id);
|
|
38
|
+
if (existing) {
|
|
39
|
+
URL.revokeObjectURL(existing);
|
|
40
|
+
this.objectUrls.delete(id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anton-gustafsson/snapshot-core",
|
|
3
|
+
"version": "0.0.2",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/anton-gustafsson/snapshot.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"module": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"lit": "^3.2.0",
|
|
26
|
+
"html2canvas": "^1.4.1",
|
|
27
|
+
"idb-keyval": "^6.2.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "^5.5.0"
|
|
31
|
+
}
|
|
32
|
+
}
|