Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554<script module lang="ts"> import { tv } from "tailwind-variants";
export const lightboxChrome = tv({ slots: { veil: "absolute inset-0 bg-black/80", frame: "absolute inset-0 cursor-default touch-none overflow-hidden outline-none select-none", flight: "absolute inset-0 flex items-center justify-center p-4 sm:p-14", image: "size-auto max-h-full max-w-full", bar: "pointer-events-none absolute inset-0 flex items-start gap-2 p-3 sm:p-4", badge: "flex min-h-9.5 items-center rounded-sm border border-border-default bg-background-default px-3 typography-paragraph-small text-foreground-default", controls: "pointer-events-auto ml-auto flex items-center gap-2", button: "pointer-events-auto min-w-9 px-0" }, variants: { zoomed: { true: { image: "cursor-grab active:cursor-grabbing" }, false: { image: "cursor-zoom-in" } } }, defaultVariants: { zoomed: false } });</script>
<script lang="ts"> import { onDestroy, tick, untrack } from "svelte"; import type { Attachment } from "svelte/attachments"; import Button from "$lib/components/ui/Button.svelte"; import ChevronLeft from "$icon/chevron-left"; import ChevronRight from "$icon/chevron-right"; import X from "$icon/x"; import ZoomIn from "$icon/zoom-in"; import ZoomOut from "$icon/zoom-out"; import { CELL, HOME, Motion, VEIL } from "$lib/motion/value.svelte"; import type { LightboxImage } from "$lib/lightbox.svelte"; import ButtonGroup from "./ButtonGroup.svelte";
interface Props { open: boolean; images: LightboxImage[]; index?: number; maxScale?: number; onClose: () => void; }
let { open, images, index = $bindable(0), maxScale = 4, onClose }: Props = $props();
const TOGGLE = 2.5; const KEY_ZOOM = 1.6; const KEY_PAN = 56; const WHEEL_RATE = 140; const SLOP = 8; const NEAR_HOME = 1.02; const SNAP_HOME = 1.05; const RADIUS_FALLBACK = 4; const ORIGIN_RADIUS = 9; const ANNOUNCE = 220;
const restingRadius = (): number => { const root = getComputedStyle(document.documentElement); const raw = root.getPropertyValue("--radius-sm").trim(); const value = Number.parseFloat(raw); if (!Number.isFinite(value)) return RADIUS_FALLBACK; return raw.endsWith("rem") ? value * Number.parseFloat(root.fontSize) : value; };
const id = $props.id(); const hintId = `${id}-hint`;
let panel = $state<HTMLDialogElement>(); let frame = $state<HTMLElement>(); let content = $state<HTMLImageElement>(); let staged = $state(false); let closing = $state(false); let announced = $state(1); let generation = 0;
const scale = new Motion(1); const x = new Motion(0); const y = new Motion(0);
const flightX = new Motion(0); const flightY = new Motion(0); const flightScale = new Motion(1); const flightOpacity = new Motion(0); const flightRadius = new Motion(RADIUS_FALLBACK);
const veil = new Motion(0); const bar = new Motion(0);
const top = $derived(Math.max(1.1, maxScale)); const current = $derived(images[index]); const zoomed = $derived(scale.current > SNAP_HOME); const caption = $derived(current?.alt || `Image ${index + 1} of ${images.length}`); const classes = $derived(lightboxChrome({ zoomed }));
const reduced = (): boolean => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const clamp = (value: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, value));
const limit = (s: number): { mx: number; my: number } => { if (!frame || !content) return { mx: 0, my: 0 }; return { mx: Math.max(0, (content.offsetWidth * s - frame.clientWidth) / 2), my: Math.max(0, (content.offsetHeight * s - frame.clientHeight) / 2) }; };
const place = (s: number, nx: number, ny: number): void => { const { mx, my } = limit(s); scale.set(s); x.set(clamp(nx, -mx, mx)); y.set(clamp(ny, -my, my)); };
const glide = (s: number, nx: number, ny: number, spring = CELL): void => { const { mx, my } = limit(s); const tx = clamp(nx, -mx, mx); const ty = clamp(ny, -my, my); if (reduced()) { scale.set(s); x.set(tx); y.set(ty); return; } scale.animate(s, spring); x.animate(tx, spring); y.animate(ty, spring); };
const home = (): void => glide(1, 0, 0, HOME);
const zoomAt = (next: number, cx: number, cy: number, animated: boolean): void => { if (!frame) return; const box = frame.getBoundingClientRect(); const px = cx - (box.left + box.width / 2); const py = cy - (box.top + box.height / 2); const from = scale.current; const ax = (px - x.current) / from; const ay = (py - y.current) / from; const s = clamp(next, 1, top); const nx = px - ax * s; const ny = py - ay * s; if (animated) glide(s, nx, ny, s <= 1 ? HOME : CELL); else place(s, nx, ny); };
const toggleZoom = (): void => { if (!frame) return; if (zoomed) { home(); return; } const box = frame.getBoundingClientRect(); zoomAt(Math.min(TOGGLE, top), box.left + box.width / 2, box.top + box.height / 2, true); };
const step = (delta: number): void => { if (images.length < 2) return; index = (index + delta + images.length) % images.length; };
interface Drag { id: number; fromX: number; fromY: number; x: number; y: number; }
let drag: Drag | null = null; let onContent = false;
const finish = (): void => { if (scale.current < SNAP_HOME) home(); };
const onPointerDown = (event: PointerEvent): void => { if (event.pointerType === "mouse" && event.button !== 0) return; onContent = content ? content.contains(event.target as Node) : false; if (event.currentTarget instanceof HTMLElement) event.currentTarget.setPointerCapture(event.pointerId); drag = { id: event.pointerId, fromX: event.clientX, fromY: event.clientY, x: x.current, y: y.current }; };
const onPointerMove = (event: PointerEvent): void => { if (!drag || drag.id !== event.pointerId || scale.current <= 1) return; place( scale.current, drag.x + (event.clientX - drag.fromX), drag.y + (event.clientY - drag.fromY) ); };
const onPointerUp = (event: PointerEvent): void => { if (!drag || drag.id !== event.pointerId) return; const held = drag; drag = null; const moved = Math.hypot(event.clientX - held.fromX, event.clientY - held.fromY); if (moved < SLOP) { if (!onContent && scale.current <= NEAR_HOME) { onClose(); return; } if (onContent) { zoomAt( scale.current > SNAP_HOME ? 1 : Math.min(TOGGLE, top), event.clientX, event.clientY, true ); return; } } finish(); };
const onPointerCancel = (event: PointerEvent): void => { if (!drag || drag.id !== event.pointerId) return; drag = null; finish(); };
const onKeyDown = (event: KeyboardEvent): void => { if (!frame) return; const box = frame.getBoundingClientRect(); const cx = box.left + box.width / 2; const cy = box.top + box.height / 2; const from = scale.current;
if (event.key === "+" || event.key === "=") { event.preventDefault(); zoomAt(from * KEY_ZOOM, cx, cy, true); return; } if (event.key === "-" || event.key === "_") { event.preventDefault(); zoomAt(from / KEY_ZOOM, cx, cy, true); return; } if (event.key === "0") { event.preventDefault(); home(); return; } if (event.key === "Escape") { event.preventDefault(); if (from > NEAR_HOME) home(); else onClose(); return; } if (!event.key.startsWith("Arrow")) return;
if (from > NEAR_HOME) { event.preventDefault(); const dx = event.key === "ArrowLeft" ? KEY_PAN : event.key === "ArrowRight" ? -KEY_PAN : 0; const dy = event.key === "ArrowUp" ? KEY_PAN : event.key === "ArrowDown" ? -KEY_PAN : 0; glide(from, x.current + dx, y.current + dy); return; } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); step(event.key === "ArrowLeft" ? -1 : 1); } };
const wheelZoom: Attachment<HTMLElement> = (node) => { const onWheel = (event: WheelEvent): void => { event.preventDefault(); zoomAt( scale.current * Math.exp(-event.deltaY / WHEEL_RATE), event.clientX, event.clientY, false ); }; node.addEventListener("wheel", onWheel, { passive: false }); return () => node.removeEventListener("wheel", onWheel); };
const toBody: Attachment<HTMLElement> = (node) => { document.body.appendChild(node); return () => node.remove(); };
interface Landing { dx: number; dy: number; scale: number; opacity: number; radius: number; }
const landing = (): Landing => { const origin = images[index]?.origin; if (frame && content && origin && content.offsetWidth > 0) { const box = frame.getBoundingClientRect(); const seat = origin.getBoundingClientRect(); if (seat.width > 0) { const s = seat.width / content.offsetWidth; const radius = Number.parseFloat(getComputedStyle(origin).borderTopLeftRadius) || ORIGIN_RADIUS; return { dx: seat.left + seat.width / 2 - (box.left + box.width / 2), dy: seat.top + seat.height / 2 - (box.top + box.height / 2), scale: s, opacity: 1, radius: radius / s }; } } return { dx: 0, dy: 10, scale: 0.97, opacity: 0, radius: restingRadius() }; };
const settle = (): void => { flightX.set(0); flightY.set(0); flightScale.set(1); flightOpacity.set(1); flightRadius.set(restingRadius()); veil.set(1); bar.set(1); };
const enter = async (): Promise<void> => { const mine = ++generation; staged = true; closing = false; await tick(); if (mine !== generation) return; if (!panel?.open) panel?.showModal(); frame?.focus({ preventScroll: true });
scale.set(1); x.set(0); y.set(0);
const image = content; if (image && !image.complete) await new Promise((resolve) => image.addEventListener("load", resolve, { once: true })); await tick(); if (mine !== generation) return;
if (reduced()) { settle(); return; }
const to = landing(); flightX.set(to.dx); flightY.set(to.dy); flightScale.set(to.scale); flightOpacity.set(to.opacity); flightRadius.set(to.radius);
flightX.animate(0, HOME); flightY.animate(0, HOME); flightScale.animate(1, HOME); flightOpacity.animate(1, HOME); flightRadius.animate(restingRadius(), HOME); veil.animate(1, VEIL); bar.animate(1, VEIL); };
const leave = (): void => { if (!staged) return; generation += 1; closing = true;
const done = (): void => { panel?.close(); staged = false; closing = false; };
if (reduced()) { done(); return; }
const to = landing(); let pending = 4; const arrived = (): void => { pending -= 1; if (pending === 0) done(); };
x.animate(0, HOME); y.animate(0, HOME); scale.animate(1, HOME); flightRadius.animate(to.radius, HOME); veil.animate(0, VEIL); bar.animate(0, VEIL);
flightX.animate(to.dx, HOME, arrived); flightY.animate(to.dy, HOME, arrived); flightScale.animate(to.scale, HOME, arrived); flightOpacity.animate(to.opacity, HOME, arrived); };
$effect(() => { if (open) untrack(() => void enter()); else untrack(leave); });
$effect(() => { void index; untrack(() => { scale.set(1); x.set(0); y.set(0); }); });
$effect(() => { const reading = scale.current; const timer = setTimeout(() => (announced = reading), ANNOUNCE); return () => clearTimeout(timer); });
const motions = [ scale, x, y, flightX, flightY, flightScale, flightOpacity, flightRadius, veil, bar ];
onDestroy(() => { for (const motion of motions) motion.stop(); });
$effect(() => { const bail = (): void => { drag = null; }; window.addEventListener("blur", bail); return () => window.removeEventListener("blur", bail); });</script>
{#if staged && current} <dialog bind:this={panel} closedby="none" class="lightbox-panel fixed inset-0 z-50 size-full max-h-[none] max-w-[none] border-0 bg-transparent p-0 text-foreground-default outline-none" aria-label={caption} data-closing={closing ? "" : undefined} onkeydown={onKeyDown} onclose={onClose} {@attach toBody} > <div aria-hidden="true" class={classes.veil()} style="opacity: {veil.current}"></div>
<div bind:this={frame} tabindex="-1" role="group" aria-label={caption} aria-describedby={hintId} class={classes.frame()} onpointerdown={onPointerDown} onpointermove={onPointerMove} onpointerup={onPointerUp} onpointercancel={onPointerCancel} onlostpointercapture={onPointerCancel} {@attach wheelZoom} > <div class={classes.flight()} style="translate: {flightX.current}px {flightY.current}px; scale: {flightScale.current}; opacity: {flightOpacity.current}" > <img bind:this={content} src={current.src} alt={current.alt} width={current.width} height={current.height} draggable="false" class="{classes.image()} {current.class}" style="translate: {x.current}px {y.current}px; scale: {scale.current}; border-radius: {flightRadius.current}px" /> </div> </div>
<div class={classes.bar()} style="opacity: {bar.current}"> {#if images.length > 1} <p class={classes.badge()}> {index + 1} / {images.length} </p> <ButtonGroup> <Button class={classes.button()} icon={ChevronLeft} aria-label="Previous image" onclick={() => step(-1)} /> <Button class={classes.button()} icon={ChevronRight} aria-label="Next image" onclick={() => step(1)} /> </ButtonGroup> {/if}
<div class={classes.controls()}> <Button class={classes.button()} icon={zoomed ? ZoomOut : ZoomIn} aria-label={zoomed ? "Zoom out" : "Zoom in"} onclick={toggleZoom} /> <Button class={classes.button()} icon={X} aria-label="Close" onclick={onClose} /> </div> </div>
<p id={hintId} class="sr-only"> 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. </p> <p role="status" class="sr-only">Zoom {announced.toFixed(1)} times</p> </dialog>{/if}
<style> :global(html:has(.lightbox-panel:modal:not([data-closing]))) { overflow: hidden; }
.lightbox-panel::backdrop { background: transparent; }</style>