@joewinke/jatui 0.33.0 → 0.34.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.
- package/CLAUDE.md +15 -0
- package/dist/actions/reviewKeys.d.ts +159 -0
- package/dist/actions/reviewKeys.js +353 -0
- package/dist/components/review/MediaZoomLightbox.svelte +628 -0
- package/dist/components/review/MediaZoomLightbox.svelte.d.ts +56 -0
- package/dist/components/review/ReviewFilterBar.svelte +296 -0
- package/dist/components/review/ReviewFilterBar.svelte.d.ts +45 -0
- package/dist/components/review/ReviewNote.svelte +213 -0
- package/dist/components/review/ReviewNote.svelte.d.ts +37 -0
- package/dist/components/review/ReviewProgress.svelte +99 -0
- package/dist/components/review/ReviewProgress.svelte.d.ts +32 -0
- package/dist/components/review/ReviewShortcuts.svelte +80 -0
- package/dist/components/review/ReviewShortcuts.svelte.d.ts +15 -0
- package/dist/components/review/RoundHistory.svelte +151 -0
- package/dist/components/review/RoundHistory.svelte.d.ts +35 -0
- package/dist/components/review/VerdictControl.svelte +163 -0
- package/dist/components/review/VerdictControl.svelte.d.ts +23 -0
- package/dist/components/review/index.d.ts +24 -0
- package/dist/components/review/index.js +23 -0
- package/dist/components/review/mediaZoom.d.ts +115 -0
- package/dist/components/review/mediaZoom.js +161 -0
- package/dist/components/review/reviewSummary.d.ts +101 -0
- package/dist/components/review/reviewSummary.js +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/package.json +6 -1
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* MediaZoomLightbox — one full-viewport viewer for images AND video
|
|
4
|
+
* (jatui-c79nt; design: mardukos docs/review-ux/proposal.md, mos-lsmve).
|
|
5
|
+
*
|
|
6
|
+
* Ported from the MOS media-picker ZoomLightbox (mos-0wvol): zoom/pan that
|
|
7
|
+
* applies to a PLAYING video (+ / − / 0, wheel, pinch, drag), frame-step
|
|
8
|
+
* (, .), speed (< > or the chips), loop, scrub; ←/→ page the item's views,
|
|
9
|
+
* 1–9 pick one. Images get the same zoom.
|
|
10
|
+
*
|
|
11
|
+
* Reviewing inside the zoom view (decision D5=A): the `footer` snippet
|
|
12
|
+
* renders in the bottom bar, so a host drops VerdictControl + ReviewNote
|
|
13
|
+
* there and the reviewer decides without closing. The lightbox imports
|
|
14
|
+
* neither. Keys it doesn't own (a/x/r/u/n, [ ], ?) bubble out to the host.
|
|
15
|
+
* `onNextItem` / `onPrevItem` let the host page to the next item while
|
|
16
|
+
* open (j/k, ↓/↑, or ←/→ past the last/first view): the host swaps `items`
|
|
17
|
+
* and the viewer resets to the new item's first (or, going back, last) view.
|
|
18
|
+
*
|
|
19
|
+
* Focus moves into the dialog on mount, Tab is trapped, Esc closes and focus
|
|
20
|
+
* returns to whatever had it before. Mount it to open, unmount to close.
|
|
21
|
+
* No autoplay under prefers-reduced-motion. Token-only theming.
|
|
22
|
+
*/
|
|
23
|
+
import { onMount, tick, untrack, type Snippet } from 'svelte';
|
|
24
|
+
import type { ShortcutRegistry } from '../../utils/keyboardShortcutRegistry';
|
|
25
|
+
import { isTypingTarget } from '../../utils/keyboardShortcutRegistry';
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_FPS,
|
|
28
|
+
DEFAULT_SPEEDS,
|
|
29
|
+
ZOOM_IDENTITY,
|
|
30
|
+
ZOOM_STEP,
|
|
31
|
+
clampIndex,
|
|
32
|
+
formatMediaTime,
|
|
33
|
+
mediaZoomHint,
|
|
34
|
+
panBy,
|
|
35
|
+
resolveMediaZoomKey,
|
|
36
|
+
stepFrameTime,
|
|
37
|
+
stepSpeed,
|
|
38
|
+
stepView,
|
|
39
|
+
wheelFactor,
|
|
40
|
+
zoomAt,
|
|
41
|
+
zoomTransform,
|
|
42
|
+
type MediaZoomFooterContext,
|
|
43
|
+
type MediaZoomState,
|
|
44
|
+
type MediaZoomView
|
|
45
|
+
} from './mediaZoom';
|
|
46
|
+
|
|
47
|
+
interface Props {
|
|
48
|
+
/** The views of the item being judged (one or more). */
|
|
49
|
+
items: MediaZoomView[];
|
|
50
|
+
/** Current view (bindable). Seeded from the host; clamped to `items`. */
|
|
51
|
+
index?: number;
|
|
52
|
+
/** Item name shown before the view label ("Grease trap card"). */
|
|
53
|
+
title?: string;
|
|
54
|
+
/** Extra header text after the view counter, e.g. "item 3 of 12". */
|
|
55
|
+
position?: string;
|
|
56
|
+
onClose: () => void;
|
|
57
|
+
/** Host pages to the next item. Omit (or pass undefined at the end) to disable. */
|
|
58
|
+
onNextItem?: () => void;
|
|
59
|
+
onPrevItem?: () => void;
|
|
60
|
+
/** Bottom-bar slot — VerdictControl + ReviewNote live here in review hosts. */
|
|
61
|
+
footer?: Snippet<[MediaZoomFooterContext]>;
|
|
62
|
+
/** Frame-step rate. Review loops are 30fps. */
|
|
63
|
+
fps?: number;
|
|
64
|
+
speeds?: readonly number[];
|
|
65
|
+
/** Autoplay video (always off under prefers-reduced-motion). */
|
|
66
|
+
autoplay?: boolean;
|
|
67
|
+
muted?: boolean;
|
|
68
|
+
/** Show the one-line key hint under the controls. */
|
|
69
|
+
showHint?: boolean;
|
|
70
|
+
zIndex?: string | number;
|
|
71
|
+
/** While open, push a modal context so page shortcuts don't fire behind it. */
|
|
72
|
+
shortcutRegistry?: ShortcutRegistry;
|
|
73
|
+
shortcutContext?: string;
|
|
74
|
+
class?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let {
|
|
78
|
+
items,
|
|
79
|
+
index = $bindable(0),
|
|
80
|
+
title = '',
|
|
81
|
+
position = '',
|
|
82
|
+
onClose,
|
|
83
|
+
onNextItem,
|
|
84
|
+
onPrevItem,
|
|
85
|
+
footer,
|
|
86
|
+
fps = DEFAULT_FPS,
|
|
87
|
+
speeds = DEFAULT_SPEEDS,
|
|
88
|
+
autoplay = true,
|
|
89
|
+
muted = true,
|
|
90
|
+
showHint = true,
|
|
91
|
+
zIndex = 9040,
|
|
92
|
+
shortcutRegistry,
|
|
93
|
+
shortcutContext = 'media-zoom',
|
|
94
|
+
class: className = ''
|
|
95
|
+
}: Props = $props();
|
|
96
|
+
|
|
97
|
+
let zoom = $state<MediaZoomState>({ ...ZOOM_IDENTITY });
|
|
98
|
+
let paused = $state(true);
|
|
99
|
+
let looping = $state(true);
|
|
100
|
+
let speed = $state(1);
|
|
101
|
+
let time = $state(0);
|
|
102
|
+
let duration = $state(0);
|
|
103
|
+
let dialogEl = $state<HTMLElement | null>(null);
|
|
104
|
+
let stageEl = $state<HTMLElement | null>(null);
|
|
105
|
+
let videoEl = $state<HTMLVideoElement | null>(null);
|
|
106
|
+
|
|
107
|
+
const safeIndex = $derived(clampIndex(index, items.length));
|
|
108
|
+
const current = $derived(items[safeIndex]);
|
|
109
|
+
const isVideo = $derived(current?.kind === 'video');
|
|
110
|
+
const hasItems = $derived(!!onNextItem || !!onPrevItem);
|
|
111
|
+
const keyCtx = $derived({ isVideo, hasItems, viewCount: items.length });
|
|
112
|
+
const zoomPct = $derived(Math.round(zoom.scale * 100));
|
|
113
|
+
const hint = $derived(mediaZoomHint(keyCtx));
|
|
114
|
+
|
|
115
|
+
function resetView() {
|
|
116
|
+
zoom = { ...ZOOM_IDENTITY };
|
|
117
|
+
time = 0;
|
|
118
|
+
duration = 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The host swapped `items` (paged to another item): land on the edge we
|
|
122
|
+
// came in from, with a fresh zoom.
|
|
123
|
+
let seenItems: MediaZoomView[] = untrack(() => items);
|
|
124
|
+
let enterAt: 'start' | 'end' | null = null;
|
|
125
|
+
$effect.pre(() => {
|
|
126
|
+
const next = items;
|
|
127
|
+
if (next === seenItems) return;
|
|
128
|
+
seenItems = next;
|
|
129
|
+
untrack(() => {
|
|
130
|
+
if (enterAt === 'end') index = Math.max(0, next.length - 1);
|
|
131
|
+
else if (enterAt === 'start') index = 0;
|
|
132
|
+
else index = clampIndex(index, next.length);
|
|
133
|
+
enterAt = null;
|
|
134
|
+
resetView();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function reducedMotion(): boolean {
|
|
139
|
+
try {
|
|
140
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const shouldAutoplay = $derived(autoplay && !reducedMotion());
|
|
146
|
+
|
|
147
|
+
function stageSize(): { w: number; h: number } {
|
|
148
|
+
const r = stageEl?.getBoundingClientRect();
|
|
149
|
+
return { w: r?.width ?? 0, h: r?.height ?? 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function zoomBy(factor: number, cx = 0, cy = 0) {
|
|
153
|
+
const { w, h } = stageSize();
|
|
154
|
+
zoom = zoomAt(zoom, factor, cx, cy, w, h);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resetZoom() {
|
|
158
|
+
zoom = { ...ZOOM_IDENTITY };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function setView(i: number) {
|
|
162
|
+
if (i === safeIndex) return;
|
|
163
|
+
index = clampIndex(i, items.length);
|
|
164
|
+
resetView();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pageItem(dir: 1 | -1) {
|
|
168
|
+
const cb = dir > 0 ? onNextItem : onPrevItem;
|
|
169
|
+
if (!cb) return;
|
|
170
|
+
enterAt = dir > 0 ? 'start' : 'end';
|
|
171
|
+
cb();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function go(delta: 1 | -1) {
|
|
175
|
+
const s = stepView(safeIndex, delta, items.length, {
|
|
176
|
+
hasNextItem: !!onNextItem,
|
|
177
|
+
hasPrevItem: !!onPrevItem
|
|
178
|
+
});
|
|
179
|
+
if (s.kind === 'view') setView(s.index);
|
|
180
|
+
else if (s.kind === 'item') pageItem(s.dir);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function togglePlay() {
|
|
184
|
+
const v = videoEl;
|
|
185
|
+
if (!v) return;
|
|
186
|
+
if (v.paused) v.play().catch(() => {});
|
|
187
|
+
else v.pause();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function step(frames: number) {
|
|
191
|
+
const v = videoEl;
|
|
192
|
+
if (!v) return;
|
|
193
|
+
v.pause();
|
|
194
|
+
v.currentTime = stepFrameTime(v.currentTime, frames, v.duration, fps);
|
|
195
|
+
time = v.currentTime;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setSpeed(s: number) {
|
|
199
|
+
speed = s;
|
|
200
|
+
if (videoEl) videoEl.playbackRate = s;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function seek(sec: number) {
|
|
204
|
+
if (!videoEl) return;
|
|
205
|
+
videoEl.currentTime = sec;
|
|
206
|
+
time = sec;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ARROW_OWNERS =
|
|
210
|
+
'input, select, textarea, [contenteditable], [role="radio"], [role="slider"], [role="tab"], [role="option"], [role="menuitem"], [role="radiogroup"] *, [role="listbox"] *';
|
|
211
|
+
const ACTIVATABLE = 'button, a[href], input, select, textarea, summary, [role="button"], [role="radio"], [role="checkbox"], [role="switch"], [role="tab"], [role="option"], [role="menuitem"]';
|
|
212
|
+
|
|
213
|
+
function onKeydown(e: KeyboardEvent) {
|
|
214
|
+
if (e.defaultPrevented || e.isComposing) return;
|
|
215
|
+
const k = e.key;
|
|
216
|
+
if (k === 'Tab') {
|
|
217
|
+
trapTab(e);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
221
|
+
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
222
|
+
const onDialog = target === dialogEl;
|
|
223
|
+
if (target && !onDialog && isTypingTarget(target)) {
|
|
224
|
+
// Typing in the footer's note: keys are text. Esc leaves the field
|
|
225
|
+
// (back to the viewer) instead of closing the whole lightbox.
|
|
226
|
+
if (k === 'Escape') {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
e.stopPropagation();
|
|
229
|
+
dialogEl?.focus();
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (target && !onDialog) {
|
|
234
|
+
// A focused control keeps its own activation and arrow keys.
|
|
235
|
+
if ((k === ' ' || k === 'Enter') && target.matches(ACTIVATABLE)) return;
|
|
236
|
+
if (k.startsWith('Arrow') && target.closest(ARROW_OWNERS)) return;
|
|
237
|
+
}
|
|
238
|
+
const action = resolveMediaZoomKey(k, keyCtx);
|
|
239
|
+
if (!action) return; // not ours — bubbles to the host (verdict keys, ?, [ ])
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
switch (action) {
|
|
243
|
+
case 'close':
|
|
244
|
+
onClose();
|
|
245
|
+
break;
|
|
246
|
+
case 'next-view':
|
|
247
|
+
go(1);
|
|
248
|
+
break;
|
|
249
|
+
case 'prev-view':
|
|
250
|
+
go(-1);
|
|
251
|
+
break;
|
|
252
|
+
case 'next-item':
|
|
253
|
+
pageItem(1);
|
|
254
|
+
break;
|
|
255
|
+
case 'prev-item':
|
|
256
|
+
pageItem(-1);
|
|
257
|
+
break;
|
|
258
|
+
case 'pick-view':
|
|
259
|
+
setView(Number(k) - 1);
|
|
260
|
+
break;
|
|
261
|
+
case 'zoom-in':
|
|
262
|
+
zoomBy(ZOOM_STEP);
|
|
263
|
+
break;
|
|
264
|
+
case 'zoom-out':
|
|
265
|
+
zoomBy(1 / ZOOM_STEP);
|
|
266
|
+
break;
|
|
267
|
+
case 'zoom-reset':
|
|
268
|
+
resetZoom();
|
|
269
|
+
break;
|
|
270
|
+
case 'play-pause':
|
|
271
|
+
togglePlay();
|
|
272
|
+
break;
|
|
273
|
+
case 'frame-back':
|
|
274
|
+
step(-1);
|
|
275
|
+
break;
|
|
276
|
+
case 'frame-forward':
|
|
277
|
+
step(1);
|
|
278
|
+
break;
|
|
279
|
+
case 'loop':
|
|
280
|
+
looping = !looping;
|
|
281
|
+
break;
|
|
282
|
+
case 'slower':
|
|
283
|
+
setSpeed(stepSpeed(speed, -1, speeds));
|
|
284
|
+
break;
|
|
285
|
+
case 'faster':
|
|
286
|
+
setSpeed(stepSpeed(speed, 1, speeds));
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function trapTab(e: KeyboardEvent) {
|
|
292
|
+
if (!dialogEl) return;
|
|
293
|
+
const f = [
|
|
294
|
+
...dialogEl.querySelectorAll<HTMLElement>(
|
|
295
|
+
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
296
|
+
)
|
|
297
|
+
].filter((el) => !el.hidden && !el.closest('[hidden], [inert]'));
|
|
298
|
+
if (f.length === 0) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
dialogEl.focus();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const first = f[0];
|
|
304
|
+
const last = f[f.length - 1];
|
|
305
|
+
const active = document.activeElement;
|
|
306
|
+
if (e.shiftKey && (active === first || active === dialogEl)) {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
last.focus();
|
|
309
|
+
} else if (!e.shiftKey && (active === last || !dialogEl.contains(active))) {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
first.focus();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- pointer: drag to pan, pinch to zoom, click to play/pause -------------
|
|
316
|
+
|
|
317
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
318
|
+
let moved = 0;
|
|
319
|
+
let pinchDist = 0;
|
|
320
|
+
|
|
321
|
+
function centred(clientX: number, clientY: number) {
|
|
322
|
+
const r = stageEl?.getBoundingClientRect();
|
|
323
|
+
if (!r) return { cx: 0, cy: 0 };
|
|
324
|
+
return { cx: clientX - (r.left + r.width / 2), cy: clientY - (r.top + r.height / 2) };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function onPointerDown(e: PointerEvent) {
|
|
328
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
329
|
+
moved = 0;
|
|
330
|
+
try {
|
|
331
|
+
stageEl?.setPointerCapture?.(e.pointerId);
|
|
332
|
+
} catch {
|
|
333
|
+
/* jsdom / detached */
|
|
334
|
+
}
|
|
335
|
+
if (pointers.size === 2) {
|
|
336
|
+
const [a, b] = [...pointers.values()];
|
|
337
|
+
pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function onPointerMove(e: PointerEvent) {
|
|
342
|
+
const prev = pointers.get(e.pointerId);
|
|
343
|
+
if (!prev) return;
|
|
344
|
+
const next = { x: e.clientX, y: e.clientY };
|
|
345
|
+
pointers.set(e.pointerId, next);
|
|
346
|
+
const { w, h } = stageSize();
|
|
347
|
+
if (pointers.size >= 2) {
|
|
348
|
+
const [a, b] = [...pointers.values()];
|
|
349
|
+
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
350
|
+
if (pinchDist > 0 && d > 0) {
|
|
351
|
+
const mid = centred((a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
352
|
+
zoom = zoomAt(zoom, d / pinchDist, mid.cx, mid.cy, w, h);
|
|
353
|
+
}
|
|
354
|
+
pinchDist = d;
|
|
355
|
+
moved += 10;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const dx = next.x - prev.x;
|
|
359
|
+
const dy = next.y - prev.y;
|
|
360
|
+
moved += Math.abs(dx) + Math.abs(dy);
|
|
361
|
+
zoom = panBy(zoom, dx, dy, w, h);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function onPointerUp(e: PointerEvent) {
|
|
365
|
+
const wasTap = pointers.size === 1 && moved < 4;
|
|
366
|
+
pointers.delete(e.pointerId);
|
|
367
|
+
if (pointers.size < 2) pinchDist = 0;
|
|
368
|
+
if (wasTap && isVideo && e.pointerType !== 'touch') togglePlay();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
$effect(() => {
|
|
372
|
+
const el = stageEl;
|
|
373
|
+
if (!el) return;
|
|
374
|
+
// Non-passive: the wheel zooms the media instead of scrolling the page.
|
|
375
|
+
const onWheel = (e: WheelEvent) => {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
const { cx, cy } = centred(e.clientX, e.clientY);
|
|
378
|
+
zoomBy(wheelFactor(e.deltaY), cx, cy);
|
|
379
|
+
};
|
|
380
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
381
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Keep the element in step with the chosen speed/loop across view changes.
|
|
385
|
+
$effect(() => {
|
|
386
|
+
const v = videoEl;
|
|
387
|
+
if (!v) return;
|
|
388
|
+
v.playbackRate = speed;
|
|
389
|
+
v.loop = looping;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
$effect(() => {
|
|
393
|
+
if (!shortcutRegistry) return;
|
|
394
|
+
return shortcutRegistry.pushContext(shortcutContext, {
|
|
395
|
+
modal: true,
|
|
396
|
+
label: untrack(() => title) || 'Media viewer'
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
onMount(() => {
|
|
401
|
+
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
402
|
+
void tick().then(() => dialogEl?.focus());
|
|
403
|
+
const prevOverflow = document.body.style.overflow;
|
|
404
|
+
document.body.style.overflow = 'hidden';
|
|
405
|
+
return () => {
|
|
406
|
+
document.body.style.overflow = prevOverflow;
|
|
407
|
+
if (previousFocus?.isConnected) previousFocus.focus();
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
</script>
|
|
411
|
+
|
|
412
|
+
<div
|
|
413
|
+
bind:this={dialogEl}
|
|
414
|
+
class="fixed inset-0 flex flex-col bg-base-300/95 text-base-content outline-none {className}"
|
|
415
|
+
style:z-index={zIndex}
|
|
416
|
+
role="dialog"
|
|
417
|
+
aria-modal="true"
|
|
418
|
+
aria-label="{title ? `${title}: ` : ''}{current?.label ?? 'media'}, full size"
|
|
419
|
+
tabindex="-1"
|
|
420
|
+
data-media-zoom-lightbox
|
|
421
|
+
onkeydown={onKeydown}
|
|
422
|
+
>
|
|
423
|
+
<header class="flex items-center gap-2 px-3 py-2 sm:px-4">
|
|
424
|
+
<p class="min-w-0 flex-1 truncate text-sm" data-media-zoom-caption>
|
|
425
|
+
{#if title}<span class="font-semibold">{title}</span>{' · '}{/if}<span>{current?.label}</span
|
|
426
|
+
>{#if items.length > 1}<span class="tabular-nums text-base-content/70"
|
|
427
|
+
>{' · '}{safeIndex + 1} of {items.length}</span
|
|
428
|
+
>{/if}{#if position}<span class="text-base-content/70">{' · '}{position}</span>{/if}
|
|
429
|
+
</p>
|
|
430
|
+
{#if onPrevItem || onNextItem}
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
434
|
+
aria-label="Previous item"
|
|
435
|
+
aria-keyshortcuts="K"
|
|
436
|
+
disabled={!onPrevItem}
|
|
437
|
+
onclick={() => pageItem(-1)}>↑</button
|
|
438
|
+
>
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
442
|
+
aria-label="Next item"
|
|
443
|
+
aria-keyshortcuts="J"
|
|
444
|
+
disabled={!onNextItem}
|
|
445
|
+
onclick={() => pageItem(1)}>↓</button
|
|
446
|
+
>
|
|
447
|
+
{/if}
|
|
448
|
+
{#if items.length > 1}
|
|
449
|
+
<button
|
|
450
|
+
type="button"
|
|
451
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
452
|
+
aria-label="Previous view"
|
|
453
|
+
aria-keyshortcuts="ArrowLeft"
|
|
454
|
+
onclick={() => go(-1)}>←</button
|
|
455
|
+
>
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
459
|
+
aria-label="Next view"
|
|
460
|
+
aria-keyshortcuts="ArrowRight"
|
|
461
|
+
onclick={() => go(1)}>→</button
|
|
462
|
+
>
|
|
463
|
+
{/if}
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
467
|
+
aria-label="Close full size view"
|
|
468
|
+
aria-keyshortcuts="Escape"
|
|
469
|
+
onclick={onClose}>✕</button
|
|
470
|
+
>
|
|
471
|
+
</header>
|
|
472
|
+
|
|
473
|
+
<!-- The stage: absolute-inset media (Firefox h-full in flex chains is
|
|
474
|
+
broken), transformed for zoom/pan. -->
|
|
475
|
+
<div
|
|
476
|
+
bind:this={stageEl}
|
|
477
|
+
data-zoom-stage
|
|
478
|
+
class="relative min-h-0 flex-1 touch-none select-none overflow-hidden {zoom.scale > 1
|
|
479
|
+
? 'cursor-grab active:cursor-grabbing'
|
|
480
|
+
: isVideo
|
|
481
|
+
? 'cursor-pointer'
|
|
482
|
+
: ''}"
|
|
483
|
+
role="presentation"
|
|
484
|
+
onpointerdown={onPointerDown}
|
|
485
|
+
onpointermove={onPointerMove}
|
|
486
|
+
onpointerup={onPointerUp}
|
|
487
|
+
onpointercancel={onPointerUp}
|
|
488
|
+
>
|
|
489
|
+
{#key current}
|
|
490
|
+
<div
|
|
491
|
+
data-zoom-layer
|
|
492
|
+
class="absolute inset-0 grid place-items-center"
|
|
493
|
+
style:transform={zoomTransform(zoom)}
|
|
494
|
+
>
|
|
495
|
+
{#if current?.kind === 'video'}
|
|
496
|
+
<!-- svelte-ignore a11y_media_has_caption -->
|
|
497
|
+
<video
|
|
498
|
+
bind:this={videoEl}
|
|
499
|
+
src={current.src}
|
|
500
|
+
poster={current.poster}
|
|
501
|
+
{muted}
|
|
502
|
+
playsinline
|
|
503
|
+
loop={looping}
|
|
504
|
+
autoplay={shouldAutoplay}
|
|
505
|
+
preload="auto"
|
|
506
|
+
aria-label={current.label}
|
|
507
|
+
class="max-h-full max-w-full object-contain"
|
|
508
|
+
onplay={() => (paused = false)}
|
|
509
|
+
onpause={() => (paused = true)}
|
|
510
|
+
ontimeupdate={(e) => (time = e.currentTarget.currentTime)}
|
|
511
|
+
onloadedmetadata={(e) => {
|
|
512
|
+
duration = e.currentTarget.duration || 0;
|
|
513
|
+
e.currentTarget.playbackRate = speed;
|
|
514
|
+
}}
|
|
515
|
+
></video>
|
|
516
|
+
{:else if current}
|
|
517
|
+
<img
|
|
518
|
+
src={current.src}
|
|
519
|
+
alt={current.alt ?? current.label}
|
|
520
|
+
draggable="false"
|
|
521
|
+
class="max-h-full max-w-full object-contain"
|
|
522
|
+
/>
|
|
523
|
+
{/if}
|
|
524
|
+
</div>
|
|
525
|
+
{/key}
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
<div
|
|
529
|
+
class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-base-content/10 bg-base-200 px-3 py-2 text-sm sm:px-4"
|
|
530
|
+
data-media-zoom-controls
|
|
531
|
+
>
|
|
532
|
+
{#if isVideo}
|
|
533
|
+
<div class="flex items-center gap-1">
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
class="btn btn-sm rounded-field"
|
|
537
|
+
aria-label={paused ? 'Play' : 'Pause'}
|
|
538
|
+
aria-keyshortcuts="Space"
|
|
539
|
+
onclick={togglePlay}>{paused ? '▶' : '❚❚'}</button
|
|
540
|
+
>
|
|
541
|
+
<button
|
|
542
|
+
type="button"
|
|
543
|
+
class="btn btn-ghost btn-sm rounded-field tabular-nums"
|
|
544
|
+
aria-label="Back one frame"
|
|
545
|
+
aria-keyshortcuts=","
|
|
546
|
+
onclick={() => step(-1)}>‹ frame</button
|
|
547
|
+
>
|
|
548
|
+
<button
|
|
549
|
+
type="button"
|
|
550
|
+
class="btn btn-ghost btn-sm rounded-field tabular-nums"
|
|
551
|
+
aria-label="Forward one frame"
|
|
552
|
+
aria-keyshortcuts="."
|
|
553
|
+
onclick={() => step(1)}>frame ›</button
|
|
554
|
+
>
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
558
|
+
aria-pressed={looping}
|
|
559
|
+
aria-keyshortcuts="L"
|
|
560
|
+
onclick={() => (looping = !looping)}>{looping ? 'Loop on' : 'Loop off'}</button
|
|
561
|
+
>
|
|
562
|
+
</div>
|
|
563
|
+
<label class="flex min-w-48 flex-1 items-center gap-2">
|
|
564
|
+
<span class="sr-only">Scrub</span>
|
|
565
|
+
<input
|
|
566
|
+
type="range"
|
|
567
|
+
class="range range-xs range-primary flex-1"
|
|
568
|
+
min="0"
|
|
569
|
+
max={duration || 0}
|
|
570
|
+
step="any"
|
|
571
|
+
value={time}
|
|
572
|
+
aria-valuetext="{formatMediaTime(time)} of {formatMediaTime(duration)}"
|
|
573
|
+
oninput={(e) => seek(Number(e.currentTarget.value))}
|
|
574
|
+
/>
|
|
575
|
+
<span class="text-xs tabular-nums text-base-content/70"
|
|
576
|
+
>{formatMediaTime(time)} / {formatMediaTime(duration)}</span
|
|
577
|
+
>
|
|
578
|
+
</label>
|
|
579
|
+
<div class="flex items-center gap-1" role="group" aria-label="Speed">
|
|
580
|
+
{#each speeds as s (s)}
|
|
581
|
+
<button
|
|
582
|
+
type="button"
|
|
583
|
+
class="btn btn-xs rounded-field {speed === s ? 'btn-primary' : 'btn-ghost'}"
|
|
584
|
+
aria-pressed={speed === s}
|
|
585
|
+
onclick={() => setSpeed(s)}>{s}×</button
|
|
586
|
+
>
|
|
587
|
+
{/each}
|
|
588
|
+
</div>
|
|
589
|
+
{/if}
|
|
590
|
+
<div class="ml-auto flex items-center gap-1" role="group" aria-label="Zoom">
|
|
591
|
+
<button
|
|
592
|
+
type="button"
|
|
593
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
594
|
+
aria-label="Zoom out"
|
|
595
|
+
aria-keyshortcuts="-"
|
|
596
|
+
disabled={zoom.scale <= 1}
|
|
597
|
+
onclick={() => zoomBy(1 / ZOOM_STEP)}>−</button
|
|
598
|
+
>
|
|
599
|
+
<button
|
|
600
|
+
type="button"
|
|
601
|
+
class="btn btn-ghost btn-sm min-w-14 rounded-field tabular-nums"
|
|
602
|
+
aria-label="Reset zoom ({zoomPct}%)"
|
|
603
|
+
aria-keyshortcuts="0"
|
|
604
|
+
data-zoom-level
|
|
605
|
+
onclick={resetZoom}>{zoomPct}%</button
|
|
606
|
+
>
|
|
607
|
+
<button
|
|
608
|
+
type="button"
|
|
609
|
+
class="btn btn-ghost btn-sm rounded-field"
|
|
610
|
+
aria-label="Zoom in"
|
|
611
|
+
aria-keyshortcuts="+"
|
|
612
|
+
onclick={() => zoomBy(ZOOM_STEP)}>+</button
|
|
613
|
+
>
|
|
614
|
+
</div>
|
|
615
|
+
{#if showHint}
|
|
616
|
+
<p class="w-full text-2xs text-base-content/60" data-media-zoom-hint>{hint}</p>
|
|
617
|
+
{/if}
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
{#if footer}
|
|
621
|
+
<footer
|
|
622
|
+
class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-base-content/10 bg-base-100 px-3 py-2 sm:px-4"
|
|
623
|
+
data-media-zoom-footer
|
|
624
|
+
>
|
|
625
|
+
{@render footer({ view: current, index: safeIndex, isVideo })}
|
|
626
|
+
</footer>
|
|
627
|
+
{/if}
|
|
628
|
+
</div>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaZoomLightbox — one full-viewport viewer for images AND video
|
|
3
|
+
* (jatui-c79nt; design: mardukos docs/review-ux/proposal.md, mos-lsmve).
|
|
4
|
+
*
|
|
5
|
+
* Ported from the MOS media-picker ZoomLightbox (mos-0wvol): zoom/pan that
|
|
6
|
+
* applies to a PLAYING video (+ / − / 0, wheel, pinch, drag), frame-step
|
|
7
|
+
* (, .), speed (< > or the chips), loop, scrub; ←/→ page the item's views,
|
|
8
|
+
* 1–9 pick one. Images get the same zoom.
|
|
9
|
+
*
|
|
10
|
+
* Reviewing inside the zoom view (decision D5=A): the `footer` snippet
|
|
11
|
+
* renders in the bottom bar, so a host drops VerdictControl + ReviewNote
|
|
12
|
+
* there and the reviewer decides without closing. The lightbox imports
|
|
13
|
+
* neither. Keys it doesn't own (a/x/r/u/n, [ ], ?) bubble out to the host.
|
|
14
|
+
* `onNextItem` / `onPrevItem` let the host page to the next item while
|
|
15
|
+
* open (j/k, ↓/↑, or ←/→ past the last/first view): the host swaps `items`
|
|
16
|
+
* and the viewer resets to the new item's first (or, going back, last) view.
|
|
17
|
+
*
|
|
18
|
+
* Focus moves into the dialog on mount, Tab is trapped, Esc closes and focus
|
|
19
|
+
* returns to whatever had it before. Mount it to open, unmount to close.
|
|
20
|
+
* No autoplay under prefers-reduced-motion. Token-only theming.
|
|
21
|
+
*/
|
|
22
|
+
import { type Snippet } from 'svelte';
|
|
23
|
+
import type { ShortcutRegistry } from '../../utils/keyboardShortcutRegistry';
|
|
24
|
+
import { type MediaZoomFooterContext, type MediaZoomView } from './mediaZoom';
|
|
25
|
+
interface Props {
|
|
26
|
+
/** The views of the item being judged (one or more). */
|
|
27
|
+
items: MediaZoomView[];
|
|
28
|
+
/** Current view (bindable). Seeded from the host; clamped to `items`. */
|
|
29
|
+
index?: number;
|
|
30
|
+
/** Item name shown before the view label ("Grease trap card"). */
|
|
31
|
+
title?: string;
|
|
32
|
+
/** Extra header text after the view counter, e.g. "item 3 of 12". */
|
|
33
|
+
position?: string;
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
/** Host pages to the next item. Omit (or pass undefined at the end) to disable. */
|
|
36
|
+
onNextItem?: () => void;
|
|
37
|
+
onPrevItem?: () => void;
|
|
38
|
+
/** Bottom-bar slot — VerdictControl + ReviewNote live here in review hosts. */
|
|
39
|
+
footer?: Snippet<[MediaZoomFooterContext]>;
|
|
40
|
+
/** Frame-step rate. Review loops are 30fps. */
|
|
41
|
+
fps?: number;
|
|
42
|
+
speeds?: readonly number[];
|
|
43
|
+
/** Autoplay video (always off under prefers-reduced-motion). */
|
|
44
|
+
autoplay?: boolean;
|
|
45
|
+
muted?: boolean;
|
|
46
|
+
/** Show the one-line key hint under the controls. */
|
|
47
|
+
showHint?: boolean;
|
|
48
|
+
zIndex?: string | number;
|
|
49
|
+
/** While open, push a modal context so page shortcuts don't fire behind it. */
|
|
50
|
+
shortcutRegistry?: ShortcutRegistry;
|
|
51
|
+
shortcutContext?: string;
|
|
52
|
+
class?: string;
|
|
53
|
+
}
|
|
54
|
+
declare const MediaZoomLightbox: import("svelte").Component<Props, {}, "index">;
|
|
55
|
+
type MediaZoomLightbox = ReturnType<typeof MediaZoomLightbox>;
|
|
56
|
+
export default MediaZoomLightbox;
|