@a2f0/agent-tool 0.1.31 → 0.1.32
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/README.md +4 -2
- package/package.json +10 -2
- package/src/cli.ts +17 -0
- package/src/config.ts +4 -0
- package/src/images/build.ts +2 -45
- package/src/images/png.ts +10 -0
- package/src/permissions/catalog.ts +1 -1
- package/src/screenshots/build.ts +271 -0
- package/src/screenshots/config.ts +94 -0
- package/src/screenshots/gallery.css +528 -0
- package/src/screenshots/gallery.ts +713 -0
- package/src/screenshots/manifest.ts +176 -0
- package/src/system/files.ts +46 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free screenshot elements: `<screenshot-gallery>` and
|
|
3
|
+
* `<screenshot-carousel>`, rendered into light DOM so a site's CSS reaches
|
|
4
|
+
* them, and themed through the `--shots-*` custom properties in
|
|
5
|
+
* `@a2f0/agent-tool/screenshots/gallery.css`. Importing this module defines
|
|
6
|
+
* nothing and touches no DOM API, so server-side frontmatter can import it;
|
|
7
|
+
* call `defineScreenshotElements()` in the browser.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_HEADING_TEMPLATE,
|
|
11
|
+
findScreenshot,
|
|
12
|
+
formatScreenshotTemplate,
|
|
13
|
+
parseScreenshotManifest,
|
|
14
|
+
parseScreenshotPath,
|
|
15
|
+
projectFrame,
|
|
16
|
+
projectInfo,
|
|
17
|
+
projectScreens,
|
|
18
|
+
screenInfo,
|
|
19
|
+
screenshotPath,
|
|
20
|
+
startingProject,
|
|
21
|
+
startingScreen,
|
|
22
|
+
startingTheme,
|
|
23
|
+
themeInfo,
|
|
24
|
+
wrapIndex,
|
|
25
|
+
type ScreenshotEntry,
|
|
26
|
+
type ScreenshotFrame,
|
|
27
|
+
type ScreenshotManifest,
|
|
28
|
+
} from "./manifest";
|
|
29
|
+
|
|
30
|
+
export * from "./manifest";
|
|
31
|
+
|
|
32
|
+
/** Bezel padding and screen corner radius as fractions of the screen's width; iPhone 13's display corner is 47.33pt of 390pt. */
|
|
33
|
+
export const FRAME_METRICS: Record<ScreenshotFrame, { pad: number; radius: number }> = {
|
|
34
|
+
phone: { pad: 0.035, radius: 0.1214 },
|
|
35
|
+
tablet: { pad: 0.03, radius: 0.025 },
|
|
36
|
+
none: { pad: 0, radius: 0 },
|
|
37
|
+
};
|
|
38
|
+
/** A frameless capture keeps a 1px border on each side. */
|
|
39
|
+
const BORDER = 1;
|
|
40
|
+
|
|
41
|
+
export interface FrameFit { width: number; height: number; pad: number; radius: number }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The image size, bezel padding, and screen radius, in CSS pixels, that fit a
|
|
45
|
+
* `width` x `height` capture and its frame inside the available box. Never
|
|
46
|
+
* scales a capture above its pixel size.
|
|
47
|
+
*/
|
|
48
|
+
export function fitFrame(frame: ScreenshotFrame, width: number, height: number, availableWidth: number, availableHeight: number): FrameFit {
|
|
49
|
+
const { pad, radius } = FRAME_METRICS[frame];
|
|
50
|
+
const extra = frame === "none" ? 2 * BORDER : 0;
|
|
51
|
+
const scale = Math.max(0, Math.min((availableWidth - extra) / (width * (1 + 2 * pad)), (availableHeight - extra) / (height + 2 * pad * width), 1));
|
|
52
|
+
const imageWidth = Math.floor(width * scale), imageHeight = Math.floor(height * scale);
|
|
53
|
+
return { width: imageWidth, height: imageHeight, pad: Math.round(imageWidth * pad * 10) / 10, radius: Math.round(imageWidth * radius * 10) / 10 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The slide a scroll position rests on: `offset` is the scroll position of slide 0, `pitch` the distance between slides. */
|
|
57
|
+
export function nearestSlide(scrollLeft: number, offset: number, pitch: number, count: number): number {
|
|
58
|
+
if (!(pitch > 0) || count <= 0) return 0;
|
|
59
|
+
return Math.min(count - 1, Math.max(0, Math.round((scrollLeft - offset) / pitch)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A looping carousel renders its `count` slides `2 * copies + 1` times. Any
|
|
64
|
+
* slide index maps to the same slide in the middle copy, where the track jumps
|
|
65
|
+
* silently once scrolling settles, so it wraps in both directions.
|
|
66
|
+
*/
|
|
67
|
+
export function loopSlide(index: number, count: number, copies: number): number {
|
|
68
|
+
return copies * count + wrapIndex(index - copies * count, count);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How many copies each side needs so a full viewport of clones is always ready. */
|
|
72
|
+
export function loopCopies(viewportWidth: number, copyWidth: number): number {
|
|
73
|
+
return copyWidth > 0 ? Math.max(1, Math.ceil(viewportWidth / copyWidth)) : 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ScreenshotChangeDetail { project: string; theme: string; screen: string; index: number; total: number }
|
|
77
|
+
|
|
78
|
+
export interface ScreenshotGalleryElement extends HTMLElement {
|
|
79
|
+
/** The manifest to render, instead of fetching the `manifest` attribute's URL. */
|
|
80
|
+
manifestData: ScreenshotManifest | null;
|
|
81
|
+
readonly project: string;
|
|
82
|
+
readonly theme: string;
|
|
83
|
+
readonly screen: string | undefined;
|
|
84
|
+
/** Selects a project, theme, or screen; unknown values are ignored. */
|
|
85
|
+
select(selection: { project?: string; theme?: string; screen?: string }): void;
|
|
86
|
+
/** Steps `delta` screens, wrapping at either end. */
|
|
87
|
+
step(delta: number): void;
|
|
88
|
+
}
|
|
89
|
+
export interface ScreenshotCarouselElement extends HTMLElement {
|
|
90
|
+
manifestData: ScreenshotManifest | null;
|
|
91
|
+
/** Scrolls `delta` pages of slides, wrapping seamlessly. */
|
|
92
|
+
step(delta: number): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ICONS: Record<string, string> = {
|
|
96
|
+
prev: '<path d="M15 5l-7 7 7 7"/>',
|
|
97
|
+
next: '<path d="M9 5l7 7-7 7"/>',
|
|
98
|
+
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
|
99
|
+
collapse: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
|
|
100
|
+
theme: '<circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor"/>',
|
|
101
|
+
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
102
|
+
phone: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
|
|
103
|
+
tablet: '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M11 18h2"/>',
|
|
104
|
+
none: '<rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/>',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function element<K extends keyof HTMLElementTagNameMap>(tag: K, attributes: Record<string, string | number | boolean | undefined> = {}, children: (Node | string)[] = []): HTMLElementTagNameMap[K] {
|
|
108
|
+
const node = document.createElement(tag);
|
|
109
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
110
|
+
if (value === undefined || value === false) continue;
|
|
111
|
+
if (name === "class") node.className = String(value);
|
|
112
|
+
else if (name === "text") node.textContent = String(value);
|
|
113
|
+
else node.setAttribute(name, value === true ? "" : String(value));
|
|
114
|
+
}
|
|
115
|
+
node.append(...children);
|
|
116
|
+
return node;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Icons are constants, never manifest text, so markup is safe here. */
|
|
120
|
+
function icon(name: string): HTMLElement {
|
|
121
|
+
const holder = element("span", { class: "shots-icon", "aria-hidden": "true" });
|
|
122
|
+
holder.innerHTML = `<svg viewBox="0 0 24 24" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] ?? ""}</svg>`;
|
|
123
|
+
return holder;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function prefers(query: string): boolean {
|
|
127
|
+
return typeof matchMedia === "function" && matchMedia(query).matches;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function editable(target: EventTarget | null): boolean {
|
|
131
|
+
return target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function loadManifest(url: string, signal: AbortSignal): Promise<ScreenshotManifest> {
|
|
135
|
+
const response = await fetch(url, { signal });
|
|
136
|
+
if (!response.ok) throw new Error(`Screenshot manifest request failed (${response.status}).`);
|
|
137
|
+
return parseScreenshotManifest(await response.json());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reports when scrolling settles: `scrollend` where the browser has it, since
|
|
142
|
+
* a smooth scroll can pause between frames; else a pause in `scroll` events.
|
|
143
|
+
*/
|
|
144
|
+
function onScrollSettle(target: HTMLElement, settle: () => void, signal: AbortSignal): void {
|
|
145
|
+
if (typeof window !== "undefined" && "onscrollend" in window) { target.addEventListener("scrollend", settle, { signal }); return; }
|
|
146
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
147
|
+
target.addEventListener("scroll", () => { clearTimeout(timer); timer = setTimeout(settle, 200); }, { passive: true, signal });
|
|
148
|
+
signal.addEventListener("abort", () => clearTimeout(timer));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function scrollBehavior(): ScrollBehavior {
|
|
152
|
+
return prefers("(prefers-reduced-motion: reduce)") ? "instant" : "smooth";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function status(root: HTMLElement, state: string, message: string): void {
|
|
156
|
+
root.replaceChildren(element("div", { class: "shots-status", role: "status", "data-state": state }, [element("p", { text: message })]));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function defineGallery(): CustomElementConstructor {
|
|
160
|
+
return class ScreenshotGallery extends HTMLElement implements ScreenshotGalleryElement {
|
|
161
|
+
static observedAttributes = ["manifest"];
|
|
162
|
+
#data: ScreenshotManifest | null = null;
|
|
163
|
+
#manifest: ScreenshotManifest | null = null;
|
|
164
|
+
#abort: AbortController | null = null;
|
|
165
|
+
#root: HTMLDivElement | null = null;
|
|
166
|
+
#track: HTMLDivElement | null = null;
|
|
167
|
+
#strip: HTMLElement | null = null;
|
|
168
|
+
#counter: HTMLParagraphElement | null = null;
|
|
169
|
+
#title: HTMLParagraphElement | null = null;
|
|
170
|
+
#lightbox: HTMLDialogElement | null = null;
|
|
171
|
+
#project = "";
|
|
172
|
+
#theme = "";
|
|
173
|
+
#screens: string[] = [];
|
|
174
|
+
#index = 0;
|
|
175
|
+
#loaded = new Set<number>();
|
|
176
|
+
#target: number | null = null;
|
|
177
|
+
#expanded = false;
|
|
178
|
+
#overflow = "";
|
|
179
|
+
#rendered = false;
|
|
180
|
+
#announced = "";
|
|
181
|
+
|
|
182
|
+
get manifestData(): ScreenshotManifest | null { return this.#data; }
|
|
183
|
+
set manifestData(value: ScreenshotManifest | null) { this.#data = value && parseScreenshotManifest(value); if (this.#connected) this.#load(); }
|
|
184
|
+
get project(): string { return this.#project; }
|
|
185
|
+
get theme(): string { return this.#theme; }
|
|
186
|
+
get screen(): string | undefined { return this.#screens[this.#index]; }
|
|
187
|
+
|
|
188
|
+
#connected = false;
|
|
189
|
+
connectedCallback(): void { this.#connected = true; this.#load(); }
|
|
190
|
+
disconnectedCallback(): void { this.#connected = false; this.#teardown(); }
|
|
191
|
+
// An upgrade reports initial attributes before connecting; only later changes reload.
|
|
192
|
+
attributeChangedCallback(_name: string, previous: string | null, next: string | null): void {
|
|
193
|
+
if (previous !== next && this.#connected && !this.#data) this.#load();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#teardown(): void {
|
|
197
|
+
this.#abort?.abort();
|
|
198
|
+
this.#abort = null;
|
|
199
|
+
if (this.#expanded) document.body.style.overflow = this.#overflow;
|
|
200
|
+
this.#expanded = false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#load(): void {
|
|
204
|
+
this.#teardown();
|
|
205
|
+
const abort = new AbortController();
|
|
206
|
+
this.#abort = abort;
|
|
207
|
+
this.#rendered = false;
|
|
208
|
+
this.classList.add("shots-host");
|
|
209
|
+
if (this.#data) { this.#render(this.#data); return; }
|
|
210
|
+
const url = this.getAttribute("manifest");
|
|
211
|
+
status(this, "loading", "Loading screenshots…");
|
|
212
|
+
if (!url) { this.#fail(new Error("screenshot-gallery needs a manifest attribute.")); return; }
|
|
213
|
+
loadManifest(url, abort.signal).then(manifest => { if (!abort.signal.aborted) this.#render(manifest); }, (error: unknown) => {
|
|
214
|
+
if (!abort.signal.aborted) this.#fail(error);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#fail(error: unknown): void {
|
|
219
|
+
status(this, "error", this.getAttribute("error-message") ?? "Screenshots didn't load. Refresh to try again.");
|
|
220
|
+
this.dispatchEvent(new CustomEvent("screenshot-error", { bubbles: true, detail: { error } }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#basePath(): string { return this.getAttribute("base-path") ?? "/screenshots"; }
|
|
224
|
+
|
|
225
|
+
#render(manifest: ScreenshotManifest): void {
|
|
226
|
+
this.#manifest = manifest;
|
|
227
|
+
if (!manifest.entries.length) { status(this, "empty", this.getAttribute("empty-message") ?? "Screenshots aren't available yet."); return; }
|
|
228
|
+
const deepLink = typeof location === "undefined" ? undefined : parseScreenshotPath(this.#basePath(), location.pathname);
|
|
229
|
+
const requestedProject = this.getAttribute("initial-project") ?? deepLink?.project;
|
|
230
|
+
const requestedScreen = this.getAttribute("initial-screen") ?? deepLink?.screen;
|
|
231
|
+
this.#project = startingProject(manifest, requestedProject, prefers("(max-width: 699px)"));
|
|
232
|
+
const screen = startingScreen(manifest, this.#project, requestedScreen);
|
|
233
|
+
this.#theme = startingTheme(manifest, this.#project, screen, prefers("(prefers-color-scheme: dark)"));
|
|
234
|
+
const signal = this.#abort!.signal;
|
|
235
|
+
|
|
236
|
+
const title = element("p", { class: "shots-title", "aria-live": "polite" });
|
|
237
|
+
const toolbar = element("div", { class: "shots-toolbar" }, [title]);
|
|
238
|
+
if (manifest.projects.length > 1) {
|
|
239
|
+
const toggle = element("div", { class: "shots-toggle", role: "group", "aria-label": "Device" });
|
|
240
|
+
for (const project of manifest.projects) {
|
|
241
|
+
const button = element("button", { type: "button", class: "shots-toggle-button", "data-project": project.name, "aria-pressed": "false" }, [icon(projectFrame(manifest, project.name)), element("span", { text: project.label })]);
|
|
242
|
+
button.addEventListener("click", () => this.select({ project: project.name }), { signal });
|
|
243
|
+
toggle.append(button);
|
|
244
|
+
}
|
|
245
|
+
toolbar.append(toggle);
|
|
246
|
+
}
|
|
247
|
+
const actions = element("div", { class: "shots-actions" });
|
|
248
|
+
if (manifest.themes.length > 1) {
|
|
249
|
+
const theme = element("button", { type: "button", class: "shots-icon-button", "data-action": "theme" }, [icon("theme")]);
|
|
250
|
+
theme.addEventListener("click", () => {
|
|
251
|
+
const themes = manifest.themes.map(item => item.name);
|
|
252
|
+
const theme = themes[wrapIndex(themes.indexOf(this.#theme) + 1, themes.length)];
|
|
253
|
+
if (theme) this.select({ theme });
|
|
254
|
+
}, { signal });
|
|
255
|
+
actions.append(theme);
|
|
256
|
+
}
|
|
257
|
+
const expand = element("button", { type: "button", class: "shots-icon-button", "data-action": "expand", "aria-pressed": "false", "aria-label": "Expand viewer", title: "Expand viewer" }, [icon("expand")]);
|
|
258
|
+
expand.addEventListener("click", () => this.#setExpanded(!this.#expanded), { signal });
|
|
259
|
+
actions.append(expand);
|
|
260
|
+
toolbar.append(actions);
|
|
261
|
+
|
|
262
|
+
const track = element("div", { class: "shots-track", tabindex: "0", role: "group", "aria-roledescription": "carousel" });
|
|
263
|
+
onScrollSettle(track, () => this.#settle(), signal);
|
|
264
|
+
const previous = element("button", { type: "button", class: "shots-icon-button", "data-action": "previous", "aria-label": "Previous screen" }, [icon("prev")]);
|
|
265
|
+
const next = element("button", { type: "button", class: "shots-icon-button", "data-action": "next", "aria-label": "Next screen" }, [icon("next")]);
|
|
266
|
+
previous.addEventListener("click", () => this.step(-1), { signal });
|
|
267
|
+
next.addEventListener("click", () => this.step(1), { signal });
|
|
268
|
+
const counter = element("p", { class: "shots-counter" });
|
|
269
|
+
const strip = element("nav", { class: "shots-filmstrip", "aria-label": "Screens" });
|
|
270
|
+
const lightbox = element("dialog", { class: "shots-lightbox" });
|
|
271
|
+
lightbox.addEventListener("close", () => { lightbox.replaceChildren(); this.#track?.querySelector<HTMLElement>(`.shots-slide[data-index="${this.#index}"] .shots-frame`)?.focus({ preventScroll: true }); }, { signal });
|
|
272
|
+
const root = element("div", { class: "shots-gallery", role: "region", "aria-label": this.getAttribute("label") ?? "Screenshots", "data-state": "ready" }, [
|
|
273
|
+
toolbar,
|
|
274
|
+
element("div", { class: "shots-body" }, [track, element("div", { class: "shots-navrow" }, [previous, counter, next]), strip]),
|
|
275
|
+
lightbox,
|
|
276
|
+
]);
|
|
277
|
+
this.#root = root; this.#track = track; this.#strip = strip; this.#counter = counter; this.#title = title; this.#lightbox = lightbox;
|
|
278
|
+
this.replaceChildren(root);
|
|
279
|
+
|
|
280
|
+
if (typeof ResizeObserver === "function") {
|
|
281
|
+
const observer = new ResizeObserver(() => this.#fit());
|
|
282
|
+
observer.observe(track);
|
|
283
|
+
signal.addEventListener("abort", () => observer.disconnect());
|
|
284
|
+
}
|
|
285
|
+
window.addEventListener("keydown", (event: KeyboardEvent) => this.#key(event), { signal });
|
|
286
|
+
this.#renderProject(screen);
|
|
287
|
+
this.#rendered = true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#key(event: KeyboardEvent): void {
|
|
291
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || editable(event.target)) return;
|
|
292
|
+
if (event.key === "Escape" && this.#expanded && !this.#lightbox?.open) { event.preventDefault(); this.#setExpanded(false); return; }
|
|
293
|
+
const inside = this.contains(document.activeElement);
|
|
294
|
+
// With several galleries on a page, Left/Right outside every gallery steps the first.
|
|
295
|
+
if (!inside && (document.activeElement?.closest(SCREENSHOT_ELEMENTS.gallery) || document.querySelector(SCREENSHOT_ELEMENTS.gallery) !== this)) return;
|
|
296
|
+
if (event.key === "ArrowRight" || (inside && event.key === "ArrowDown")) { event.preventDefault(); this.step(1); }
|
|
297
|
+
else if (event.key === "ArrowLeft" || (inside && event.key === "ArrowUp")) { event.preventDefault(); this.step(-1); }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#setExpanded(expanded: boolean): void {
|
|
301
|
+
if (expanded === this.#expanded || !this.#root) return;
|
|
302
|
+
this.#expanded = expanded;
|
|
303
|
+
this.#root.toggleAttribute("data-expanded", expanded);
|
|
304
|
+
if (expanded) { this.#overflow = document.body.style.overflow; document.body.style.overflow = "hidden"; }
|
|
305
|
+
else document.body.style.overflow = this.#overflow;
|
|
306
|
+
const button = this.#root.querySelector<HTMLButtonElement>('[data-action="expand"]');
|
|
307
|
+
if (button) {
|
|
308
|
+
button.setAttribute("aria-pressed", String(expanded));
|
|
309
|
+
button.setAttribute("aria-label", expanded ? "Collapse viewer" : "Expand viewer");
|
|
310
|
+
button.title = button.getAttribute("aria-label")!;
|
|
311
|
+
button.replaceChildren(icon(expanded ? "collapse" : "expand"));
|
|
312
|
+
button.focus({ preventScroll: true });
|
|
313
|
+
}
|
|
314
|
+
this.#fit();
|
|
315
|
+
this.#scrollTo(this.#index, "instant");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Rebuilds slides and thumbnails for the selected project, keeping `screen` where it exists. */
|
|
319
|
+
#renderProject(screen: string | undefined): void {
|
|
320
|
+
const manifest = this.#manifest!, track = this.#track!, strip = this.#strip!;
|
|
321
|
+
this.#screens = projectScreens(manifest, this.#project);
|
|
322
|
+
this.#index = Math.max(0, screen === undefined ? 0 : this.#screens.indexOf(screen));
|
|
323
|
+
this.#loaded.clear();
|
|
324
|
+
const frame = projectFrame(manifest, this.#project);
|
|
325
|
+
this.#root!.dataset["frame"] = frame;
|
|
326
|
+
this.#root!.dataset["project"] = this.#project;
|
|
327
|
+
for (const button of this.#root!.querySelectorAll<HTMLButtonElement>(".shots-toggle-button")) button.setAttribute("aria-pressed", String(button.dataset["project"] === this.#project));
|
|
328
|
+
track.setAttribute("aria-label", `${projectInfo(manifest, this.#project).label} screens`);
|
|
329
|
+
track.replaceChildren(...this.#screens.map((_, index) => element("div", { class: "shots-slide", "data-index": index, role: "group", "aria-roledescription": "slide" })));
|
|
330
|
+
strip.replaceChildren(...this.#screens.map((name, index) => {
|
|
331
|
+
const button = element("button", { type: "button", class: "shots-thumb", "data-index": index, "aria-current": "false" }, [element("span", { class: "shots-thumb-image" }), element("span", { class: "shots-thumb-label", text: screenInfo(manifest, name).label })]);
|
|
332
|
+
button.addEventListener("click", () => this.#select(index, scrollBehavior()), { signal: this.#abort!.signal });
|
|
333
|
+
return button;
|
|
334
|
+
}));
|
|
335
|
+
this.#renderTheme();
|
|
336
|
+
this.#fit();
|
|
337
|
+
this.#select(this.#index, "instant", true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The capture for `name` in the current theme, else in any theme (for thumbnails). */
|
|
341
|
+
#entry(name: string, anyTheme = false): ScreenshotEntry | undefined {
|
|
342
|
+
const manifest = this.#manifest!;
|
|
343
|
+
return findScreenshot(manifest, this.#project, this.#theme, name)
|
|
344
|
+
?? (anyTheme ? manifest.themes.map(theme => findScreenshot(manifest, this.#project, theme.name, name)).find(Boolean) : undefined);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#alt(name: string): string {
|
|
348
|
+
const manifest = this.#manifest!;
|
|
349
|
+
const screen = screenInfo(manifest, name);
|
|
350
|
+
return screen.alt ?? `${screen.label}, ${projectInfo(manifest, this.#project).label.toLowerCase()} layout, ${themeInfo(manifest, this.#theme).label.toLowerCase()} theme`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#renderTheme(): void {
|
|
354
|
+
const manifest = this.#manifest!, signal = this.#abort!.signal;
|
|
355
|
+
const themes = manifest.themes.map(theme => theme.name);
|
|
356
|
+
const button = this.#root!.querySelector<HTMLButtonElement>('[data-action="theme"]');
|
|
357
|
+
if (button) {
|
|
358
|
+
const label = `Show ${themeInfo(manifest, themes[wrapIndex(themes.indexOf(this.#theme) + 1, themes.length)]!).label.toLowerCase()} theme`;
|
|
359
|
+
button.setAttribute("aria-label", label);
|
|
360
|
+
button.title = label;
|
|
361
|
+
}
|
|
362
|
+
this.#root!.dataset["theme"] = this.#theme;
|
|
363
|
+
this.#loaded.clear();
|
|
364
|
+
for (const slide of this.#track!.querySelectorAll<HTMLElement>(".shots-slide")) {
|
|
365
|
+
const index = Number(slide.dataset["index"]), name = this.#screens[index]!, entry = this.#entry(name);
|
|
366
|
+
slide.setAttribute("aria-label", `${index + 1} of ${this.#screens.length}: ${screenInfo(manifest, name).label}`);
|
|
367
|
+
if (!entry) {
|
|
368
|
+
const screen = screenInfo(manifest, name).label;
|
|
369
|
+
slide.replaceChildren(element("div", { class: "shots-frame shots-missing" }, [element("p", { text: `No ${themeInfo(manifest, this.#theme).label.toLowerCase()}-theme capture of ${screen} in the ${projectInfo(manifest, this.#project).label.toLowerCase()} layout.` })]));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const image = element("img", { class: "shots-image", alt: this.#alt(name), width: entry.width, height: entry.height, decoding: "async", "data-src": entry.src });
|
|
373
|
+
const frame = element("button", { type: "button", class: "shots-frame", "aria-label": `View full size: ${this.#alt(name)}` }, [image]);
|
|
374
|
+
frame.addEventListener("click", () => { if (index === this.#index) this.#openLightbox(); else this.#select(index, scrollBehavior()); }, { signal });
|
|
375
|
+
slide.replaceChildren(frame);
|
|
376
|
+
}
|
|
377
|
+
for (const thumb of this.#strip!.querySelectorAll<HTMLElement>(".shots-thumb")) {
|
|
378
|
+
const name = this.#screens[Number(thumb.dataset["index"])]!, entry = this.#entry(name, true);
|
|
379
|
+
const holder = thumb.querySelector(".shots-thumb-image")!;
|
|
380
|
+
thumb.setAttribute("aria-label", screenInfo(manifest, name).label);
|
|
381
|
+
if (!entry) { holder.replaceChildren(element("span", { class: "shots-thumb-missing" })); continue; }
|
|
382
|
+
holder.setAttribute("style", `aspect-ratio: ${entry.width} / ${entry.height}`);
|
|
383
|
+
holder.replaceChildren(element("img", { src: entry.src, alt: "", loading: "lazy", decoding: "async", width: entry.width, height: entry.height }));
|
|
384
|
+
}
|
|
385
|
+
this.#fit();
|
|
386
|
+
this.#loadNear();
|
|
387
|
+
if (this.#lightbox?.open) this.#fillLightbox();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Sizes every frame from its capture's pixel size, scaled to fit the stage. */
|
|
391
|
+
#fit(): void {
|
|
392
|
+
const track = this.#track;
|
|
393
|
+
const slide = track?.querySelector<HTMLElement>(".shots-slide");
|
|
394
|
+
if (!track || !slide || !slide.clientWidth) return;
|
|
395
|
+
const style = getComputedStyle(slide);
|
|
396
|
+
const width = slide.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
397
|
+
const height = slide.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
|
398
|
+
const frame = projectFrame(this.#manifest!, this.#project);
|
|
399
|
+
for (const image of track.querySelectorAll<HTMLImageElement>(".shots-image")) {
|
|
400
|
+
const fit = fitFrame(frame, Number(image.getAttribute("width")), Number(image.getAttribute("height")), width, height);
|
|
401
|
+
image.style.width = `${fit.width}px`;
|
|
402
|
+
image.style.height = `${fit.height}px`;
|
|
403
|
+
const holder = image.parentElement!;
|
|
404
|
+
holder.style.setProperty("--shots-frame-pad", `${fit.pad}px`);
|
|
405
|
+
holder.style.setProperty("--shots-frame-radius", `${fit.radius}px`);
|
|
406
|
+
}
|
|
407
|
+
if (this.#target === null) this.#scrollTo(this.#index, "instant");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Loads the current capture and its neighbors; the rest wait until they are near. */
|
|
411
|
+
#loadNear(): void {
|
|
412
|
+
const count = this.#screens.length;
|
|
413
|
+
for (const index of new Set([this.#index, wrapIndex(this.#index - 1, count), wrapIndex(this.#index + 1, count)])) {
|
|
414
|
+
if (this.#loaded.has(index)) continue;
|
|
415
|
+
const image = this.#track?.querySelector<HTMLImageElement>(`.shots-slide[data-index="${index}"] img[data-src]`);
|
|
416
|
+
if (image) { image.src = image.dataset["src"]!; image.removeAttribute("data-src"); }
|
|
417
|
+
this.#loaded.add(index);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#scrollTo(index: number, behavior: ScrollBehavior): void {
|
|
422
|
+
const track = this.#track, slide = track?.querySelector<HTMLElement>(`.shots-slide[data-index="${index}"]`);
|
|
423
|
+
if (!track || !slide || !track.clientWidth) { this.#target = null; return; }
|
|
424
|
+
const left = slide.offsetLeft - (track.firstElementChild as HTMLElement).offsetLeft;
|
|
425
|
+
if (Math.abs(track.scrollLeft - left) < 1) { this.#target = null; return; }
|
|
426
|
+
this.#target = behavior === "instant" ? null : index;
|
|
427
|
+
track.scrollTo({ left, behavior });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A user scroll that came to rest selects the screen it shows. */
|
|
431
|
+
#settle(): void {
|
|
432
|
+
const track = this.#track;
|
|
433
|
+
if (!track || !track.clientWidth || !this.#screens.length) return;
|
|
434
|
+
const slides = track.querySelectorAll<HTMLElement>(".shots-slide");
|
|
435
|
+
const pitch = slides.length > 1 ? slides[1]!.offsetLeft - slides[0]!.offsetLeft : track.clientWidth;
|
|
436
|
+
const index = nearestSlide(track.scrollLeft, 0, pitch, this.#screens.length);
|
|
437
|
+
const target = this.#target;
|
|
438
|
+
this.#target = null;
|
|
439
|
+
if (target !== null && index === target) return;
|
|
440
|
+
if (index !== this.#index) this.#select(index, false);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#select(index: number, scroll: ScrollBehavior | false, initial = false): void {
|
|
444
|
+
const count = this.#screens.length;
|
|
445
|
+
if (!count) return;
|
|
446
|
+
const previous = this.#index;
|
|
447
|
+
this.#index = wrapIndex(index, count);
|
|
448
|
+
// Wrapping past an end jumps rather than scrolling across every screen.
|
|
449
|
+
const wrapped = count > 2 && Math.abs(this.#index - previous) === count - 1;
|
|
450
|
+
if (scroll) this.#scrollTo(this.#index, wrapped ? "instant" : scroll);
|
|
451
|
+
this.#loadNear();
|
|
452
|
+
const manifest = this.#manifest!, name = this.#screens[this.#index]!;
|
|
453
|
+
const label = screenInfo(manifest, name).label;
|
|
454
|
+
this.#title!.textContent = label;
|
|
455
|
+
this.#counter!.textContent = `${this.#index + 1} / ${count}`;
|
|
456
|
+
for (const button of this.#root!.querySelectorAll<HTMLButtonElement>('[data-action="previous"], [data-action="next"]')) button.disabled = count < 2;
|
|
457
|
+
for (const slide of this.#track!.querySelectorAll<HTMLElement>(".shots-slide")) slide.toggleAttribute("data-active", Number(slide.dataset["index"]) === this.#index);
|
|
458
|
+
const thumbs = this.#strip!.querySelectorAll<HTMLElement>(".shots-thumb");
|
|
459
|
+
for (const thumb of thumbs) thumb.setAttribute("aria-current", String(Number(thumb.dataset["index"]) === this.#index));
|
|
460
|
+
this.#revealThumb(thumbs[this.#index]);
|
|
461
|
+
if (this.#lightbox?.open) this.#fillLightbox();
|
|
462
|
+
const key = `${this.#project} ${this.#theme} ${name}`;
|
|
463
|
+
if (key === this.#announced) return;
|
|
464
|
+
this.#announced = key;
|
|
465
|
+
if (this.#rendered || !initial) this.#syncUrl(name);
|
|
466
|
+
this.dispatchEvent(new CustomEvent<ScreenshotChangeDetail>("screenshot-change", { bubbles: true, detail: { project: this.#project, theme: this.#theme, screen: name, index: this.#index, total: count } }));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Keeps the active thumbnail visible by scrolling only the strip, never the page. */
|
|
470
|
+
#revealThumb(thumb: HTMLElement | undefined): void {
|
|
471
|
+
const strip = this.#strip;
|
|
472
|
+
if (!strip || !thumb || !strip.clientWidth) return;
|
|
473
|
+
const start = thumb.offsetLeft - (strip.firstElementChild as HTMLElement).offsetLeft;
|
|
474
|
+
const end = start + thumb.offsetWidth;
|
|
475
|
+
if (start < strip.scrollLeft) strip.scrollTo({ left: start, behavior: scrollBehavior() });
|
|
476
|
+
else if (end > strip.scrollLeft + strip.clientWidth) strip.scrollTo({ left: end - strip.clientWidth, behavior: scrollBehavior() });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** `/<base>/<project>/<screen>/` with replaceState, so stepping adds no history; the first render never rewrites the URL. */
|
|
480
|
+
#syncUrl(name: string): void {
|
|
481
|
+
if (this.hasAttribute("no-history") || typeof history === "undefined") return;
|
|
482
|
+
const manifest = this.#manifest!;
|
|
483
|
+
const heading = formatScreenshotTemplate(this.getAttribute("heading-template") ?? DEFAULT_HEADING_TEMPLATE, manifest, this.#project, name);
|
|
484
|
+
history.replaceState(history.state, "", `${screenshotPath(this.#basePath(), this.#project, name)}${location.search}${location.hash}`);
|
|
485
|
+
document.title = formatScreenshotTemplate(this.getAttribute("title-template") ?? "{heading}", manifest, this.#project, name, heading);
|
|
486
|
+
const id = this.getAttribute("heading-id");
|
|
487
|
+
const target = id ? document.getElementById(id) : null;
|
|
488
|
+
if (target) target.textContent = heading;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#openLightbox(): void {
|
|
492
|
+
const lightbox = this.#lightbox;
|
|
493
|
+
if (!lightbox || lightbox.open) return;
|
|
494
|
+
this.#fillLightbox();
|
|
495
|
+
if (typeof lightbox.showModal === "function") lightbox.showModal(); else lightbox.setAttribute("open", "");
|
|
496
|
+
lightbox.querySelector<HTMLButtonElement>(".shots-lightbox-close")?.focus();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#fillLightbox(): void {
|
|
500
|
+
const lightbox = this.#lightbox!, name = this.#screens[this.#index]!, entry = this.#entry(name);
|
|
501
|
+
const close = () => { if (typeof lightbox.close === "function") lightbox.close(); else { lightbox.removeAttribute("open"); lightbox.dispatchEvent(new Event("close")); } };
|
|
502
|
+
const backdrop = element("button", { type: "button", class: "shots-lightbox-backdrop", "aria-label": "Close", tabindex: "-1" });
|
|
503
|
+
const button = element("button", { type: "button", class: "shots-lightbox-close", "aria-label": "Close" }, [icon("close")]);
|
|
504
|
+
backdrop.addEventListener("click", close);
|
|
505
|
+
button.addEventListener("click", close);
|
|
506
|
+
lightbox.setAttribute("aria-label", this.#alt(name));
|
|
507
|
+
const frame = projectFrame(this.#manifest!, this.#project);
|
|
508
|
+
let content: HTMLElement;
|
|
509
|
+
if (entry) {
|
|
510
|
+
const image = element("img", { class: "shots-lightbox-image", src: entry.src, alt: this.#alt(name), width: entry.width, height: entry.height });
|
|
511
|
+
content = element("div", { class: "shots-lightbox-frame" }, [image]);
|
|
512
|
+
// The viewport less the room the stylesheet leaves around the image.
|
|
513
|
+
const fit = fitFrame(frame, entry.width, entry.height, innerWidth * 0.94, innerHeight * 0.92);
|
|
514
|
+
if (fit.width > 0) {
|
|
515
|
+
image.style.width = `${fit.width}px`;
|
|
516
|
+
image.style.height = `${fit.height}px`;
|
|
517
|
+
content.style.setProperty("--shots-frame-pad", `${fit.pad}px`);
|
|
518
|
+
content.style.setProperty("--shots-frame-radius", `${fit.radius}px`);
|
|
519
|
+
}
|
|
520
|
+
} else content = element("p", { class: "shots-missing", text: `No capture of ${screenInfo(this.#manifest!, name).label} in this theme.` });
|
|
521
|
+
lightbox.dataset["frame"] = frame;
|
|
522
|
+
lightbox.replaceChildren(backdrop, content, button);
|
|
523
|
+
if (lightbox.open) button.focus({ preventScroll: true });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
select(selection: { project?: string; theme?: string; screen?: string }): void {
|
|
527
|
+
const manifest = this.#manifest;
|
|
528
|
+
if (!manifest || !this.#root) return;
|
|
529
|
+
const screen = selection.screen ?? this.screen;
|
|
530
|
+
if (selection.project && selection.project !== this.#project && manifest.projects.some(project => project.name === selection.project)) {
|
|
531
|
+
this.#project = selection.project;
|
|
532
|
+
const next = startingScreen(manifest, this.#project, screen);
|
|
533
|
+
if (selection.theme && manifest.themes.some(theme => theme.name === selection.theme)) this.#theme = selection.theme;
|
|
534
|
+
else if (next !== undefined && !findScreenshot(manifest, this.#project, this.#theme, next)) this.#theme = startingTheme(manifest, this.#project, next, this.#theme === "dark");
|
|
535
|
+
this.#renderProject(next);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (selection.theme && selection.theme !== this.#theme && manifest.themes.some(theme => theme.name === selection.theme)) {
|
|
539
|
+
this.#theme = selection.theme;
|
|
540
|
+
this.#renderTheme();
|
|
541
|
+
this.#announced = "";
|
|
542
|
+
this.#select(this.#index, false);
|
|
543
|
+
}
|
|
544
|
+
if (selection.screen && this.#screens.includes(selection.screen)) this.#select(this.#screens.indexOf(selection.screen), scrollBehavior());
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
step(delta: number): void { this.#select(this.#index + delta, scrollBehavior()); }
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function defineCarousel(): CustomElementConstructor {
|
|
552
|
+
return class ScreenshotCarousel extends HTMLElement implements ScreenshotCarouselElement {
|
|
553
|
+
static observedAttributes = ["manifest", "project", "limit"];
|
|
554
|
+
#data: ScreenshotManifest | null = null;
|
|
555
|
+
#abort: AbortController | null = null;
|
|
556
|
+
#track: HTMLDivElement | null = null;
|
|
557
|
+
#count = 0;
|
|
558
|
+
#copies = 1;
|
|
559
|
+
#slides: HTMLElement[] = [];
|
|
560
|
+
#manifest: ScreenshotManifest | null = null;
|
|
561
|
+
|
|
562
|
+
get manifestData(): ScreenshotManifest | null { return this.#data; }
|
|
563
|
+
set manifestData(value: ScreenshotManifest | null) { this.#data = value && parseScreenshotManifest(value); if (this.#connected) this.#load(); }
|
|
564
|
+
|
|
565
|
+
#connected = false;
|
|
566
|
+
connectedCallback(): void { this.#connected = true; this.#load(); }
|
|
567
|
+
disconnectedCallback(): void { this.#connected = false; this.#abort?.abort(); this.#abort = null; }
|
|
568
|
+
attributeChangedCallback(_name: string, previous: string | null, next: string | null): void {
|
|
569
|
+
if (previous !== next && this.#connected) this.#load();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
#load(): void {
|
|
573
|
+
this.#abort?.abort();
|
|
574
|
+
const abort = new AbortController();
|
|
575
|
+
this.#abort = abort;
|
|
576
|
+
this.classList.add("shots-host");
|
|
577
|
+
if (this.#data) { this.#render(this.#data); return; }
|
|
578
|
+
const url = this.getAttribute("manifest");
|
|
579
|
+
if (!url) return;
|
|
580
|
+
loadManifest(url, abort.signal).then(manifest => { if (!abort.signal.aborted) this.#render(manifest); }, (error: unknown) => {
|
|
581
|
+
if (abort.signal.aborted) return;
|
|
582
|
+
// The fallback children stay: a home page reads well without the carousel.
|
|
583
|
+
this.dispatchEvent(new CustomEvent("screenshot-error", { bubbles: true, detail: { error } }));
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** One slide per captured screen, in manifest order, each with its light and dark captures. */
|
|
588
|
+
#slidesFor(manifest: ScreenshotManifest): { name: string; light: ScreenshotEntry; dark: ScreenshotEntry | undefined }[] {
|
|
589
|
+
const project = this.getAttribute("project") ?? startingProject(manifest, "mobile", true);
|
|
590
|
+
const limit = Number.parseInt(this.getAttribute("limit") ?? "", 10);
|
|
591
|
+
const themes = manifest.themes.map(theme => theme.name);
|
|
592
|
+
const light = themes.includes("light") ? "light" : themes[0]!;
|
|
593
|
+
const slides = projectScreens(manifest, project).flatMap(name => {
|
|
594
|
+
const base = findScreenshot(manifest, project, light, name) ?? themes.map(theme => findScreenshot(manifest, project, theme, name)).find(Boolean);
|
|
595
|
+
if (!base) return [];
|
|
596
|
+
const dark = findScreenshot(manifest, project, "dark", name);
|
|
597
|
+
return [{ name, light: base, dark: dark === base ? undefined : dark }];
|
|
598
|
+
});
|
|
599
|
+
return limit > 0 ? slides.slice(0, limit) : slides;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#render(manifest: ScreenshotManifest, keep?: number): void {
|
|
603
|
+
this.#manifest = manifest;
|
|
604
|
+
const slides = this.#slidesFor(manifest);
|
|
605
|
+
const signal = this.#abort!.signal;
|
|
606
|
+
this.#count = slides.length;
|
|
607
|
+
if (!slides.length) { this.replaceChildren(); this.hidden = true; this.dataset["state"] = "empty"; return; }
|
|
608
|
+
this.hidden = false;
|
|
609
|
+
this.dataset["state"] = "ready";
|
|
610
|
+
const project = slides[0]!.light.project;
|
|
611
|
+
const frame = projectFrame(manifest, project);
|
|
612
|
+
const linkBase = this.getAttribute("link-base");
|
|
613
|
+
const loop = slides.length > 1;
|
|
614
|
+
const copies = loop ? this.#copies : 0;
|
|
615
|
+
const list = element("ul", { class: "shots-carousel-list", role: "list" });
|
|
616
|
+
this.#slides = [];
|
|
617
|
+
for (let copy = -copies; copy <= copies; copy += 1) {
|
|
618
|
+
slides.forEach((slide, index) => {
|
|
619
|
+
const clone = copy !== 0;
|
|
620
|
+
const screen = screenInfo(manifest, slide.name);
|
|
621
|
+
const picture = element("picture", {}, slide.dark ? [element("source", { media: "(prefers-color-scheme: dark)", srcset: slide.dark.src, width: slide.dark.width, height: slide.dark.height })] : []);
|
|
622
|
+
picture.append(element("img", {
|
|
623
|
+
src: slide.light.src, alt: clone ? "" : screen.alt ?? `${screen.label}, ${projectInfo(manifest, project).label.toLowerCase()} screenshot`,
|
|
624
|
+
width: slide.light.width, height: slide.light.height, loading: !clone && index < 3 ? "eager" : "lazy", decoding: "async",
|
|
625
|
+
}));
|
|
626
|
+
const device = linkBase
|
|
627
|
+
? element("a", { class: "shots-device", href: screenshotPath(linkBase, project, slide.name), tabindex: clone ? "-1" : undefined }, [picture])
|
|
628
|
+
: element("div", { class: "shots-device" }, [picture]);
|
|
629
|
+
// Copies stay clickable where they show, but out of the accessibility tree and tab order.
|
|
630
|
+
const item = element("li", { class: "shots-carousel-slide", "data-index": index, "aria-hidden": clone ? "true" : undefined, "data-clone": clone || undefined }, [
|
|
631
|
+
element("figure", {}, [device, element("figcaption", { text: screen.label })]),
|
|
632
|
+
]);
|
|
633
|
+
this.#slides.push(item);
|
|
634
|
+
list.append(item);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
const track = element("div", { class: "shots-carousel-track", tabindex: "0" }, [list]);
|
|
638
|
+
this.#track = track;
|
|
639
|
+
const root = element("div", { class: "shots-carousel", role: "region", "aria-roledescription": "carousel", "aria-label": this.getAttribute("label") ?? "Screenshots", "data-frame": frame }, [track]);
|
|
640
|
+
if (loop) {
|
|
641
|
+
const previous = element("button", { type: "button", class: "shots-carousel-step", "data-step": "-1", "aria-label": "Previous screens" }, [icon("prev")]);
|
|
642
|
+
const next = element("button", { type: "button", class: "shots-carousel-step", "data-step": "1", "aria-label": "Next screens" }, [icon("next")]);
|
|
643
|
+
previous.addEventListener("click", () => this.step(-1), { signal });
|
|
644
|
+
next.addEventListener("click", () => this.step(1), { signal });
|
|
645
|
+
root.append(element("div", { class: "shots-carousel-controls" }, [previous, next]));
|
|
646
|
+
onScrollSettle(track, () => this.#normalize(), signal);
|
|
647
|
+
if (typeof ResizeObserver === "function") {
|
|
648
|
+
const observer = new ResizeObserver(() => this.#resize());
|
|
649
|
+
observer.observe(track);
|
|
650
|
+
signal.addEventListener("abort", () => observer.disconnect());
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
this.replaceChildren(root);
|
|
654
|
+
if (loop) this.#jump(copies * slides.length + (keep ?? 0));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
#pitch(): number {
|
|
658
|
+
const [first, second] = this.#slides;
|
|
659
|
+
return first && second ? second.offsetLeft - first.offsetLeft : 0;
|
|
660
|
+
}
|
|
661
|
+
/** Scroll position that rests slide 0 at the start edge. */
|
|
662
|
+
#offset(): number {
|
|
663
|
+
const track = this.#track!, padding = parseFloat(getComputedStyle(track).scrollPaddingLeft);
|
|
664
|
+
return this.#slides[0]!.offsetLeft - (Number.isFinite(padding) ? padding : 0);
|
|
665
|
+
}
|
|
666
|
+
#current(): number { return nearestSlide(this.#track!.scrollLeft, this.#offset(), this.#pitch(), this.#slides.length); }
|
|
667
|
+
#jump(slide: number): void {
|
|
668
|
+
const track = this.#track;
|
|
669
|
+
if (!track || !this.#pitch()) return;
|
|
670
|
+
track.scrollTo({ left: this.#offset() + slide * this.#pitch(), behavior: "instant" });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Silently moves a settled track back to the same slide in the middle copy. */
|
|
674
|
+
#normalize(): void {
|
|
675
|
+
const track = this.#track, pitch = this.#pitch();
|
|
676
|
+
if (!track || !pitch) return;
|
|
677
|
+
const current = this.#current(), target = loopSlide(current, this.#count, this.#copies);
|
|
678
|
+
// Only a track resting on a slide jumps; a snap still in progress settles again.
|
|
679
|
+
if (Math.abs(track.scrollLeft - (this.#offset() + current * pitch)) > 2) return;
|
|
680
|
+
if (target !== current) this.#jump(target);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
#resize(): void {
|
|
684
|
+
const track = this.#track, pitch = this.#pitch();
|
|
685
|
+
if (!track || !pitch || !this.#manifest) return;
|
|
686
|
+
const copies = loopCopies(track.clientWidth, pitch * this.#count);
|
|
687
|
+
if (copies > this.#copies) {
|
|
688
|
+
const logical = wrapIndex(this.#current(), this.#count), manifest = this.#manifest, signal = this.#abort?.signal;
|
|
689
|
+
this.#copies = copies;
|
|
690
|
+
// Outside the observer callback, so the new slides do not resize the track within it.
|
|
691
|
+
requestAnimationFrame(() => { if (!signal?.aborted) this.#render(manifest, logical); });
|
|
692
|
+
} else this.#normalize();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
step(delta: number): void {
|
|
696
|
+
const track = this.#track, pitch = this.#pitch();
|
|
697
|
+
if (!track || !pitch) return;
|
|
698
|
+
this.#normalize();
|
|
699
|
+
// A page of every slide would show the same slides again, so then step one.
|
|
700
|
+
const visible = Math.max(1, Math.floor((track.clientWidth + 1) / pitch));
|
|
701
|
+
const fit = visible >= this.#count ? 1 : visible;
|
|
702
|
+
track.scrollTo({ left: this.#offset() + (this.#current() + delta * fit) * pitch, behavior: scrollBehavior() });
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export const SCREENSHOT_ELEMENTS = { gallery: "screenshot-gallery", carousel: "screenshot-carousel" } as const;
|
|
708
|
+
|
|
709
|
+
/** Registers `<screenshot-gallery>` and `<screenshot-carousel>`; calling it again does nothing. */
|
|
710
|
+
export function defineScreenshotElements(registry: CustomElementRegistry = customElements): void {
|
|
711
|
+
if (!registry.get(SCREENSHOT_ELEMENTS.gallery)) registry.define(SCREENSHOT_ELEMENTS.gallery, defineGallery());
|
|
712
|
+
if (!registry.get(SCREENSHOT_ELEMENTS.carousel)) registry.define(SCREENSHOT_ELEMENTS.carousel, defineCarousel());
|
|
713
|
+
}
|