@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.
@@ -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
- <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
+ }