@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,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,
|
package/src/tokens/tokens.css
CHANGED
|
@@ -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
|