@leavepulse/ui 0.93.3 → 0.94.1

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 (79) hide show
  1. package/README.md +53 -3
  2. package/dist/assets/LpTabs-7yQjX2ih.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-DlpQierx.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-BFjiHWbr.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-BKhVDsMt.js} +3 -3
  15. package/dist/chunks/{LpSidebarNav-CFcI7--7.js → LpSidebarNav-DbCap8z1.js} +33 -19
  16. package/dist/component-names.d.ts +1 -1
  17. package/dist/components/AppShellContextNav.vue.js +1 -1
  18. package/dist/components/LpAppShell.vue.js +1 -1
  19. package/dist/components/LpChart.vue.js +3 -3
  20. package/dist/components/LpDateRangePicker.vue.js +1 -1
  21. package/dist/components/LpDrawer.vue.d.ts +10 -2
  22. package/dist/components/LpDrawer.vue.js +1 -1
  23. package/dist/components/LpDropzone.vue.d.ts +1 -1
  24. package/dist/components/LpLightbox.vue.js +1 -1
  25. package/dist/components/LpMapPicker.vue.js +1 -1
  26. package/dist/components/LpMediaGallery.vue.js +1 -1
  27. package/dist/components/LpModalNav.vue.js +1 -1
  28. package/dist/components/LpRangeCalendar.vue.js +1 -1
  29. package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
  30. package/dist/components/LpScrollStrip.vue.d.ts +48 -0
  31. package/dist/components/LpScrollStrip.vue.js +4 -0
  32. package/dist/components/LpSidebar.vue.js +1 -1
  33. package/dist/components/LpSidebarNav.vue.js +1 -1
  34. package/dist/components/LpTabs.vue.d.ts +4 -4
  35. package/dist/components/LpTabs.vue.js +111 -92
  36. package/dist/components/LpVideoTrim.vue.js +41 -54
  37. package/dist/composables/gesture.d.ts +141 -0
  38. package/dist/composables/gesture.js +176 -0
  39. package/dist/composables/scroll.d.ts +5 -1
  40. package/dist/composables/scroll.js +16 -9
  41. package/dist/composables/useAppUpdate.d.ts +26 -0
  42. package/dist/composables/useAppUpdate.js +91 -0
  43. package/dist/composables/useGesture.d.ts +107 -0
  44. package/dist/composables/useGesture.js +281 -0
  45. package/dist/composables/usePaintSelect.js +3 -4
  46. package/dist/composables/useScrollStrip.d.ts +27 -0
  47. package/dist/composables/useScrollStrip.js +194 -0
  48. package/dist/composables/useTouchHold.d.ts +3 -1
  49. package/dist/composables/useTouchHold.js +9 -51
  50. package/dist/composables/useZoomPan.js +4 -6
  51. package/dist/index.d.ts +9 -0
  52. package/dist/index.js +180 -152
  53. package/package.json +1 -1
  54. package/src/component-names.ts +1 -0
  55. package/src/components/AppShellContextNav.vue +3 -2
  56. package/src/components/LpAppShell.vue +10 -1
  57. package/src/components/LpChart.vue +2 -2
  58. package/src/components/LpDrawer.vue +121 -54
  59. package/src/components/LpLightbox.vue +33 -20
  60. package/src/components/LpMapPicker.vue +22 -64
  61. package/src/components/LpRangeCalendar.vue +4 -5
  62. package/src/components/LpScrollStrip.vue +96 -0
  63. package/src/components/LpSidebar.vue +3 -3
  64. package/src/components/LpSidebarNav.vue +18 -8
  65. package/src/components/LpTabs.vue +47 -19
  66. package/src/components/LpVideoTrim.vue +35 -46
  67. package/src/composables/gesture.ts +314 -0
  68. package/src/composables/scroll.ts +20 -9
  69. package/src/composables/useAppUpdate.ts +156 -0
  70. package/src/composables/useGesture.ts +451 -0
  71. package/src/composables/usePaintSelect.ts +4 -5
  72. package/src/composables/useScrollStrip.ts +249 -0
  73. package/src/composables/useTouchHold.ts +15 -73
  74. package/src/composables/useZoomPan.ts +6 -9
  75. package/src/index.ts +27 -0
  76. package/src/tokens/effects.css +40 -0
  77. package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
  78. package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
  79. package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
@@ -24,6 +24,7 @@ export type RangeChange = { via: "pick" | "drag" | "keyboard" }
24
24
  * "keyboard"), so a popover around the calendar can stay open while the user
25
25
  * fine-tunes instead of closing on the first nudge.
26
26
  */
27
+ import { HOLD_TO_DRAG_MS, TOUCH_SLOP } from "../composables/gesture"
27
28
  import { type DateValue, getLocalTimeZone, today } from "@internationalized/date"
28
29
  import {
29
30
  type DateRange as RekaRange,
@@ -119,12 +120,10 @@ const root = ref<HTMLElement | null>(null)
119
120
  * Press-and-drag, the way a phone selects text. A press on either end of the
120
121
  * range drags that end; a press anywhere else starts a new range that follows
121
122
  * the pointer. A mouse or pen drags at once. A finger has to hold still for
122
- * HOLD_MS first, so an ordinary swipe still scrolls the page; after that the
123
+ * HOLD_TO_DRAG_MS first, so an ordinary swipe still scrolls the page; after that the
123
124
  * page stops scrolling and the finger selects. A press that never moves stays
124
125
  * an ordinary click for reka.
125
126
  */
126
- const HOLD_MS = 320
127
- const SLOP_PX = 8
128
127
 
129
128
  /** The day the range pivots around while the pointer moves. */
130
129
  let anchor: string | null = null
@@ -183,14 +182,14 @@ function onPointerDown(e: PointerEvent) {
183
182
  dragging.value = true
184
183
  draft.value = toReka({ start: day, end: day })
185
184
  navigator.vibrate?.(8)
186
- }, HOLD_MS)
185
+ }, HOLD_TO_DRAG_MS)
187
186
  }
188
187
 
189
188
  function onPointerMove(e: PointerEvent) {
190
189
  if (anchor === null || e.pointerId !== pointerId) return
191
190
  if (!armed) {
192
191
  // Moved before the hold landed: that is a scroll, not a selection.
193
- if (Math.hypot(e.clientX - startX, e.clientY - startY) > SLOP_PX) reset()
192
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) > TOUCH_SLOP) reset()
194
193
  return
195
194
  }
196
195
  const day = cellAt(e.clientX, e.clientY)?.dataset.value
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A row that scrolls sideways and can be reached with any input: tabs, chips,
4
+ * branch pills, a filmstrip. The scrollbar is hidden, so useScrollStrip gives
5
+ * the mouse its way in (the wheel and drag-to-scroll), the edges with items
6
+ * beyond them fade, and on a device with a fine pointer a hover shows a
7
+ * previous and a next button on whichever side still has something to show.
8
+ * Touch keeps its native scrolling and any scroll snap the row sets.
9
+ *
10
+ * The default slot is the row's content; `contentClass` styles the scrolling
11
+ * row itself (gap, padding, snap), the class on the component its frame. The
12
+ * buttons are a pointer's shortcut and stay out of the tab order: the
13
+ * keyboard reaches the items, and the focused one is scrolled into view.
14
+ * Class and style go on the frame; any other attribute (an aria-label, a
15
+ * role, listeners) lands on the row, which is the element the items sit in.
16
+ */
17
+ import { computed, ref, useAttrs } from "vue"
18
+ import { useMediaQuery } from "../composables/useMediaQuery"
19
+ import { useScrollStrip } from "../composables/useScrollStrip"
20
+ import LpIcon from "./LpIcon.vue"
21
+
22
+ defineOptions({ inheritAttrs: false })
23
+ const attrs = useAttrs()
24
+ const rowAttrs = computed(() => {
25
+ const { class: _class, style: _style, ...rest } = attrs
26
+ return rest
27
+ })
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Classes for the scrolling row (layout, gap, padding, snap). */
32
+ contentClass?: unknown
33
+ /** Fade the edges that have items beyond them. */
34
+ fade?: boolean
35
+ /** Previous/next buttons: `auto` shows them on hover where the pointer is fine (a mouse), true on any device, false never. */
36
+ buttons?: boolean | "auto"
37
+ /** Map a vertical wheel to sideways scrolling while the row can move. */
38
+ wheel?: boolean
39
+ /** Drag to scroll with a mouse or pen. */
40
+ drag?: boolean
41
+ /** The row element's tag. */
42
+ as?: string
43
+ /** Size to the content up to the parent's width (inline-flex), rather than fill the line. */
44
+ inline?: boolean
45
+ }>(),
46
+ { fade: true, buttons: "auto", wheel: true, drag: true, as: "div", inline: false },
47
+ )
48
+
49
+ const scroller = ref<HTMLElement | null>(null)
50
+ const strip = useScrollStrip(scroller, { wheel: () => props.wheel, drag: () => props.drag })
51
+ const fine = useMediaQuery("(hover: hover) and (pointer: fine)")
52
+ const withButtons = computed(() => props.buttons === true || (props.buttons === "auto" && fine.value))
53
+
54
+ defineExpose({ scroller, page: strip.page, scrollBy: strip.scrollBy, reveal: strip.reveal, measure: strip.measure, overflowing: strip.overflowing, atStart: strip.atStart, atEnd: strip.atEnd })
55
+
56
+ const BUTTON =
57
+ "absolute top-1/2 z-10 grid size-7 -translate-y-1/2 place-items-center rounded-full border border-line bg-surface-raised text-muted shadow-sm outline-none transition-[opacity,color] duration-(--duration-fast) ease-(--ease-emphasized) hover:text-ink motion-reduce:transition-none"
58
+ </script>
59
+
60
+ <template>
61
+ <div class="group/strip relative min-w-0" :class="[inline ? 'inline-flex max-w-full' : 'flex', attrs.class]" :style="attrs.style as never" data-lp-strip>
62
+ <component
63
+ :is="as"
64
+ v-bind="rowAttrs"
65
+ ref="scroller"
66
+ class="lp-scrollbar-none flex min-w-0 flex-1 overflow-x-auto"
67
+ :class="[contentClass, fade ? 'lp-strip-fade' : '', strip.dragging.value ? 'cursor-grabbing select-none' : '']"
68
+ :data-lp-more-start="strip.overflowing.value && !strip.atStart.value ? '' : undefined"
69
+ :data-lp-more-end="strip.overflowing.value && !strip.atEnd.value ? '' : undefined"
70
+ data-lp-strip-row
71
+ >
72
+ <slot />
73
+ </component>
74
+ <template v-if="withButtons">
75
+ <button
76
+ v-for="side in ([-1, 1] as const)"
77
+ :key="side"
78
+ type="button"
79
+ tabindex="-1"
80
+ aria-hidden="true"
81
+ :class="[
82
+ BUTTON,
83
+ side < 0 ? 'left-0.5' : 'right-0.5',
84
+ strip.overflowing.value && !(side < 0 ? strip.atStart.value : strip.atEnd.value)
85
+ ? (buttons === true ? 'opacity-100' : 'opacity-0 group-hover/strip:opacity-100')
86
+ : 'pointer-events-none opacity-0',
87
+ ]"
88
+ :data-lp-strip-button="side < 0 ? 'prev' : 'next'"
89
+ @pointerdown.prevent
90
+ @click="strip.page(side)"
91
+ >
92
+ <LpIcon :name="side < 0 ? 'lucide:chevron-left' : 'lucide:chevron-right'" :size="16" />
93
+ </button>
94
+ </template>
95
+ </div>
96
+ </template>
@@ -8,7 +8,7 @@
8
8
  * v-model. Each item can route via href (rendered as <a>) or just emit select.
9
9
  *
10
10
  * Phones: set `responsive` and the rail hides below the `mobileBreakpoint` and
11
- * is replaced by a swipeable LpDrawer (vaul-vue gives drag-to-close for free).
11
+ * is replaced by a swipeable LpDrawer (pulled in from the edge, swiped shut).
12
12
  * The host owns the burger button and binds v-model:open; selecting an item
13
13
  * auto-closes the drawer. With `responsive` off the component renders exactly
14
14
  * the static <nav> it always has — existing call sites are unaffected.
@@ -185,7 +185,7 @@ const props = withDefaults(
185
185
  }>(),
186
186
  {
187
187
  skeletonRows: 6,
188
- skeletonHeader: true,
188
+ skeletonHeader: false,
189
189
  responsive: false,
190
190
  mobileBreakpoint: "md",
191
191
  divider: true,
@@ -659,7 +659,7 @@ const searchField = computed(() => ({
659
659
  </div>
660
660
  </nav>
661
661
 
662
- <!-- Mobile: same nav inside a swipeable drawer (drag-to-close via vaul). -->
662
+ <!-- Mobile: same nav inside a swipeable drawer (LpDrawer's edge pull and swipe to close). -->
663
663
  <LpDrawer
664
664
  v-if="responsive"
665
665
  :open="open"
@@ -56,7 +56,7 @@ const props = withDefaults(
56
56
  previewDelay?: number
57
57
  labels: { pinned: string; pin: string; unpin: string; expand: string }
58
58
  }>(),
59
- { skeletonRows: 6, skeletonHeader: true, collapsedKeys: () => [], hoverFollow: false, pinned: () => [], previewDelay: 900 },
59
+ { skeletonRows: 6, skeletonHeader: false, collapsedKeys: () => [], hoverFollow: false, pinned: () => [], previewDelay: 900 },
60
60
  )
61
61
 
62
62
  const emit = defineEmits<{
@@ -212,6 +212,8 @@ function activate(item: SidebarItem) {
212
212
  }
213
213
 
214
214
  // The nav fills the remaining height with a fixed gap between groups; a short sidebar needs both under the consumer's control.
215
+ const SKELETON_WIDTHS = ["55%", "70%", "45%", "62%", "50%", "68%"]
216
+
215
217
  const { class: rootClass, attrs: rest } = useMergedAttrs(
216
218
  "flex min-h-0 flex-1 flex-col gap-4",
217
219
  )
@@ -222,7 +224,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
222
224
  LpSkeleton wrapper so it pulses in phase with every other placeholder
223
225
  on the page and stops under reduced motion. -->
224
226
  <LpSkeleton v-if="loading"
225
- :class="rootClass"
227
+ :class="[rootClass, 'overflow-hidden']"
226
228
  v-bind="rest">
227
229
  <div v-if="skeletonHeader" class="flex items-center gap-3 px-1">
228
230
  <div class="lp-skeleton-item size-10 shrink-0 rounded-pill bg-surface-soft" />
@@ -231,12 +233,20 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
231
233
  <div class="lp-skeleton-item h-3 w-16 rounded-small bg-surface-soft/60" />
232
234
  </div>
233
235
  </div>
234
- <div class="space-y-1">
235
- <div
236
- v-for="n in skeletonRows"
237
- :key="n"
238
- class="lp-skeleton-item h-9 rounded-control bg-surface-soft"
239
- />
236
+ <!-- Rows in the shape of the items they stand in for: an icon and a label
237
+ of uneven width at the item's own height and padding, with a section
238
+ label after the first three. A run of full-width bars read as a list
239
+ of buttons, not as navigation. -->
240
+ <div class="flex flex-col gap-1">
241
+ <template v-for="n in skeletonRows" :key="n">
242
+ <div v-if="n === 4" class="px-3 pb-1 pt-3">
243
+ <div class="lp-skeleton-item h-2.5 w-20 rounded-small bg-surface-soft/60" />
244
+ </div>
245
+ <div class="flex h-9 items-center gap-3 px-3">
246
+ <div class="lp-skeleton-item size-4 shrink-0 rounded-small bg-surface-soft" />
247
+ <div class="lp-skeleton-item h-3 rounded-small bg-surface-soft" :style="{ width: SKELETON_WIDTHS[(n - 1) % SKELETON_WIDTHS.length] }" />
248
+ </div>
249
+ </template>
240
250
  </div>
241
251
  </LpSkeleton>
242
252
 
@@ -3,13 +3,14 @@ import { isHTMLElement } from "../composables/useView"
3
3
  import { usePaletteCommands, usePaletteScope } from "../composables/usePaletteCommands"
4
4
  import { Motion } from "motion-v"
5
5
  import { TabsContent, TabsList, TabsRoot, TabsTrigger } from "reka-ui"
6
- import { computed, nextTick, ref, useId, watch } from "vue"
6
+ import { computed, nextTick, onMounted, ref, useId, watch, type FunctionalComponent } from "vue"
7
+ import { useResizeObserver } from "../composables/useResizeObserver"
7
8
  import { usePillTransition } from "../composables/usePillTransition"
8
9
  import { useStepHistory } from "../composables/useOverlayHistory"
9
- import { prefersReducedMotion } from "../composables/easing"
10
10
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
11
11
  import LpSkeleton from "./LpSkeleton.vue"
12
12
  import LpTabFace from "./LpTabFace.vue"
13
+ import LpScrollStrip from "./LpScrollStrip.vue"
13
14
  import { BOTTOM_BAR, BOTTOM_ITEM, BOTTOM_MARKER, pickTab } from "./tabs"
14
15
 
15
16
  export interface TabItem {
@@ -104,21 +105,38 @@ watch(
104
105
  )
105
106
 
106
107
  // A row wider than its container scrolls sideways (a phone, a narrow card)
107
- // instead of wrapping each label onto two lines; the active tab is brought
108
- // into view whenever it changes, including from outside.
108
+ // instead of wrapping each label onto two lines. LpScrollStrip lets a mouse
109
+ // reach the tabs off to the side (wheel, drag, edge buttons), and the active
110
+ // tab is brought into view whenever it changes, including from outside, and
111
+ // also on mount and when the row resizes: a tab picked by the address before
112
+ // the first paint, or left beyond the edge by a narrower window, would
113
+ // otherwise sit off-screen with nothing saying which section is open. Those
114
+ // two jump; only a change the person can follow eases.
115
+ const strip = ref<InstanceType<typeof LpScrollStrip> | null>(null)
116
+ const Passthrough: FunctionalComponent = (_, { slots }) => slots.default?.()
109
117
  const list = ref<{ $el: HTMLElement } | null>(null)
110
- watch(current, () => {
111
- void nextTick(() => {
112
- const row = list.value?.$el
113
- const active = row?.querySelector<HTMLElement>('[data-state="active"]')
114
- if (!row || !active || row.scrollWidth <= row.clientWidth) return
115
- const start = active.offsetLeft - row.offsetLeft
116
- const end = start + active.offsetWidth
117
- if (start >= row.scrollLeft && end <= row.scrollLeft + row.clientWidth) return
118
- const left = start < row.scrollLeft ? start : end - row.clientWidth
119
- row.scrollTo({ left, behavior: prefersReducedMotion() ? "auto" : "smooth" })
120
- })
121
- })
118
+ function revealActive(smooth: boolean) {
119
+ if (bottom.value) return
120
+ strip.value?.reveal(list.value?.$el?.querySelector('[data-state="active"]'), { smooth })
121
+ }
122
+ watch(current, () => void nextTick(() => revealActive(true)))
123
+ onMounted(() => requestAnimationFrame(() => revealActive(false)))
124
+ useResizeObserver(() => strip.value?.scroller, () => revealActive(false))
125
+
126
+ /*
127
+ * reka selects a tab on mousedown, so a press meant to drag the row would
128
+ * pick whichever tab it started on. Mouse picks wait for the click instead,
129
+ * which a drag swallows; the keyboard still selects as focus moves.
130
+ */
131
+ function holdMousePick(event: MouseEvent) {
132
+ if (event.button !== 0 || event.ctrlKey || bottom.value) return
133
+ if (!isHTMLElement(event.target) || !event.target.closest('[role="tab"]')) return
134
+ event.stopPropagation()
135
+ event.preventDefault()
136
+ }
137
+ function clickPick(item: TabItem) {
138
+ if (!item.disabled && !bottom.value) select(item.value)
139
+ }
122
140
 
123
141
  function show(value: string) {
124
142
  if (props.modelValue === undefined) current.value = value
@@ -195,18 +213,26 @@ usePaletteCommands(() => props.items.map(item => ({
195
213
  :model-value="current"
196
214
  @update:model-value="(v) => select(v as string)"
197
215
  >
216
+ <component
217
+ :is="bottom ? Passthrough : LpScrollStrip"
218
+ ref="strip"
219
+ v-bind="bottom ? {} : {
220
+ inline: !block,
221
+ class: variant === 'contained' ? 'rounded-control border border-line bg-surface-soft' : undefined,
222
+ contentClass: variant === 'contained' ? 'p-1' : undefined,
223
+ }"
224
+ >
198
225
  <TabsList
199
226
  ref="list"
200
227
  :aria-label="label"
201
228
  :class="bottom
202
229
  ? BOTTOM_BAR
203
230
  : [
204
- 'max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
205
- block ? 'flex w-full' : 'inline-flex',
206
- variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
231
+ 'flex w-max min-w-full gap-1',
207
232
  variant === 'underline' ? 'border-b border-line' : '',
208
233
  ]"
209
234
  @pointerleave="hovered = null"
235
+ @mousedown.capture="holdMousePick"
210
236
  >
211
237
  <!-- Passthrough when the tab has no menu, so it keeps the native one. -->
212
238
  <LpContextMenu
@@ -233,6 +259,7 @@ usePaletteCommands(() => props.items.map(item => ({
233
259
  ]"
234
260
  @pointerenter="hoverFollow && !item.disabled && (hovered = item.value)"
235
261
  @pointerup="releaseFocusAfterPointer"
262
+ @click="clickPick(item)"
236
263
  >
237
264
  <!-- Active marker: a bar under the tab in "underline", over it in
238
265
  "bottom", a filled pill otherwise. All slide between tabs via the
@@ -268,6 +295,7 @@ usePaletteCommands(() => props.items.map(item => ({
268
295
  </span>
269
296
  </template>
270
297
  </TabsList>
298
+ </component>
271
299
 
272
300
  <!-- Animated panels: only rendered if a per-item #panel slot is provided.
273
301
  The Motion wrapper layout-animates its height so switching to a taller
@@ -29,6 +29,7 @@ export default { inheritAttrs: false }
29
29
  */
30
30
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
31
31
  import { startPointerDrag } from "../composables/pointerDrag"
32
+ import { createGesture } from "../composables/useGesture"
32
33
  import { isHTMLElement, nodeWindow } from "../composables/realm"
33
34
  import { useLabels } from "../composables/useLpConfig"
34
35
  import { useMergedAttrs } from "../composables/useMergedClass"
@@ -367,8 +368,6 @@ const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
367
368
 
368
369
  /* ---------- timeline pointers ---------- */
369
370
 
370
- const pointers = new Map<number, number>()
371
- let pinch: { distance: number; anchor: number } | null = null
372
371
  const pinching = ref(false)
373
372
  let edgeFrame = 0
374
373
  let lastX = 0
@@ -420,48 +419,41 @@ function edgeScroll() {
420
419
  })
421
420
  }
422
421
 
423
- function onTrackDown(event: PointerEvent) {
424
- if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return
425
- ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
426
- pointers.set(event.pointerId, event.clientX)
427
- if (pointers.size === 2) {
422
+ /* The track on the kit's gesture engine: a press scrubs at once, a drag keeps scrubbing, two fingers zoom about where they started. */
423
+ let pinchDistance = 0
424
+ let pinchAnchor = 0
425
+ const track = createGesture({
426
+ threshold: 0,
427
+ onPress(event) {
428
+ if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return false
429
+ event.preventDefault()
430
+ trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
431
+ pause()
432
+ scrubbing.value = true
433
+ lastX = event.clientX
434
+ seek(timeAt(event.clientX))
435
+ },
436
+ onDrag({ point }) {
437
+ if (!scrubbing.value) return
438
+ lastX = point.x
439
+ seek(timeAt(point.x))
440
+ edgeScroll()
441
+ },
442
+ onDragEnd: () => (scrubbing.value = false),
443
+ onTap: () => (scrubbing.value = false),
444
+ onPinchStart(pinch) {
428
445
  scrubbing.value = false
429
- const [a, b] = [...pointers.values()] as [number, number]
430
- pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
431
446
  pinching.value = true
432
- return
433
- }
434
- if (pointers.size > 2) return
435
- event.preventDefault()
436
- trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
437
- pause()
438
- scrubbing.value = true
439
- lastX = event.clientX
440
- seek(timeAt(event.clientX))
441
- }
442
- function onTrackMove(event: PointerEvent) {
443
- if (!pointers.has(event.pointerId)) return
444
- pointers.set(event.pointerId, event.clientX)
445
- if (pinch && pointers.size === 2) {
446
- const [a, b] = [...pointers.values()] as [number, number]
447
- const distance = Math.max(8, Math.abs(a - b))
448
- zoomBy(distance / pinch.distance, pinch.anchor)
449
- pinch.distance = distance
450
- return
451
- }
452
- if (!scrubbing.value) return
453
- lastX = event.clientX
454
- seek(timeAt(event.clientX))
455
- edgeScroll()
456
- }
457
- function onTrackUp(event: PointerEvent) {
458
- pointers.delete(event.pointerId)
459
- if (pointers.size < 2) {
460
- pinch = null
461
- pinching.value = false
462
- }
463
- if (!pointers.size) scrubbing.value = false
464
- }
447
+ pinchDistance = pinch.distance
448
+ pinchAnchor = timeAt(pinch.origin.x)
449
+ },
450
+ onPinch(pinch) {
451
+ zoomBy(pinch.distance / Math.max(8, pinchDistance), pinchAnchor)
452
+ pinchDistance = pinch.distance
453
+ },
454
+ onPinchEnd: () => (pinching.value = false),
455
+ })
456
+ onBeforeUnmount(track.dispose)
465
457
 
466
458
  /* The overview: press or drag to centre the view there. */
467
459
  function onOverviewDown(event: PointerEvent) {
@@ -948,10 +940,7 @@ const widestLeft = computed(() => leftOf(widestClipTime(limits.value.maxClip), w
948
940
  :class="off ? 'opacity-60' : 'cursor-text'"
949
941
  :aria-label="text.timeline"
950
942
  role="group"
951
- @pointerdown="onTrackDown"
952
- @pointermove="onTrackMove"
953
- @pointerup="onTrackUp"
954
- @pointercancel="onTrackUp"
943
+ v-bind="track.handlers"
955
944
  >
956
945
  <div class="absolute inset-0 overflow-hidden rounded-control">
957
946
  <TransitionGroup v-bind="FADE">