@leavepulse/ui 0.93.2 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -3
- package/dist/assets/LpTabs-D7EyG21l.css +18 -0
- package/dist/assets/LpVideoTrim-D8JDg9Qn.css +8 -0
- package/dist/chunks/AppShellContextNav-BoH4ffnC.js +54 -0
- package/dist/chunks/{LpAppShell-BWaVRsDB.js → LpAppShell-xEBmGV78.js} +12 -5
- package/dist/chunks/{LpDateRangePicker-CAn2yGbn.js → LpDateRangePicker-DRBB8z6t.js} +1 -1
- package/dist/chunks/{LpDrawer-iRHwJOGn.js → LpDrawer-BkWjFdV0.js} +81 -42
- package/dist/chunks/{LpLightbox-a24V_0Wk.js → LpLightbox-DPgz7IsT.js} +62 -49
- package/dist/chunks/{LpMapPicker-DVfiy3rR.js → LpMapPicker-V0Lybi10.js} +19 -52
- package/dist/chunks/{LpMediaGallery-B7DOV_aL.js → LpMediaGallery-CTejKF2-.js} +36 -15
- package/dist/chunks/{LpModalNav-DivqT1NV.js → LpModalNav-DVXAF-DG.js} +1 -1
- package/dist/chunks/{LpRangeCalendar-C9gkNMYw.js → LpRangeCalendar-9BvaLZji.js} +3 -4
- package/dist/chunks/LpScrollStrip-D4tsOfAQ.js +78 -0
- package/dist/chunks/{LpSidebar-DQtCYlT4.js → LpSidebar-BIhusppV.js} +1 -1
- package/dist/component-names.d.ts +1 -1
- package/dist/components/AppShellContextNav.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpChart.vue.js +3 -3
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +10 -2
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +2 -0
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRevealOnScroll.vue.d.ts +1 -1
- package/dist/components/LpScrollStrip.vue.d.ts +48 -0
- package/dist/components/LpScrollStrip.vue.js +4 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpTabs.vue.d.ts +4 -4
- package/dist/components/LpTabs.vue.js +104 -90
- package/dist/components/LpVideoTrim.vue.js +41 -54
- package/dist/composables/gesture.d.ts +141 -0
- package/dist/composables/gesture.js +176 -0
- package/dist/composables/scroll.d.ts +5 -1
- package/dist/composables/scroll.js +16 -9
- package/dist/composables/useAppUpdate.d.ts +26 -0
- package/dist/composables/useAppUpdate.js +91 -0
- package/dist/composables/useGesture.d.ts +107 -0
- package/dist/composables/useGesture.js +281 -0
- package/dist/composables/usePaintSelect.js +3 -4
- package/dist/composables/useScrollStrip.d.ts +27 -0
- package/dist/composables/useScrollStrip.js +194 -0
- package/dist/composables/useTouchHold.d.ts +3 -1
- package/dist/composables/useTouchHold.js +9 -51
- package/dist/composables/useZoomPan.js +4 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +180 -152
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/AppShellContextNav.vue +3 -2
- package/src/components/LpAppShell.vue +10 -1
- package/src/components/LpChart.vue +2 -2
- package/src/components/LpDrawer.vue +121 -54
- package/src/components/LpLightbox.vue +33 -20
- package/src/components/LpMapPicker.vue +22 -64
- package/src/components/LpMediaGallery.vue +44 -11
- package/src/components/LpRangeCalendar.vue +4 -5
- package/src/components/LpScrollStrip.vue +96 -0
- package/src/components/LpSidebar.vue +2 -2
- package/src/components/LpTabs.vue +36 -17
- package/src/components/LpVideoTrim.vue +35 -46
- package/src/composables/gesture.ts +314 -0
- package/src/composables/scroll.ts +20 -9
- package/src/composables/useAppUpdate.ts +156 -0
- package/src/composables/useGesture.ts +451 -0
- package/src/composables/usePaintSelect.ts +4 -5
- package/src/composables/useScrollStrip.ts +249 -0
- package/src/composables/useTouchHold.ts +15 -73
- package/src/composables/useZoomPan.ts +6 -9
- package/src/index.ts +27 -0
- package/src/tokens/effects.css +40 -0
- package/dist/assets/LpTabs-Bh7a-Bdl.css +0 -18
- package/dist/assets/LpVideoTrim-CMoUw3dJ.css +0 -8
- package/dist/chunks/AppShellContextNav-C6Ho2mof.js +0 -49
|
@@ -58,6 +58,7 @@ import { easingToken, durationToken, prefersReducedMotion } from "../composables
|
|
|
58
58
|
import { useLabels } from "../composables/useLpConfig"
|
|
59
59
|
import { useResizeObserver } from "../composables/useResizeObserver"
|
|
60
60
|
import { useScrollMemory } from "../composables/scrollMemory"
|
|
61
|
+
import { useScrollStrip } from "../composables/useScrollStrip"
|
|
61
62
|
import { isHTMLElement, nodeWindow, useView, viewWindow } from "../composables/useView"
|
|
62
63
|
import AppShellContextNav from "./AppShellContextNav.vue"
|
|
63
64
|
import {
|
|
@@ -87,6 +88,11 @@ export type { SidebarItem, SidebarSection } from "./sidebar"
|
|
|
87
88
|
/* Past this much scrolled the way back up is worth a button. */
|
|
88
89
|
const BACK_TO_TOP_AFTER_PX = 600
|
|
89
90
|
|
|
91
|
+
// The header actions scroll sideways on a narrow bar; a mouse reaches the ones
|
|
92
|
+
// past the edge with the wheel or a drag, and the clipped edge fades.
|
|
93
|
+
const headerActions = ref<{ $el: HTMLElement } | null>(null)
|
|
94
|
+
const actionStrip = useScrollStrip(() => headerActions.value?.$el)
|
|
95
|
+
|
|
90
96
|
const props = withDefaults(
|
|
91
97
|
defineProps<{
|
|
92
98
|
/** Flat items, or sections for grouped nav. Forwarded to LpSidebar. */
|
|
@@ -753,10 +759,13 @@ const backToTopClass = computed(() =>
|
|
|
753
759
|
actions now cap at two thirds of the bar and scroll within it, so
|
|
754
760
|
the title keeps a third and every button stays reachable. -->
|
|
755
761
|
<LpShift
|
|
762
|
+
ref="headerActions"
|
|
756
763
|
axis="width"
|
|
757
764
|
anchor="end"
|
|
758
765
|
presence
|
|
759
|
-
class="ml-auto flex max-w-[66%] shrink items-center gap-2 overflow-x-auto lp-scrollbar-none md:max-w-none md:shrink-0 md:overflow-visible"
|
|
766
|
+
class="lp-strip-fade ml-auto flex max-w-[66%] shrink items-center gap-2 overflow-x-auto lp-scrollbar-none md:max-w-none md:shrink-0 md:overflow-visible"
|
|
767
|
+
:data-lp-more-start="actionStrip.overflowing.value && !actionStrip.atStart.value ? '' : undefined"
|
|
768
|
+
:data-lp-more-end="actionStrip.overflowing.value && !actionStrip.atEnd.value ? '' : undefined"
|
|
760
769
|
>
|
|
761
770
|
<slot name="header-actions" />
|
|
762
771
|
</LpShift>
|
|
@@ -24,6 +24,7 @@ import { useLabels } from "../composables/useLpConfig"
|
|
|
24
24
|
import { easeTravel } from "../composables/easing"
|
|
25
25
|
import { usePrefersReducedMotion } from "../composables/useReducedMotion"
|
|
26
26
|
import { startPointerDrag } from "../composables/pointerDrag"
|
|
27
|
+
import { MOUSE_SLOP } from "../composables/gesture"
|
|
27
28
|
import { nodeWindow } from "../composables/useView"
|
|
28
29
|
import { usePillTransition } from "../composables/usePillTransition"
|
|
29
30
|
import { useReveal } from "../composables/useReveal"
|
|
@@ -976,7 +977,6 @@ const crosshair = computed(() => (active.value >= 0 ? active.value : peerSlot.va
|
|
|
976
977
|
watch(active, (slot) => publish(slot >= 0 ? (labels.value[slot] ?? null) : null))
|
|
977
978
|
|
|
978
979
|
/* Past this many px a press on the plot is a selection, not a click. */
|
|
979
|
-
const SELECT_SLOP = 4
|
|
980
980
|
let swallowClick = false
|
|
981
981
|
const selection = ref<{ from: number; to: number } | null>(null)
|
|
982
982
|
|
|
@@ -988,7 +988,7 @@ const onPointerDown = (e: PointerEvent) => {
|
|
|
988
988
|
startPointerDrag(e, {
|
|
989
989
|
move(ev) {
|
|
990
990
|
const x = offset(ev)
|
|
991
|
-
if (Math.abs(x - pressedAt) >
|
|
991
|
+
if (Math.abs(x - pressedAt) > MOUSE_SLOP) selection.value = { from: pressedAt, to: x }
|
|
992
992
|
},
|
|
993
993
|
end(completed) {
|
|
994
994
|
const picked = selection.value
|
|
@@ -20,7 +20,8 @@ export type DrawerLabels = {
|
|
|
20
20
|
* primitive — same base as the rest of the kit, so accessibility and the token
|
|
21
21
|
* theming carry over). Over the old reka-Dialog version this adds drag-to-
|
|
22
22
|
* dismiss with inertia, snap points, a draggable handle and an optional
|
|
23
|
-
* scale-background effect.
|
|
23
|
+
* scale-background effect. Side drawers swipe on the kit's gesture engine
|
|
24
|
+
* instead (see the edge pull below); vaul drags sheets. Slot/prop surface stays backwards compatible: `side`
|
|
24
25
|
* still works (mapped onto `direction`), plus `size`/`width`/title/footer.
|
|
25
26
|
*
|
|
26
27
|
* scale-background note: the effect scales the page behind the drawer, so the
|
|
@@ -50,6 +51,8 @@ import LpIcon from "./LpIcon.vue"
|
|
|
50
51
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
51
52
|
import { anyModalOpen, useModalLayer } from "./modalDepth"
|
|
52
53
|
import { startPointerDrag } from "../composables/pointerDrag"
|
|
54
|
+
import { createDragSession, rubberBand, shouldDismiss, TOUCH_SLOP, type DragSession } from "../composables/gesture"
|
|
55
|
+
import { createGesture, type GestureEnd, type GestureState } from "../composables/useGesture"
|
|
53
56
|
import { isElement, nodeWindow, useOutsideGuard, usePortalTarget, useView, viewDocument, viewWindow } from "../composables/useView"
|
|
54
57
|
|
|
55
58
|
type Direction = "top" | "bottom" | "left" | "right"
|
|
@@ -104,7 +107,11 @@ const props = withDefaults(
|
|
|
104
107
|
* does), so a wide touch screen with the rail visible doesn't pull it too.
|
|
105
108
|
*/
|
|
106
109
|
edgeBreakpoint?: "sm" | "md" | "lg" | "xl"
|
|
107
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Distance (px) from the edge within which a touch can start a pull. Wide
|
|
112
|
+
* enough to start a thumb's width in, clear of the system's back gesture
|
|
113
|
+
* along the very edge.
|
|
114
|
+
*/
|
|
108
115
|
edgeSize?: number
|
|
109
116
|
/** Fraction (0–1) of the panel width the pull must reach to open on release. */
|
|
110
117
|
edgeOpenThreshold?: number
|
|
@@ -155,7 +162,7 @@ const props = withDefaults(
|
|
|
155
162
|
size: "sm",
|
|
156
163
|
dismissible: true,
|
|
157
164
|
noDragControls: true,
|
|
158
|
-
edgeSize:
|
|
165
|
+
edgeSize: 72,
|
|
159
166
|
edgeOpenThreshold: 0.3,
|
|
160
167
|
minSize: "22rem",
|
|
161
168
|
maxSize: "96vw",
|
|
@@ -345,22 +352,29 @@ const vNoDragControls = {
|
|
|
345
352
|
updated: tagNoDrag,
|
|
346
353
|
}
|
|
347
354
|
|
|
348
|
-
// ──
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
355
|
+
// ── Side drawers: pulled open from the edge, swiped shut ───────────────────
|
|
356
|
+
// One gesture model for both ways, on the kit's drag session (gesture.ts): a
|
|
357
|
+
// press becomes a pull once it travels past the slop and mostly sideways, a
|
|
358
|
+
// vertical start is a scroll and is let go, the panel's edge follows the
|
|
359
|
+
// finger 1:1, and a release completes when it is past `edgeOpenThreshold` /
|
|
360
|
+
// `closeThreshold` of the panel or was a flick (shouldDismiss), otherwise it
|
|
361
|
+
// springs back. A cancelled touch (the system took it) never completes.
|
|
362
|
+
//
|
|
363
|
+
// Opening is watched on the document with touch events rather than on an
|
|
364
|
+
// overlay strip: a strip over the screen edge swallowed every tap there, and
|
|
365
|
+
// only a non-passive touchmove can stop the page scrolling mid-gesture. The
|
|
366
|
+
// zone reaches `edgeSize` in from the edge, so a pull can start a thumb's
|
|
367
|
+
// width in, clear of the system's own back gesture at the very edge. While
|
|
368
|
+
// pulling, a preview of the real panel content follows the finger, then hands
|
|
369
|
+
// off to the real drawer without a second slide.
|
|
370
|
+
//
|
|
371
|
+
// Closing is useGesture on the panel and the scrim (touch and pen; a mouse
|
|
372
|
+
// closes with the scrim, Escape or the button). vaul's own drag is off for
|
|
373
|
+
// side drawers, so there is one behaviour, not two.
|
|
360
374
|
const BREAKPOINT_PX = { sm: 640, md: 768, lg: 1024, xl: 1280 } as const
|
|
361
375
|
// Controls whose own drag must win over the pull, plus explicit opt-outs.
|
|
362
376
|
const EDGE_IGNORE =
|
|
363
|
-
"input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-vaul-no-drag], [data-lp-no-edge-pull]"
|
|
377
|
+
"input, textarea, select, [contenteditable=''], [contenteditable='true'], div[data-vaul-no-drag], [data-lp-no-edge-pull]"
|
|
364
378
|
|
|
365
379
|
// 0 (closed) … 1 (fully pulled in). Drives the preview transform + scrim.
|
|
366
380
|
const peek = ref(0)
|
|
@@ -377,7 +391,10 @@ const skipOpenSlide = ref(false)
|
|
|
377
391
|
watch(
|
|
378
392
|
() => props.open,
|
|
379
393
|
(open) => {
|
|
380
|
-
if (open)
|
|
394
|
+
if (open) {
|
|
395
|
+
shut.value = 0
|
|
396
|
+
return
|
|
397
|
+
}
|
|
381
398
|
skipOpenSlide.value = false
|
|
382
399
|
// Closing mid-resize removes the grip, which takes its capture events along.
|
|
383
400
|
endResize?.()
|
|
@@ -403,22 +420,22 @@ function edgeEnabled(): boolean {
|
|
|
403
420
|
return bp === null || edgeWindow().innerWidth < bp
|
|
404
421
|
}
|
|
405
422
|
|
|
406
|
-
let track: { id: number;
|
|
423
|
+
let track: { id: number; session: DragSession } | null = null
|
|
407
424
|
const owner = Symbol("edge-pull")
|
|
408
425
|
|
|
409
426
|
// A horizontally scrollable ancestor that can still move the way the finger
|
|
410
|
-
//
|
|
411
|
-
// what nothing under the finger wants.
|
|
412
|
-
function scrollsWithPull(target: EventTarget | null): boolean {
|
|
427
|
+
// goes (a carousel, a wide table) keeps the gesture: the pull only claims
|
|
428
|
+
// what nothing under the finger wants. `towardOpen` is the opening direction.
|
|
429
|
+
function scrollsWithPull(target: EventTarget | null, towardOpen: boolean): boolean {
|
|
413
430
|
let el = isElement(target) ? target : null
|
|
414
|
-
const
|
|
431
|
+
const right = (dir.value === "left") === towardOpen
|
|
415
432
|
const body = el?.ownerDocument.body
|
|
416
433
|
while (el && el !== body) {
|
|
417
434
|
if (el.closest(EDGE_IGNORE) === el) return true
|
|
418
435
|
if (el.scrollWidth > el.clientWidth) {
|
|
419
436
|
const overflow = nodeWindow(el).getComputedStyle(el).overflowX
|
|
420
437
|
if (overflow === "auto" || overflow === "scroll") {
|
|
421
|
-
if (
|
|
438
|
+
if (right ? el.scrollLeft > 0 : el.scrollLeft + el.clientWidth < el.scrollWidth) return true
|
|
422
439
|
}
|
|
423
440
|
}
|
|
424
441
|
el = el.parentElement
|
|
@@ -426,31 +443,32 @@ function scrollsWithPull(target: EventTarget | null): boolean {
|
|
|
426
443
|
return false
|
|
427
444
|
}
|
|
428
445
|
|
|
446
|
+
/** Px from the drawer's edge of the screen, inward. */
|
|
429
447
|
function inward(x: number): number {
|
|
430
448
|
return dir.value === "right" ? edgeWindow().innerWidth - x : x
|
|
431
449
|
}
|
|
450
|
+
/** A movement or velocity along x, positive toward opening. */
|
|
451
|
+
const opening = (x: number) => (dir.value === "right" ? -x : x)
|
|
432
452
|
|
|
433
453
|
function onTouchStart(e: TouchEvent) {
|
|
434
454
|
if (track) dropTrack()
|
|
435
455
|
if (e.touches.length !== 1 || !edgeEnabled() || anyModalOpen() || edgeOwner.value) return
|
|
436
|
-
const t = e.touches[0]
|
|
437
|
-
if (inward(t.clientX) > props.edgeSize || scrollsWithPull(e.target)) return
|
|
438
|
-
track = { id: t.identifier, x: t.clientX, y: t.clientY,
|
|
456
|
+
const t = e.touches[0]!
|
|
457
|
+
if (inward(t.clientX) > props.edgeSize || scrollsWithPull(e.target, true)) return
|
|
458
|
+
track = { id: t.identifier, session: createDragSession({ x: t.clientX, y: t.clientY }, e.timeStamp, { slop: TOUCH_SLOP, axis: "x" }) }
|
|
439
459
|
}
|
|
440
460
|
|
|
441
461
|
function onTouchMove(e: TouchEvent) {
|
|
442
462
|
if (!track) return
|
|
443
463
|
const t = Array.from(e.changedTouches).find((x) => x.identifier === track!.id)
|
|
444
464
|
if (!t) return
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
if (dx < EDGE_SLOP || Math.abs(dx) < Math.abs(dy)) return
|
|
465
|
+
const frame = track.session.move({ x: t.clientX, y: t.clientY }, e.timeStamp)
|
|
466
|
+
if (frame.phase === "rejected" || (frame.first && opening(frame.movement.x) <= 0)) {
|
|
467
|
+
dropTrack()
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
if (frame.phase !== "drag") return
|
|
471
|
+
if (frame.first) {
|
|
454
472
|
if (edgeOwner.value && edgeOwner.value !== owner) {
|
|
455
473
|
track = null
|
|
456
474
|
return
|
|
@@ -459,10 +477,6 @@ function onTouchMove(e: TouchEvent) {
|
|
|
459
477
|
peeking.value = true
|
|
460
478
|
}
|
|
461
479
|
if (e.cancelable) e.preventDefault()
|
|
462
|
-
const dt = e.timeStamp - track.lastT
|
|
463
|
-
if (dt > 0) track.speed = (((t.clientX - track.lastX) * sign) / dt) * 0.6 + track.speed * 0.4
|
|
464
|
-
track.lastX = t.clientX
|
|
465
|
-
track.lastT = e.timeStamp
|
|
466
480
|
peek.value = clamp(inward(t.clientX) / panelPx(), 0, 1)
|
|
467
481
|
}
|
|
468
482
|
|
|
@@ -480,12 +494,15 @@ function cancelPull() {
|
|
|
480
494
|
peek.value = 0
|
|
481
495
|
}
|
|
482
496
|
|
|
483
|
-
function settle() {
|
|
484
|
-
const
|
|
497
|
+
function settle(e: TouchEvent) {
|
|
498
|
+
const t = track && Array.from(e.changedTouches).find((x) => x.identifier === track!.id)
|
|
499
|
+
const end = track && t ? track.session.end({ x: t.clientX, y: t.clientY }, e.timeStamp) : null
|
|
485
500
|
dropTrack()
|
|
486
501
|
if (!peeking.value) return
|
|
487
502
|
peeking.value = false
|
|
488
|
-
|
|
503
|
+
const size = panelPx()
|
|
504
|
+
const opens = !!end && shouldDismiss({ offset: peek.value * size, velocity: opening(end.velocity.x), size, ratio: props.edgeOpenThreshold })
|
|
505
|
+
if (opens) {
|
|
489
506
|
peek.value = 1
|
|
490
507
|
handingOff.value = true
|
|
491
508
|
skipOpenSlide.value = true
|
|
@@ -505,6 +522,52 @@ function settle() {
|
|
|
505
522
|
}
|
|
506
523
|
}
|
|
507
524
|
|
|
525
|
+
// How far the open panel has been swiped toward closing, in px (negative is a
|
|
526
|
+
// pull the other way, which only gives a little). Drives the panel and scrim.
|
|
527
|
+
const shut = ref(0)
|
|
528
|
+
const shutting = ref(false)
|
|
529
|
+
const closeSwipe = {
|
|
530
|
+
pointerTypes: ["touch", "pen"] as const,
|
|
531
|
+
axis: "x" as const,
|
|
532
|
+
ignore: EDGE_IGNORE,
|
|
533
|
+
enabled: () => !!props.open && isHorizontal.value && props.dismissible && !guarded.value && !handingOff.value,
|
|
534
|
+
onPress: (e: PointerEvent) => !scrollsWithPull(e.target, false),
|
|
535
|
+
onDragStart: () => (shutting.value = true),
|
|
536
|
+
onDrag: ({ movement }: GestureState) => {
|
|
537
|
+
const toward = -opening(movement.x)
|
|
538
|
+
shut.value = toward >= 0 ? toward : rubberBand(toward, panelPx(), 0.2)
|
|
539
|
+
},
|
|
540
|
+
onDragEnd: ({ velocity, cancelled }: GestureEnd) => {
|
|
541
|
+
shutting.value = false
|
|
542
|
+
const size = panelPx()
|
|
543
|
+
if (!cancelled && shouldDismiss({ offset: shut.value, velocity: -opening(velocity.x), size, ratio: props.closeThreshold ?? 0.25 })) {
|
|
544
|
+
emit("update:open", false)
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
shut.value = 0
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
// Bound as attributes: they fall through to the panel and scrim elements,
|
|
551
|
+
// which reka renders through a presence wrapper with no element of its own.
|
|
552
|
+
const panelSwipe = createGesture(closeSwipe)
|
|
553
|
+
const scrimSwipe = createGesture(closeSwipe)
|
|
554
|
+
onBeforeUnmount(() => {
|
|
555
|
+
panelSwipe.dispose()
|
|
556
|
+
scrimSwipe.dispose()
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
// The panel and scrim during a swipe: the panel's edge stays under the finger
|
|
560
|
+
// and the scrim lightens with it. On release vaul's own transition springs it
|
|
561
|
+
// back, and its close keyframes run from wherever the finger left it.
|
|
562
|
+
const shutStyle = computed(() => {
|
|
563
|
+
if (!shut.value && !shutting.value) return undefined
|
|
564
|
+
const px = dir.value === "right" ? shut.value : -shut.value
|
|
565
|
+
return { transform: `translate3d(${px}px, 0, 0)`, transition: shutting.value ? "none" : undefined }
|
|
566
|
+
})
|
|
567
|
+
const scrimStyle = computed(() => {
|
|
568
|
+
if (!shut.value && !shutting.value) return undefined
|
|
569
|
+
return { opacity: clamp(1 - shut.value / Math.max(1, panelPx()), 0, 1), transition: shutting.value ? "none" : "opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1)" }
|
|
570
|
+
})
|
|
508
571
|
const timers: number[] = []
|
|
509
572
|
onBeforeUnmount(() => {
|
|
510
573
|
for (const id of timers) window.clearTimeout(id)
|
|
@@ -513,7 +576,7 @@ onBeforeUnmount(() => {
|
|
|
513
576
|
|
|
514
577
|
function onTouchEnd(e: TouchEvent) {
|
|
515
578
|
if (track && !Array.from(e.changedTouches).some((x) => x.identifier === track!.id)) return
|
|
516
|
-
settle()
|
|
579
|
+
settle(e)
|
|
517
580
|
}
|
|
518
581
|
|
|
519
582
|
if (typeof document !== "undefined") {
|
|
@@ -522,16 +585,18 @@ if (typeof document !== "undefined") {
|
|
|
522
585
|
(on, _, onCleanup) => {
|
|
523
586
|
if (!on) return
|
|
524
587
|
const doc = viewDocument(view.value)
|
|
525
|
-
|
|
588
|
+
// Capture phase, so a control that stops its own touches still lets the
|
|
589
|
+
// pull see where the finger went.
|
|
590
|
+
const passive = { passive: true, capture: true }
|
|
526
591
|
doc.addEventListener("touchstart", onTouchStart, passive)
|
|
527
|
-
doc.addEventListener("touchmove", onTouchMove, { passive: false })
|
|
592
|
+
doc.addEventListener("touchmove", onTouchMove, { passive: false, capture: true })
|
|
528
593
|
doc.addEventListener("touchend", onTouchEnd, passive)
|
|
529
594
|
doc.addEventListener("touchcancel", cancelPull, passive)
|
|
530
595
|
onCleanup(() => {
|
|
531
|
-
doc.removeEventListener("touchstart", onTouchStart)
|
|
532
|
-
doc.removeEventListener("touchmove", onTouchMove)
|
|
533
|
-
doc.removeEventListener("touchend", onTouchEnd)
|
|
534
|
-
doc.removeEventListener("touchcancel", cancelPull)
|
|
596
|
+
doc.removeEventListener("touchstart", onTouchStart, true)
|
|
597
|
+
doc.removeEventListener("touchmove", onTouchMove, true)
|
|
598
|
+
doc.removeEventListener("touchend", onTouchEnd, true)
|
|
599
|
+
doc.removeEventListener("touchcancel", cancelPull, true)
|
|
535
600
|
cancelPull()
|
|
536
601
|
})
|
|
537
602
|
},
|
|
@@ -592,25 +657,26 @@ const guardOutside = useOutsideGuard()
|
|
|
592
657
|
:direction="dir"
|
|
593
658
|
:dismissible="dismissible && !guarded"
|
|
594
659
|
:snap-points="snapPoints"
|
|
595
|
-
:handle-only="handleOnly"
|
|
660
|
+
:handle-only="handleOnly || isHorizontal"
|
|
596
661
|
:should-scale-background="scaleBackground"
|
|
597
662
|
:close-threshold="closeThreshold"
|
|
598
663
|
@update:open="(v: boolean) => emit('update:open', v)"
|
|
599
664
|
>
|
|
600
665
|
<DrawerPortal :to="portalTo">
|
|
601
666
|
<DrawerOverlay
|
|
602
|
-
|
|
667
|
+
v-bind="scrimSwipe.handlers"
|
|
668
|
+
:style="[{ zIndex: layer.scrim }, scrimStyle]"
|
|
603
669
|
@click="guarded && requestClose()"
|
|
604
|
-
class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
|
|
670
|
+
class="lp-scrim fixed inset-0 z-(--z-overlay) touch-none data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
|
|
605
671
|
:class="{ 'lp-scrim--light': frosted, 'lp-scrim--nested': layer.nested, 'lp-scrim--covered': layer.covered }"
|
|
606
672
|
/>
|
|
607
673
|
<DrawerContent
|
|
608
674
|
@interact-outside="guardOutside"
|
|
609
675
|
@escape-key-down="guardOutside"
|
|
610
676
|
ref="contentRef"
|
|
611
|
-
v-bind="description ? {} : { 'aria-describedby': undefined }"
|
|
677
|
+
v-bind="{ ...(description ? {} : { 'aria-describedby': undefined }), ...panelSwipe.handlers }"
|
|
612
678
|
:class="contentClass"
|
|
613
|
-
:style="[sizeStyle, { zIndex: layer.panel }]"
|
|
679
|
+
:style="[sizeStyle, { zIndex: layer.panel }, shutStyle]"
|
|
614
680
|
:data-vaul-animate="handingOff || skipOpenSlide ? 'false' : undefined"
|
|
615
681
|
@keydown.escape="guardEscape"
|
|
616
682
|
>
|
|
@@ -676,6 +742,7 @@ const guardOutside = useOutsideGuard()
|
|
|
676
742
|
<div
|
|
677
743
|
v-no-drag-controls="noDragControls"
|
|
678
744
|
class="flex min-h-0 min-w-0 flex-1 flex-col [-webkit-user-select:text]! [user-select:text]!"
|
|
745
|
+
:class="isHorizontal ? '[&_[data-reka-scroll-area-viewport]]:touch-pan-y' : undefined"
|
|
679
746
|
:data-vaul-no-drag="noDragContent ? '' : undefined"
|
|
680
747
|
>
|
|
681
748
|
<!-- min-w-0 on the body + scroll content so a wide child (e.g. a log
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
|
27
27
|
import { useLabels } from "../composables/useLpConfig"
|
|
28
28
|
import { useZoomPan } from "../composables/useZoomPan"
|
|
29
|
+
import LpScrollStrip from "./LpScrollStrip.vue"
|
|
30
|
+
import { createDragSession, slopFor, type DragSession } from "../composables/gesture"
|
|
29
31
|
import { clickLink } from "./exportData"
|
|
30
32
|
import { fileNameOf, isVideoItem, type LightboxItem } from "./lightbox"
|
|
31
33
|
import LpCopyButton from "./LpCopyButton.vue"
|
|
@@ -177,7 +179,7 @@ const loading = ref(false)
|
|
|
177
179
|
const failed = ref(false)
|
|
178
180
|
const viewport = ref<HTMLElement | null>(null)
|
|
179
181
|
const stageImage = ref<HTMLImageElement | null>(null)
|
|
180
|
-
const stripEl = ref<
|
|
182
|
+
const stripEl = ref<InstanceType<typeof LpScrollStrip> | null>(null)
|
|
181
183
|
|
|
182
184
|
watch(viewport, (el) => zoom.setViewport(el))
|
|
183
185
|
|
|
@@ -277,8 +279,8 @@ function imageSettled(event: Event, ok: boolean) {
|
|
|
277
279
|
}
|
|
278
280
|
|
|
279
281
|
function scrollThumbIntoView() {
|
|
280
|
-
const el = stripEl.value?.querySelector<HTMLElement>(`[data-index="${current.value}"]`)
|
|
281
|
-
|
|
282
|
+
const el = stripEl.value?.scroller?.querySelector<HTMLElement>(`[data-index="${current.value}"]`)
|
|
283
|
+
stripEl.value?.reveal(el, { block: "center" })
|
|
282
284
|
}
|
|
283
285
|
|
|
284
286
|
function close() {
|
|
@@ -363,45 +365,54 @@ function rotate() {
|
|
|
363
365
|
|
|
364
366
|
// Swipe to page through, but only while the image is at fit scale — once zoomed
|
|
365
367
|
// a horizontal drag is a pan, and stealing it would make the image unreachable.
|
|
366
|
-
|
|
368
|
+
// The kit's drag session decides what a swipe is (far enough, or a flick, and
|
|
369
|
+
// mostly sideways), as for every other swipe in the kit.
|
|
370
|
+
let swipe: DragSession | null = null
|
|
367
371
|
// Fingers on the stage. A second one makes the gesture a pinch, and a pinch
|
|
368
372
|
// whose fingers part sideways is not a swipe: it zoomed and then paged away
|
|
369
373
|
// from what it zoomed into.
|
|
370
374
|
const fingers = new Set<number>()
|
|
371
375
|
|
|
376
|
+
function startSwipe(e: PointerEvent) {
|
|
377
|
+
swipe = createDragSession({ x: e.clientX, y: e.clientY }, e.timeStamp, { slop: slopFor(e.pointerType), axis: "x" })
|
|
378
|
+
}
|
|
379
|
+
|
|
372
380
|
function onPointerDown(e: PointerEvent) {
|
|
373
381
|
fingers.add(e.pointerId)
|
|
374
382
|
if (fingers.size > 1) {
|
|
375
|
-
|
|
383
|
+
swipe = null
|
|
376
384
|
if (!isVideo.value) zoom.onPointerDown(e)
|
|
377
385
|
return
|
|
378
386
|
}
|
|
379
387
|
if (isVideo.value) {
|
|
380
388
|
// A drag on the player's bar is a seek or a volume change, never a page.
|
|
381
|
-
|
|
389
|
+
if ((e.target as Element).closest?.("[data-lp-player-controls]")) swipe = null
|
|
390
|
+
else startSwipe(e)
|
|
382
391
|
return
|
|
383
392
|
}
|
|
384
393
|
zoom.onPointerDown(e)
|
|
385
|
-
|
|
394
|
+
if (zoom.zoomed.value) swipe = null
|
|
395
|
+
else startSwipe(e)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function onPointerMove(e: PointerEvent) {
|
|
399
|
+
if (!isVideo.value) zoom.onPointerMove(e)
|
|
400
|
+
if (swipe?.move({ x: e.clientX, y: e.clientY }, e.timeStamp).phase === "rejected") swipe = null
|
|
386
401
|
}
|
|
387
402
|
|
|
388
403
|
function onPointerCancel(e: PointerEvent) {
|
|
389
404
|
fingers.delete(e.pointerId)
|
|
390
|
-
|
|
405
|
+
swipe = null
|
|
391
406
|
if (!isVideo.value) zoom.onPointerUp(e)
|
|
392
407
|
}
|
|
393
408
|
|
|
394
409
|
function onPointerUp(e: PointerEvent) {
|
|
395
410
|
fingers.delete(e.pointerId)
|
|
396
411
|
if (!isVideo.value) zoom.onPointerUp(e)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Mostly-horizontal and past a threshold, so a sloppy tap doesn't page.
|
|
402
|
-
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
|
403
|
-
go(current.value + (dx < 0 ? 1 : -1))
|
|
404
|
-
}
|
|
412
|
+
const ended = swipe?.end({ x: e.clientX, y: e.clientY }, e.timeStamp, { axis: "x" })
|
|
413
|
+
swipe = null
|
|
414
|
+
if (ended?.swipe === "left") go(current.value + 1)
|
|
415
|
+
else if (ended?.swipe === "right") go(current.value - 1)
|
|
405
416
|
}
|
|
406
417
|
|
|
407
418
|
function onDoubleClick(e: MouseEvent) {
|
|
@@ -620,7 +631,7 @@ const guardOutside = useOutsideGuard()
|
|
|
620
631
|
:class="isPanning ? 'cursor-grabbing' : isZoomed ? 'cursor-grab' : ''"
|
|
621
632
|
@wheel="onStageWheel"
|
|
622
633
|
@pointerdown="onPointerDown"
|
|
623
|
-
@pointermove="
|
|
634
|
+
@pointermove="onPointerMove"
|
|
624
635
|
@pointerup="onPointerUp"
|
|
625
636
|
@pointercancel="onPointerCancel"
|
|
626
637
|
@dblclick="onDoubleClick"
|
|
@@ -722,11 +733,12 @@ const guardOutside = useOutsideGuard()
|
|
|
722
733
|
</div>
|
|
723
734
|
</slot>
|
|
724
735
|
|
|
725
|
-
<
|
|
736
|
+
<LpScrollStrip
|
|
726
737
|
v-if="showStrip && total > 1"
|
|
727
738
|
ref="stripEl"
|
|
728
|
-
|
|
739
|
+
:buttons="false"
|
|
729
740
|
>
|
|
741
|
+
<div class="mx-auto flex w-max gap-2 p-1">
|
|
730
742
|
<button
|
|
731
743
|
v-for="(entry, i) in items"
|
|
732
744
|
:key="entry.src + i"
|
|
@@ -754,7 +766,8 @@ const guardOutside = useOutsideGuard()
|
|
|
754
766
|
draggable="false"
|
|
755
767
|
/>
|
|
756
768
|
</button>
|
|
757
|
-
|
|
769
|
+
</div>
|
|
770
|
+
</LpScrollStrip>
|
|
758
771
|
</div>
|
|
759
772
|
</DialogContent>
|
|
760
773
|
</DialogPortal>
|
|
@@ -22,7 +22,9 @@ export default { inheritAttrs: false }
|
|
|
22
22
|
*/
|
|
23
23
|
import { clamp } from "../composables/clamp"
|
|
24
24
|
import { computed, onBeforeUnmount, ref, watch } from "vue"
|
|
25
|
-
import {
|
|
25
|
+
import { createDragSession, slopFor, type DragSession } from "../composables/gesture"
|
|
26
|
+
import { glide, type Glide } from "../composables/useGesture"
|
|
27
|
+
import { nodeWindow } from "../composables/realm"
|
|
26
28
|
import { type LatLon, OSM_TILES, type TileSource, project, tileUrl, unproject } from "./address"
|
|
27
29
|
import LpIcon from "./LpIcon.vue"
|
|
28
30
|
import LpLoader from "./LpLoader.vue"
|
|
@@ -306,88 +308,44 @@ function toLatLon(at: { x: number; y: number }): LatLon {
|
|
|
306
308
|
}
|
|
307
309
|
|
|
308
310
|
const panning = ref(false)
|
|
309
|
-
// A drag is a pan, not a pick
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
let
|
|
313
|
-
|
|
314
|
-
// flick doesn't decide where the map coasts to.
|
|
315
|
-
let velocity = { x: 0, y: 0 }
|
|
316
|
-
let lastMove = 0
|
|
317
|
-
let glideFrame: number | undefined
|
|
311
|
+
// A drag is a pan, not a pick: a press that never leaves the slop drops the
|
|
312
|
+
// pin on release, one that does pans. The kit's drag session makes that call
|
|
313
|
+
// and measures the speed at release, and glide coasts on from it.
|
|
314
|
+
let session: DragSession | null = null
|
|
315
|
+
let coast: Glide | null = null
|
|
318
316
|
|
|
319
317
|
function stopGlide() {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
velocity = { x: 0, y: 0 }
|
|
318
|
+
coast?.stop()
|
|
319
|
+
coast = null
|
|
323
320
|
}
|
|
324
321
|
|
|
325
322
|
function onPointerDown(e: PointerEvent) {
|
|
326
323
|
if (props.disabled || e.button !== 0 || zooming.value) return
|
|
327
324
|
stopGlide()
|
|
328
325
|
panning.value = true
|
|
329
|
-
|
|
330
|
-
last = { x: e.clientX, y: e.clientY }
|
|
331
|
-
lastMove = performance.now()
|
|
326
|
+
session = createDragSession({ x: e.clientX, y: e.clientY }, e.timeStamp, { slop: slopFor(e.pointerType) })
|
|
332
327
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
333
328
|
}
|
|
334
329
|
|
|
335
330
|
function onPointerMove(e: PointerEvent) {
|
|
336
|
-
if (!panning.value) return
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const now = performance.now()
|
|
342
|
-
const dt = now - lastMove
|
|
343
|
-
if (dt > 0) {
|
|
344
|
-
velocity = {
|
|
345
|
-
x: velocity.x * 0.7 + (dx / dt) * 0.3,
|
|
346
|
-
y: velocity.y * 0.7 + (dy / dt) * 0.3,
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
lastMove = now
|
|
350
|
-
last = { x: e.clientX, y: e.clientY }
|
|
351
|
-
panBy(-dx, -dy)
|
|
331
|
+
if (!panning.value || !session) return
|
|
332
|
+
const frame = session.move({ x: e.clientX, y: e.clientY }, e.timeStamp)
|
|
333
|
+
if (frame.phase !== "drag") return
|
|
334
|
+
panBy(-(frame.first ? frame.movement.x : frame.delta.x), -(frame.first ? frame.movement.y : frame.delta.y))
|
|
352
335
|
}
|
|
353
336
|
|
|
354
337
|
function onPointerUp(e: PointerEvent) {
|
|
355
|
-
if (!panning.value) return
|
|
338
|
+
if (!panning.value || !session) return
|
|
356
339
|
panning.value = false
|
|
357
340
|
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// Nothing to coast from if the pointer was resting when it lifted.
|
|
363
|
-
if (performance.now() - lastMove > 90) velocity = { x: 0, y: 0 }
|
|
364
|
-
glide()
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/*
|
|
368
|
-
* Momentum: keep panning after release and bleed the speed off exponentially,
|
|
369
|
-
* so letting go of a flick drifts to a stop instead of stopping dead. Time-based
|
|
370
|
-
* rather than per-frame, which keeps the feel identical at 60 and 144 Hz.
|
|
371
|
-
*/
|
|
372
|
-
function glide() {
|
|
373
|
-
if (prefersReducedMotion() || Math.hypot(velocity.x, velocity.y) < 0.05) {
|
|
374
|
-
velocity = { x: 0, y: 0 }
|
|
341
|
+
const end = session.end({ x: e.clientX, y: e.clientY }, e.timeStamp)
|
|
342
|
+
session = null
|
|
343
|
+
if (end.phase !== "drag") {
|
|
344
|
+
if (!props.disabled && e.type !== "pointercancel") emit("update:modelValue", toLatLon(viewportPoint(e)))
|
|
375
345
|
return
|
|
376
346
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const dt = Math.min(now - previous, 32)
|
|
380
|
-
previous = now
|
|
381
|
-
const friction = Math.exp(-dt / 240)
|
|
382
|
-
velocity = { x: velocity.x * friction, y: velocity.y * friction }
|
|
383
|
-
panBy(-velocity.x * dt, -velocity.y * dt)
|
|
384
|
-
if (Math.hypot(velocity.x, velocity.y) > 0.015) {
|
|
385
|
-
glideFrame = requestAnimationFrame(step)
|
|
386
|
-
} else {
|
|
387
|
-
stopGlide()
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
glideFrame = requestAnimationFrame(step)
|
|
347
|
+
if (e.type === "pointercancel") return
|
|
348
|
+
coast = glide(end.velocity, (dx, dy) => panBy(-dx, -dy), { win: nodeWindow(e.currentTarget as Node) })
|
|
391
349
|
}
|
|
392
350
|
|
|
393
351
|
function panBy(dx: number, dy: number) {
|