@leavepulse/ui 0.93.3 → 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.
Files changed (76) hide show
  1. package/README.md +53 -3
  2. package/dist/assets/LpTabs-D7EyG21l.css +18 -0
  3. package/dist/assets/LpVideoTrim-D8JDg9Qn.css +8 -0
  4. package/dist/chunks/AppShellContextNav-BoH4ffnC.js +54 -0
  5. package/dist/chunks/{LpAppShell-BWaVRsDB.js → LpAppShell-xEBmGV78.js} +12 -5
  6. package/dist/chunks/{LpDateRangePicker-CAn2yGbn.js → LpDateRangePicker-DRBB8z6t.js} +1 -1
  7. package/dist/chunks/{LpDrawer-iRHwJOGn.js → LpDrawer-BkWjFdV0.js} +81 -42
  8. package/dist/chunks/{LpLightbox-a24V_0Wk.js → LpLightbox-DPgz7IsT.js} +62 -49
  9. package/dist/chunks/{LpMapPicker-DVfiy3rR.js → LpMapPicker-V0Lybi10.js} +19 -52
  10. package/dist/chunks/{LpMediaGallery-CtWA5Uab.js → LpMediaGallery-CTejKF2-.js} +1 -1
  11. package/dist/chunks/{LpModalNav-DivqT1NV.js → LpModalNav-DVXAF-DG.js} +1 -1
  12. package/dist/chunks/{LpRangeCalendar-C9gkNMYw.js → LpRangeCalendar-9BvaLZji.js} +3 -4
  13. package/dist/chunks/LpScrollStrip-D4tsOfAQ.js +78 -0
  14. package/dist/chunks/{LpSidebar-DQtCYlT4.js → LpSidebar-BIhusppV.js} +1 -1
  15. package/dist/component-names.d.ts +1 -1
  16. package/dist/components/AppShellContextNav.vue.js +1 -1
  17. package/dist/components/LpAppShell.vue.js +1 -1
  18. package/dist/components/LpChart.vue.js +3 -3
  19. package/dist/components/LpDateRangePicker.vue.js +1 -1
  20. package/dist/components/LpDrawer.vue.d.ts +10 -2
  21. package/dist/components/LpDrawer.vue.js +1 -1
  22. package/dist/components/LpDropzone.vue.d.ts +1 -1
  23. package/dist/components/LpLightbox.vue.js +1 -1
  24. package/dist/components/LpMapPicker.vue.js +1 -1
  25. package/dist/components/LpMediaGallery.vue.js +1 -1
  26. package/dist/components/LpModalNav.vue.js +1 -1
  27. package/dist/components/LpRangeCalendar.vue.js +1 -1
  28. package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
  29. package/dist/components/LpScrollStrip.vue.d.ts +48 -0
  30. package/dist/components/LpScrollStrip.vue.js +4 -0
  31. package/dist/components/LpSidebar.vue.js +1 -1
  32. package/dist/components/LpTabs.vue.d.ts +4 -4
  33. package/dist/components/LpTabs.vue.js +104 -90
  34. package/dist/components/LpVideoTrim.vue.js +41 -54
  35. package/dist/composables/gesture.d.ts +141 -0
  36. package/dist/composables/gesture.js +176 -0
  37. package/dist/composables/scroll.d.ts +5 -1
  38. package/dist/composables/scroll.js +16 -9
  39. package/dist/composables/useAppUpdate.d.ts +26 -0
  40. package/dist/composables/useAppUpdate.js +91 -0
  41. package/dist/composables/useGesture.d.ts +107 -0
  42. package/dist/composables/useGesture.js +281 -0
  43. package/dist/composables/usePaintSelect.js +3 -4
  44. package/dist/composables/useScrollStrip.d.ts +27 -0
  45. package/dist/composables/useScrollStrip.js +194 -0
  46. package/dist/composables/useTouchHold.d.ts +3 -1
  47. package/dist/composables/useTouchHold.js +9 -51
  48. package/dist/composables/useZoomPan.js +4 -6
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.js +180 -152
  51. package/package.json +1 -1
  52. package/src/component-names.ts +1 -0
  53. package/src/components/AppShellContextNav.vue +3 -2
  54. package/src/components/LpAppShell.vue +10 -1
  55. package/src/components/LpChart.vue +2 -2
  56. package/src/components/LpDrawer.vue +121 -54
  57. package/src/components/LpLightbox.vue +33 -20
  58. package/src/components/LpMapPicker.vue +22 -64
  59. package/src/components/LpRangeCalendar.vue +4 -5
  60. package/src/components/LpScrollStrip.vue +96 -0
  61. package/src/components/LpSidebar.vue +2 -2
  62. package/src/components/LpTabs.vue +36 -17
  63. package/src/components/LpVideoTrim.vue +35 -46
  64. package/src/composables/gesture.ts +314 -0
  65. package/src/composables/scroll.ts +20 -9
  66. package/src/composables/useAppUpdate.ts +156 -0
  67. package/src/composables/useGesture.ts +451 -0
  68. package/src/composables/usePaintSelect.ts +4 -5
  69. package/src/composables/useScrollStrip.ts +249 -0
  70. package/src/composables/useTouchHold.ts +15 -73
  71. package/src/composables/useZoomPan.ts +6 -9
  72. package/src/index.ts +27 -0
  73. package/src/tokens/effects.css +40 -0
  74. package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
  75. package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
  76. package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
@@ -45,6 +45,10 @@ export interface ScrollToElementOptions {
45
45
  behavior?: ScrollBehavior
46
46
  /** Room kept between the element and the scroller's edge. */
47
47
  margin?: number
48
+ /** Which way to scroll: vertical (default) or horizontal, for a strip. */
49
+ axis?: "y" | "x"
50
+ /** Scroll this element rather than the nearest scroller along `axis`. */
51
+ container?: HTMLElement | null
48
52
  }
49
53
 
50
54
  /** Scroll the nearest scroller, and only it, so that `el` is in view. */
@@ -55,21 +59,28 @@ export function scrollMarginTop(el: Element): number {
55
59
 
56
60
  export function scrollToElement(
57
61
  el: Element | null | undefined,
58
- { block = "nearest", behavior = "auto", margin = 0 }: ScrollToElementOptions = {},
62
+ { block = "nearest", behavior = "auto", margin = 0, axis = "y", container }: ScrollToElementOptions = {},
59
63
  ): void {
60
64
  if (!el) return
61
65
  // The element's own document and window: inside an LpFrame they are the frame's.
62
66
  const doc = el.ownerDocument
63
67
  const win = doc.defaultView ?? window
64
- const box = scrollParent(el) ?? (doc.scrollingElement as HTMLElement | null)
68
+ const box = container ?? scrollParent(el, { axis }) ?? (doc.scrollingElement as HTMLElement | null)
65
69
  if (!box) return
66
70
  const outer = box === doc.scrollingElement ? new DOMRect(0, 0, win.innerWidth, win.innerHeight) : box.getBoundingClientRect()
67
71
  const inner = el.getBoundingClientRect()
68
- const top = inner.top - outer.top + box.scrollTop
69
- let target = box.scrollTop
70
- if (block === "start") target = top - margin
71
- else if (block === "center") target = top - (box.clientHeight - inner.height) / 2
72
- else if (inner.top < outer.top + margin) target = top - margin
73
- else if (inner.bottom > outer.bottom - margin) target = top - box.clientHeight + inner.height + margin
74
- if (target !== box.scrollTop) box.scrollTo({ top: target, behavior })
72
+ const x = axis === "x"
73
+ const from = x ? box.scrollLeft : box.scrollTop
74
+ const view = x ? box.clientWidth : box.clientHeight
75
+ const size = x ? inner.width : inner.height
76
+ const lo = x ? inner.left - outer.left : inner.top - outer.top
77
+ const hi = x ? inner.right - outer.right : inner.bottom - outer.bottom
78
+ const at = lo + from
79
+ let target = from
80
+ if (block === "start") target = at - margin
81
+ else if (block === "center") target = at - (view - size) / 2
82
+ else if (lo < margin) target = at - margin
83
+ else if (hi > -margin) target = at - view + size + margin
84
+ if (target === from) return
85
+ box.scrollTo(x ? { left: target, behavior } : { top: target, behavior })
75
86
  }
@@ -0,0 +1,156 @@
1
+ import { onScopeDispose, readonly, ref, type Ref } from "vue"
2
+
3
+ /*
4
+ * A new build is out and every open tab should run it, without anyone losing
5
+ * what they were doing.
6
+ *
7
+ * The app tells the kit two things: which build this page is (`current`) and
8
+ * how to ask which build is live (`latest`). The kit asks on its own when the
9
+ * tab comes back into view and on a timer, and the app calls `check()` when it
10
+ * has a better hint (a live stream reconnecting usually means the server was
11
+ * redeployed).
12
+ *
13
+ * Reloading is the easy part; the rule is WHEN. A reload is only safe when it
14
+ * cannot take anything with it, so the kit waits until one of these holds:
15
+ * - the tab is hidden (nobody is looking, the next look gets the new build);
16
+ * - the page has been left alone for `idleMs`, no dialog, menu, popover or
17
+ * listbox is open, focus is not in a text field, and `busy()` (the app's own
18
+ * "there is unsaved work" answer) is false.
19
+ * What survives the reload: the address (routes keep their state in it), the
20
+ * window scroll position, and anything the app already keeps in storage, such
21
+ * as form drafts. After the reload `restored` is true for the app to say
22
+ * "updated" if it wants to.
23
+ */
24
+
25
+ export interface AppUpdateOptions {
26
+ /** The build this page is running. */
27
+ current: string
28
+ /** The build that is live now, or null when it cannot be told. */
29
+ latest: () => Promise<string | null>
30
+ /** Unsaved work the kit cannot see, such as a dirty form outside a dialog. */
31
+ busy?: () => boolean
32
+ /** Ms without input before a visible tab may reload. */
33
+ idleMs?: number
34
+ /** Ms between background checks; 0 turns the timer off. */
35
+ everyMs?: number
36
+ /** sessionStorage key for the scroll position carried over the reload. */
37
+ storageKey?: string
38
+ }
39
+
40
+ export interface AppUpdate {
41
+ /** A newer build is live and the tab is waiting for a safe moment. */
42
+ pending: Readonly<Ref<boolean>>
43
+ /** This page load is the reload of an update. */
44
+ restored: Readonly<Ref<boolean>>
45
+ /** Ask the server now. */
46
+ check: () => Promise<void>
47
+ /** Reload at once, keeping the scroll position. */
48
+ reloadNow: () => void
49
+ }
50
+
51
+ const OVERLAY = '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-state="open"][data-side]'
52
+ const INPUT_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart", "input"] as const
53
+ const CARRY_MS = 60_000
54
+
55
+ interface Carried {
56
+ href: string
57
+ top: number
58
+ at: number
59
+ }
60
+
61
+ function editableFocused(): boolean {
62
+ const el = document.activeElement
63
+ if (!(el instanceof HTMLElement)) return false
64
+ if (el.isContentEditable) return true
65
+ if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) return true
66
+ return el instanceof HTMLInputElement && !["button", "checkbox", "radio", "range", "submit", "reset"].includes(el.type)
67
+ }
68
+
69
+ function overlayOpen(): boolean {
70
+ return [...document.querySelectorAll<HTMLElement>(OVERLAY)].some((el) => el.getClientRects().length > 0)
71
+ }
72
+
73
+ function readCarried(key: string): Carried | null {
74
+ try {
75
+ const raw = sessionStorage.getItem(key)
76
+ sessionStorage.removeItem(key)
77
+ if (!raw) return null
78
+ const value = JSON.parse(raw) as Partial<Carried>
79
+ if (typeof value.href !== "string" || typeof value.top !== "number" || typeof value.at !== "number") return null
80
+ return value as Carried
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ export function useAppUpdate(options: AppUpdateOptions): AppUpdate {
87
+ const key = options.storageKey ?? "lp:app-update"
88
+ const idleMs = options.idleMs ?? 30_000
89
+ const pending = ref(false)
90
+ const restored = ref(false)
91
+ let lastInput = Date.now()
92
+ let timer: ReturnType<typeof setInterval> | undefined
93
+
94
+ const carried = readCarried(key)
95
+ if (carried && carried.href === location.href && Date.now() - carried.at < CARRY_MS) {
96
+ restored.value = true
97
+ const top = carried.top
98
+ const restore = () => window.scrollTo({ top, behavior: "instant" })
99
+ requestAnimationFrame(() => requestAnimationFrame(restore))
100
+ window.addEventListener("load", restore, { once: true })
101
+ }
102
+
103
+ function reloadNow(): void {
104
+ try {
105
+ sessionStorage.setItem(key, JSON.stringify({ href: location.href, top: window.scrollY, at: Date.now() }))
106
+ } catch {
107
+ // Storage off: the reload still happens, only the scroll is lost.
108
+ }
109
+ location.reload()
110
+ }
111
+
112
+ function safe(): boolean {
113
+ if (document.hidden) return true
114
+ if (Date.now() - lastInput < idleMs) return false
115
+ return !overlayOpen() && !editableFocused() && !options.busy?.()
116
+ }
117
+
118
+ function attempt(): void {
119
+ if (pending.value && safe()) reloadNow()
120
+ }
121
+
122
+ async function check(): Promise<void> {
123
+ if (pending.value) return attempt()
124
+ let live: string | null = null
125
+ try {
126
+ live = await options.latest()
127
+ } catch {
128
+ return
129
+ }
130
+ if (!live || live === options.current) return
131
+ pending.value = true
132
+ attempt()
133
+ }
134
+
135
+ const onInput = () => {
136
+ lastInput = Date.now()
137
+ }
138
+ const onVisibility = () => {
139
+ if (document.hidden) attempt()
140
+ else void check()
141
+ }
142
+ for (const name of INPUT_EVENTS) window.addEventListener(name, onInput, { passive: true, capture: true })
143
+ document.addEventListener("visibilitychange", onVisibility)
144
+ const every = options.everyMs ?? 5 * 60_000
145
+ const tick = setInterval(attempt, 5_000)
146
+ if (every > 0) timer = setInterval(() => void check(), every)
147
+
148
+ onScopeDispose(() => {
149
+ for (const name of INPUT_EVENTS) window.removeEventListener(name, onInput, { capture: true })
150
+ document.removeEventListener("visibilitychange", onVisibility)
151
+ clearInterval(tick)
152
+ if (timer) clearInterval(timer)
153
+ })
154
+
155
+ return { pending: readonly(pending), restored: readonly(restored), check, reloadNow }
156
+ }
@@ -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 HOLD_MS first, so a
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 ?? HOLD_MS),
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) > SLOP_PX) cancelPending()
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