@leavepulse/ui 0.15.4 → 0.15.5

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.
@@ -0,0 +1,421 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Full-screen image viewer — the Discord-style lightbox: click a thumbnail, the
4
+ * image fills the screen, arrows step through the set, the wheel zooms toward
5
+ * the cursor, dragging pans, and the backdrop or Esc closes it.
6
+ *
7
+ * Built on reka's Dialog so focus trapping, Esc and scroll locking come from the
8
+ * same primitive the rest of the kit's overlays use. The zoom/pan maths lives in
9
+ * useZoomPan — this component is the shell: chrome, keyboard map, filmstrip and
10
+ * the swipe-to-navigate gesture.
11
+ *
12
+ * Controlled via v-model:open and v-model:index, so the caller owns which image
13
+ * is showing and can drive it from a grid, a route or a keyboard shortcut.
14
+ */
15
+ import {
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogOverlay,
19
+ DialogPortal,
20
+ DialogRoot,
21
+ DialogTitle,
22
+ } from "reka-ui"
23
+ import { computed, nextTick, ref, watch } from "vue"
24
+ import { useZoomPan } from "../composables/useZoomPan"
25
+ import { fileNameOf, type LightboxItem } from "./lightbox"
26
+ import LpIcon from "./LpIcon.vue"
27
+
28
+ const props = withDefaults(
29
+ defineProps<{
30
+ open?: boolean
31
+ /** Images to page through. */
32
+ items: LightboxItem[]
33
+ /** Index of the visible image (v-model:index). */
34
+ index?: number
35
+ /** Thumbnail strip along the bottom. Defaults on when there's more than one. */
36
+ filmstrip?: boolean
37
+ /** Show the rotate control. */
38
+ rotatable?: boolean
39
+ /** Show the download control. */
40
+ downloadable?: boolean
41
+ /** Show "copy image" — needs a same-origin or CORS-readable source. */
42
+ copyable?: boolean
43
+ /** Wrap from the last image back to the first. */
44
+ loop?: boolean
45
+ /** Largest zoom level. */
46
+ maxZoom?: number
47
+ }>(),
48
+ {
49
+ open: false,
50
+ index: 0,
51
+ rotatable: true,
52
+ downloadable: true,
53
+ copyable: false,
54
+ loop: true,
55
+ maxZoom: 8,
56
+ },
57
+ )
58
+
59
+ const emit = defineEmits<{
60
+ (e: "update:open", value: boolean): void
61
+ (e: "update:index", value: number): void
62
+ /** The visible image changed (after clamping/looping). */
63
+ (e: "change", index: number, item: LightboxItem): void
64
+ (e: "download", item: LightboxItem): void
65
+ /** Copy finished — `false` when the browser refused (tainted/cross-origin). */
66
+ (e: "copy", item: LightboxItem, ok: boolean): void
67
+ }>()
68
+
69
+ defineSlots<{
70
+ /** Replace the caption block under the image. */
71
+ caption(props: { item: LightboxItem; index: number; total: number }): unknown
72
+ /** Extra controls in the toolbar, before the close button. */
73
+ actions(props: { item: LightboxItem; index: number }): unknown
74
+ }>()
75
+
76
+ const zoom = useZoomPan({ max: props.maxZoom })
77
+
78
+ // `zoom` is a plain object, so its refs don't unwrap in the template — surface
79
+ // the two the chrome reads as computeds rather than writing `.value` in markup.
80
+ const scale = computed(() => zoom.scale.value)
81
+ const isZoomed = computed(() => zoom.zoomed.value)
82
+ const isPanning = computed(() => zoom.panning.value)
83
+
84
+ const current = computed(() => Math.min(Math.max(props.index, 0), Math.max(props.items.length - 1, 0)))
85
+ const item = computed<LightboxItem | undefined>(() => props.items[current.value])
86
+ const total = computed(() => props.items.length)
87
+ const showStrip = computed(() => props.filmstrip ?? total.value > 1)
88
+
89
+ const rotation = ref(0)
90
+ const loading = ref(false)
91
+ const failed = ref(false)
92
+ const viewport = ref<HTMLElement | null>(null)
93
+ const stripEl = ref<HTMLElement | null>(null)
94
+
95
+ watch(viewport, (el) => zoom.setViewport(el))
96
+
97
+ function go(next: number) {
98
+ if (!total.value) return
99
+ const wrapped = props.loop
100
+ ? (next + total.value) % total.value
101
+ : Math.min(Math.max(next, 0), total.value - 1)
102
+ if (wrapped === current.value) return
103
+ emit("update:index", wrapped)
104
+ emit("change", wrapped, props.items[wrapped])
105
+ }
106
+
107
+ const canPrev = computed(() => total.value > 1 && (props.loop || current.value > 0))
108
+ const canNext = computed(() => total.value > 1 && (props.loop || current.value < total.value - 1))
109
+
110
+ // Reset the view whenever the image changes — carrying a 4x zoom onto the next
111
+ // picture means landing on a random crop of it.
112
+ watch(
113
+ () => [current.value, props.open] as const,
114
+ () => {
115
+ zoom.reset()
116
+ rotation.value = 0
117
+ failed.value = false
118
+ loading.value = true
119
+ if (props.open) nextTick(scrollThumbIntoView)
120
+ },
121
+ )
122
+
123
+ function scrollThumbIntoView() {
124
+ const el = stripEl.value?.querySelector<HTMLElement>(`[data-index="${current.value}"]`)
125
+ el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" })
126
+ }
127
+
128
+ function close() {
129
+ emit("update:open", false)
130
+ }
131
+
132
+ function onKeydown(e: KeyboardEvent) {
133
+ switch (e.key) {
134
+ case "ArrowRight":
135
+ e.preventDefault()
136
+ go(current.value + 1)
137
+ break
138
+ case "ArrowLeft":
139
+ e.preventDefault()
140
+ go(current.value - 1)
141
+ break
142
+ case "Home":
143
+ e.preventDefault()
144
+ go(0)
145
+ break
146
+ case "End":
147
+ e.preventDefault()
148
+ go(total.value - 1)
149
+ break
150
+ case "+":
151
+ case "=":
152
+ e.preventDefault()
153
+ zoom.zoomBy(1.3)
154
+ break
155
+ case "-":
156
+ e.preventDefault()
157
+ zoom.zoomBy(1 / 1.3)
158
+ break
159
+ case "0":
160
+ e.preventDefault()
161
+ zoom.reset()
162
+ break
163
+ case "r":
164
+ if (props.rotatable) {
165
+ e.preventDefault()
166
+ rotate()
167
+ }
168
+ break
169
+ }
170
+ }
171
+
172
+ function rotate() {
173
+ rotation.value = (rotation.value + 90) % 360
174
+ zoom.reset()
175
+ }
176
+
177
+ // Swipe to page through, but only while the image is at fit scale — once zoomed
178
+ // a horizontal drag is a pan, and stealing it would make the image unreachable.
179
+ let swipeStart: { x: number; y: number } | null = null
180
+
181
+ function onPointerDown(e: PointerEvent) {
182
+ zoom.onPointerDown(e)
183
+ swipeStart = zoom.zoomed.value ? null : { x: e.clientX, y: e.clientY }
184
+ }
185
+
186
+ function onPointerUp(e: PointerEvent) {
187
+ zoom.onPointerUp(e)
188
+ if (!swipeStart) return
189
+ const dx = e.clientX - swipeStart.x
190
+ const dy = e.clientY - swipeStart.y
191
+ swipeStart = null
192
+ // Mostly-horizontal and past a threshold, so a sloppy tap doesn't page.
193
+ if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
194
+ go(current.value + (dx < 0 ? 1 : -1))
195
+ }
196
+ }
197
+
198
+ function onDoubleClick(e: MouseEvent) {
199
+ zoom.toggleZoom({ x: e.clientX, y: e.clientY })
200
+ }
201
+
202
+ /** Backdrop click closes; a click that ends a pan or lands on the image doesn't. */
203
+ function onViewportClick(e: MouseEvent) {
204
+ if (zoom.zoomed.value) return
205
+ if ((e.target as HTMLElement).closest("[data-lightbox-image]")) return
206
+ close()
207
+ }
208
+
209
+ async function download() {
210
+ const target = item.value
211
+ if (!target) return
212
+ emit("download", target)
213
+ const a = document.createElement("a")
214
+ a.href = target.src
215
+ a.download = fileNameOf(target)
216
+ // Cross-origin sources ignore `download` and just navigate, so open those in
217
+ // a new tab instead of replacing the app.
218
+ a.rel = "noopener"
219
+ document.body.appendChild(a)
220
+ a.click()
221
+ a.remove()
222
+ }
223
+
224
+ async function copy() {
225
+ const target = item.value
226
+ if (!target) return
227
+ try {
228
+ const response = await fetch(target.src)
229
+ const blob = await response.blob()
230
+ // Clipboard only takes PNG; re-encode anything else through a canvas.
231
+ const png =
232
+ blob.type === "image/png" ? blob : await reencodeToPng(await createImageBitmap(blob))
233
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png })])
234
+ emit("copy", target, true)
235
+ } catch {
236
+ emit("copy", target, false)
237
+ }
238
+ }
239
+
240
+ async function reencodeToPng(bitmap: ImageBitmap): Promise<Blob> {
241
+ const canvas = document.createElement("canvas")
242
+ canvas.width = bitmap.width
243
+ canvas.height = bitmap.height
244
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0)
245
+ return new Promise((resolve, reject) =>
246
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("encode failed"))), "image/png"),
247
+ )
248
+ }
249
+
250
+ const imageStyle = computed(() => ({
251
+ ...zoom.style.value,
252
+ rotate: `${rotation.value}deg`,
253
+ }))
254
+
255
+ const BTN =
256
+ "flex size-9 items-center justify-center rounded-control text-white/80 outline-none transition-colors duration-[var(--duration-fast)] hover:bg-white/15 hover:text-white focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
257
+ const ARROW =
258
+ "absolute top-1/2 z-10 flex size-11 -translate-y-1/2 items-center justify-center rounded-pill bg-black/45 text-white/90 outline-none backdrop-blur-sm transition-[background-color,scale] duration-[var(--duration-fast)] hover:bg-black/65 hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-0"
259
+ </script>
260
+
261
+ <template>
262
+ <DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
263
+ <DialogPortal>
264
+ <DialogOverlay
265
+ class="fixed inset-0 z-(--z-overlay) bg-black/85 backdrop-blur-sm data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
266
+ />
267
+ <DialogContent
268
+ class="fixed inset-0 z-(--z-modal) flex flex-col outline-none data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
269
+ @keydown="onKeydown"
270
+ >
271
+ <!-- reka wants both for a labelled dialog; the caption is the title. -->
272
+ <DialogTitle class="sr-only">{{ item?.title ?? "Image viewer" }}</DialogTitle>
273
+ <DialogDescription class="sr-only">
274
+ {{ item?.description ?? `Image ${current + 1} of ${total}` }}
275
+ </DialogDescription>
276
+
277
+ <!-- Toolbar -->
278
+ <div class="flex shrink-0 items-center gap-1 p-3 text-sm text-white/70">
279
+ <span v-if="total > 1" class="px-2 tabular-nums">{{ current + 1 }} / {{ total }}</span>
280
+ <span v-if="isZoomed" class="px-2 tabular-nums">
281
+ {{ Math.round(scale * 100) }}%
282
+ </span>
283
+
284
+ <div class="ml-auto flex items-center gap-1">
285
+ <slot name="actions" :item="item!" :index="current" />
286
+
287
+ <button :class="BTN" type="button" title="Zoom out (−)" @click="zoom.zoomBy(1 / 1.3)">
288
+ <LpIcon name="lucide:zoom-out" :size="18" />
289
+ </button>
290
+ <button :class="BTN" type="button" title="Zoom in (+)" @click="zoom.zoomBy(1.3)">
291
+ <LpIcon name="lucide:zoom-in" :size="18" />
292
+ </button>
293
+ <button
294
+ v-if="rotatable"
295
+ :class="BTN"
296
+ type="button"
297
+ title="Rotate (R)"
298
+ @click="rotate"
299
+ >
300
+ <LpIcon name="lucide:rotate-cw" :size="18" />
301
+ </button>
302
+ <button v-if="copyable" :class="BTN" type="button" title="Copy image" @click="copy">
303
+ <LpIcon name="lucide:copy" :size="18" />
304
+ </button>
305
+ <button
306
+ v-if="downloadable"
307
+ :class="BTN"
308
+ type="button"
309
+ title="Download"
310
+ @click="download"
311
+ >
312
+ <LpIcon name="lucide:download" :size="18" />
313
+ </button>
314
+ <button :class="BTN" type="button" title="Close (Esc)" @click="close">
315
+ <LpIcon name="lucide:x" :size="20" />
316
+ </button>
317
+ </div>
318
+ </div>
319
+
320
+ <!-- Stage -->
321
+ <div
322
+ ref="viewport"
323
+ class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden"
324
+ :class="isPanning ? 'cursor-grabbing' : isZoomed ? 'cursor-grab' : ''"
325
+ @wheel="zoom.onWheel"
326
+ @pointerdown="onPointerDown"
327
+ @pointermove="zoom.onPointerMove"
328
+ @pointerup="onPointerUp"
329
+ @pointercancel="zoom.onPointerUp"
330
+ @dblclick="onDoubleClick"
331
+ @click="onViewportClick"
332
+ >
333
+ <button
334
+ :class="[ARROW, 'left-4']"
335
+ type="button"
336
+ :disabled="!canPrev"
337
+ aria-label="Previous image"
338
+ @click.stop="go(current - 1)"
339
+ >
340
+ <LpIcon name="lucide:chevron-left" :size="24" />
341
+ </button>
342
+
343
+ <div
344
+ v-if="failed"
345
+ class="flex flex-col items-center gap-3 text-white/60"
346
+ >
347
+ <LpIcon name="lucide:image-off" :size="40" />
348
+ <p class="text-sm">This image couldn't be loaded.</p>
349
+ </div>
350
+ <img
351
+ v-else-if="item"
352
+ :key="item.src"
353
+ data-lightbox-image
354
+ :src="item.src"
355
+ :alt="item.title ?? ''"
356
+ class="max-h-full max-w-full select-none object-contain transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
357
+ :style="imageStyle"
358
+ draggable="false"
359
+ @load="loading = false"
360
+ @error="((failed = true), (loading = false))"
361
+ />
362
+
363
+ <LpIcon
364
+ v-if="loading && !failed"
365
+ name="lucide:loader-circle"
366
+ :size="28"
367
+ class="absolute animate-spin text-white/50"
368
+ />
369
+
370
+ <button
371
+ :class="[ARROW, 'right-4']"
372
+ type="button"
373
+ :disabled="!canNext"
374
+ aria-label="Next image"
375
+ @click.stop="go(current + 1)"
376
+ >
377
+ <LpIcon name="lucide:chevron-right" :size="24" />
378
+ </button>
379
+ </div>
380
+
381
+ <!-- Caption + filmstrip -->
382
+ <div class="shrink-0 p-3 pt-2">
383
+ <slot name="caption" :item="item!" :index="current" :total="total">
384
+ <div v-if="item?.title || item?.description" class="mb-2 text-center">
385
+ <p v-if="item.title" class="truncate text-sm font-medium text-white">
386
+ {{ item.title }}
387
+ </p>
388
+ <p v-if="item.description" class="truncate text-xs text-white/55">
389
+ {{ item.description }}
390
+ </p>
391
+ </div>
392
+ </slot>
393
+
394
+ <div
395
+ v-if="showStrip && total > 1"
396
+ ref="stripEl"
397
+ class="lp-scrollbar-none flex justify-start gap-2 overflow-x-auto sm:justify-center"
398
+ >
399
+ <button
400
+ v-for="(entry, i) in items"
401
+ :key="entry.src + i"
402
+ :data-index="i"
403
+ type="button"
404
+ class="size-14 shrink-0 overflow-hidden rounded-control outline-none ring-2 transition-[opacity,box-shadow] duration-[var(--duration-fast)] focus-visible:ring-ring"
405
+ :class="i === current ? 'opacity-100 ring-brand' : 'opacity-45 ring-transparent hover:opacity-80'"
406
+ :aria-label="entry.title ?? `Image ${i + 1}`"
407
+ @click="go(i)"
408
+ >
409
+ <img
410
+ :src="entry.thumb ?? entry.src"
411
+ :alt="entry.title ?? ''"
412
+ class="size-full object-cover"
413
+ draggable="false"
414
+ />
415
+ </button>
416
+ </div>
417
+ </div>
418
+ </DialogContent>
419
+ </DialogPortal>
420
+ </DialogRoot>
421
+ </template>
@@ -78,10 +78,15 @@ const bodyPad = computed(() =>
78
78
  the difference, and while the open animation was still running that
79
79
  read as a stutter. Anchoring the wrapper instead means the panel only
80
80
  grows downward and the animation has nothing to fight.
81
- `pointer-events-none` lets clicks through to the overlay behind it. -->
82
- <div class="fixed inset-0 z-(--z-modal) flex items-center justify-center p-4 pointer-events-none">
81
+ `pointer-events-none` lets clicks through to the overlay behind it.
82
+
83
+ The panel keeps its OWN max-height rather than inheriting one from
84
+ this wrapper: `max-h-full` on a centred flex child resolves against a
85
+ box it is free to overflow, so a tall body stopped handing its
86
+ overflow to the scroll area below and simply ran off-screen. -->
87
+ <div class="fixed inset-0 z-(--z-modal) flex items-center justify-center pointer-events-none">
83
88
  <DialogContent
84
- class="pointer-events-auto flex max-h-full flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
89
+ class="pointer-events-auto flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[rise-out_120ms_cubic-bezier(0.4,0,1,1)]"
85
90
  :class="widthClass"
86
91
  :style="width ? { width } : undefined"
87
92
  >
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Shared lightbox types. Its own module so LpLightbox and anything building a
3
+ * gallery around it can import the item shape without a component cycle.
4
+ */
5
+
6
+ export interface LightboxItem {
7
+ /** Full-size source. */
8
+ src: string
9
+ /** Smaller source for the filmstrip; falls back to `src`. */
10
+ thumb?: string
11
+ /** Caption under the image, and the img alt text. */
12
+ title?: string
13
+ /** Secondary line under the title (size, date, resolution…). */
14
+ description?: string
15
+ /** Filename used when downloading; derived from `src` when absent. */
16
+ filename?: string
17
+ }
18
+
19
+ /** Last path segment of a URL, or a fallback — used to name a download. */
20
+ export function fileNameOf(item: LightboxItem, fallback = "image"): string {
21
+ if (item.filename) return item.filename
22
+ try {
23
+ // Works for blob:/data: too — those just have no useful tail, hence the
24
+ // fallback below.
25
+ const path = new URL(item.src, "http://x").pathname
26
+ const tail = decodeURIComponent(path.split("/").pop() ?? "")
27
+ if (tail) return tail
28
+ } catch {
29
+ // Not a parseable URL; fall through.
30
+ }
31
+ return fallback
32
+ }
@@ -0,0 +1,211 @@
1
+ import { computed, ref, type ComputedRef, type CSSProperties, type Ref } from "vue"
2
+
3
+ /*
4
+ * useZoomPan — zoom + pan for a single element inside a viewport, the way an
5
+ * image viewer behaves: the wheel zooms toward the pointer (so the pixel under
6
+ * the cursor stays put), dragging pans, and a two-finger pinch does both at
7
+ * once. Pans are clamped so the content can't be flung off into empty space.
8
+ *
9
+ * Split out of LpLightbox because none of this is lightbox-specific — a map, a
10
+ * diagram or a zoomable preview needs exactly the same maths.
11
+ *
12
+ * Bind `style` to the moving element, the handlers to the viewport, and call
13
+ * `reset()` whenever the content changes.
14
+ */
15
+ export interface UseZoomPanOptions {
16
+ /** Smallest allowed scale. Default 1 (fit — never shrink past the viewport). */
17
+ min?: number
18
+ /** Largest allowed scale. Default 8. */
19
+ max?: number
20
+ /** Multiplier per wheel notch. Default 1.15. */
21
+ step?: number
22
+ /** Scale that a double-click/tap toggles to, from `min`. Default 2. */
23
+ zoomedScale?: number
24
+ }
25
+
26
+ export interface UseZoomPan {
27
+ scale: Ref<number>
28
+ /** True once zoomed past the minimum — the point at which panning matters. */
29
+ zoomed: ComputedRef<boolean>
30
+ /** True while a drag is in flight, so the caller can swap the cursor. */
31
+ panning: Ref<boolean>
32
+ style: ComputedRef<CSSProperties>
33
+ onWheel: (e: WheelEvent) => void
34
+ onPointerDown: (e: PointerEvent) => void
35
+ onPointerMove: (e: PointerEvent) => void
36
+ onPointerUp: (e: PointerEvent) => void
37
+ /** Zoom about the viewport centre (buttons, keyboard). */
38
+ zoomBy: (factor: number) => void
39
+ /** Toggle between fit and `zoomedScale`, optionally about a point. */
40
+ toggleZoom: (origin?: { x: number; y: number }) => void
41
+ reset: () => void
42
+ /** Call with the viewport element so pointer coords can be made relative. */
43
+ setViewport: (el: HTMLElement | null) => void
44
+ }
45
+
46
+ export function useZoomPan(options: UseZoomPanOptions = {}): UseZoomPan {
47
+ const min = options.min ?? 1
48
+ const max = options.max ?? 8
49
+ const step = options.step ?? 1.15
50
+ const zoomedScale = options.zoomedScale ?? 2
51
+
52
+ const scale = ref(min)
53
+ const x = ref(0)
54
+ const y = ref(0)
55
+ const panning = ref(false)
56
+
57
+ let viewport: HTMLElement | null = null
58
+ // Active pointers, so a second finger can be detected for pinch.
59
+ const pointers = new Map<number, { x: number; y: number }>()
60
+ let start = { x: 0, y: 0, panX: 0, panY: 0 }
61
+ let pinchStartDistance = 0
62
+ let pinchStartScale = 1
63
+
64
+ const zoomed = computed(() => scale.value > min + 0.001)
65
+
66
+ function setViewport(el: HTMLElement | null) {
67
+ viewport = el
68
+ }
69
+
70
+ function clamp(value: number, lo: number, hi: number) {
71
+ return Math.min(hi, Math.max(lo, value))
72
+ }
73
+
74
+ /**
75
+ * Keep the content overlapping the viewport. The translate is applied before
76
+ * the scale, so the slack on each axis is half of what the growth adds — at
77
+ * fit scale there's none, which pins the image centred.
78
+ */
79
+ function clampPan() {
80
+ if (!viewport) return
81
+ const rect = viewport.getBoundingClientRect()
82
+ const slackX = (rect.width * (scale.value - 1)) / 2 / scale.value
83
+ const slackY = (rect.height * (scale.value - 1)) / 2 / scale.value
84
+ x.value = clamp(x.value, -slackX, slackX)
85
+ y.value = clamp(y.value, -slackY, slackY)
86
+ }
87
+
88
+ /** Zoom to `next`, holding the viewport-relative point `px,py` in place. */
89
+ function zoomAbout(next: number, px: number, py: number) {
90
+ const clamped = clamp(next, min, max)
91
+ if (clamped === scale.value) return
92
+ // Where the cursor sits in content space before the change; solving for the
93
+ // same content point after gives the translate that keeps it under the
94
+ // cursor rather than drifting toward the centre.
95
+ const contentX = px / scale.value - x.value
96
+ const contentY = py / scale.value - y.value
97
+ scale.value = clamped
98
+ x.value = px / clamped - contentX
99
+ y.value = py / clamped - contentY
100
+ clampPan()
101
+ }
102
+
103
+ /** Pointer position relative to the viewport centre. */
104
+ function relative(e: { clientX: number; clientY: number }) {
105
+ if (!viewport) return { x: 0, y: 0 }
106
+ const rect = viewport.getBoundingClientRect()
107
+ return {
108
+ x: e.clientX - rect.left - rect.width / 2,
109
+ y: e.clientY - rect.top - rect.height / 2,
110
+ }
111
+ }
112
+
113
+ function onWheel(e: WheelEvent) {
114
+ e.preventDefault()
115
+ const point = relative(e)
116
+ zoomAbout(scale.value * (e.deltaY < 0 ? step : 1 / step), point.x, point.y)
117
+ }
118
+
119
+ function distance(a: { x: number; y: number }, b: { x: number; y: number }) {
120
+ return Math.hypot(a.x - b.x, a.y - b.y)
121
+ }
122
+
123
+ function onPointerDown(e: PointerEvent) {
124
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
125
+
126
+ if (pointers.size === 2) {
127
+ const [a, b] = [...pointers.values()]
128
+ pinchStartDistance = distance(a, b)
129
+ pinchStartScale = scale.value
130
+ panning.value = false
131
+ return
132
+ }
133
+ if (pointers.size > 2) return
134
+
135
+ // Pan only when there's somewhere to pan to; at fit scale a drag is left
136
+ // free for the caller (LpLightbox uses it to swipe between images).
137
+ if (!zoomed.value) return
138
+ panning.value = true
139
+ start = { x: e.clientX, y: e.clientY, panX: x.value, panY: y.value }
140
+ ;(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId)
141
+ }
142
+
143
+ function onPointerMove(e: PointerEvent) {
144
+ if (!pointers.has(e.pointerId)) return
145
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
146
+
147
+ if (pointers.size === 2 && pinchStartDistance > 0) {
148
+ const [a, b] = [...pointers.values()]
149
+ const mid = { clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 }
150
+ const point = relative(mid)
151
+ zoomAbout(pinchStartScale * (distance(a, b) / pinchStartDistance), point.x, point.y)
152
+ return
153
+ }
154
+
155
+ if (!panning.value) return
156
+ // Divide by scale: the translate is in content space, which the transform
157
+ // then magnifies — without this the image races ahead of the cursor.
158
+ x.value = start.panX + (e.clientX - start.x) / scale.value
159
+ y.value = start.panY + (e.clientY - start.y) / scale.value
160
+ clampPan()
161
+ }
162
+
163
+ function onPointerUp(e: PointerEvent) {
164
+ pointers.delete(e.pointerId)
165
+ if (pointers.size < 2) pinchStartDistance = 0
166
+ if (pointers.size === 0) panning.value = false
167
+ }
168
+
169
+ function zoomBy(factor: number) {
170
+ zoomAbout(scale.value * factor, 0, 0)
171
+ }
172
+
173
+ function toggleZoom(origin?: { x: number; y: number }) {
174
+ if (zoomed.value) {
175
+ reset()
176
+ return
177
+ }
178
+ const point = origin ? relative({ clientX: origin.x, clientY: origin.y }) : { x: 0, y: 0 }
179
+ zoomAbout(zoomedScale, point.x, point.y)
180
+ }
181
+
182
+ function reset() {
183
+ scale.value = min
184
+ x.value = 0
185
+ y.value = 0
186
+ panning.value = false
187
+ pointers.clear()
188
+ pinchStartDistance = 0
189
+ }
190
+
191
+ const style = computed<CSSProperties>(() => ({
192
+ transform: `scale(${scale.value}) translate(${x.value}px, ${y.value}px)`,
193
+ // Settle smoothly on discrete zooms, but follow a drag or pinch exactly.
194
+ transition: panning.value || pinchStartDistance > 0 ? "none" : undefined,
195
+ }))
196
+
197
+ return {
198
+ scale,
199
+ zoomed,
200
+ panning,
201
+ style,
202
+ onWheel,
203
+ onPointerDown,
204
+ onPointerMove,
205
+ onPointerUp,
206
+ zoomBy,
207
+ toggleZoom,
208
+ reset,
209
+ setViewport,
210
+ }
211
+ }