@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.
- package/dist/chunks/{LpConfirmDialog-D6Rhge21.js → LpConfirmDialog-C-iOXJIM.js} +1 -1
- package/dist/chunks/LpLightbox-BYIA9qne.js +419 -0
- package/dist/chunks/{LpModal-d6SZpfBH.js → LpModal-DeZlvK1B.js} +2 -2
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpLightbox.vue.d.ts +62 -0
- package/dist/components/LpLightbox.vue.js +4 -0
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/lightbox.d.ts +14 -0
- package/dist/components/lightbox.js +13 -0
- package/dist/composables/useZoomPan.d.ts +34 -0
- package/dist/composables/useZoomPan.js +133 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +73 -67
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/LpLightbox.vue +421 -0
- package/src/components/LpModal.vue +8 -3
- package/src/components/lightbox.ts +32 -0
- package/src/composables/useZoomPan.ts +211 -0
- package/src/index.ts +5 -0
- package/src/tokens/tokens.css +36 -0
|
@@ -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
|
-
|
|
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-
|
|
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
|
+
}
|