From ff1b5e7e134c3715aa2ef283fa6e549d4d69a016 Mon Sep 17 00:00:00 2001 From: eti Date: Mon, 14 Sep 2026 14:57:11 +0200 Subject: [PATCH] web/components/ui: add the image lightbox Signed-off-by: eti --- .../lib/components/ui/Lightbox.stories.svelte | 79 +++ web/src/lib/components/ui/Lightbox.svelte | 539 ++++++++++++++++++ web/src/lib/lightbox.svelte.ts | 67 +++ 3 files changed, 685 insertions(+) create mode 100644 web/src/lib/components/ui/Lightbox.stories.svelte create mode 100644 web/src/lib/components/ui/Lightbox.svelte create mode 100644 web/src/lib/lightbox.svelte.ts diff --git a/web/src/lib/components/ui/Lightbox.stories.svelte b/web/src/lib/components/ui/Lightbox.stories.svelte new file mode 100644 index 000000000..8abfaa67f --- /dev/null +++ b/web/src/lib/components/ui/Lightbox.stories.svelte @@ -0,0 +1,79 @@ + + + + + + (single = false)} /> + + + + (gallery = false)} /> + + + +
+ {#each shots as shot (shot.src)} + {shot.alt} + {/each} + + Screenshot inside a link + +
+ lightbox.close()} + /> +
diff --git a/web/src/lib/components/ui/Lightbox.svelte b/web/src/lib/components/ui/Lightbox.svelte new file mode 100644 index 000000000..9772de21f --- /dev/null +++ b/web/src/lib/components/ui/Lightbox.svelte @@ -0,0 +1,539 @@ + + + + +{#if staged && current} + + + +
+
+ {current.alt} +
+
+ +
+ {#if images.length > 1} +

+ {index + 1} / {images.length} +

+ {/if} + +
+ {#if images.length > 1} +
+
+ +

+ Click the image to switch between fit and close-up, scroll to zoom toward the pointer, + or press plus and minus. Drag or use the arrow keys to pan. Press zero to return to the + starting frame; Escape returns home first, then closes. At the starting frame the left + and right arrows step through the images. +

+

Zoom {announced.toFixed(1)} times

+
+{/if} + + diff --git a/web/src/lib/lightbox.svelte.ts b/web/src/lib/lightbox.svelte.ts new file mode 100644 index 000000000..5332f3cd5 --- /dev/null +++ b/web/src/lib/lightbox.svelte.ts @@ -0,0 +1,67 @@ +export interface LightboxImage { + src: string; + alt: string; + width?: number; + height?: number; + origin?: HTMLElement; +} + +class LightboxState { + images = $state([]); + index = $state(0); + open = $state(false); + + readonly current = $derived(this.images[this.index]); + + show(images: LightboxImage[], index: number): void { + if (images.length === 0) return; + this.images = images; + this.index = Math.min(Math.max(index, 0), images.length - 1); + this.open = true; + } + + close(): void { + this.open = false; + } + + step(delta: number): void { + const count = this.images.length; + if (count < 2) return; + this.index = (this.index + delta + count) % count; + } +} + +export const lightbox = new LightboxState(); + +const zoomable = (image: HTMLImageElement): boolean => image.closest("a") === null; + +const describe = (image: HTMLImageElement): LightboxImage => ({ + src: image.currentSrc || image.src, + alt: image.alt, + width: image.naturalWidth || undefined, + height: image.naturalHeight || undefined, + origin: image +}); + +export const zoomMarkupImages = (): (() => void) => { + const onClick = (event: MouseEvent): void => { + if (event.defaultPrevented || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + const image = event.target; + if (!(image instanceof HTMLImageElement) || !zoomable(image)) return; + + const markup = image.closest(".markup"); + if (!markup) return; + + const gallery = [...markup.querySelectorAll("img")].filter(zoomable); + const index = gallery.indexOf(image); + if (index === -1) return; + + event.preventDefault(); + lightbox.show(gallery.map(describe), index); + }; + + document.addEventListener("click", onClick); + return () => document.removeEventListener("click", onClick); +}; -- 2.51.2