@leavepulse/ui 0.15.4 → 0.16.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/dist/chunks/{LpConfirmDialog-D6Rhge21.js → LpConfirmDialog-C-iOXJIM.js} +1 -1
- package/dist/chunks/LpLightbox-CWdIsthW.js +424 -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 +439 -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,439 @@
|
|
|
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
|
+
// Which way the last navigation went, so the incoming frame slides in from the
|
|
98
|
+
// side it travelled from instead of always drifting the same direction.
|
|
99
|
+
const direction = ref<1 | -1>(1)
|
|
100
|
+
|
|
101
|
+
function go(next: number) {
|
|
102
|
+
if (!total.value) return
|
|
103
|
+
const wrapped = props.loop
|
|
104
|
+
? (next + total.value) % total.value
|
|
105
|
+
: Math.min(Math.max(next, 0), total.value - 1)
|
|
106
|
+
if (wrapped === current.value) return
|
|
107
|
+
// Compare against the raw target, not the wrapped one: stepping past the last
|
|
108
|
+
// image is still "forward" even though the index jumps back to 0.
|
|
109
|
+
direction.value = next > current.value ? 1 : -1
|
|
110
|
+
emit("update:index", wrapped)
|
|
111
|
+
emit("change", wrapped, props.items[wrapped])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const canPrev = computed(() => total.value > 1 && (props.loop || current.value > 0))
|
|
115
|
+
const canNext = computed(() => total.value > 1 && (props.loop || current.value < total.value - 1))
|
|
116
|
+
|
|
117
|
+
// Reset the view whenever the image changes — carrying a 4x zoom onto the next
|
|
118
|
+
// picture means landing on a random crop of it.
|
|
119
|
+
watch(
|
|
120
|
+
() => [current.value, props.open] as const,
|
|
121
|
+
() => {
|
|
122
|
+
zoom.reset()
|
|
123
|
+
rotation.value = 0
|
|
124
|
+
failed.value = false
|
|
125
|
+
loading.value = true
|
|
126
|
+
if (props.open) nextTick(scrollThumbIntoView)
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
function scrollThumbIntoView() {
|
|
131
|
+
const el = stripEl.value?.querySelector<HTMLElement>(`[data-index="${current.value}"]`)
|
|
132
|
+
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function close() {
|
|
136
|
+
emit("update:open", false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function onKeydown(e: KeyboardEvent) {
|
|
140
|
+
switch (e.key) {
|
|
141
|
+
case "ArrowRight":
|
|
142
|
+
e.preventDefault()
|
|
143
|
+
go(current.value + 1)
|
|
144
|
+
break
|
|
145
|
+
case "ArrowLeft":
|
|
146
|
+
e.preventDefault()
|
|
147
|
+
go(current.value - 1)
|
|
148
|
+
break
|
|
149
|
+
case "Home":
|
|
150
|
+
e.preventDefault()
|
|
151
|
+
go(0)
|
|
152
|
+
break
|
|
153
|
+
case "End":
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
go(total.value - 1)
|
|
156
|
+
break
|
|
157
|
+
case "+":
|
|
158
|
+
case "=":
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
zoom.zoomBy(1.3)
|
|
161
|
+
break
|
|
162
|
+
case "-":
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
zoom.zoomBy(1 / 1.3)
|
|
165
|
+
break
|
|
166
|
+
case "0":
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
zoom.reset()
|
|
169
|
+
break
|
|
170
|
+
case "r":
|
|
171
|
+
if (props.rotatable) {
|
|
172
|
+
e.preventDefault()
|
|
173
|
+
rotate()
|
|
174
|
+
}
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function rotate() {
|
|
180
|
+
rotation.value = (rotation.value + 90) % 360
|
|
181
|
+
zoom.reset()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Swipe to page through, but only while the image is at fit scale — once zoomed
|
|
185
|
+
// a horizontal drag is a pan, and stealing it would make the image unreachable.
|
|
186
|
+
let swipeStart: { x: number; y: number } | null = null
|
|
187
|
+
|
|
188
|
+
function onPointerDown(e: PointerEvent) {
|
|
189
|
+
zoom.onPointerDown(e)
|
|
190
|
+
swipeStart = zoom.zoomed.value ? null : { x: e.clientX, y: e.clientY }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function onPointerUp(e: PointerEvent) {
|
|
194
|
+
zoom.onPointerUp(e)
|
|
195
|
+
if (!swipeStart) return
|
|
196
|
+
const dx = e.clientX - swipeStart.x
|
|
197
|
+
const dy = e.clientY - swipeStart.y
|
|
198
|
+
swipeStart = null
|
|
199
|
+
// Mostly-horizontal and past a threshold, so a sloppy tap doesn't page.
|
|
200
|
+
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
|
201
|
+
go(current.value + (dx < 0 ? 1 : -1))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onDoubleClick(e: MouseEvent) {
|
|
206
|
+
zoom.toggleZoom({ x: e.clientX, y: e.clientY })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Backdrop click closes; a click that ends a pan or lands on the image doesn't. */
|
|
210
|
+
function onViewportClick(e: MouseEvent) {
|
|
211
|
+
if (zoom.zoomed.value) return
|
|
212
|
+
if ((e.target as HTMLElement).closest("[data-lightbox-image]")) return
|
|
213
|
+
close()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function download() {
|
|
217
|
+
const target = item.value
|
|
218
|
+
if (!target) return
|
|
219
|
+
emit("download", target)
|
|
220
|
+
const a = document.createElement("a")
|
|
221
|
+
a.href = target.src
|
|
222
|
+
a.download = fileNameOf(target)
|
|
223
|
+
// Cross-origin sources ignore `download` and just navigate, so open those in
|
|
224
|
+
// a new tab instead of replacing the app.
|
|
225
|
+
a.rel = "noopener"
|
|
226
|
+
document.body.appendChild(a)
|
|
227
|
+
a.click()
|
|
228
|
+
a.remove()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function copy() {
|
|
232
|
+
const target = item.value
|
|
233
|
+
if (!target) return
|
|
234
|
+
try {
|
|
235
|
+
const response = await fetch(target.src)
|
|
236
|
+
const blob = await response.blob()
|
|
237
|
+
// Clipboard only takes PNG; re-encode anything else through a canvas.
|
|
238
|
+
const png =
|
|
239
|
+
blob.type === "image/png" ? blob : await reencodeToPng(await createImageBitmap(blob))
|
|
240
|
+
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })])
|
|
241
|
+
emit("copy", target, true)
|
|
242
|
+
} catch {
|
|
243
|
+
emit("copy", target, false)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function reencodeToPng(bitmap: ImageBitmap): Promise<Blob> {
|
|
248
|
+
const canvas = document.createElement("canvas")
|
|
249
|
+
canvas.width = bitmap.width
|
|
250
|
+
canvas.height = bitmap.height
|
|
251
|
+
canvas.getContext("2d")?.drawImage(bitmap, 0, 0)
|
|
252
|
+
return new Promise((resolve, reject) =>
|
|
253
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("encode failed"))), "image/png"),
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const imageStyle = computed(() => ({
|
|
258
|
+
...zoom.style.value,
|
|
259
|
+
rotate: `${rotation.value}deg`,
|
|
260
|
+
// Consumed by the lightbox-frame-in keyframe; negative when paging backwards.
|
|
261
|
+
"--frame-from": `${direction.value * 24}px`,
|
|
262
|
+
}))
|
|
263
|
+
|
|
264
|
+
const BTN =
|
|
265
|
+
"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"
|
|
266
|
+
const ARROW =
|
|
267
|
+
"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"
|
|
268
|
+
</script>
|
|
269
|
+
|
|
270
|
+
<template>
|
|
271
|
+
<DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
|
|
272
|
+
<DialogPortal>
|
|
273
|
+
<DialogOverlay
|
|
274
|
+
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]"
|
|
275
|
+
/>
|
|
276
|
+
<DialogContent
|
|
277
|
+
class="fixed inset-0 z-(--z-modal) flex flex-col outline-none data-[state=open]:animate-[lightbox-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[lightbox-out_140ms_cubic-bezier(0.4,0,1,1)] motion-reduce:animate-none"
|
|
278
|
+
@keydown="onKeydown"
|
|
279
|
+
>
|
|
280
|
+
<!-- reka wants both for a labelled dialog; the caption is the title. -->
|
|
281
|
+
<DialogTitle class="sr-only">{{ item?.title ?? "Image viewer" }}</DialogTitle>
|
|
282
|
+
<DialogDescription class="sr-only">
|
|
283
|
+
{{ item?.description ?? `Image ${current + 1} of ${total}` }}
|
|
284
|
+
</DialogDescription>
|
|
285
|
+
|
|
286
|
+
<!-- Toolbar. Chrome drifts in from its own edge, slightly behind the
|
|
287
|
+
image, so the picture is what arrives first. -->
|
|
288
|
+
<div
|
|
289
|
+
class="flex shrink-0 items-center gap-1 p-3 text-sm text-white/70 [--chrome-from:-8px] animate-[lightbox-chrome-in_var(--duration-medium)_var(--ease-emphasized)_60ms_both] motion-reduce:animate-none"
|
|
290
|
+
>
|
|
291
|
+
<span v-if="total > 1" class="px-2 tabular-nums">{{ current + 1 }} / {{ total }}</span>
|
|
292
|
+
<span v-if="isZoomed" class="px-2 tabular-nums">
|
|
293
|
+
{{ Math.round(scale * 100) }}%
|
|
294
|
+
</span>
|
|
295
|
+
|
|
296
|
+
<div class="ml-auto flex items-center gap-1">
|
|
297
|
+
<slot name="actions" :item="item!" :index="current" />
|
|
298
|
+
|
|
299
|
+
<button :class="BTN" type="button" title="Zoom out (−)" @click="zoom.zoomBy(1 / 1.3)">
|
|
300
|
+
<LpIcon name="lucide:zoom-out" :size="18" />
|
|
301
|
+
</button>
|
|
302
|
+
<button :class="BTN" type="button" title="Zoom in (+)" @click="zoom.zoomBy(1.3)">
|
|
303
|
+
<LpIcon name="lucide:zoom-in" :size="18" />
|
|
304
|
+
</button>
|
|
305
|
+
<button
|
|
306
|
+
v-if="rotatable"
|
|
307
|
+
:class="BTN"
|
|
308
|
+
type="button"
|
|
309
|
+
title="Rotate (R)"
|
|
310
|
+
@click="rotate"
|
|
311
|
+
>
|
|
312
|
+
<LpIcon name="lucide:rotate-cw" :size="18" />
|
|
313
|
+
</button>
|
|
314
|
+
<button v-if="copyable" :class="BTN" type="button" title="Copy image" @click="copy">
|
|
315
|
+
<LpIcon name="lucide:copy" :size="18" />
|
|
316
|
+
</button>
|
|
317
|
+
<button
|
|
318
|
+
v-if="downloadable"
|
|
319
|
+
:class="BTN"
|
|
320
|
+
type="button"
|
|
321
|
+
title="Download"
|
|
322
|
+
@click="download"
|
|
323
|
+
>
|
|
324
|
+
<LpIcon name="lucide:download" :size="18" />
|
|
325
|
+
</button>
|
|
326
|
+
<button :class="BTN" type="button" title="Close (Esc)" @click="close">
|
|
327
|
+
<LpIcon name="lucide:x" :size="20" />
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<!-- Stage -->
|
|
333
|
+
<div
|
|
334
|
+
ref="viewport"
|
|
335
|
+
class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden"
|
|
336
|
+
:class="isPanning ? 'cursor-grabbing' : isZoomed ? 'cursor-grab' : ''"
|
|
337
|
+
@wheel="zoom.onWheel"
|
|
338
|
+
@pointerdown="onPointerDown"
|
|
339
|
+
@pointermove="zoom.onPointerMove"
|
|
340
|
+
@pointerup="onPointerUp"
|
|
341
|
+
@pointercancel="zoom.onPointerUp"
|
|
342
|
+
@dblclick="onDoubleClick"
|
|
343
|
+
@click="onViewportClick"
|
|
344
|
+
>
|
|
345
|
+
<button
|
|
346
|
+
:class="[ARROW, 'left-4']"
|
|
347
|
+
type="button"
|
|
348
|
+
:disabled="!canPrev"
|
|
349
|
+
aria-label="Previous image"
|
|
350
|
+
@click.stop="go(current - 1)"
|
|
351
|
+
>
|
|
352
|
+
<LpIcon name="lucide:chevron-left" :size="24" />
|
|
353
|
+
</button>
|
|
354
|
+
|
|
355
|
+
<div
|
|
356
|
+
v-if="failed"
|
|
357
|
+
class="flex flex-col items-center gap-3 text-white/60"
|
|
358
|
+
>
|
|
359
|
+
<LpIcon name="lucide:image-off" :size="40" />
|
|
360
|
+
<p class="text-sm">This image couldn't be loaded.</p>
|
|
361
|
+
</div>
|
|
362
|
+
<!-- Keyed on src so paging remounts the img and replays the slide;
|
|
363
|
+
without the key Vue patches it in place and the change is a hard
|
|
364
|
+
cut with no sense of direction. -->
|
|
365
|
+
<img
|
|
366
|
+
v-else-if="item"
|
|
367
|
+
:key="item.src"
|
|
368
|
+
data-lightbox-image
|
|
369
|
+
:src="item.src"
|
|
370
|
+
:alt="item.title ?? ''"
|
|
371
|
+
class="max-h-full max-w-full select-none object-contain transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] animate-[lightbox-frame-in_var(--duration-medium)_var(--ease-emphasized)] motion-reduce:animate-none motion-reduce:transition-none"
|
|
372
|
+
:style="imageStyle"
|
|
373
|
+
draggable="false"
|
|
374
|
+
@load="loading = false"
|
|
375
|
+
@error="((failed = true), (loading = false))"
|
|
376
|
+
/>
|
|
377
|
+
|
|
378
|
+
<LpIcon
|
|
379
|
+
v-if="loading && !failed"
|
|
380
|
+
name="lucide:loader-circle"
|
|
381
|
+
:size="28"
|
|
382
|
+
class="absolute animate-spin text-white/50"
|
|
383
|
+
/>
|
|
384
|
+
|
|
385
|
+
<button
|
|
386
|
+
:class="[ARROW, 'right-4']"
|
|
387
|
+
type="button"
|
|
388
|
+
:disabled="!canNext"
|
|
389
|
+
aria-label="Next image"
|
|
390
|
+
@click.stop="go(current + 1)"
|
|
391
|
+
>
|
|
392
|
+
<LpIcon name="lucide:chevron-right" :size="24" />
|
|
393
|
+
</button>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<!-- Caption + filmstrip -->
|
|
397
|
+
<div
|
|
398
|
+
class="shrink-0 p-3 pt-2 animate-[lightbox-chrome-in_var(--duration-medium)_var(--ease-emphasized)_60ms_both] motion-reduce:animate-none"
|
|
399
|
+
>
|
|
400
|
+
<slot name="caption" :item="item!" :index="current" :total="total">
|
|
401
|
+
<div v-if="item?.title || item?.description" class="mb-2 text-center">
|
|
402
|
+
<p v-if="item.title" class="truncate text-sm font-medium text-white">
|
|
403
|
+
{{ item.title }}
|
|
404
|
+
</p>
|
|
405
|
+
<p v-if="item.description" class="truncate text-xs text-white/55">
|
|
406
|
+
{{ item.description }}
|
|
407
|
+
</p>
|
|
408
|
+
</div>
|
|
409
|
+
</slot>
|
|
410
|
+
|
|
411
|
+
<div
|
|
412
|
+
v-if="showStrip && total > 1"
|
|
413
|
+
ref="stripEl"
|
|
414
|
+
class="lp-scrollbar-none flex justify-start gap-2 overflow-x-auto sm:justify-center"
|
|
415
|
+
>
|
|
416
|
+
<button
|
|
417
|
+
v-for="(entry, i) in items"
|
|
418
|
+
:key="entry.src + i"
|
|
419
|
+
:data-index="i"
|
|
420
|
+
type="button"
|
|
421
|
+
:style="{ '--thumb-delay': `${Math.min(i * 30, 240)}ms` }"
|
|
422
|
+
class="size-14 shrink-0 overflow-hidden rounded-control outline-none ring-2 transition-[opacity,box-shadow,scale] duration-[var(--duration-fast)] hover:scale-105 focus-visible:ring-ring animate-[lightbox-chrome-in_var(--duration-medium)_var(--ease-emphasized)_both] [animation-delay:var(--thumb-delay)] motion-reduce:animate-none"
|
|
423
|
+
:class="i === current ? 'opacity-100 ring-brand' : 'opacity-45 ring-transparent hover:opacity-80'"
|
|
424
|
+
:aria-label="entry.title ?? `Image ${i + 1}`"
|
|
425
|
+
@click="go(i)"
|
|
426
|
+
>
|
|
427
|
+
<img
|
|
428
|
+
:src="entry.thumb ?? entry.src"
|
|
429
|
+
:alt="entry.title ?? ''"
|
|
430
|
+
class="size-full object-cover"
|
|
431
|
+
draggable="false"
|
|
432
|
+
/>
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</DialogContent>
|
|
437
|
+
</DialogPortal>
|
|
438
|
+
</DialogRoot>
|
|
439
|
+
</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
|
+
}
|