@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.
- 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-CtWA5Uab.js → LpMediaGallery-CTejKF2-.js} +1 -1
- 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.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/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
|
@@ -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
|
-
*
|
|
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
|
-
},
|
|
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) >
|
|
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 (
|
|
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.
|
|
@@ -659,7 +659,7 @@ const searchField = computed(() => ({
|
|
|
659
659
|
</div>
|
|
660
660
|
</nav>
|
|
661
661
|
|
|
662
|
-
<!-- Mobile: same nav inside a swipeable drawer (
|
|
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"
|
|
@@ -3,13 +3,13 @@ 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, ref, useId, watch, type FunctionalComponent } from "vue"
|
|
7
7
|
import { usePillTransition } from "../composables/usePillTransition"
|
|
8
8
|
import { useStepHistory } from "../composables/useOverlayHistory"
|
|
9
|
-
import { prefersReducedMotion } from "../composables/easing"
|
|
10
9
|
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
11
10
|
import LpSkeleton from "./LpSkeleton.vue"
|
|
12
11
|
import LpTabFace from "./LpTabFace.vue"
|
|
12
|
+
import LpScrollStrip from "./LpScrollStrip.vue"
|
|
13
13
|
import { BOTTOM_BAR, BOTTOM_ITEM, BOTTOM_MARKER, pickTab } from "./tabs"
|
|
14
14
|
|
|
15
15
|
export interface TabItem {
|
|
@@ -104,22 +104,31 @@ watch(
|
|
|
104
104
|
)
|
|
105
105
|
|
|
106
106
|
// A row wider than its container scrolls sideways (a phone, a narrow card)
|
|
107
|
-
// instead of wrapping each label onto two lines
|
|
108
|
-
//
|
|
107
|
+
// instead of wrapping each label onto two lines. LpScrollStrip lets a mouse
|
|
108
|
+
// reach the tabs off to the side (wheel, drag, edge buttons), and the active
|
|
109
|
+
// tab is brought into view whenever it changes, including from outside.
|
|
110
|
+
const strip = ref<InstanceType<typeof LpScrollStrip> | null>(null)
|
|
111
|
+
const Passthrough: FunctionalComponent = (_, { slots }) => slots.default?.()
|
|
109
112
|
const list = ref<{ $el: HTMLElement } | null>(null)
|
|
110
113
|
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
|
-
})
|
|
114
|
+
void nextTick(() => strip.value?.reveal(list.value?.$el?.querySelector('[data-state="active"]')))
|
|
121
115
|
})
|
|
122
116
|
|
|
117
|
+
/*
|
|
118
|
+
* reka selects a tab on mousedown, so a press meant to drag the row would
|
|
119
|
+
* pick whichever tab it started on. Mouse picks wait for the click instead,
|
|
120
|
+
* which a drag swallows; the keyboard still selects as focus moves.
|
|
121
|
+
*/
|
|
122
|
+
function holdMousePick(event: MouseEvent) {
|
|
123
|
+
if (event.button !== 0 || event.ctrlKey || bottom.value) return
|
|
124
|
+
if (!isHTMLElement(event.target) || !event.target.closest('[role="tab"]')) return
|
|
125
|
+
event.stopPropagation()
|
|
126
|
+
event.preventDefault()
|
|
127
|
+
}
|
|
128
|
+
function clickPick(item: TabItem) {
|
|
129
|
+
if (!item.disabled && !bottom.value) select(item.value)
|
|
130
|
+
}
|
|
131
|
+
|
|
123
132
|
function show(value: string) {
|
|
124
133
|
if (props.modelValue === undefined) current.value = value
|
|
125
134
|
emit("update:modelValue", value)
|
|
@@ -195,18 +204,26 @@ usePaletteCommands(() => props.items.map(item => ({
|
|
|
195
204
|
:model-value="current"
|
|
196
205
|
@update:model-value="(v) => select(v as string)"
|
|
197
206
|
>
|
|
207
|
+
<component
|
|
208
|
+
:is="bottom ? Passthrough : LpScrollStrip"
|
|
209
|
+
ref="strip"
|
|
210
|
+
v-bind="bottom ? {} : {
|
|
211
|
+
inline: !block,
|
|
212
|
+
class: variant === 'contained' ? 'rounded-control border border-line bg-surface-soft' : undefined,
|
|
213
|
+
contentClass: variant === 'contained' ? 'p-1' : undefined,
|
|
214
|
+
}"
|
|
215
|
+
>
|
|
198
216
|
<TabsList
|
|
199
217
|
ref="list"
|
|
200
218
|
:aria-label="label"
|
|
201
219
|
:class="bottom
|
|
202
220
|
? BOTTOM_BAR
|
|
203
221
|
: [
|
|
204
|
-
'max-w-full gap-1
|
|
205
|
-
block ? 'flex w-full' : 'inline-flex',
|
|
206
|
-
variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
|
|
222
|
+
'flex w-max min-w-full gap-1',
|
|
207
223
|
variant === 'underline' ? 'border-b border-line' : '',
|
|
208
224
|
]"
|
|
209
225
|
@pointerleave="hovered = null"
|
|
226
|
+
@mousedown.capture="holdMousePick"
|
|
210
227
|
>
|
|
211
228
|
<!-- Passthrough when the tab has no menu, so it keeps the native one. -->
|
|
212
229
|
<LpContextMenu
|
|
@@ -233,6 +250,7 @@ usePaletteCommands(() => props.items.map(item => ({
|
|
|
233
250
|
]"
|
|
234
251
|
@pointerenter="hoverFollow && !item.disabled && (hovered = item.value)"
|
|
235
252
|
@pointerup="releaseFocusAfterPointer"
|
|
253
|
+
@click="clickPick(item)"
|
|
236
254
|
>
|
|
237
255
|
<!-- Active marker: a bar under the tab in "underline", over it in
|
|
238
256
|
"bottom", a filled pill otherwise. All slide between tabs via the
|
|
@@ -268,6 +286,7 @@ usePaletteCommands(() => props.items.map(item => ({
|
|
|
268
286
|
</span>
|
|
269
287
|
</template>
|
|
270
288
|
</TabsList>
|
|
289
|
+
</component>
|
|
271
290
|
|
|
272
291
|
<!-- Animated panels: only rendered if a per-item #panel slot is provided.
|
|
273
292
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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">
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { clamp } from "./clamp"
|
|
2
|
+
import type { Direction } from "./spatialNav"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The arithmetic behind every gesture in the kit, kept apart from the DOM so
|
|
6
|
+
* it can be tested and shared: how far a finger may drift before a press
|
|
7
|
+
* becomes a drag, how fast it was moving when it let go, which way a swipe
|
|
8
|
+
* went, how much an edge gives when it is pulled past, and where a flick
|
|
9
|
+
* comes to rest. useGesture feeds pointer events into these; a component that
|
|
10
|
+
* has to read touch events itself (a pull from the screen edge) uses them
|
|
11
|
+
* directly, so both decide the same way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface GesturePoint {
|
|
15
|
+
x: number
|
|
16
|
+
y: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type GestureAxis = "x" | "y"
|
|
20
|
+
|
|
21
|
+
/** How far a touch may drift before a press stops being a tap or a hold. */
|
|
22
|
+
export const TOUCH_SLOP = 8
|
|
23
|
+
/** A mouse is steadier: a press that moves this far is a drag. */
|
|
24
|
+
export const MOUSE_SLOP = 4
|
|
25
|
+
/** Px/ms at release past which a short swipe still counts (a flick). */
|
|
26
|
+
export const FLICK_VELOCITY = 0.45
|
|
27
|
+
/** How long a finger holds still before it drags (selects, paints) rather than scrolls the page. */
|
|
28
|
+
export const HOLD_TO_DRAG_MS = 320
|
|
29
|
+
/** How long a press must stay for a long press, in ms. */
|
|
30
|
+
export const LONG_PRESS_MS = 500
|
|
31
|
+
/** Only movement this recent says how fast the pointer was going at release. */
|
|
32
|
+
const VELOCITY_WINDOW_MS = 100
|
|
33
|
+
/** A pointer that rested this long before letting go was not flung. */
|
|
34
|
+
const REST_MS = 90
|
|
35
|
+
|
|
36
|
+
/** The slop for a pointer type: a finger gets more room than a mouse. */
|
|
37
|
+
export function slopFor(pointerType: string): number {
|
|
38
|
+
return pointerType === "mouse" ? MOUSE_SLOP : TOUCH_SLOP
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface VelocityTracker {
|
|
42
|
+
/** Record where the pointer is at `time` (ms, an event's timeStamp). */
|
|
43
|
+
add: (point: GesturePoint, time: number) => void
|
|
44
|
+
/** Px/ms over the last moments, zero when the pointer rested before `now`. */
|
|
45
|
+
velocity: (now?: number) => GesturePoint
|
|
46
|
+
reset: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Velocity from the samples in the last VELOCITY_WINDOW_MS rather than from
|
|
51
|
+
* the last two events: a jittery final frame (two events a millisecond apart)
|
|
52
|
+
* would otherwise decide how far a flick travels.
|
|
53
|
+
*/
|
|
54
|
+
export function createVelocityTracker(): VelocityTracker {
|
|
55
|
+
let samples: { x: number; y: number; t: number }[] = []
|
|
56
|
+
return {
|
|
57
|
+
add(point, time) {
|
|
58
|
+
samples.push({ x: point.x, y: point.y, t: time })
|
|
59
|
+
const cut = time - VELOCITY_WINDOW_MS
|
|
60
|
+
while (samples.length > 2 && samples[0]!.t < cut) samples.shift()
|
|
61
|
+
},
|
|
62
|
+
velocity(now) {
|
|
63
|
+
if (samples.length < 2) return { x: 0, y: 0 }
|
|
64
|
+
const last = samples[samples.length - 1]!
|
|
65
|
+
if (now !== undefined && now - last.t > REST_MS) return { x: 0, y: 0 }
|
|
66
|
+
const first = samples[0]!
|
|
67
|
+
const dt = last.t - first.t
|
|
68
|
+
if (dt <= 0) return { x: 0, y: 0 }
|
|
69
|
+
return { x: (last.x - first.x) / dt, y: (last.y - first.y) / dt }
|
|
70
|
+
},
|
|
71
|
+
reset() {
|
|
72
|
+
samples = []
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which axis a movement has committed to, or null while it is still inside
|
|
79
|
+
* the slop or too diagonal to call. `ratio` is how much the winning axis must
|
|
80
|
+
* lead by: 1 takes any lead, higher waits for a clearer one.
|
|
81
|
+
*/
|
|
82
|
+
export function lockAxis(movement: GesturePoint, slop: number, ratio = 1): GestureAxis | null {
|
|
83
|
+
const ax = Math.abs(movement.x)
|
|
84
|
+
const ay = Math.abs(movement.y)
|
|
85
|
+
if (Math.hypot(ax, ay) < slop) return null
|
|
86
|
+
if (ax > ay * ratio) return "x"
|
|
87
|
+
if (ay > ax * ratio) return "y"
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SwipeOptions {
|
|
92
|
+
/** Distance in px that is a swipe at any speed. Default 60. */
|
|
93
|
+
distance?: number
|
|
94
|
+
/** Px/ms that turns a shorter move into a swipe. Default FLICK_VELOCITY. */
|
|
95
|
+
velocity?: number
|
|
96
|
+
/** A flick must still travel this far, so a twitch is not one. Default 16. */
|
|
97
|
+
minDistance?: number
|
|
98
|
+
/** Only these axes count. Default both. */
|
|
99
|
+
axis?: GestureAxis
|
|
100
|
+
/** How much the swipe's axis must lead the other. Default 1.5. */
|
|
101
|
+
ratio?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which way a released movement swiped, or null when it was not a swipe. */
|
|
105
|
+
export function swipeDirection(movement: GesturePoint, velocity: GesturePoint, options: SwipeOptions = {}): Direction | null {
|
|
106
|
+
const { distance = 60, velocity: flick = FLICK_VELOCITY, minDistance = 16, ratio = 1.5 } = options
|
|
107
|
+
const ax = Math.abs(movement.x)
|
|
108
|
+
const ay = Math.abs(movement.y)
|
|
109
|
+
const axis: GestureAxis | null = options.axis ?? (ax > ay * ratio ? "x" : ay > ax * ratio ? "y" : null)
|
|
110
|
+
if (!axis) return null
|
|
111
|
+
const along = axis === "x" ? movement.x : movement.y
|
|
112
|
+
const across = axis === "x" ? ay : ax
|
|
113
|
+
if (Math.abs(along) <= across * ratio) return null
|
|
114
|
+
const speed = axis === "x" ? velocity.x : velocity.y
|
|
115
|
+
const far = Math.abs(along) >= distance
|
|
116
|
+
const flung = Math.abs(along) >= minDistance && Math.abs(speed) >= flick && Math.sign(speed) === Math.sign(along)
|
|
117
|
+
if (!far && !flung) return null
|
|
118
|
+
if (axis === "x") return along < 0 ? "left" : "right"
|
|
119
|
+
return along < 0 ? "up" : "down"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How far an edge moves when pulled `overshoot` px past it: the further the
|
|
124
|
+
* pull, the less each pixel gives, approaching `size` and never passing it.
|
|
125
|
+
* The sign follows the overshoot. `c` is the stiffness, iOS uses 0.55.
|
|
126
|
+
*/
|
|
127
|
+
export function rubberBand(overshoot: number, size: number, c = 0.55): number {
|
|
128
|
+
if (!overshoot || size <= 0) return 0
|
|
129
|
+
const distance = Math.abs(overshoot)
|
|
130
|
+
return Math.sign(overshoot) * (1 - 1 / ((distance * c) / size + 1)) * size
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Clamp `value` into [min, max], letting it stretch past either end by rubberBand. */
|
|
134
|
+
export function rubberClamp(value: number, min: number, max: number, size: number, c?: number): number {
|
|
135
|
+
if (value < min) return min + rubberBand(value - min, size, c)
|
|
136
|
+
if (value > max) return max + rubberBand(value - max, size, c)
|
|
137
|
+
return value
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DismissOptions {
|
|
141
|
+
/** Offset toward the dismissing side, in px (positive is toward it). */
|
|
142
|
+
offset: number
|
|
143
|
+
/** Velocity toward the dismissing side, px/ms. */
|
|
144
|
+
velocity: number
|
|
145
|
+
/** The size of what is being swiped away along the swipe, in px. */
|
|
146
|
+
size: number
|
|
147
|
+
/** Fraction of `size` past which a slow release dismisses. Default 0.5. */
|
|
148
|
+
ratio?: number
|
|
149
|
+
/** Px/ms past which a release dismisses from any distance. Default FLICK_VELOCITY. */
|
|
150
|
+
flick?: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Swipe to dismiss: whether a release should carry it away or let it spring back. */
|
|
154
|
+
export function shouldDismiss({ offset, velocity, size, ratio = 0.5, flick = FLICK_VELOCITY }: DismissOptions): boolean {
|
|
155
|
+
if (offset <= 0) return false
|
|
156
|
+
if (velocity >= flick && offset > 4) return true
|
|
157
|
+
return offset >= size * ratio && velocity > -flick
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PageSwipeOptions {
|
|
161
|
+
index: number
|
|
162
|
+
count: number
|
|
163
|
+
/** Drag offset along the strip in px, positive toward the previous item. */
|
|
164
|
+
offset: number
|
|
165
|
+
/** Velocity in px/ms, same sign as `offset`. */
|
|
166
|
+
velocity: number
|
|
167
|
+
/** Width of one item in px. */
|
|
168
|
+
size: number
|
|
169
|
+
/** Fraction of `size` that pages at any speed. Default 0.25. */
|
|
170
|
+
ratio?: number
|
|
171
|
+
flick?: number
|
|
172
|
+
/** Wrap past the ends instead of stopping. */
|
|
173
|
+
loop?: boolean
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Swipe between items: the index a release lands on, one step at most. */
|
|
177
|
+
export function pageAfterSwipe({ index, count, offset, velocity, size, ratio = 0.25, flick = FLICK_VELOCITY, loop = false }: PageSwipeOptions): number {
|
|
178
|
+
if (count <= 0) return 0
|
|
179
|
+
const far = Math.abs(offset) >= size * ratio
|
|
180
|
+
const flung = Math.abs(velocity) >= flick && Math.sign(velocity) === Math.sign(offset) && Math.abs(offset) > 8
|
|
181
|
+
if (!far && !flung) return index
|
|
182
|
+
const next = index + (offset < 0 ? 1 : -1)
|
|
183
|
+
if (loop) return (next + count) % count
|
|
184
|
+
return clamp(next, 0, count - 1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Where a fling at `velocity` px/ms comes to rest with exponential friction of time constant `tau` ms. */
|
|
188
|
+
export function momentumDistance(velocity: number, tau = 240): number {
|
|
189
|
+
return velocity * tau
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface PinchFrame {
|
|
193
|
+
/** Distance between the two pointers now, over the distance at the start. */
|
|
194
|
+
scale: number
|
|
195
|
+
/** Midpoint of the two pointers. */
|
|
196
|
+
origin: GesturePoint
|
|
197
|
+
distance: number
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function distance(a: GesturePoint, b: GesturePoint): number {
|
|
201
|
+
return Math.hypot(a.x - b.x, a.y - b.y)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function midpoint(a: GesturePoint, b: GesturePoint): GesturePoint {
|
|
205
|
+
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The pinch between two pointers, measured against the distance it started at. */
|
|
209
|
+
export function pinchFrame(a: GesturePoint, b: GesturePoint, startDistance: number): PinchFrame {
|
|
210
|
+
const now = Math.max(1, distance(a, b))
|
|
211
|
+
return { scale: now / Math.max(1, startDistance), origin: midpoint(a, b), distance: now }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The wheel's delta in pixels, whatever unit the device reported it in. */
|
|
215
|
+
export function wheelPixels(delta: number, mode: number, page: number): number {
|
|
216
|
+
if (mode === 1) return delta * 16
|
|
217
|
+
if (mode === 2) return delta * page
|
|
218
|
+
return delta
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface DragSessionOptions {
|
|
222
|
+
/** Px of travel before a press becomes a drag. */
|
|
223
|
+
slop: number
|
|
224
|
+
/** Only a drag along this axis counts; one along the other is rejected. `lock` takes whichever comes first. */
|
|
225
|
+
axis?: GestureAxis | "lock"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface DragFrame {
|
|
229
|
+
/** `pending` inside the slop, `drag` once committed, `rejected` when it went the other way. */
|
|
230
|
+
phase: "pending" | "drag" | "rejected"
|
|
231
|
+
/** True on the frame the drag committed. */
|
|
232
|
+
first: boolean
|
|
233
|
+
start: GesturePoint
|
|
234
|
+
point: GesturePoint
|
|
235
|
+
movement: GesturePoint
|
|
236
|
+
delta: GesturePoint
|
|
237
|
+
velocity: GesturePoint
|
|
238
|
+
axis: GestureAxis | null
|
|
239
|
+
elapsed: number
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface DragSession {
|
|
243
|
+
readonly phase: DragFrame["phase"]
|
|
244
|
+
/** Feed the pointer's position; returns where the gesture stands. */
|
|
245
|
+
move: (point: GesturePoint, time: number) => DragFrame
|
|
246
|
+
/** The final frame at release, with the swipe it made (if it was a drag). */
|
|
247
|
+
end: (point: GesturePoint, time: number, swipe?: SwipeOptions) => DragFrame & { swipe: Direction | null }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* One press followed from down to up, whatever events carry it: useGesture
|
|
252
|
+
* feeds it pointer events, a pull from the screen edge feeds it touches. It
|
|
253
|
+
* decides when the press becomes a drag, along which axis, how fast it moves
|
|
254
|
+
* and whether the release was a swipe, so every gesture in the kit draws the
|
|
255
|
+
* line in the same place.
|
|
256
|
+
*/
|
|
257
|
+
export function createDragSession(start: GesturePoint, time: number, options: DragSessionOptions): DragSession {
|
|
258
|
+
const tracker = createVelocityTracker()
|
|
259
|
+
tracker.add(start, time)
|
|
260
|
+
let phase: DragFrame["phase"] = "pending"
|
|
261
|
+
let axis: GestureAxis | null = null
|
|
262
|
+
let last = start
|
|
263
|
+
|
|
264
|
+
function frame(point: GesturePoint, at: number, first: boolean): DragFrame {
|
|
265
|
+
const f: DragFrame = {
|
|
266
|
+
phase,
|
|
267
|
+
first,
|
|
268
|
+
start,
|
|
269
|
+
point,
|
|
270
|
+
movement: { x: point.x - start.x, y: point.y - start.y },
|
|
271
|
+
delta: { x: point.x - last.x, y: point.y - last.y },
|
|
272
|
+
velocity: tracker.velocity(at),
|
|
273
|
+
axis,
|
|
274
|
+
elapsed: at - time,
|
|
275
|
+
}
|
|
276
|
+
last = point
|
|
277
|
+
return f
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
get phase() {
|
|
282
|
+
return phase
|
|
283
|
+
},
|
|
284
|
+
move(point, at) {
|
|
285
|
+
tracker.add(point, at)
|
|
286
|
+
let first = false
|
|
287
|
+
if (phase === "pending") {
|
|
288
|
+
const movement = { x: point.x - start.x, y: point.y - start.y }
|
|
289
|
+
if (Math.hypot(movement.x, movement.y) >= options.slop) {
|
|
290
|
+
if (!options.axis) {
|
|
291
|
+
phase = "drag"
|
|
292
|
+
first = true
|
|
293
|
+
} else {
|
|
294
|
+
const taken = lockAxis(movement, options.slop)
|
|
295
|
+
if (taken && options.axis !== "lock" && taken !== options.axis) phase = "rejected"
|
|
296
|
+
else if (taken) {
|
|
297
|
+
axis = taken
|
|
298
|
+
phase = "drag"
|
|
299
|
+
first = true
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (phase !== "drag") return frame(point, at, false)
|
|
305
|
+
return frame(point, at, first)
|
|
306
|
+
},
|
|
307
|
+
end(point, at, swipe) {
|
|
308
|
+
tracker.add(point, at)
|
|
309
|
+
const f = frame(point, at, false)
|
|
310
|
+
const direction = phase === "drag" ? swipeDirection(f.movement, f.velocity, { axis: axis ?? undefined, ...swipe }) : null
|
|
311
|
+
return { ...f, swipe: direction }
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
}
|