@leavepulse/ui 0.93.2 → 0.94.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/README.md +53 -3
- package/dist/assets/LpTabs-D7EyG21l.css +18 -0
- package/dist/assets/LpVideoTrim-D8JDg9Qn.css +8 -0
- package/dist/chunks/AppShellContextNav-BoH4ffnC.js +54 -0
- package/dist/chunks/{LpAppShell-BWaVRsDB.js → LpAppShell-xEBmGV78.js} +12 -5
- package/dist/chunks/{LpDateRangePicker-CAn2yGbn.js → LpDateRangePicker-DRBB8z6t.js} +1 -1
- package/dist/chunks/{LpDrawer-iRHwJOGn.js → LpDrawer-BkWjFdV0.js} +81 -42
- package/dist/chunks/{LpLightbox-a24V_0Wk.js → LpLightbox-DPgz7IsT.js} +62 -49
- package/dist/chunks/{LpMapPicker-DVfiy3rR.js → LpMapPicker-V0Lybi10.js} +19 -52
- package/dist/chunks/{LpMediaGallery-B7DOV_aL.js → LpMediaGallery-CTejKF2-.js} +36 -15
- package/dist/chunks/{LpModalNav-DivqT1NV.js → LpModalNav-DVXAF-DG.js} +1 -1
- package/dist/chunks/{LpRangeCalendar-C9gkNMYw.js → LpRangeCalendar-9BvaLZji.js} +3 -4
- package/dist/chunks/LpScrollStrip-D4tsOfAQ.js +78 -0
- package/dist/chunks/{LpSidebar-DQtCYlT4.js → LpSidebar-BIhusppV.js} +1 -1
- package/dist/component-names.d.ts +1 -1
- package/dist/components/AppShellContextNav.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpChart.vue.js +3 -3
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +10 -2
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +2 -0
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
- package/dist/components/LpScrollStrip.vue.d.ts +48 -0
- package/dist/components/LpScrollStrip.vue.js +4 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpTabs.vue.d.ts +4 -4
- package/dist/components/LpTabs.vue.js +104 -90
- package/dist/components/LpVideoTrim.vue.js +41 -54
- package/dist/composables/gesture.d.ts +141 -0
- package/dist/composables/gesture.js +176 -0
- package/dist/composables/scroll.d.ts +5 -1
- package/dist/composables/scroll.js +16 -9
- package/dist/composables/useAppUpdate.d.ts +26 -0
- package/dist/composables/useAppUpdate.js +91 -0
- package/dist/composables/useGesture.d.ts +107 -0
- package/dist/composables/useGesture.js +281 -0
- package/dist/composables/usePaintSelect.js +3 -4
- package/dist/composables/useScrollStrip.d.ts +27 -0
- package/dist/composables/useScrollStrip.js +194 -0
- package/dist/composables/useTouchHold.d.ts +3 -1
- package/dist/composables/useTouchHold.js +9 -51
- package/dist/composables/useZoomPan.js +4 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +180 -152
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/AppShellContextNav.vue +3 -2
- package/src/components/LpAppShell.vue +10 -1
- package/src/components/LpChart.vue +2 -2
- package/src/components/LpDrawer.vue +121 -54
- package/src/components/LpLightbox.vue +33 -20
- package/src/components/LpMapPicker.vue +22 -64
- package/src/components/LpMediaGallery.vue +44 -11
- package/src/components/LpRangeCalendar.vue +4 -5
- package/src/components/LpScrollStrip.vue +96 -0
- package/src/components/LpSidebar.vue +2 -2
- package/src/components/LpTabs.vue +36 -17
- package/src/components/LpVideoTrim.vue +35 -46
- package/src/composables/gesture.ts +314 -0
- package/src/composables/scroll.ts +20 -9
- package/src/composables/useAppUpdate.ts +156 -0
- package/src/composables/useGesture.ts +451 -0
- package/src/composables/usePaintSelect.ts +4 -5
- package/src/composables/useScrollStrip.ts +249 -0
- package/src/composables/useTouchHold.ts +15 -73
- package/src/composables/useZoomPan.ts +6 -9
- package/src/index.ts +27 -0
- package/src/tokens/effects.css +40 -0
- package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
- package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
- package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { onBeforeUnmount, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue"
|
|
2
|
+
import { prefersReducedMotion } from "./easing"
|
|
3
|
+
import {
|
|
4
|
+
createDragSession,
|
|
5
|
+
distance,
|
|
6
|
+
LONG_PRESS_MS,
|
|
7
|
+
pinchFrame,
|
|
8
|
+
slopFor,
|
|
9
|
+
type DragFrame,
|
|
10
|
+
type DragSession,
|
|
11
|
+
type GestureAxis,
|
|
12
|
+
type GesturePoint,
|
|
13
|
+
type PinchFrame,
|
|
14
|
+
type SwipeOptions,
|
|
15
|
+
} from "./gesture"
|
|
16
|
+
import { isElement, nodeWindow } from "./realm"
|
|
17
|
+
import type { Direction } from "./spatialNav"
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* One engine for pointer gestures: drag, swipe, pinch, long press and tap on
|
|
21
|
+
* one element, decided the same way everywhere.
|
|
22
|
+
*
|
|
23
|
+
* - A press becomes a drag once it travels past the slop (more for a finger
|
|
24
|
+
* than for a mouse). Until then nothing is captured and nothing is
|
|
25
|
+
* prevented, so a button or link under the press still gets its click.
|
|
26
|
+
* - With `axis: "x"` (or "y") a press whose first clear movement runs along
|
|
27
|
+
* the other axis is let go: the page scrolls as it would without us.
|
|
28
|
+
* `axis: "lock"` commits to whichever axis the movement first takes.
|
|
29
|
+
* - Once dragging, a mouse or pen is captured (a touch already is, to what it
|
|
30
|
+
* landed on), so the drag survives leaving the element and works inside an
|
|
31
|
+
* LpFrame; text selection is off, and the click
|
|
32
|
+
* the release produces is swallowed: a drag over a link does not follow it.
|
|
33
|
+
* - A cancelled pointer (the browser took the touch for a scroll or a system
|
|
34
|
+
* gesture), a lost capture, a second finger or Escape end the drag with
|
|
35
|
+
* `cancelled`: nothing should be committed from it.
|
|
36
|
+
* - Two fingers are a pinch, reported against the distance they started at.
|
|
37
|
+
* - A long press fires after `longPress.delay` of staying within the slop,
|
|
38
|
+
* and the click that follows it is swallowed.
|
|
39
|
+
* - Velocity is measured over the last 100 ms, so a final jittery frame does
|
|
40
|
+
* not decide a flick. Release reports a swipe direction when the movement
|
|
41
|
+
* was one (see swipeDirection).
|
|
42
|
+
*
|
|
43
|
+
* Touch handling is the browser's until the element says otherwise with
|
|
44
|
+
* `touch-action`: an element that pans by finger needs `touch-none` (or
|
|
45
|
+
* `pan-y` for a sideways gesture inside a vertical page), or the browser takes
|
|
46
|
+
* the touch for a scroll and cancels the pointer.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export interface GestureState {
|
|
50
|
+
event: PointerEvent
|
|
51
|
+
pointerType: string
|
|
52
|
+
/** Where the press started, client coordinates. */
|
|
53
|
+
start: GesturePoint
|
|
54
|
+
/** Where the pointer is now. */
|
|
55
|
+
point: GesturePoint
|
|
56
|
+
/** Travel since the press. */
|
|
57
|
+
movement: GesturePoint
|
|
58
|
+
/** Travel since the previous report. */
|
|
59
|
+
delta: GesturePoint
|
|
60
|
+
/** Px/ms over the last moments. */
|
|
61
|
+
velocity: GesturePoint
|
|
62
|
+
/** The axis the drag committed to, with `axis` set. */
|
|
63
|
+
axis: GestureAxis | null
|
|
64
|
+
/** Ms since the press. */
|
|
65
|
+
elapsed: number
|
|
66
|
+
/** A drag that ended without being completed: do not commit it. */
|
|
67
|
+
cancelled: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GestureEnd extends GestureState {
|
|
71
|
+
/** The swipe this release made, or null. */
|
|
72
|
+
swipe: Direction | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface GesturePinch extends PinchFrame {
|
|
76
|
+
event: PointerEvent
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface GestureOptions {
|
|
80
|
+
/** Off while false; read on every press. */
|
|
81
|
+
enabled?: MaybeRefOrGetter<boolean | undefined>
|
|
82
|
+
/** Pointer types that may start a gesture. Default all. */
|
|
83
|
+
pointerTypes?: readonly ("mouse" | "pen" | "touch")[]
|
|
84
|
+
/** Restrict drags to an axis, or `lock` to the first one taken. */
|
|
85
|
+
axis?: GestureAxis | "lock"
|
|
86
|
+
/** Override the slop in px (default by pointer type). */
|
|
87
|
+
threshold?: number
|
|
88
|
+
/** Presses starting inside an element matching this selector are ignored. */
|
|
89
|
+
ignore?: string
|
|
90
|
+
/** Capture the pointer once dragging. Default true. */
|
|
91
|
+
capture?: boolean
|
|
92
|
+
/** Swallow the click a drag ends with. Default true. */
|
|
93
|
+
preventClick?: boolean
|
|
94
|
+
/** Options for the swipe reported on release. */
|
|
95
|
+
swipe?: SwipeOptions
|
|
96
|
+
/** A press landed (before any threshold). Return false to ignore this press. */
|
|
97
|
+
onPress?: (event: PointerEvent) => boolean | void
|
|
98
|
+
onDragStart?: (state: GestureState) => void
|
|
99
|
+
onDrag?: (state: GestureState) => void
|
|
100
|
+
onDragEnd?: (state: GestureEnd) => void
|
|
101
|
+
/** Released after a swipe; also reported on onDragEnd as `swipe`. */
|
|
102
|
+
onSwipe?: (direction: Direction, state: GestureEnd) => void
|
|
103
|
+
/** A press that never became a drag, released. */
|
|
104
|
+
onTap?: (event: PointerEvent) => void
|
|
105
|
+
onPinchStart?: (pinch: GesturePinch) => void
|
|
106
|
+
onPinch?: (pinch: GesturePinch) => void
|
|
107
|
+
onPinchEnd?: (pinch: GesturePinch & { cancelled: boolean }) => void
|
|
108
|
+
/** A press held still for `delay` ms (default LONG_PRESS_MS); the click its release makes is swallowed. */
|
|
109
|
+
longPress?: { delay?: MaybeRefOrGetter<number | undefined>; pointerTypes?: readonly ("mouse" | "pen" | "touch")[]; handler: (event: PointerEvent) => void }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Gesture {
|
|
113
|
+
/** True while a drag (past the slop) is in flight. */
|
|
114
|
+
dragging: Ref<boolean>
|
|
115
|
+
/** True while two pointers are pinching. */
|
|
116
|
+
pinching: Ref<boolean>
|
|
117
|
+
/** End whatever is in flight as cancelled. */
|
|
118
|
+
cancel: () => void
|
|
119
|
+
/** Bind these to the element when not using useGesture. */
|
|
120
|
+
handlers: {
|
|
121
|
+
onPointerdown: (event: PointerEvent) => void
|
|
122
|
+
onPointermove: (event: PointerEvent) => void
|
|
123
|
+
onPointerup: (event: PointerEvent) => void
|
|
124
|
+
onPointercancel: (event: PointerEvent) => void
|
|
125
|
+
onLostpointercapture: (event: PointerEvent) => void
|
|
126
|
+
onClickCapture: (event: MouseEvent) => void
|
|
127
|
+
onDragstart: (event: DragEvent) => void
|
|
128
|
+
onContextmenu: (event: Event) => void
|
|
129
|
+
}
|
|
130
|
+
/** Detach window listeners and timers; useGesture calls it on unmount. */
|
|
131
|
+
dispose: () => void
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const CLICK_AFTER_HOLD_MS = 350
|
|
135
|
+
|
|
136
|
+
interface Press {
|
|
137
|
+
id: number
|
|
138
|
+
type: string
|
|
139
|
+
el: HTMLElement
|
|
140
|
+
session: DragSession
|
|
141
|
+
dragging: boolean
|
|
142
|
+
first: PointerEvent
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The engine without an element: bind `handlers` yourself (to compose with other handlers). */
|
|
146
|
+
export function createGesture(options: GestureOptions): Gesture {
|
|
147
|
+
const dragging = ref(false)
|
|
148
|
+
const pinching = ref(false)
|
|
149
|
+
const pointers = new Map<number, GesturePoint>()
|
|
150
|
+
let press: Press | null = null
|
|
151
|
+
let pinchStart = 0
|
|
152
|
+
let pinchLast: GesturePinch | null = null
|
|
153
|
+
let swallow = false
|
|
154
|
+
let swallowTimer = 0
|
|
155
|
+
let holdTimer = 0
|
|
156
|
+
let held = false
|
|
157
|
+
let bodyStyle: { style: CSSStyleDeclaration; userSelect: string } | null = null
|
|
158
|
+
let keyWindow: Window | null = null
|
|
159
|
+
|
|
160
|
+
const pt = (e: PointerEvent): GesturePoint => ({ x: e.clientX, y: e.clientY })
|
|
161
|
+
|
|
162
|
+
function state(e: PointerEvent, p: Press, f: DragFrame, cancelled = false): GestureState {
|
|
163
|
+
return { event: e, pointerType: p.type, start: f.start, point: f.point, movement: f.movement, delta: f.delta, velocity: f.velocity, axis: f.axis, elapsed: f.elapsed, cancelled }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Swallow the click a release makes. A long press may make none at all, so past `within` ms the next click is the user's own. */
|
|
167
|
+
function clickSoon(win: Window, within = 0) {
|
|
168
|
+
swallow = true
|
|
169
|
+
win.clearTimeout(swallowTimer)
|
|
170
|
+
swallowTimer = win.setTimeout(() => (swallow = false), within)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stopHold() {
|
|
174
|
+
if (holdTimer) window.clearTimeout(holdTimer)
|
|
175
|
+
holdTimer = 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onKey(e: KeyboardEvent) {
|
|
179
|
+
if (e.key === "Escape" && dragging.value) {
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
cancel()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function beginDrag(e: PointerEvent, p: Press, f: DragFrame) {
|
|
186
|
+
p.dragging = true
|
|
187
|
+
dragging.value = true
|
|
188
|
+
stopHold()
|
|
189
|
+
// A touch is already captured to the element it landed on (inside this
|
|
190
|
+
// one), so its events keep coming here; moving that capture over has been
|
|
191
|
+
// seen to drop it again a frame later in Chromium.
|
|
192
|
+
if (options.capture !== false && p.type !== "touch") {
|
|
193
|
+
try {
|
|
194
|
+
p.el.setPointerCapture(p.id)
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
const doc = p.el.ownerDocument
|
|
198
|
+
bodyStyle = { style: doc.body.style, userSelect: doc.body.style.userSelect }
|
|
199
|
+
doc.body.style.userSelect = "none"
|
|
200
|
+
doc.getSelection()?.removeAllRanges()
|
|
201
|
+
keyWindow = nodeWindow(p.el)
|
|
202
|
+
keyWindow.addEventListener("keydown", onKey, true)
|
|
203
|
+
options.onDragStart?.(state(e, p, f))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function release() {
|
|
207
|
+
if (bodyStyle) bodyStyle.style.userSelect = bodyStyle.userSelect
|
|
208
|
+
bodyStyle = null
|
|
209
|
+
keyWindow?.removeEventListener("keydown", onKey, true)
|
|
210
|
+
keyWindow = null
|
|
211
|
+
dragging.value = false
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function endPress(e: PointerEvent, cancelled: boolean) {
|
|
215
|
+
const p = press
|
|
216
|
+
if (!p) return
|
|
217
|
+
press = null
|
|
218
|
+
stopHold()
|
|
219
|
+
if (p.el.hasPointerCapture?.(p.id)) {
|
|
220
|
+
try {
|
|
221
|
+
p.el.releasePointerCapture(p.id)
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
if (!p.dragging) {
|
|
225
|
+
release()
|
|
226
|
+
if (held) {
|
|
227
|
+
if (cancelled) swallow = false
|
|
228
|
+
else clickSoon(nodeWindow(p.el), CLICK_AFTER_HOLD_MS)
|
|
229
|
+
held = false
|
|
230
|
+
} else if (!cancelled) options.onTap?.(e)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
const f = p.session.end(pt(e), e.timeStamp, options.swipe)
|
|
234
|
+
const s = state(e, p, f, cancelled)
|
|
235
|
+
const swipe = cancelled ? null : f.swipe
|
|
236
|
+
if (!cancelled && options.preventClick !== false) clickSoon(nodeWindow(p.el))
|
|
237
|
+
release()
|
|
238
|
+
const end: GestureEnd = { ...s, swipe }
|
|
239
|
+
options.onDragEnd?.(end)
|
|
240
|
+
if (swipe) options.onSwipe?.(swipe, end)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function endPinch(e: PointerEvent, cancelled: boolean) {
|
|
244
|
+
if (!pinching.value) return
|
|
245
|
+
pinching.value = false
|
|
246
|
+
pinchStart = 0
|
|
247
|
+
if (pinchLast) options.onPinchEnd?.({ ...pinchLast, event: e, cancelled })
|
|
248
|
+
pinchLast = null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function allowed(e: PointerEvent): boolean {
|
|
252
|
+
if (toValue(options.enabled) === false) return false
|
|
253
|
+
if (options.pointerTypes && !options.pointerTypes.includes(e.pointerType as "mouse")) return false
|
|
254
|
+
if (e.pointerType === "mouse" && e.button !== 0) return false
|
|
255
|
+
if (options.ignore && isElement(e.target) && e.target.closest(options.ignore)) return false
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onPointerdown(e: PointerEvent) {
|
|
260
|
+
if (!allowed(e)) return
|
|
261
|
+
pointers.set(e.pointerId, pt(e))
|
|
262
|
+
if (pointers.size === 2 && (options.onPinch || options.onPinchStart)) {
|
|
263
|
+
if (press) endPress(e, true)
|
|
264
|
+
const [a, b] = [...pointers.values()] as [GesturePoint, GesturePoint]
|
|
265
|
+
pinchStart = distance(a, b)
|
|
266
|
+
pinching.value = true
|
|
267
|
+
pinchLast = { ...pinchFrame(a, b, pinchStart), event: e }
|
|
268
|
+
try {
|
|
269
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
270
|
+
} catch {}
|
|
271
|
+
options.onPinchStart?.(pinchLast)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
if (pointers.size > 1 || press) return
|
|
275
|
+
if (options.onPress?.(e) === false) {
|
|
276
|
+
pointers.delete(e.pointerId)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const el = e.currentTarget as HTMLElement
|
|
280
|
+
held = false
|
|
281
|
+
const session = createDragSession(pt(e), e.timeStamp, { slop: options.threshold ?? slopFor(e.pointerType), axis: options.axis })
|
|
282
|
+
press = { id: e.pointerId, type: e.pointerType, el, session, dragging: false, first: e }
|
|
283
|
+
const lp = options.longPress
|
|
284
|
+
if (lp && (!lp.pointerTypes || lp.pointerTypes.includes(e.pointerType as "mouse"))) {
|
|
285
|
+
holdTimer = window.setTimeout(() => {
|
|
286
|
+
holdTimer = 0
|
|
287
|
+
if (!press || press.dragging) return
|
|
288
|
+
held = true
|
|
289
|
+
lp.handler(e)
|
|
290
|
+
}, toValue(lp.delay) ?? LONG_PRESS_MS)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function onPointermove(e: PointerEvent) {
|
|
295
|
+
if (!pointers.has(e.pointerId)) return
|
|
296
|
+
pointers.set(e.pointerId, pt(e))
|
|
297
|
+
if (pinching.value && pointers.size >= 2) {
|
|
298
|
+
const [a, b] = [...pointers.values()] as [GesturePoint, GesturePoint]
|
|
299
|
+
pinchLast = { ...pinchFrame(a, b, pinchStart), event: e }
|
|
300
|
+
options.onPinch?.(pinchLast)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
const p = press
|
|
304
|
+
if (!p || e.pointerId !== p.id) return
|
|
305
|
+
const f = p.session.move(pt(e), e.timeStamp)
|
|
306
|
+
if (f.phase === "rejected") {
|
|
307
|
+
press = null
|
|
308
|
+
pointers.delete(e.pointerId)
|
|
309
|
+
stopHold()
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (f.phase === "pending") return
|
|
313
|
+
if (!p.dragging) {
|
|
314
|
+
stopHold()
|
|
315
|
+
if (held) return
|
|
316
|
+
if (!options.onDrag && !options.onDragStart && !options.onDragEnd && !options.onSwipe) return
|
|
317
|
+
beginDrag(e, p, f)
|
|
318
|
+
}
|
|
319
|
+
options.onDrag?.(state(e, p, f))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function onPointerup(e: PointerEvent) {
|
|
323
|
+
if (!pointers.delete(e.pointerId)) return
|
|
324
|
+
if (pinching.value) {
|
|
325
|
+
if (pointers.size < 2) endPinch(e, false)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (press?.id === e.pointerId) endPress(e, false)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function onPointercancel(e: PointerEvent) {
|
|
332
|
+
if (!pointers.delete(e.pointerId)) return
|
|
333
|
+
if (pinching.value) {
|
|
334
|
+
endPinch(e, true)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
if (press?.id === e.pointerId) endPress(e, true)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function onLostpointercapture(e: PointerEvent) {
|
|
341
|
+
if (press?.dragging && press.id === e.pointerId) {
|
|
342
|
+
pointers.delete(e.pointerId)
|
|
343
|
+
endPress(e, true)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function onClickCapture(e: MouseEvent) {
|
|
348
|
+
if (!swallow) return
|
|
349
|
+
swallow = false
|
|
350
|
+
e.preventDefault()
|
|
351
|
+
e.stopImmediatePropagation()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function onDragstart(e: DragEvent) {
|
|
355
|
+
if (press) e.preventDefault()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function onContextmenu(e: Event) {
|
|
359
|
+
if (holdTimer || held || dragging.value) e.preventDefault()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function cancel() {
|
|
363
|
+
const p = press
|
|
364
|
+
if (p) {
|
|
365
|
+
pointers.delete(p.id)
|
|
366
|
+
endPress(p.first, true)
|
|
367
|
+
}
|
|
368
|
+
if (pinching.value && pinchLast) endPinch(pinchLast.event, true)
|
|
369
|
+
pointers.clear()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function dispose() {
|
|
373
|
+
stopHold()
|
|
374
|
+
if (press?.dragging) release()
|
|
375
|
+
press = null
|
|
376
|
+
pointers.clear()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
dragging,
|
|
381
|
+
pinching,
|
|
382
|
+
cancel,
|
|
383
|
+
dispose,
|
|
384
|
+
handlers: { onPointerdown, onPointermove, onPointerup, onPointercancel, onLostpointercapture, onClickCapture, onDragstart, onContextmenu },
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The engine bound to `target`: listeners follow the element and leave on unmount. */
|
|
389
|
+
export function useGesture(target: MaybeRefOrGetter<HTMLElement | null | undefined>, options: GestureOptions): Gesture {
|
|
390
|
+
const gesture = createGesture(options)
|
|
391
|
+
const h = gesture.handlers
|
|
392
|
+
const bindings: [string, EventListener, AddEventListenerOptions?][] = [
|
|
393
|
+
["pointerdown", h.onPointerdown as EventListener],
|
|
394
|
+
["pointermove", h.onPointermove as EventListener],
|
|
395
|
+
["pointerup", h.onPointerup as EventListener],
|
|
396
|
+
["pointercancel", h.onPointercancel as EventListener],
|
|
397
|
+
["lostpointercapture", h.onLostpointercapture as EventListener],
|
|
398
|
+
["click", h.onClickCapture as EventListener, { capture: true }],
|
|
399
|
+
["dragstart", h.onDragstart as EventListener],
|
|
400
|
+
["contextmenu", h.onContextmenu as EventListener],
|
|
401
|
+
]
|
|
402
|
+
watch(
|
|
403
|
+
() => toValue(target),
|
|
404
|
+
(el, _, onCleanup) => {
|
|
405
|
+
if (!el) return
|
|
406
|
+
for (const [name, fn, opts] of bindings) el.addEventListener(name, fn, opts)
|
|
407
|
+
onCleanup(() => {
|
|
408
|
+
for (const [name, fn, opts] of bindings) el.removeEventListener(name, fn, opts)
|
|
409
|
+
gesture.cancel()
|
|
410
|
+
})
|
|
411
|
+
},
|
|
412
|
+
{ immediate: true, flush: "post" },
|
|
413
|
+
)
|
|
414
|
+
onBeforeUnmount(gesture.dispose)
|
|
415
|
+
return gesture
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface Glide {
|
|
419
|
+
stop: () => void
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Momentum after a fling: keep moving at `velocity` px/ms and bleed the speed
|
|
424
|
+
* off exponentially (time constant `tau` ms), so letting go drifts to a stop.
|
|
425
|
+
* Time-based, so it feels the same at 60 and 144 Hz. `step` gets the px to
|
|
426
|
+
* move this frame and returns false to stop early (an edge was hit). Under
|
|
427
|
+
* reduced motion nothing glides.
|
|
428
|
+
*/
|
|
429
|
+
export function glide(velocity: GesturePoint, step: (dx: number, dy: number) => boolean | void, { tau = 240, win = window }: { tau?: number; win?: Window } = {}): Glide {
|
|
430
|
+
let v = { ...velocity }
|
|
431
|
+
let frame = 0
|
|
432
|
+
const stop = () => {
|
|
433
|
+
if (frame) win.cancelAnimationFrame(frame)
|
|
434
|
+
frame = 0
|
|
435
|
+
}
|
|
436
|
+
if (prefersReducedMotion(win) || Math.hypot(v.x, v.y) < 0.05) return { stop }
|
|
437
|
+
let previous = win.performance.now()
|
|
438
|
+
const tick = (now: number) => {
|
|
439
|
+
const dt = Math.min(now - previous, 32)
|
|
440
|
+
previous = now
|
|
441
|
+
const friction = Math.exp(-dt / tau)
|
|
442
|
+
v = { x: v.x * friction, y: v.y * friction }
|
|
443
|
+
if (step(v.x * dt, v.y * dt) === false || Math.hypot(v.x, v.y) <= 0.015) {
|
|
444
|
+
frame = 0
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
frame = win.requestAnimationFrame(tick)
|
|
448
|
+
}
|
|
449
|
+
frame = win.requestAnimationFrame(tick)
|
|
450
|
+
return { stop }
|
|
451
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HOLD_TO_DRAG_MS, TOUCH_SLOP } from "./gesture"
|
|
1
2
|
import { onBeforeUnmount, onMounted, readonly, ref, toValue, type MaybeRefOrGetter, type Ref } from "vue"
|
|
2
3
|
import { useHotkeys, type HotkeyHelp } from "./useHotkeys"
|
|
3
4
|
import { scrollParent } from "./scroll"
|
|
@@ -13,7 +14,7 @@ import { nodeWindow } from "./useView"
|
|
|
13
14
|
*
|
|
14
15
|
* Items are found in the DOM by `data-paint-key`, in document order, so the
|
|
15
16
|
* run follows what the user sees (sorted, filtered, collapsed groups skipped).
|
|
16
|
-
* A mouse or pen paints at once. A finger holds still for
|
|
17
|
+
* A mouse or pen paints at once. A finger holds still for HOLD_TO_DRAG_MS first, so a
|
|
17
18
|
* swipe through the list still scrolls it.
|
|
18
19
|
*
|
|
19
20
|
* The press itself ticks the first item, so the click the browser sends after
|
|
@@ -40,8 +41,6 @@ export interface UsePaintSelectOptions<K> {
|
|
|
40
41
|
help?: MaybeRefOrGetter<HotkeyHelp | undefined>
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
const HOLD_MS = 320
|
|
44
|
-
const SLOP_PX = 8
|
|
45
44
|
const EDGE_PX = 40
|
|
46
45
|
const SCROLL_STEP = 14
|
|
47
46
|
|
|
@@ -154,13 +153,13 @@ export function usePaintSelect<K = string>(options: UsePaintSelectOptions<K>) {
|
|
|
154
153
|
pending = null
|
|
155
154
|
begin(key, x, y)
|
|
156
155
|
navigator.vibrate?.(8)
|
|
157
|
-
}, behaviour.hold ??
|
|
156
|
+
}, behaviour.hold ?? HOLD_TO_DRAG_MS),
|
|
158
157
|
}
|
|
159
158
|
}
|
|
160
159
|
|
|
161
160
|
function onMove(event: PointerEvent) {
|
|
162
161
|
if (pending && event.pointerId === pending.pointerId) {
|
|
163
|
-
if (Math.hypot(event.clientX - pending.x, event.clientY - pending.y) >
|
|
162
|
+
if (Math.hypot(event.clientX - pending.x, event.clientY - pending.y) > TOUCH_SLOP) cancelPending()
|
|
164
163
|
return
|
|
165
164
|
}
|
|
166
165
|
if (!stroke) return
|