@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,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
+ }
package/src/index.ts CHANGED
@@ -45,6 +45,9 @@ export { default as LpIcon } from "./components/LpIcon.vue"
45
45
  export { default as LpInfraNode } from "./components/LpInfraNode.vue"
46
46
  export type { InfraNodeData } from "./components/LpInfraNode.vue"
47
47
  export { default as LpInput } from "./components/LpInput.vue"
48
+ export { default as LpLightbox } from "./components/LpLightbox.vue"
49
+ export type { LightboxItem } from "./components/lightbox"
50
+ export { fileNameOf } from "./components/lightbox"
48
51
  export { default as LpLink } from "./components/LpLink.vue"
49
52
  export { default as LpLogViewer } from "./components/LpLogViewer.vue"
50
53
  export type { LogLevel, LogLine } from "./components/LpLogViewer.vue"
@@ -155,6 +158,8 @@ export { useInputFilter } from "./composables/useInputFilter"
155
158
  export type { UseInputFilterOptions } from "./composables/useInputFilter"
156
159
  export { useTilt } from "./composables/useTilt"
157
160
  export type { UseTilt, UseTiltOptions } from "./composables/useTilt"
161
+ export { useZoomPan } from "./composables/useZoomPan"
162
+ export type { UseZoomPan, UseZoomPanOptions } from "./composables/useZoomPan"
158
163
  export { useToast } from "./composables/useToast"
159
164
  export type {
160
165
  ToastAction,
@@ -311,6 +311,42 @@
311
311
  }
312
312
  }
313
313
 
314
+ /*
315
+ * Lightbox. The image grows in from slightly under full size so opening reads as
316
+ * the picture coming toward you rather than a panel appearing; the exit is
317
+ * shorter and on an accelerating curve, since a dismissal shouldn't be savoured.
318
+ */
319
+ @keyframes lightbox-in {
320
+ from {
321
+ opacity: 0;
322
+ transform: scale(0.92);
323
+ }
324
+ }
325
+ @keyframes lightbox-out {
326
+ to {
327
+ opacity: 0;
328
+ transform: scale(0.96);
329
+ }
330
+ }
331
+ /*
332
+ * Frame change. Paging slides the incoming picture in from the side it came
333
+ * from, so the direction of travel matches the arrow that was pressed. The
334
+ * component sets --frame-from per navigation.
335
+ */
336
+ @keyframes lightbox-frame-in {
337
+ from {
338
+ opacity: 0;
339
+ transform: translateX(var(--frame-from, 24px));
340
+ }
341
+ }
342
+ /* Chrome (toolbar, caption, filmstrip) drifts in after the image lands. */
343
+ @keyframes lightbox-chrome-in {
344
+ from {
345
+ opacity: 0;
346
+ transform: translateY(var(--chrome-from, 8px));
347
+ }
348
+ }
349
+
314
350
  /*
315
351
  * Skin painter. A raised panel opts in with .lp-skin-panel and gets the active
316
352
  * skin (flat → glass) purely from the surface tokens: border width, an overlay