@mystravil/hardpoint 0.2.0 → 0.3.0

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +51 -38
  2. package/LICENSE +25 -25
  3. package/README.md +67 -64
  4. package/dist/base.css +625 -625
  5. package/dist/components/AppShell.svelte +698 -698
  6. package/dist/components/Button.svelte +221 -174
  7. package/dist/components/Button.svelte.d.ts +18 -2
  8. package/dist/components/Checkbox.svelte +235 -235
  9. package/dist/components/CheckboxGroup.svelte +207 -207
  10. package/dist/components/Combobox.svelte +901 -901
  11. package/dist/components/Dialog.svelte +359 -359
  12. package/dist/components/Disclosure.svelte +201 -201
  13. package/dist/components/EmptyState.svelte +88 -88
  14. package/dist/components/ErrorState.svelte +135 -135
  15. package/dist/components/ImageViewer.svelte +764 -764
  16. package/dist/components/KeyValueList.svelte +114 -114
  17. package/dist/components/ListDetail.svelte +218 -218
  18. package/dist/components/LoadRegion.svelte +219 -219
  19. package/dist/components/MediaFrame.svelte +110 -110
  20. package/dist/components/MediaImage.svelte +256 -256
  21. package/dist/components/Menu.svelte +275 -258
  22. package/dist/components/MenuCheckboxItem.svelte +119 -119
  23. package/dist/components/MenuGroup.svelte +50 -50
  24. package/dist/components/MenuItem.svelte +94 -94
  25. package/dist/components/MenuRadioGroup.svelte +62 -62
  26. package/dist/components/MenuRadioItem.svelte +110 -110
  27. package/dist/components/MenuSeparator.svelte +28 -28
  28. package/dist/components/Meter.svelte +283 -283
  29. package/dist/components/NativeSelect.svelte +135 -135
  30. package/dist/components/Notice.svelte +262 -262
  31. package/dist/components/Popover.svelte +222 -222
  32. package/dist/components/Progress.svelte +157 -157
  33. package/dist/components/RadioGroup.svelte +440 -440
  34. package/dist/components/Select.svelte +471 -471
  35. package/dist/components/Skeleton.svelte +149 -149
  36. package/dist/components/Spinner.svelte +118 -118
  37. package/dist/components/Stat.svelte +253 -253
  38. package/dist/components/Steps.svelte +189 -189
  39. package/dist/components/Switch.svelte +222 -222
  40. package/dist/components/Table.svelte +504 -504
  41. package/dist/components/Tabs.svelte +246 -246
  42. package/dist/components/Tag.svelte +40 -40
  43. package/dist/components/TextArea.svelte +272 -272
  44. package/dist/components/TextField.svelte +396 -256
  45. package/dist/components/TextField.svelte.d.ts +16 -1
  46. package/dist/components/Timeline.svelte +156 -156
  47. package/dist/components/Toaster.svelte +411 -411
  48. package/dist/components/Tooltip.svelte +200 -200
  49. package/dist/components/TooltipGroup.svelte +46 -46
  50. package/dist/components/internal/Scrim.svelte +52 -52
  51. package/dist/components/internal/Sheet.svelte +263 -263
  52. package/dist/fonts.css +71 -71
  53. package/dist/tokens.css +283 -283
  54. package/package.json +81 -76
@@ -1,764 +1,764 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import { Dialog as BitsDialog } from 'bits-ui';
4
-
5
- export type ImageViewerTriggerSnippetProps = {
6
- /**
7
- * Spread onto the button that opens the viewer, usually around a
8
- * thumbnail, so the image opens from the keyboard too.
9
- */
10
- props: Record<string, unknown>;
11
- };
12
-
13
- /**
14
- * Attributes and handlers not listed here go to the viewer element (Bits
15
- * UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
16
- * `onEscapeKeydown` and `onInteractOutside`.
17
- */
18
- export type ImageViewerProps = Omit<
19
- BitsDialog.ContentProps,
20
- 'child' | 'children' | 'forceMount' | 'ref' | 'title'
21
- > & {
22
- open?: boolean;
23
- /** The image's URL. A different URL while open starts a new load at scale 1. */
24
- src: string;
25
- /**
26
- * The text alternative, required. It also names the viewer when there
27
- * is no `title`, so it should describe the image, never be empty.
28
- */
29
- alt: string;
30
- /**
31
- * A line in the top bar, shown as written (a file name, a caption) on
32
- * one line, cut with an ellipsis. It names the viewer when set.
33
- */
34
- title?: string;
35
- /** The button that opens the viewer: spread `props` onto it. */
36
- trigger?: Snippet<[ImageViewerTriggerSnippetProps]>;
37
- /**
38
- * Shows the Download button, which saves `src` under this file name
39
- * through a link with `download`. A `data:` URL becomes a `blob:` URL
40
- * first. Browsers ignore `download` on a link to another origin, so an
41
- * image from another origin needs `ondownload`.
42
- */
43
- downloadName?: string;
44
- /**
45
- * Shows the Download button and replaces the built-in save, for an app
46
- * with its own save picker or confirmation. While its promise is
47
- * pending, another press does nothing. Errors are the app's to catch.
48
- */
49
- ondownload?: () => void | Promise<void>;
50
- /** The close button's accessible name. */
51
- closeLabel?: string;
52
- /** The Download button's label. */
53
- downloadLabel?: string;
54
- /** The spinner's label while the image loads. */
55
- loadingLabel?: string;
56
- /** The viewer element. */
57
- ref?: HTMLElement | null;
58
- };
59
- </script>
60
-
61
- <script lang="ts">
62
- import { untrack } from 'svelte';
63
- import { prefersReducedMotion } from 'svelte/motion';
64
- import { duration, fade } from '../motion/index.js';
65
- import Button from './Button.svelte';
66
- import Spinner from './Spinner.svelte';
67
- import { swipeDismiss } from './internal/swipe-dismiss.js';
68
-
69
- /** The largest scale, of the size that fits the screen. */
70
- const MAX_SCALE = 6;
71
- /** How far a pinch may shrink the image before it lets go. */
72
- const PINCH_MIN = 0.5;
73
- /** A pinch released below this settles to scale 1, so the image never rests a few pixels too large. */
74
- const SNAP = 1.05;
75
- /** The scale a double-tap or double-click zooms to. */
76
- const DOUBLE_SCALE = 2.5;
77
- /** One key press of + or - multiplies or divides the scale by this. */
78
- const KEY_STEP = 1.5;
79
- /** One arrow key press pans this share of the stage. */
80
- const KEY_PAN = 1 / 8;
81
- /** Travel before a press counts as a drag (px): touch and pen, then mouse. */
82
- const SLOP = { touch: 8, mouse: 4 };
83
- /** A second tap within this time (ms) and distance (px) is a double-tap. */
84
- const DOUBLE_TIME = { touch: 300, mouse: 500 };
85
- const DOUBLE_DISTANCE = { touch: 40, mouse: 8 };
86
- /** A blob: URL made for a download is revoked after this (ms); a browser may read it after click() returns. */
87
- const REVOKE_AFTER = 60_000;
88
-
89
- const uid = $props.id();
90
-
91
- let {
92
- open = $bindable(false),
93
- src,
94
- alt,
95
- title,
96
- trigger,
97
- downloadName,
98
- ondownload,
99
- closeLabel = 'Close',
100
- downloadLabel = 'Download',
101
- loadingLabel = 'Loading image',
102
- ref = $bindable(null),
103
- onOpenAutoFocus,
104
- class: className,
105
- ...rest
106
- }: ImageViewerProps = $props();
107
-
108
- const titleId = `${uid}-title`;
109
- const downloadable = $derived(ondownload !== undefined || downloadName !== undefined);
110
-
111
- // loading: the image has not arrived; a spinner shows after its delay.
112
- // loaded: decoded and on screen. error: it failed; the alt shows instead.
113
- let phase = $state<'loading' | 'loaded' | 'error'>('loading');
114
-
115
- // The view: the image's scale of its fitted size, and its offset from
116
- // the stage's centre in px. transform: translate(x, y) scale(scale),
117
- // about the image's centre.
118
- let scale = $state(1);
119
- let x = $state(0);
120
- let y = $state(0);
121
- // A pan or a pinch in progress, for the cursor and for apps.
122
- let gesture = $state<'pan' | 'pinch' | null>(null);
123
- const atRest = $derived(scale === 1);
124
-
125
- let stage = $state<HTMLElement | null>(null);
126
- let image = $state<HTMLImageElement | null>(null);
127
-
128
- type Kind = 'touch' | 'mouse';
129
- type Press = { id: number; kind: Kind; x0: number; y0: number; tx0: number; ty0: number; moved: boolean };
130
- type Pinch = { d0: number; s0: number; qx: number; qy: number };
131
-
132
- // Pointers on the stage by id, at their last position.
133
- const pointers = new Map<number, { x: number; y: number; kind: Kind }>();
134
- // One pointer down: a tap, a pan, or a swipe's drag.
135
- let press: Press | null = null;
136
- // Two pointers down: the scale and the image point under their midpoint
137
- // when the pinch began.
138
- let pinch: Pinch | null = null;
139
- // The last tap, for a double-tap.
140
- let lastTap: { t: number; x: number; y: number; kind: Kind } | null = null;
141
- // The animation of a double-tap zoom or a settle back to 1.
142
- let settle: Animation | null = null;
143
- // A pending ondownload.
144
- let saving = false;
145
-
146
- function resetView() {
147
- settle?.cancel();
148
- settle = null;
149
- scale = 1;
150
- x = 0;
151
- y = 0;
152
- gesture = null;
153
- pointers.clear();
154
- press = null;
155
- pinch = null;
156
- lastTap = null;
157
- }
158
-
159
- // Every open, and every new src, starts at scale 1 with a new load.
160
- $effect.pre(() => {
161
- if (!open) return;
162
- void src;
163
- untrack(() => {
164
- resetView();
165
- phase = 'loading';
166
- });
167
- });
168
-
169
- function close() {
170
- open = false;
171
- }
172
-
173
- // Focus starts on the close button (Bits UI would pick the first
174
- // control, which is Download when there is one). An app's own
175
- // onOpenAutoFocus may take over by calling preventDefault().
176
- function openAutoFocus(e: Event) {
177
- onOpenAutoFocus?.(e);
178
- if (e.defaultPrevented) return;
179
- e.preventDefault();
180
- const closeButton = () => ref?.querySelector<HTMLElement>('.hp-image-viewer-close');
181
- const now = closeButton();
182
- if (now) now.focus({ preventScroll: true });
183
- else requestAnimationFrame(() => closeButton()?.focus({ preventScroll: true }));
184
- }
185
-
186
- // ---- Loading -----------------------------------------------------------
187
-
188
- // One <img> per src (see the {#key} below), so this runs once per load.
189
- // The image shows once it is decoded, so its first frame is never blank.
190
- function watch(img: HTMLImageElement) {
191
- return untrack(() => {
192
- let alive = true;
193
- const done = () => {
194
- if (alive) phase = 'loaded';
195
- };
196
- const fail = () => {
197
- if (alive && phase === 'loading') phase = 'error';
198
- };
199
- const onload = () => {
200
- img.decode().then(done, () => (img.naturalWidth > 0 ? done() : fail()));
201
- };
202
- if (img.complete) {
203
- // Cached: at once. A complete image without a size is broken,
204
- // unless it is an SVG without one.
205
- if (img.naturalWidth > 0) phase = 'loaded';
206
- else img.decode().then(done, fail);
207
- }
208
- img.addEventListener('load', onload);
209
- img.addEventListener('error', fail);
210
- return () => {
211
- alive = false;
212
- img.removeEventListener('load', onload);
213
- img.removeEventListener('error', fail);
214
- };
215
- });
216
- }
217
-
218
- // ---- Geometry ------------------------------------------------------------
219
-
220
- const transform = (s: number, tx: number, ty: number) => `translate(${tx}px, ${ty}px) scale(${s})`;
221
-
222
- /** The stage's centre and size, and the image's size at scale 1 (it is centred in the stage). */
223
- function frame() {
224
- const r = stage?.getBoundingClientRect();
225
- return {
226
- cx: r ? r.left + r.width / 2 : 0,
227
- cy: r ? r.top + r.height / 2 : 0,
228
- sw: r?.width ?? 0,
229
- sh: r?.height ?? 0,
230
- w: image?.offsetWidth ?? 0,
231
- h: image?.offsetHeight ?? 0
232
- };
233
- }
234
- type Frame = ReturnType<typeof frame>;
235
-
236
- /**
237
- * The offset held so the image leaves no gap at an edge it can fill:
238
- * along an axis where it is larger than the stage its edges stay at or
239
- * past the stage's; along one where it is not, it stays centred.
240
- */
241
- function hold(s: number, tx: number, ty: number, f: Frame): [number, number] {
242
- const mx = Math.max(0, (s * f.w - f.sw) / 2);
243
- const my = Math.max(0, (s * f.h - f.sh) / 2);
244
- return [Math.min(mx, Math.max(-mx, tx)), Math.min(my, Math.max(-my, ty))];
245
- }
246
-
247
- /** The view at scale `s` that keeps the image point under (px, py) where it is. */
248
- function about(s: number, px: number, py: number, f = frame()): [number, number, number] {
249
- const k = s / scale;
250
- return [s, ...hold(s, px - f.cx - k * (px - f.cx - x), py - f.cy - k * (py - f.cy - y), f)];
251
- }
252
-
253
- function onImage(px: number, py: number) {
254
- if (phase !== 'loaded' || !image) return false;
255
- const r = image.getBoundingClientRect();
256
- return px >= r.left && px <= r.right && py >= r.top && py <= r.bottom;
257
- }
258
-
259
- // ---- Motion ------------------------------------------------------------
260
-
261
- /** Ends a double-tap zoom or a settle where it is on screen, so a new gesture starts from there. */
262
- function stop() {
263
- if (!settle) return;
264
- if (image && settle.playState === 'running') {
265
- const m = new DOMMatrixReadOnly(getComputedStyle(image).transform);
266
- scale = m.a;
267
- x = m.e;
268
- y = m.f;
269
- }
270
- settle.cancel();
271
- settle = null;
272
- }
273
-
274
- /** Pinch, pan, wheel and keys: the view follows at once. */
275
- function place([s, tx, ty]: [number, number, number]) {
276
- stop();
277
- scale = s;
278
- x = tx;
279
- y = ty;
280
- }
281
-
282
- /**
283
- * A double-tap zoom and the settle back to 1: the transform moves over
284
- * --dur-pop on --settle, both read from the image. Reduced motion jumps.
285
- */
286
- function animate([s, tx, ty]: [number, number, number]) {
287
- stop();
288
- const from = transform(scale, x, y);
289
- scale = s;
290
- x = tx;
291
- y = ty;
292
- const to = transform(s, tx, ty);
293
- if (!image || from === to || prefersReducedMotion.current) return;
294
- const ms = duration(image, '--dur-pop');
295
- if (ms <= 0) return;
296
- const curve = getComputedStyle(image).getPropertyValue('--settle').trim();
297
- const run = image.animate([{ transform: from }, { transform: to }], {
298
- duration: ms,
299
- easing: CSS.supports('transition-timing-function', curve) ? curve : 'ease-out'
300
- });
301
- settle = run;
302
- run.onfinish = () => {
303
- if (settle === run) settle = null;
304
- };
305
- }
306
-
307
- /** The view at rest: scale 1, centred. */
308
- const REST: [number, number, number] = [1, 0, 0];
309
-
310
- function toggle(px: number, py: number) {
311
- animate(scale > 1 ? REST : about(DOUBLE_SCALE, px, py));
312
- }
313
-
314
- // ---- Pointers ----------------------------------------------------------
315
-
316
- const kindOf = (e: PointerEvent): Kind => (e.pointerType === 'mouse' ? 'mouse' : 'touch');
317
-
318
- function startPinch() {
319
- const [a, b] = pointers.values();
320
- const f = frame();
321
- const mx = (a.x + b.x) / 2;
322
- const my = (a.y + b.y) / 2;
323
- pinch = {
324
- d0: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
325
- s0: scale,
326
- qx: (mx - f.cx - x) / scale,
327
- qy: (my - f.cy - y) / scale
328
- };
329
- gesture = 'pinch';
330
- }
331
-
332
- function movePinch() {
333
- if (!pinch) return;
334
- const [a, b] = pointers.values();
335
- const f = frame();
336
- const mx = (a.x + b.x) / 2;
337
- const my = (a.y + b.y) / 2;
338
- const s = Math.min(MAX_SCALE, Math.max(PINCH_MIN, (pinch.s0 * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d0));
339
- place([s, ...hold(s, mx - f.cx - s * pinch.qx, my - f.cy - s * pinch.qy, f)]);
340
- }
341
-
342
- function down(e: PointerEvent) {
343
- if (e.pointerType === 'mouse' && e.button !== 0) return;
344
- // Focus stays where it is, as on a scrim.
345
- e.preventDefault();
346
- stop();
347
- const kind = kindOf(e);
348
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, kind });
349
- stage?.setPointerCapture(e.pointerId);
350
- if (pointers.size === 1) {
351
- press = { id: e.pointerId, kind, x0: e.clientX, y0: e.clientY, tx0: x, ty0: y, moved: false };
352
- } else {
353
- // A second finger: never a tap, and a pinch once the image is there.
354
- if (press) press.moved = true;
355
- if (pointers.size === 2 && phase === 'loaded') startPinch();
356
- }
357
- }
358
-
359
- function move(e: PointerEvent) {
360
- const p = pointers.get(e.pointerId);
361
- if (!p) return;
362
- p.x = e.clientX;
363
- p.y = e.clientY;
364
- if (pinch) {
365
- movePinch();
366
- return;
367
- }
368
- if (!press || press.id !== e.pointerId) return;
369
- const dx = e.clientX - press.x0;
370
- const dy = e.clientY - press.y0;
371
- if (!press.moved && Math.hypot(dx, dy) > SLOP[press.kind]) {
372
- press.moved = true;
373
- // Zoomed: the image follows the pointer. At scale 1 a drag is the
374
- // swipe's, or nothing.
375
- if (scale > 1 && phase === 'loaded') gesture = 'pan';
376
- }
377
- if (gesture === 'pan') {
378
- const f = frame();
379
- place([scale, ...hold(scale, press.tx0 + dx, press.ty0 + dy, f)]);
380
- }
381
- }
382
-
383
- function up(e: PointerEvent) {
384
- if (!pointers.delete(e.pointerId)) return;
385
- if (pinch) {
386
- if (pointers.size >= 2) {
387
- // A third finger lifted, or one of the two: pinch on with the two left.
388
- startPinch();
389
- return;
390
- }
391
- pinch = null;
392
- gesture = null;
393
- if (scale < SNAP) animate(REST);
394
- // The finger left over pans from here, if the image is still zoomed.
395
- const left = [...pointers.entries()][0];
396
- press = left
397
- ? { id: left[0], kind: left[1].kind, x0: left[1].x, y0: left[1].y, tx0: x, ty0: y, moved: true }
398
- : null;
399
- if (press && scale > 1) gesture = 'pan';
400
- return;
401
- }
402
- if (!press || press.id !== e.pointerId) return;
403
- const done = press;
404
- press = null;
405
- gesture = null;
406
- if (!done.moved && e.type === 'pointerup') tap(done, e);
407
- }
408
-
409
- // A tap: the second of a double-tap toggles the zoom about the pressed
410
- // point; a single one on the backdrop, outside the image, closes at
411
- // scale 1. A tap on the image waits for its second.
412
- function tap(start: Press, e: PointerEvent) {
413
- const t = e.timeStamp;
414
- const last = lastTap;
415
- if (
416
- last &&
417
- last.kind === start.kind &&
418
- t - last.t < DOUBLE_TIME[start.kind] &&
419
- Math.hypot(start.x0 - last.x, start.y0 - last.y) < DOUBLE_DISTANCE[start.kind]
420
- ) {
421
- lastTap = null;
422
- if (phase === 'loaded') toggle(start.x0, start.y0);
423
- return;
424
- }
425
- lastTap = { t, x: start.x0, y: start.y0, kind: start.kind };
426
- if (scale === 1 && !onImage(start.x0, start.y0)) close();
427
- }
428
-
429
- // The wheel zooms about the cursor; a trackpad pinch arrives as a wheel
430
- // with ctrlKey and smaller steps.
431
- function wheel(e: WheelEvent) {
432
- e.preventDefault();
433
- if (phase !== 'loaded' || pinch) return;
434
- stop();
435
- const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? (stage?.clientHeight ?? 800) : 1;
436
- const rate = e.ctrlKey ? 0.01 : 0.002;
437
- const s = Math.min(MAX_SCALE, Math.max(1, scale * Math.exp(-e.deltaY * unit * rate)));
438
- place(about(s, e.clientX, e.clientY));
439
- }
440
-
441
- function gestures(node: HTMLElement) {
442
- const capture = { capture: true };
443
- // Capture, so a swipe that stops a drag's pointerup at the stage
444
- // cannot hide it from here.
445
- node.addEventListener('pointerdown', down, capture);
446
- node.addEventListener('pointermove', move, capture);
447
- node.addEventListener('pointerup', up, capture);
448
- node.addEventListener('pointercancel', up, capture);
449
- node.addEventListener('wheel', wheel, { passive: false });
450
- return () => {
451
- node.removeEventListener('pointerdown', down, capture);
452
- node.removeEventListener('pointermove', move, capture);
453
- node.removeEventListener('pointerup', up, capture);
454
- node.removeEventListener('pointercancel', up, capture);
455
- node.removeEventListener('wheel', wheel);
456
- };
457
- }
458
-
459
- // ---- Keys --------------------------------------------------------------
460
-
461
- /** Each arrow key's pan, as a share of the stage: it shows more of that side, so the image moves the other way. */
462
- const ARROWS: Record<string, [number, number]> = {
463
- ArrowLeft: [KEY_PAN, 0],
464
- ArrowRight: [-KEY_PAN, 0],
465
- ArrowUp: [0, KEY_PAN],
466
- ArrowDown: [0, -KEY_PAN]
467
- };
468
-
469
- // + or = zooms in, - zooms out, 0 goes back to 1, about the stage's
470
- // centre; arrow keys pan while zoomed. Each press jumps: no motion
471
- // repeats on a key press. Keys with Ctrl, Alt or Meta stay the browser's.
472
- function key(e: KeyboardEvent) {
473
- if (e.ctrlKey || e.altKey || e.metaKey || e.defaultPrevented || phase !== 'loaded') return;
474
- const arrow = ARROWS[e.key];
475
- if (arrow ? scale <= 1 : !['+', '=', '-', '0'].includes(e.key)) return;
476
- e.preventDefault();
477
- stop();
478
- const f = frame();
479
- if (arrow) place([scale, ...hold(scale, x + arrow[0] * f.sw, y + arrow[1] * f.sh, f)]);
480
- else if (e.key === '0') place(REST);
481
- else if (e.key === '-') place(about(Math.max(1, scale / KEY_STEP), f.cx, f.cy, f));
482
- else place(about(Math.min(MAX_SCALE, scale * KEY_STEP), f.cx, f.cy, f));
483
- }
484
-
485
- function keys(node: HTMLElement) {
486
- node.addEventListener('keydown', key);
487
- return () => node.removeEventListener('keydown', key);
488
- }
489
-
490
- // ---- Download ----------------------------------------------------------
491
-
492
- /** A `data:` URL's bytes as a Blob, decoded here, so no fetch() meets a Content Security Policy. */
493
- function dataBlob(url: string): Blob {
494
- const comma = url.indexOf(',');
495
- const head = url.slice(5, comma);
496
- const raw = url.slice(comma + 1);
497
- const body = raw.includes('%') ? decodeURIComponent(raw) : raw;
498
- const base64 = /;base64$/i.test(head);
499
- const type = (base64 ? head.slice(0, -7) : head) || 'text/plain';
500
- if (!base64) return new Blob([body], { type });
501
- const text = atob(body);
502
- const bytes = new Uint8Array(text.length);
503
- for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
504
- return new Blob([bytes], { type });
505
- }
506
-
507
- // A link with `download` saves the file under its name. It opens in a
508
- // new tab, so an image from another origin, whose `download` the browser
509
- // ignores, never replaces the app.
510
- function save(name: string) {
511
- const made = /^data:/i.test(src) ? URL.createObjectURL(dataBlob(src)) : null;
512
- const link = document.createElement('a');
513
- link.href = made ?? src;
514
- link.download = name;
515
- link.target = '_blank';
516
- link.rel = 'noopener';
517
- (ref ?? document.body).append(link);
518
- link.click();
519
- link.remove();
520
- if (made) setTimeout(() => URL.revokeObjectURL(made), REVOKE_AFTER);
521
- }
522
-
523
- async function download() {
524
- if (!ondownload) {
525
- if (downloadName !== undefined) save(downloadName);
526
- return;
527
- }
528
- if (saving) return;
529
- saving = true;
530
- try {
531
- await ondownload();
532
- } finally {
533
- saving = false;
534
- }
535
- }
536
- </script>
537
-
538
- <!--
539
- One image over everything, to look at closely: a top bar with the
540
- `title`, Download and Close, and the image fitted inside the rest of the
541
- screen at scale 1, centred. Full screen at every width, on
542
- --layer-dialog, filled with --canvas. Bits UI Dialog traps focus, locks
543
- the page's scroll, closes on Escape and gives focus back to the opener;
544
- focus starts on Close.
545
-
546
- Zoom (1 to 6 times the fitted size): a pinch about the fingers'
547
- midpoint, a double-tap or double-click (1 and 2.5 about the pressed
548
- point), the wheel about the cursor, + = - and 0. While zoomed, one
549
- pointer or the arrow keys pan, held so the image leaves no gap at an edge
550
- it can fill. A pinch released below 1, or less than 5 percent above it,
551
- settles back to 1.
552
-
553
- Closing: Close, Escape, and at scale 1 a swipe down
554
- (internal/swipe-dismiss.ts) or a press on the backdrop outside the
555
- image. A drag is never a press. No gesture closes it while zoomed. The
556
- system back gesture is the app's: push a history entry on open and close
557
- on popstate (see the docs).
558
-
559
- Motion: the viewer fades in over --dur-sheet and out over
560
- --dur-sheet-exit. Pinch, pan, wheel and keys follow at once. A
561
- double-tap zoom and the settle back to 1 move the transform over
562
- --dur-pop on --settle, read from the image; reduced motion jumps.
563
-
564
- The stage `.hp-image-viewer-stage` carries `data-state` loading |
565
- loaded | error, `data-zoomed` above scale 1 and `data-gesture` pan |
566
- pinch. While loading, a Spinner shows after its delay; on an error the
567
- alt shows as a muted caption and Download stays. Nothing is announced on
568
- zoom.
569
- -->
570
- <BitsDialog.Root bind:open>
571
- {#if trigger}
572
- <BitsDialog.Trigger>
573
- {#snippet child({ props })}
574
- {@render trigger({ props })}
575
- {/snippet}
576
- </BitsDialog.Trigger>
577
- {/if}
578
-
579
- <BitsDialog.Portal>
580
- <BitsDialog.Content
581
- forceMount
582
- bind:ref
583
- aria-labelledby={title ? titleId : undefined}
584
- aria-label={title ? undefined : alt}
585
- onOpenAutoFocus={openAutoFocus}
586
- {...rest}
587
- >
588
- {#snippet child({
589
- props: { interactOutsideBehavior: _outside, escapeKeydownBehavior: _escape, ...props },
590
- open: shown
591
- })}
592
- {#if shown}
593
- <div {...props} class={['hp-image-viewer', className]} {@attach keys} transition:fade>
594
- <div class="hp-image-viewer-bar">
595
- {#if title}
596
- <h2 id={titleId} class="hp-image-viewer-title">{title}</h2>
597
- {/if}
598
- {#if downloadable}
599
- <Button class="hp-image-viewer-download" onclick={download}>{downloadLabel}</Button>
600
- {/if}
601
- <Button
602
- variant="tertiary"
603
- icon
604
- class="hp-image-viewer-close"
605
- aria-label={closeLabel}
606
- onclick={close}
607
- >
608
- <svg
609
- viewBox="0 0 16 16"
610
- fill="none"
611
- stroke="currentColor"
612
- stroke-width="1.5"
613
- stroke-linecap="square"
614
- aria-hidden="true"
615
- >
616
- <path d="M3 3l10 10M13 3L3 13" />
617
- </svg>
618
- </Button>
619
- </div>
620
- <div
621
- class="hp-image-viewer-stage"
622
- data-state={phase}
623
- data-zoomed={scale > 1 ? '' : undefined}
624
- data-gesture={gesture ?? undefined}
625
- bind:this={stage}
626
- {@attach gestures}
627
- {@attach atRest ? swipeDismiss(close) : undefined}
628
- >
629
- {#key src}
630
- <img
631
- class="hp-image-viewer-image"
632
- {src}
633
- {alt}
634
- draggable="false"
635
- style:transform={transform(scale, x, y)}
636
- bind:this={image}
637
- {@attach watch}
638
- />
639
- {/key}
640
- {#if phase === 'loading'}
641
- <div class="hp-image-viewer-spinner"><Spinner label={loadingLabel} /></div>
642
- {:else if phase === 'error' && alt}
643
- <span class="hp-image-viewer-caption hp-caption" aria-hidden="true">{alt}</span>
644
- {/if}
645
- </div>
646
- </div>
647
- {/if}
648
- {/snippet}
649
- </BitsDialog.Content>
650
- </BitsDialog.Portal>
651
- </BitsDialog.Root>
652
-
653
- <style>
654
- /* The whole screen in --canvas, so it follows the theme. The side and
655
- bottom safe areas keep the stage clear of the screen's cut-outs; the
656
- bar adds the top one. Bits UI may have turned pointer events off on
657
- <body>. */
658
- .hp-image-viewer {
659
- position: fixed;
660
- inset: 0;
661
- z-index: var(--layer-dialog);
662
- display: flex;
663
- flex-direction: column;
664
- box-sizing: border-box;
665
- padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
666
- background: var(--canvas);
667
- color: var(--text);
668
- overscroll-behavior: contain;
669
- pointer-events: auto;
670
- }
671
-
672
- /* The shell header's height under the top safe area: the title takes
673
- what the buttons leave and ends in an ellipsis; without a title the
674
- buttons keep the end. Download and Close keep their size, 8px apart,
675
- with Close 8px from the edge, so its focus contour (4px out, 2px wide)
676
- stays on screen. */
677
- .hp-image-viewer-bar {
678
- flex: none;
679
- display: flex;
680
- align-items: center;
681
- justify-content: flex-end;
682
- gap: var(--space-2);
683
- box-sizing: border-box;
684
- min-height: calc(var(--header-height) + env(safe-area-inset-top));
685
- padding: env(safe-area-inset-top) var(--space-2) 0 var(--gutter);
686
- }
687
- .hp-image-viewer-bar > :global(.hp-button) {
688
- flex: none;
689
- }
690
- .hp-image-viewer-title {
691
- flex: 1 1 auto;
692
- min-width: 0;
693
- overflow: hidden;
694
- font-size: var(--type-body);
695
- line-height: 24px;
696
- text-overflow: ellipsis;
697
- white-space: nowrap;
698
- }
699
-
700
- /* The rest of the screen. Every touch is the viewer's: no browser pan,
701
- zoom or overscroll. The image never shows outside it, so the bar stays
702
- clear. */
703
- .hp-image-viewer-stage {
704
- position: relative;
705
- flex: 1 1 auto;
706
- min-height: 0;
707
- overflow: hidden;
708
- overscroll-behavior: contain;
709
- touch-action: none;
710
- user-select: none;
711
- -webkit-user-select: none;
712
- }
713
- .hp-image-viewer-stage[data-zoomed] {
714
- cursor: grab;
715
- }
716
- .hp-image-viewer-stage[data-gesture] {
717
- cursor: grabbing;
718
- }
719
-
720
- /* Fitted inside the stage at its own size or smaller, centred. Hidden by
721
- opacity only while it loads or after an error, so it stays in the
722
- accessibility tree and no broken-image glyph shows. */
723
- .hp-image-viewer-image {
724
- position: absolute;
725
- inset: 0;
726
- width: auto;
727
- height: auto;
728
- max-width: 100%;
729
- max-height: 100%;
730
- margin: auto;
731
- transform-origin: 50% 50%;
732
- -webkit-user-drag: none;
733
- }
734
- .hp-image-viewer-stage[data-state='loading'] > .hp-image-viewer-image,
735
- .hp-image-viewer-stage[data-state='error'] > .hp-image-viewer-image {
736
- opacity: 0;
737
- }
738
-
739
- /* The spinner in the middle, on whole pixels (Spinner: never centre
740
- pixel text in odd free space). Browsers without round() centre it. */
741
- .hp-image-viewer-spinner {
742
- position: absolute;
743
- top: 50%;
744
- left: 50%;
745
- translate: -50% -50%;
746
- top: round(down, 50%, 1px);
747
- left: round(down, 50%, 1px);
748
- translate: round(down, -50%, 1px) round(down, -50%, 1px);
749
- pointer-events: none;
750
- }
751
-
752
- /* The alt of an image that failed, as MediaImage shows it. */
753
- .hp-image-viewer-caption {
754
- position: absolute;
755
- inset: 0;
756
- display: grid;
757
- place-items: center;
758
- padding: var(--space-8);
759
- overflow: hidden;
760
- color: var(--muted);
761
- text-align: center;
762
- pointer-events: none;
763
- }
764
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { Dialog as BitsDialog } from 'bits-ui';
4
+
5
+ export type ImageViewerTriggerSnippetProps = {
6
+ /**
7
+ * Spread onto the button that opens the viewer, usually around a
8
+ * thumbnail, so the image opens from the keyboard too.
9
+ */
10
+ props: Record<string, unknown>;
11
+ };
12
+
13
+ /**
14
+ * Attributes and handlers not listed here go to the viewer element (Bits
15
+ * UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
16
+ * `onEscapeKeydown` and `onInteractOutside`.
17
+ */
18
+ export type ImageViewerProps = Omit<
19
+ BitsDialog.ContentProps,
20
+ 'child' | 'children' | 'forceMount' | 'ref' | 'title'
21
+ > & {
22
+ open?: boolean;
23
+ /** The image's URL. A different URL while open starts a new load at scale 1. */
24
+ src: string;
25
+ /**
26
+ * The text alternative, required. It also names the viewer when there
27
+ * is no `title`, so it should describe the image, never be empty.
28
+ */
29
+ alt: string;
30
+ /**
31
+ * A line in the top bar, shown as written (a file name, a caption) on
32
+ * one line, cut with an ellipsis. It names the viewer when set.
33
+ */
34
+ title?: string;
35
+ /** The button that opens the viewer: spread `props` onto it. */
36
+ trigger?: Snippet<[ImageViewerTriggerSnippetProps]>;
37
+ /**
38
+ * Shows the Download button, which saves `src` under this file name
39
+ * through a link with `download`. A `data:` URL becomes a `blob:` URL
40
+ * first. Browsers ignore `download` on a link to another origin, so an
41
+ * image from another origin needs `ondownload`.
42
+ */
43
+ downloadName?: string;
44
+ /**
45
+ * Shows the Download button and replaces the built-in save, for an app
46
+ * with its own save picker or confirmation. While its promise is
47
+ * pending, another press does nothing. Errors are the app's to catch.
48
+ */
49
+ ondownload?: () => void | Promise<void>;
50
+ /** The close button's accessible name. */
51
+ closeLabel?: string;
52
+ /** The Download button's label. */
53
+ downloadLabel?: string;
54
+ /** The spinner's label while the image loads. */
55
+ loadingLabel?: string;
56
+ /** The viewer element. */
57
+ ref?: HTMLElement | null;
58
+ };
59
+ </script>
60
+
61
+ <script lang="ts">
62
+ import { untrack } from 'svelte';
63
+ import { prefersReducedMotion } from 'svelte/motion';
64
+ import { duration, fade } from '../motion/index.js';
65
+ import Button from './Button.svelte';
66
+ import Spinner from './Spinner.svelte';
67
+ import { swipeDismiss } from './internal/swipe-dismiss.js';
68
+
69
+ /** The largest scale, of the size that fits the screen. */
70
+ const MAX_SCALE = 6;
71
+ /** How far a pinch may shrink the image before it lets go. */
72
+ const PINCH_MIN = 0.5;
73
+ /** A pinch released below this settles to scale 1, so the image never rests a few pixels too large. */
74
+ const SNAP = 1.05;
75
+ /** The scale a double-tap or double-click zooms to. */
76
+ const DOUBLE_SCALE = 2.5;
77
+ /** One key press of + or - multiplies or divides the scale by this. */
78
+ const KEY_STEP = 1.5;
79
+ /** One arrow key press pans this share of the stage. */
80
+ const KEY_PAN = 1 / 8;
81
+ /** Travel before a press counts as a drag (px): touch and pen, then mouse. */
82
+ const SLOP = { touch: 8, mouse: 4 };
83
+ /** A second tap within this time (ms) and distance (px) is a double-tap. */
84
+ const DOUBLE_TIME = { touch: 300, mouse: 500 };
85
+ const DOUBLE_DISTANCE = { touch: 40, mouse: 8 };
86
+ /** A blob: URL made for a download is revoked after this (ms); a browser may read it after click() returns. */
87
+ const REVOKE_AFTER = 60_000;
88
+
89
+ const uid = $props.id();
90
+
91
+ let {
92
+ open = $bindable(false),
93
+ src,
94
+ alt,
95
+ title,
96
+ trigger,
97
+ downloadName,
98
+ ondownload,
99
+ closeLabel = 'Close',
100
+ downloadLabel = 'Download',
101
+ loadingLabel = 'Loading image',
102
+ ref = $bindable(null),
103
+ onOpenAutoFocus,
104
+ class: className,
105
+ ...rest
106
+ }: ImageViewerProps = $props();
107
+
108
+ const titleId = `${uid}-title`;
109
+ const downloadable = $derived(ondownload !== undefined || downloadName !== undefined);
110
+
111
+ // loading: the image has not arrived; a spinner shows after its delay.
112
+ // loaded: decoded and on screen. error: it failed; the alt shows instead.
113
+ let phase = $state<'loading' | 'loaded' | 'error'>('loading');
114
+
115
+ // The view: the image's scale of its fitted size, and its offset from
116
+ // the stage's centre in px. transform: translate(x, y) scale(scale),
117
+ // about the image's centre.
118
+ let scale = $state(1);
119
+ let x = $state(0);
120
+ let y = $state(0);
121
+ // A pan or a pinch in progress, for the cursor and for apps.
122
+ let gesture = $state<'pan' | 'pinch' | null>(null);
123
+ const atRest = $derived(scale === 1);
124
+
125
+ let stage = $state<HTMLElement | null>(null);
126
+ let image = $state<HTMLImageElement | null>(null);
127
+
128
+ type Kind = 'touch' | 'mouse';
129
+ type Press = { id: number; kind: Kind; x0: number; y0: number; tx0: number; ty0: number; moved: boolean };
130
+ type Pinch = { d0: number; s0: number; qx: number; qy: number };
131
+
132
+ // Pointers on the stage by id, at their last position.
133
+ const pointers = new Map<number, { x: number; y: number; kind: Kind }>();
134
+ // One pointer down: a tap, a pan, or a swipe's drag.
135
+ let press: Press | null = null;
136
+ // Two pointers down: the scale and the image point under their midpoint
137
+ // when the pinch began.
138
+ let pinch: Pinch | null = null;
139
+ // The last tap, for a double-tap.
140
+ let lastTap: { t: number; x: number; y: number; kind: Kind } | null = null;
141
+ // The animation of a double-tap zoom or a settle back to 1.
142
+ let settle: Animation | null = null;
143
+ // A pending ondownload.
144
+ let saving = false;
145
+
146
+ function resetView() {
147
+ settle?.cancel();
148
+ settle = null;
149
+ scale = 1;
150
+ x = 0;
151
+ y = 0;
152
+ gesture = null;
153
+ pointers.clear();
154
+ press = null;
155
+ pinch = null;
156
+ lastTap = null;
157
+ }
158
+
159
+ // Every open, and every new src, starts at scale 1 with a new load.
160
+ $effect.pre(() => {
161
+ if (!open) return;
162
+ void src;
163
+ untrack(() => {
164
+ resetView();
165
+ phase = 'loading';
166
+ });
167
+ });
168
+
169
+ function close() {
170
+ open = false;
171
+ }
172
+
173
+ // Focus starts on the close button (Bits UI would pick the first
174
+ // control, which is Download when there is one). An app's own
175
+ // onOpenAutoFocus may take over by calling preventDefault().
176
+ function openAutoFocus(e: Event) {
177
+ onOpenAutoFocus?.(e);
178
+ if (e.defaultPrevented) return;
179
+ e.preventDefault();
180
+ const closeButton = () => ref?.querySelector<HTMLElement>('.hp-image-viewer-close');
181
+ const now = closeButton();
182
+ if (now) now.focus({ preventScroll: true });
183
+ else requestAnimationFrame(() => closeButton()?.focus({ preventScroll: true }));
184
+ }
185
+
186
+ // ---- Loading -----------------------------------------------------------
187
+
188
+ // One <img> per src (see the {#key} below), so this runs once per load.
189
+ // The image shows once it is decoded, so its first frame is never blank.
190
+ function watch(img: HTMLImageElement) {
191
+ return untrack(() => {
192
+ let alive = true;
193
+ const done = () => {
194
+ if (alive) phase = 'loaded';
195
+ };
196
+ const fail = () => {
197
+ if (alive && phase === 'loading') phase = 'error';
198
+ };
199
+ const onload = () => {
200
+ img.decode().then(done, () => (img.naturalWidth > 0 ? done() : fail()));
201
+ };
202
+ if (img.complete) {
203
+ // Cached: at once. A complete image without a size is broken,
204
+ // unless it is an SVG without one.
205
+ if (img.naturalWidth > 0) phase = 'loaded';
206
+ else img.decode().then(done, fail);
207
+ }
208
+ img.addEventListener('load', onload);
209
+ img.addEventListener('error', fail);
210
+ return () => {
211
+ alive = false;
212
+ img.removeEventListener('load', onload);
213
+ img.removeEventListener('error', fail);
214
+ };
215
+ });
216
+ }
217
+
218
+ // ---- Geometry ------------------------------------------------------------
219
+
220
+ const transform = (s: number, tx: number, ty: number) => `translate(${tx}px, ${ty}px) scale(${s})`;
221
+
222
+ /** The stage's centre and size, and the image's size at scale 1 (it is centred in the stage). */
223
+ function frame() {
224
+ const r = stage?.getBoundingClientRect();
225
+ return {
226
+ cx: r ? r.left + r.width / 2 : 0,
227
+ cy: r ? r.top + r.height / 2 : 0,
228
+ sw: r?.width ?? 0,
229
+ sh: r?.height ?? 0,
230
+ w: image?.offsetWidth ?? 0,
231
+ h: image?.offsetHeight ?? 0
232
+ };
233
+ }
234
+ type Frame = ReturnType<typeof frame>;
235
+
236
+ /**
237
+ * The offset held so the image leaves no gap at an edge it can fill:
238
+ * along an axis where it is larger than the stage its edges stay at or
239
+ * past the stage's; along one where it is not, it stays centred.
240
+ */
241
+ function hold(s: number, tx: number, ty: number, f: Frame): [number, number] {
242
+ const mx = Math.max(0, (s * f.w - f.sw) / 2);
243
+ const my = Math.max(0, (s * f.h - f.sh) / 2);
244
+ return [Math.min(mx, Math.max(-mx, tx)), Math.min(my, Math.max(-my, ty))];
245
+ }
246
+
247
+ /** The view at scale `s` that keeps the image point under (px, py) where it is. */
248
+ function about(s: number, px: number, py: number, f = frame()): [number, number, number] {
249
+ const k = s / scale;
250
+ return [s, ...hold(s, px - f.cx - k * (px - f.cx - x), py - f.cy - k * (py - f.cy - y), f)];
251
+ }
252
+
253
+ function onImage(px: number, py: number) {
254
+ if (phase !== 'loaded' || !image) return false;
255
+ const r = image.getBoundingClientRect();
256
+ return px >= r.left && px <= r.right && py >= r.top && py <= r.bottom;
257
+ }
258
+
259
+ // ---- Motion ------------------------------------------------------------
260
+
261
+ /** Ends a double-tap zoom or a settle where it is on screen, so a new gesture starts from there. */
262
+ function stop() {
263
+ if (!settle) return;
264
+ if (image && settle.playState === 'running') {
265
+ const m = new DOMMatrixReadOnly(getComputedStyle(image).transform);
266
+ scale = m.a;
267
+ x = m.e;
268
+ y = m.f;
269
+ }
270
+ settle.cancel();
271
+ settle = null;
272
+ }
273
+
274
+ /** Pinch, pan, wheel and keys: the view follows at once. */
275
+ function place([s, tx, ty]: [number, number, number]) {
276
+ stop();
277
+ scale = s;
278
+ x = tx;
279
+ y = ty;
280
+ }
281
+
282
+ /**
283
+ * A double-tap zoom and the settle back to 1: the transform moves over
284
+ * --dur-pop on --settle, both read from the image. Reduced motion jumps.
285
+ */
286
+ function animate([s, tx, ty]: [number, number, number]) {
287
+ stop();
288
+ const from = transform(scale, x, y);
289
+ scale = s;
290
+ x = tx;
291
+ y = ty;
292
+ const to = transform(s, tx, ty);
293
+ if (!image || from === to || prefersReducedMotion.current) return;
294
+ const ms = duration(image, '--dur-pop');
295
+ if (ms <= 0) return;
296
+ const curve = getComputedStyle(image).getPropertyValue('--settle').trim();
297
+ const run = image.animate([{ transform: from }, { transform: to }], {
298
+ duration: ms,
299
+ easing: CSS.supports('transition-timing-function', curve) ? curve : 'ease-out'
300
+ });
301
+ settle = run;
302
+ run.onfinish = () => {
303
+ if (settle === run) settle = null;
304
+ };
305
+ }
306
+
307
+ /** The view at rest: scale 1, centred. */
308
+ const REST: [number, number, number] = [1, 0, 0];
309
+
310
+ function toggle(px: number, py: number) {
311
+ animate(scale > 1 ? REST : about(DOUBLE_SCALE, px, py));
312
+ }
313
+
314
+ // ---- Pointers ----------------------------------------------------------
315
+
316
+ const kindOf = (e: PointerEvent): Kind => (e.pointerType === 'mouse' ? 'mouse' : 'touch');
317
+
318
+ function startPinch() {
319
+ const [a, b] = pointers.values();
320
+ const f = frame();
321
+ const mx = (a.x + b.x) / 2;
322
+ const my = (a.y + b.y) / 2;
323
+ pinch = {
324
+ d0: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
325
+ s0: scale,
326
+ qx: (mx - f.cx - x) / scale,
327
+ qy: (my - f.cy - y) / scale
328
+ };
329
+ gesture = 'pinch';
330
+ }
331
+
332
+ function movePinch() {
333
+ if (!pinch) return;
334
+ const [a, b] = pointers.values();
335
+ const f = frame();
336
+ const mx = (a.x + b.x) / 2;
337
+ const my = (a.y + b.y) / 2;
338
+ const s = Math.min(MAX_SCALE, Math.max(PINCH_MIN, (pinch.s0 * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d0));
339
+ place([s, ...hold(s, mx - f.cx - s * pinch.qx, my - f.cy - s * pinch.qy, f)]);
340
+ }
341
+
342
+ function down(e: PointerEvent) {
343
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
344
+ // Focus stays where it is, as on a scrim.
345
+ e.preventDefault();
346
+ stop();
347
+ const kind = kindOf(e);
348
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, kind });
349
+ stage?.setPointerCapture(e.pointerId);
350
+ if (pointers.size === 1) {
351
+ press = { id: e.pointerId, kind, x0: e.clientX, y0: e.clientY, tx0: x, ty0: y, moved: false };
352
+ } else {
353
+ // A second finger: never a tap, and a pinch once the image is there.
354
+ if (press) press.moved = true;
355
+ if (pointers.size === 2 && phase === 'loaded') startPinch();
356
+ }
357
+ }
358
+
359
+ function move(e: PointerEvent) {
360
+ const p = pointers.get(e.pointerId);
361
+ if (!p) return;
362
+ p.x = e.clientX;
363
+ p.y = e.clientY;
364
+ if (pinch) {
365
+ movePinch();
366
+ return;
367
+ }
368
+ if (!press || press.id !== e.pointerId) return;
369
+ const dx = e.clientX - press.x0;
370
+ const dy = e.clientY - press.y0;
371
+ if (!press.moved && Math.hypot(dx, dy) > SLOP[press.kind]) {
372
+ press.moved = true;
373
+ // Zoomed: the image follows the pointer. At scale 1 a drag is the
374
+ // swipe's, or nothing.
375
+ if (scale > 1 && phase === 'loaded') gesture = 'pan';
376
+ }
377
+ if (gesture === 'pan') {
378
+ const f = frame();
379
+ place([scale, ...hold(scale, press.tx0 + dx, press.ty0 + dy, f)]);
380
+ }
381
+ }
382
+
383
+ function up(e: PointerEvent) {
384
+ if (!pointers.delete(e.pointerId)) return;
385
+ if (pinch) {
386
+ if (pointers.size >= 2) {
387
+ // A third finger lifted, or one of the two: pinch on with the two left.
388
+ startPinch();
389
+ return;
390
+ }
391
+ pinch = null;
392
+ gesture = null;
393
+ if (scale < SNAP) animate(REST);
394
+ // The finger left over pans from here, if the image is still zoomed.
395
+ const left = [...pointers.entries()][0];
396
+ press = left
397
+ ? { id: left[0], kind: left[1].kind, x0: left[1].x, y0: left[1].y, tx0: x, ty0: y, moved: true }
398
+ : null;
399
+ if (press && scale > 1) gesture = 'pan';
400
+ return;
401
+ }
402
+ if (!press || press.id !== e.pointerId) return;
403
+ const done = press;
404
+ press = null;
405
+ gesture = null;
406
+ if (!done.moved && e.type === 'pointerup') tap(done, e);
407
+ }
408
+
409
+ // A tap: the second of a double-tap toggles the zoom about the pressed
410
+ // point; a single one on the backdrop, outside the image, closes at
411
+ // scale 1. A tap on the image waits for its second.
412
+ function tap(start: Press, e: PointerEvent) {
413
+ const t = e.timeStamp;
414
+ const last = lastTap;
415
+ if (
416
+ last &&
417
+ last.kind === start.kind &&
418
+ t - last.t < DOUBLE_TIME[start.kind] &&
419
+ Math.hypot(start.x0 - last.x, start.y0 - last.y) < DOUBLE_DISTANCE[start.kind]
420
+ ) {
421
+ lastTap = null;
422
+ if (phase === 'loaded') toggle(start.x0, start.y0);
423
+ return;
424
+ }
425
+ lastTap = { t, x: start.x0, y: start.y0, kind: start.kind };
426
+ if (scale === 1 && !onImage(start.x0, start.y0)) close();
427
+ }
428
+
429
+ // The wheel zooms about the cursor; a trackpad pinch arrives as a wheel
430
+ // with ctrlKey and smaller steps.
431
+ function wheel(e: WheelEvent) {
432
+ e.preventDefault();
433
+ if (phase !== 'loaded' || pinch) return;
434
+ stop();
435
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? (stage?.clientHeight ?? 800) : 1;
436
+ const rate = e.ctrlKey ? 0.01 : 0.002;
437
+ const s = Math.min(MAX_SCALE, Math.max(1, scale * Math.exp(-e.deltaY * unit * rate)));
438
+ place(about(s, e.clientX, e.clientY));
439
+ }
440
+
441
+ function gestures(node: HTMLElement) {
442
+ const capture = { capture: true };
443
+ // Capture, so a swipe that stops a drag's pointerup at the stage
444
+ // cannot hide it from here.
445
+ node.addEventListener('pointerdown', down, capture);
446
+ node.addEventListener('pointermove', move, capture);
447
+ node.addEventListener('pointerup', up, capture);
448
+ node.addEventListener('pointercancel', up, capture);
449
+ node.addEventListener('wheel', wheel, { passive: false });
450
+ return () => {
451
+ node.removeEventListener('pointerdown', down, capture);
452
+ node.removeEventListener('pointermove', move, capture);
453
+ node.removeEventListener('pointerup', up, capture);
454
+ node.removeEventListener('pointercancel', up, capture);
455
+ node.removeEventListener('wheel', wheel);
456
+ };
457
+ }
458
+
459
+ // ---- Keys --------------------------------------------------------------
460
+
461
+ /** Each arrow key's pan, as a share of the stage: it shows more of that side, so the image moves the other way. */
462
+ const ARROWS: Record<string, [number, number]> = {
463
+ ArrowLeft: [KEY_PAN, 0],
464
+ ArrowRight: [-KEY_PAN, 0],
465
+ ArrowUp: [0, KEY_PAN],
466
+ ArrowDown: [0, -KEY_PAN]
467
+ };
468
+
469
+ // + or = zooms in, - zooms out, 0 goes back to 1, about the stage's
470
+ // centre; arrow keys pan while zoomed. Each press jumps: no motion
471
+ // repeats on a key press. Keys with Ctrl, Alt or Meta stay the browser's.
472
+ function key(e: KeyboardEvent) {
473
+ if (e.ctrlKey || e.altKey || e.metaKey || e.defaultPrevented || phase !== 'loaded') return;
474
+ const arrow = ARROWS[e.key];
475
+ if (arrow ? scale <= 1 : !['+', '=', '-', '0'].includes(e.key)) return;
476
+ e.preventDefault();
477
+ stop();
478
+ const f = frame();
479
+ if (arrow) place([scale, ...hold(scale, x + arrow[0] * f.sw, y + arrow[1] * f.sh, f)]);
480
+ else if (e.key === '0') place(REST);
481
+ else if (e.key === '-') place(about(Math.max(1, scale / KEY_STEP), f.cx, f.cy, f));
482
+ else place(about(Math.min(MAX_SCALE, scale * KEY_STEP), f.cx, f.cy, f));
483
+ }
484
+
485
+ function keys(node: HTMLElement) {
486
+ node.addEventListener('keydown', key);
487
+ return () => node.removeEventListener('keydown', key);
488
+ }
489
+
490
+ // ---- Download ----------------------------------------------------------
491
+
492
+ /** A `data:` URL's bytes as a Blob, decoded here, so no fetch() meets a Content Security Policy. */
493
+ function dataBlob(url: string): Blob {
494
+ const comma = url.indexOf(',');
495
+ const head = url.slice(5, comma);
496
+ const raw = url.slice(comma + 1);
497
+ const body = raw.includes('%') ? decodeURIComponent(raw) : raw;
498
+ const base64 = /;base64$/i.test(head);
499
+ const type = (base64 ? head.slice(0, -7) : head) || 'text/plain';
500
+ if (!base64) return new Blob([body], { type });
501
+ const text = atob(body);
502
+ const bytes = new Uint8Array(text.length);
503
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
504
+ return new Blob([bytes], { type });
505
+ }
506
+
507
+ // A link with `download` saves the file under its name. It opens in a
508
+ // new tab, so an image from another origin, whose `download` the browser
509
+ // ignores, never replaces the app.
510
+ function save(name: string) {
511
+ const made = /^data:/i.test(src) ? URL.createObjectURL(dataBlob(src)) : null;
512
+ const link = document.createElement('a');
513
+ link.href = made ?? src;
514
+ link.download = name;
515
+ link.target = '_blank';
516
+ link.rel = 'noopener';
517
+ (ref ?? document.body).append(link);
518
+ link.click();
519
+ link.remove();
520
+ if (made) setTimeout(() => URL.revokeObjectURL(made), REVOKE_AFTER);
521
+ }
522
+
523
+ async function download() {
524
+ if (!ondownload) {
525
+ if (downloadName !== undefined) save(downloadName);
526
+ return;
527
+ }
528
+ if (saving) return;
529
+ saving = true;
530
+ try {
531
+ await ondownload();
532
+ } finally {
533
+ saving = false;
534
+ }
535
+ }
536
+ </script>
537
+
538
+ <!--
539
+ One image over everything, to look at closely: a top bar with the
540
+ `title`, Download and Close, and the image fitted inside the rest of the
541
+ screen at scale 1, centred. Full screen at every width, on
542
+ --layer-dialog, filled with --canvas. Bits UI Dialog traps focus, locks
543
+ the page's scroll, closes on Escape and gives focus back to the opener;
544
+ focus starts on Close.
545
+
546
+ Zoom (1 to 6 times the fitted size): a pinch about the fingers'
547
+ midpoint, a double-tap or double-click (1 and 2.5 about the pressed
548
+ point), the wheel about the cursor, + = - and 0. While zoomed, one
549
+ pointer or the arrow keys pan, held so the image leaves no gap at an edge
550
+ it can fill. A pinch released below 1, or less than 5 percent above it,
551
+ settles back to 1.
552
+
553
+ Closing: Close, Escape, and at scale 1 a swipe down
554
+ (internal/swipe-dismiss.ts) or a press on the backdrop outside the
555
+ image. A drag is never a press. No gesture closes it while zoomed. The
556
+ system back gesture is the app's: push a history entry on open and close
557
+ on popstate (see the docs).
558
+
559
+ Motion: the viewer fades in over --dur-sheet and out over
560
+ --dur-sheet-exit. Pinch, pan, wheel and keys follow at once. A
561
+ double-tap zoom and the settle back to 1 move the transform over
562
+ --dur-pop on --settle, read from the image; reduced motion jumps.
563
+
564
+ The stage `.hp-image-viewer-stage` carries `data-state` loading |
565
+ loaded | error, `data-zoomed` above scale 1 and `data-gesture` pan |
566
+ pinch. While loading, a Spinner shows after its delay; on an error the
567
+ alt shows as a muted caption and Download stays. Nothing is announced on
568
+ zoom.
569
+ -->
570
+ <BitsDialog.Root bind:open>
571
+ {#if trigger}
572
+ <BitsDialog.Trigger>
573
+ {#snippet child({ props })}
574
+ {@render trigger({ props })}
575
+ {/snippet}
576
+ </BitsDialog.Trigger>
577
+ {/if}
578
+
579
+ <BitsDialog.Portal>
580
+ <BitsDialog.Content
581
+ forceMount
582
+ bind:ref
583
+ aria-labelledby={title ? titleId : undefined}
584
+ aria-label={title ? undefined : alt}
585
+ onOpenAutoFocus={openAutoFocus}
586
+ {...rest}
587
+ >
588
+ {#snippet child({
589
+ props: { interactOutsideBehavior: _outside, escapeKeydownBehavior: _escape, ...props },
590
+ open: shown
591
+ })}
592
+ {#if shown}
593
+ <div {...props} class={['hp-image-viewer', className]} {@attach keys} transition:fade>
594
+ <div class="hp-image-viewer-bar">
595
+ {#if title}
596
+ <h2 id={titleId} class="hp-image-viewer-title">{title}</h2>
597
+ {/if}
598
+ {#if downloadable}
599
+ <Button class="hp-image-viewer-download" onclick={download}>{downloadLabel}</Button>
600
+ {/if}
601
+ <Button
602
+ variant="tertiary"
603
+ icon
604
+ class="hp-image-viewer-close"
605
+ aria-label={closeLabel}
606
+ onclick={close}
607
+ >
608
+ <svg
609
+ viewBox="0 0 16 16"
610
+ fill="none"
611
+ stroke="currentColor"
612
+ stroke-width="1.5"
613
+ stroke-linecap="square"
614
+ aria-hidden="true"
615
+ >
616
+ <path d="M3 3l10 10M13 3L3 13" />
617
+ </svg>
618
+ </Button>
619
+ </div>
620
+ <div
621
+ class="hp-image-viewer-stage"
622
+ data-state={phase}
623
+ data-zoomed={scale > 1 ? '' : undefined}
624
+ data-gesture={gesture ?? undefined}
625
+ bind:this={stage}
626
+ {@attach gestures}
627
+ {@attach atRest ? swipeDismiss(close) : undefined}
628
+ >
629
+ {#key src}
630
+ <img
631
+ class="hp-image-viewer-image"
632
+ {src}
633
+ {alt}
634
+ draggable="false"
635
+ style:transform={transform(scale, x, y)}
636
+ bind:this={image}
637
+ {@attach watch}
638
+ />
639
+ {/key}
640
+ {#if phase === 'loading'}
641
+ <div class="hp-image-viewer-spinner"><Spinner label={loadingLabel} /></div>
642
+ {:else if phase === 'error' && alt}
643
+ <span class="hp-image-viewer-caption hp-caption" aria-hidden="true">{alt}</span>
644
+ {/if}
645
+ </div>
646
+ </div>
647
+ {/if}
648
+ {/snippet}
649
+ </BitsDialog.Content>
650
+ </BitsDialog.Portal>
651
+ </BitsDialog.Root>
652
+
653
+ <style>
654
+ /* The whole screen in --canvas, so it follows the theme. The side and
655
+ bottom safe areas keep the stage clear of the screen's cut-outs; the
656
+ bar adds the top one. Bits UI may have turned pointer events off on
657
+ <body>. */
658
+ .hp-image-viewer {
659
+ position: fixed;
660
+ inset: 0;
661
+ z-index: var(--layer-dialog);
662
+ display: flex;
663
+ flex-direction: column;
664
+ box-sizing: border-box;
665
+ padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
666
+ background: var(--canvas);
667
+ color: var(--text);
668
+ overscroll-behavior: contain;
669
+ pointer-events: auto;
670
+ }
671
+
672
+ /* The shell header's height under the top safe area: the title takes
673
+ what the buttons leave and ends in an ellipsis; without a title the
674
+ buttons keep the end. Download and Close keep their size, 8px apart,
675
+ with Close 8px from the edge, so its focus contour (4px out, 2px wide)
676
+ stays on screen. */
677
+ .hp-image-viewer-bar {
678
+ flex: none;
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: flex-end;
682
+ gap: var(--space-2);
683
+ box-sizing: border-box;
684
+ min-height: calc(var(--header-height) + env(safe-area-inset-top));
685
+ padding: env(safe-area-inset-top) var(--space-2) 0 var(--gutter);
686
+ }
687
+ .hp-image-viewer-bar > :global(.hp-button) {
688
+ flex: none;
689
+ }
690
+ .hp-image-viewer-title {
691
+ flex: 1 1 auto;
692
+ min-width: 0;
693
+ overflow: hidden;
694
+ font-size: var(--type-body);
695
+ line-height: 24px;
696
+ text-overflow: ellipsis;
697
+ white-space: nowrap;
698
+ }
699
+
700
+ /* The rest of the screen. Every touch is the viewer's: no browser pan,
701
+ zoom or overscroll. The image never shows outside it, so the bar stays
702
+ clear. */
703
+ .hp-image-viewer-stage {
704
+ position: relative;
705
+ flex: 1 1 auto;
706
+ min-height: 0;
707
+ overflow: hidden;
708
+ overscroll-behavior: contain;
709
+ touch-action: none;
710
+ user-select: none;
711
+ -webkit-user-select: none;
712
+ }
713
+ .hp-image-viewer-stage[data-zoomed] {
714
+ cursor: grab;
715
+ }
716
+ .hp-image-viewer-stage[data-gesture] {
717
+ cursor: grabbing;
718
+ }
719
+
720
+ /* Fitted inside the stage at its own size or smaller, centred. Hidden by
721
+ opacity only while it loads or after an error, so it stays in the
722
+ accessibility tree and no broken-image glyph shows. */
723
+ .hp-image-viewer-image {
724
+ position: absolute;
725
+ inset: 0;
726
+ width: auto;
727
+ height: auto;
728
+ max-width: 100%;
729
+ max-height: 100%;
730
+ margin: auto;
731
+ transform-origin: 50% 50%;
732
+ -webkit-user-drag: none;
733
+ }
734
+ .hp-image-viewer-stage[data-state='loading'] > .hp-image-viewer-image,
735
+ .hp-image-viewer-stage[data-state='error'] > .hp-image-viewer-image {
736
+ opacity: 0;
737
+ }
738
+
739
+ /* The spinner in the middle, on whole pixels (Spinner: never centre
740
+ pixel text in odd free space). Browsers without round() centre it. */
741
+ .hp-image-viewer-spinner {
742
+ position: absolute;
743
+ top: 50%;
744
+ left: 50%;
745
+ translate: -50% -50%;
746
+ top: round(down, 50%, 1px);
747
+ left: round(down, 50%, 1px);
748
+ translate: round(down, -50%, 1px) round(down, -50%, 1px);
749
+ pointer-events: none;
750
+ }
751
+
752
+ /* The alt of an image that failed, as MediaImage shows it. */
753
+ .hp-image-viewer-caption {
754
+ position: absolute;
755
+ inset: 0;
756
+ display: grid;
757
+ place-items: center;
758
+ padding: var(--space-8);
759
+ overflow: hidden;
760
+ color: var(--muted);
761
+ text-align: center;
762
+ pointer-events: none;
763
+ }
764
+ </style>