@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.
- package/README.md +53 -3
- package/dist/assets/LpTabs-7yQjX2ih.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-DlpQierx.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-BFjiHWbr.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-BKhVDsMt.js} +3 -3
- package/dist/chunks/{LpSidebarNav-CFcI7--7.js → LpSidebarNav-DbCap8z1.js} +33 -19
- 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/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTabs.vue.d.ts +4 -4
- package/dist/components/LpTabs.vue.js +111 -92
- 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 +3 -3
- package/src/components/LpSidebarNav.vue +18 -8
- package/src/components/LpTabs.vue +47 -19
- 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.
|
|
@@ -185,7 +185,7 @@ const props = withDefaults(
|
|
|
185
185
|
}>(),
|
|
186
186
|
{
|
|
187
187
|
skeletonRows: 6,
|
|
188
|
-
skeletonHeader:
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
108
|
-
//
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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">
|