@humanspeak/svelte-motion 0.5.1 → 0.5.2
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/dist/html/_MotionContainer.svelte +180 -3
- package/dist/types.d.ts +19 -0
- package/dist/utils/pan.d.ts +135 -0
- package/dist/utils/pan.js +426 -0
- package/package.json +4 -4
|
@@ -17,17 +17,21 @@
|
|
|
17
17
|
DragControls,
|
|
18
18
|
DragTransition,
|
|
19
19
|
MotionWhileDrag,
|
|
20
|
-
DragInfo
|
|
20
|
+
DragInfo,
|
|
21
|
+
MotionOnPan,
|
|
22
|
+
MotionOnPanEnd,
|
|
23
|
+
MotionOnPanSessionStart,
|
|
24
|
+
MotionOnPanStart
|
|
21
25
|
} from '../types'
|
|
22
26
|
import { isNotEmpty } from '../utils/objects'
|
|
23
27
|
import { sleep } from '../utils/testing'
|
|
24
28
|
import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
|
|
25
29
|
import { isPlaywrightEnv, pwLog } from '../utils/log'
|
|
26
|
-
import { onDestroy, type Snippet } from 'svelte'
|
|
30
|
+
import { onDestroy, untrack, type Snippet } from 'svelte'
|
|
27
31
|
import { VOID_TAGS } from '../utils/constants'
|
|
28
32
|
import { mergeTransitions, animateWithLifecycle } from '../utils/animation'
|
|
29
33
|
import { attachWhileTap } from '../utils/interaction'
|
|
30
|
-
import { attachWhileHover } from '../utils/hover'
|
|
34
|
+
import { attachWhileHover, computeHoverBaseline, splitHoverDefinition } from '../utils/hover'
|
|
31
35
|
import { attachWhileFocus } from '../utils/focus'
|
|
32
36
|
import { attachWhileInView } from '../utils/inView.svelte'
|
|
33
37
|
import {
|
|
@@ -48,6 +52,7 @@
|
|
|
48
52
|
} from '../utils/presence'
|
|
49
53
|
import { getInitialKeyframes } from '../utils/initial'
|
|
50
54
|
import { attachDrag } from '../utils/drag'
|
|
55
|
+
import { attachPan, type AttachPanCleanup } from '../utils/pan'
|
|
51
56
|
import { resolveInitial, resolveAnimate, resolveExit, resolveWhile } from '../utils/variants'
|
|
52
57
|
import {
|
|
53
58
|
setVariantContext,
|
|
@@ -97,6 +102,11 @@
|
|
|
97
102
|
whileInView: whileInViewProp,
|
|
98
103
|
viewport: viewportProp,
|
|
99
104
|
whileDrag: whileDragProp,
|
|
105
|
+
whilePan: whilePanProp,
|
|
106
|
+
onPanSessionStart: onPanSessionStartProp,
|
|
107
|
+
onPanStart: onPanStartProp,
|
|
108
|
+
onPan: onPanProp,
|
|
109
|
+
onPanEnd: onPanEndProp,
|
|
100
110
|
onHoverStart: onHoverStartProp,
|
|
101
111
|
onHoverEnd: onHoverEndProp,
|
|
102
112
|
onFocusStart: onFocusStartProp,
|
|
@@ -455,6 +465,7 @@
|
|
|
455
465
|
const resolvedWhileHover = $derived(resolveWhile(whileHoverProp, variantsProp, effectiveCustom))
|
|
456
466
|
const resolvedWhileFocus = $derived(resolveWhile(whileFocusProp, variantsProp, effectiveCustom))
|
|
457
467
|
const resolvedWhileDrag = $derived(resolveWhile(whileDragProp, variantsProp, effectiveCustom))
|
|
468
|
+
const resolvedWhilePan = $derived(resolveWhile(whilePanProp, variantsProp, effectiveCustom))
|
|
458
469
|
const resolvedWhileInView = $derived(
|
|
459
470
|
resolveWhile(whileInViewProp, variantsProp, effectiveCustom)
|
|
460
471
|
)
|
|
@@ -594,6 +605,172 @@
|
|
|
594
605
|
}
|
|
595
606
|
})
|
|
596
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Pan-gesture wiring. Active whenever any of `onPanSessionStart`,
|
|
610
|
+
* `onPanStart`, `onPan`, `onPanEnd`, or `whilePan` is set. Unlike
|
|
611
|
+
* `drag`, Pan has no constraints / momentum / origin-snap — it's a
|
|
612
|
+
* pure pointer offset+velocity reporter, useful for swipe-to-dismiss
|
|
613
|
+
* sheets, custom carousels, and any "tell me what the gesture is
|
|
614
|
+
* doing right now" interaction. Mirrors framer-motion's `PanGesture`
|
|
615
|
+
* (packages/framer-motion/src/gestures/pan/index.ts).
|
|
616
|
+
*
|
|
617
|
+
* Split into TWO effects:
|
|
618
|
+
*
|
|
619
|
+
* 1. `attach` — keyed on `element`, `isLoaded === 'ready'`, presence
|
|
620
|
+
* of any pan handler/whilePan, and absence of `drag` (drag takes
|
|
621
|
+
* precedence — upstream framer-motion routes drag THROUGH the pan
|
|
622
|
+
* gesture internally, so co-attaching pan when drag is on would
|
|
623
|
+
* fight transforms). Creates / tears down the underlying
|
|
624
|
+
* `attachPan` lifetime once per element-bound interval.
|
|
625
|
+
*
|
|
626
|
+
* 2. `swap` — keyed on the user's handler/whilePan props. Calls
|
|
627
|
+
* `teardownPan.update(next)` to hot-swap the live handler set
|
|
628
|
+
* without destroying the in-flight `PanSession`. Without this
|
|
629
|
+
* split, every parent re-render that produces a fresh inline
|
|
630
|
+
* arrow handler would tear down the live gesture mid-pan —
|
|
631
|
+
* pointer listeners removed, no `onPanEnd` ever fires, whilePan
|
|
632
|
+
* keyframes leak.
|
|
633
|
+
*/
|
|
634
|
+
let teardownPan: AttachPanCleanup | null = null
|
|
635
|
+
let activeWhilePanKeyframes: Record<string, unknown> | null = null
|
|
636
|
+
let whilePanBaseline: Record<string, unknown> | null = null
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Boolean presence-check for "is any pan surface active?". Derived
|
|
640
|
+
* so the attach effect below tracks the *boolean value*, not the
|
|
641
|
+
* individual handler/whilePan reference identities. A consumer
|
|
642
|
+
* passing `onPan={(e, i) => ...}` (inline arrow — fresh ref every
|
|
643
|
+
* render) used to re-trigger the attach effect on every parent
|
|
644
|
+
* render; with this derived in place, the attach effect only
|
|
645
|
+
* re-runs when overall presence flips (none → some, some → none).
|
|
646
|
+
* Per-ref changes flow through the hot-swap effect instead.
|
|
647
|
+
*/
|
|
648
|
+
const hasAnyPanHandler = $derived(
|
|
649
|
+
!!onPanProp ||
|
|
650
|
+
!!onPanStartProp ||
|
|
651
|
+
!!onPanEndProp ||
|
|
652
|
+
!!onPanSessionStartProp ||
|
|
653
|
+
!!resolvedWhilePan
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
const buildPanHandlers = (): {
|
|
657
|
+
onSessionStart?: MotionOnPanSessionStart
|
|
658
|
+
onStart: NonNullable<MotionOnPanStart>
|
|
659
|
+
onMove?: MotionOnPan
|
|
660
|
+
onEnd: NonNullable<MotionOnPanEnd>
|
|
661
|
+
} => ({
|
|
662
|
+
onSessionStart: onPanSessionStartProp,
|
|
663
|
+
onStart: (event, info) => {
|
|
664
|
+
if (resolvedWhilePan && element) {
|
|
665
|
+
// Snapshot the values we'll revert to BEFORE applying — same
|
|
666
|
+
// `computeHoverBaseline` path the other while-* gestures
|
|
667
|
+
// (whileHover/whileFocus/drag) use. Covers animatable transform
|
|
668
|
+
// shorthands (scale, rotate, x, y) AND restores non-animatable
|
|
669
|
+
// inline writes (cursor, pointer-events) since the baseline
|
|
670
|
+
// sniffs `animate` → `initial` → computed style → inline style.
|
|
671
|
+
whilePanBaseline = computeHoverBaseline(element, {
|
|
672
|
+
initial: (initialKeyframes ?? {}) as Record<string, unknown>,
|
|
673
|
+
animate: (resolvedAnimate ?? {}) as Record<string, unknown>,
|
|
674
|
+
whileHover: (resolvedWhilePan ?? {}) as Record<string, unknown>
|
|
675
|
+
})
|
|
676
|
+
const { keyframes, transition } = splitHoverDefinition(
|
|
677
|
+
resolvedWhilePan as Record<string, unknown>
|
|
678
|
+
)
|
|
679
|
+
activeWhilePanKeyframes = keyframes
|
|
680
|
+
animateWithLifecycle(
|
|
681
|
+
element,
|
|
682
|
+
keyframes as unknown as DOMKeyframesDefinition,
|
|
683
|
+
(transition ?? mergedTransition ?? {}) as unknown as AnimationOptions
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
onPanStartProp?.(event, info)
|
|
687
|
+
},
|
|
688
|
+
onMove: onPanProp,
|
|
689
|
+
onEnd: (event, info) => {
|
|
690
|
+
if (activeWhilePanKeyframes && whilePanBaseline && element) {
|
|
691
|
+
animateWithLifecycle(
|
|
692
|
+
element,
|
|
693
|
+
whilePanBaseline as unknown as DOMKeyframesDefinition,
|
|
694
|
+
(mergedTransition ?? {}) as unknown as AnimationOptions
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
activeWhilePanKeyframes = null
|
|
698
|
+
whilePanBaseline = null
|
|
699
|
+
onPanEndProp?.(event, info)
|
|
700
|
+
}
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
$effect(() => {
|
|
704
|
+
if (isPlaywright) {
|
|
705
|
+
pwLog('[motion] pan attach effect run', {
|
|
706
|
+
hasAnyPanHandler,
|
|
707
|
+
isLoaded
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
if (!element) return
|
|
711
|
+
// Defer attachment until the element has settled out of the enter
|
|
712
|
+
// animation phase — matches the gate every other gesture effect
|
|
713
|
+
// in this file uses (drag, whileTap, whileHover, whileFocus,
|
|
714
|
+
// whileInView). Without this, a pointerdown during the
|
|
715
|
+
// initial / mounting phase would attach pan listeners against an
|
|
716
|
+
// element whose enter animation hasn't committed its baseline.
|
|
717
|
+
if (isLoaded !== 'ready') return
|
|
718
|
+
// Drag takes precedence — upstream framer-motion's drag gesture is
|
|
719
|
+
// implemented ON TOP of Pan, not alongside it. Co-attaching here
|
|
720
|
+
// would create two competing pointer pipelines fighting for the
|
|
721
|
+
// same transforms.
|
|
722
|
+
if (dragProp) return
|
|
723
|
+
if (!hasAnyPanHandler) return
|
|
724
|
+
|
|
725
|
+
// `untrack` so the reactive reads inside `buildPanHandlers`
|
|
726
|
+
// (onPan*Prop, resolvedWhilePan, initialKeyframes, resolvedAnimate,
|
|
727
|
+
// mergedTransition) don't register as dependencies of this attach
|
|
728
|
+
// effect. Otherwise every parent re-render that passes a fresh
|
|
729
|
+
// inline arrow handler would re-run this effect and call
|
|
730
|
+
// `teardownPan?.()`, killing the live PanSession mid-gesture.
|
|
731
|
+
// Handler-ref changes flow exclusively through the hot-swap
|
|
732
|
+
// effect below, which calls `teardownPan.update(next)` — that's
|
|
733
|
+
// the path that keeps an in-flight gesture alive across re-renders.
|
|
734
|
+
teardownPan = attachPan(
|
|
735
|
+
element,
|
|
736
|
+
untrack(() => buildPanHandlers())
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
return () => {
|
|
740
|
+
// Synchronous revert of whilePan + lifecycle dispatch lives in
|
|
741
|
+
// attachPan.teardown() — the cleanup chain there calls
|
|
742
|
+
// session.dispatchTerminal(rawHandlers) BEFORE flipping isAlive,
|
|
743
|
+
// so onPanEnd fires (which runs the revert above) before the
|
|
744
|
+
// listeners go. dispatchTerminal is idempotent (PanSession's
|
|
745
|
+
// terminalDispatched flag) so a host that tears down after a
|
|
746
|
+
// natural release won't replay the lifecycle pair.
|
|
747
|
+
teardownPan?.()
|
|
748
|
+
teardownPan = null
|
|
749
|
+
activeWhilePanKeyframes = null
|
|
750
|
+
whilePanBaseline = null
|
|
751
|
+
}
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Hot-swap effect — propagates handler / whilePan changes onto the
|
|
756
|
+
* existing PanSession via `teardownPan.update(next)`. Tracked
|
|
757
|
+
* separately from the attach effect so a fresh inline-arrow handler
|
|
758
|
+
* reference does NOT trigger teardown + re-attach. Without this
|
|
759
|
+
* split, every parent re-render mid-gesture would silently kill the
|
|
760
|
+
* live pan session.
|
|
761
|
+
*/
|
|
762
|
+
$effect(() => {
|
|
763
|
+
// Track every prop the handler set depends on so this effect
|
|
764
|
+
// re-runs when any of them change.
|
|
765
|
+
void onPanSessionStartProp
|
|
766
|
+
void onPanStartProp
|
|
767
|
+
void onPanProp
|
|
768
|
+
void onPanEndProp
|
|
769
|
+
void resolvedWhilePan
|
|
770
|
+
if (!teardownPan) return
|
|
771
|
+
teardownPan.update(buildPanHandlers())
|
|
772
|
+
})
|
|
773
|
+
|
|
597
774
|
/**
|
|
598
775
|
* Execute the actual animation without wait mode checks.
|
|
599
776
|
*/
|
package/dist/types.d.ts
CHANGED
|
@@ -246,6 +246,15 @@ export type MotionOnDrag = ((event: PointerEvent, info: DragInfo) => void) | und
|
|
|
246
246
|
export type MotionOnDragEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
247
247
|
export type MotionOnDirectionLock = ((axis: 'x' | 'y') => void) | undefined;
|
|
248
248
|
export type MotionOnDragTransitionEnd = (() => void) | undefined;
|
|
249
|
+
/**
|
|
250
|
+
* Pan-gesture callbacks. PanInfo is structurally identical to DragInfo
|
|
251
|
+
* (`{ point, delta, offset, velocity }`), so we re-use the type rather
|
|
252
|
+
* than ship a parallel alias.
|
|
253
|
+
*/
|
|
254
|
+
export type MotionOnPanSessionStart = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
255
|
+
export type MotionOnPanStart = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
256
|
+
export type MotionOnPan = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
257
|
+
export type MotionOnPanEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
249
258
|
export type DragAxis = boolean | 'x' | 'y';
|
|
250
259
|
export type DragConstraints = {
|
|
251
260
|
top?: number;
|
|
@@ -319,6 +328,8 @@ export type MotionProps = {
|
|
|
319
328
|
whileFocus?: MotionWhileFocus;
|
|
320
329
|
/** Drag interaction animation */
|
|
321
330
|
whileDrag?: MotionWhileDrag;
|
|
331
|
+
/** Pan interaction animation — applied while a pan gesture is active */
|
|
332
|
+
whilePan?: MotionWhileDrag;
|
|
322
333
|
/** In-view interaction animation - animates when element enters viewport */
|
|
323
334
|
whileInView?: MotionWhileInView;
|
|
324
335
|
/** IntersectionObserver options for `whileInView` (once / root / margin / amount) */
|
|
@@ -355,6 +366,14 @@ export type MotionProps = {
|
|
|
355
366
|
onDirectionLock?: MotionOnDirectionLock;
|
|
356
367
|
/** Called when the post-drag transition finishes on all axes */
|
|
357
368
|
onDragTransitionEnd?: MotionOnDragTransitionEnd;
|
|
369
|
+
/** Pan gesture: fires once per pointerdown, before threshold */
|
|
370
|
+
onPanSessionStart?: MotionOnPanSessionStart;
|
|
371
|
+
/** Pan gesture: fires the first frame after offset crosses threshold */
|
|
372
|
+
onPanStart?: MotionOnPanStart;
|
|
373
|
+
/** Pan gesture: fires once per frame while panning */
|
|
374
|
+
onPan?: MotionOnPan;
|
|
375
|
+
/** Pan gesture: fires on pointerup if onPanStart ever fired */
|
|
376
|
+
onPanEnd?: MotionOnPanEnd;
|
|
358
377
|
/** Inline styles */
|
|
359
378
|
style?: string;
|
|
360
379
|
/** CSS classes */
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pan gesture session.
|
|
3
|
+
*
|
|
4
|
+
* Direct port of framer-motion's `PanSession`
|
|
5
|
+
* (`packages/framer-motion/src/gestures/pan/PanSession.ts`) and `PanGesture`
|
|
6
|
+
* (`packages/framer-motion/src/gestures/pan/index.ts`). Pan is the
|
|
7
|
+
* primitive that powers swipe-to-dismiss drawers, swipe-to-delete rows,
|
|
8
|
+
* carousels, and any gesture that tracks pointer offset/velocity without
|
|
9
|
+
* the constraint/momentum/snap-to-origin baggage of `drag`.
|
|
10
|
+
*
|
|
11
|
+
* Critical design notes (mirrors upstream):
|
|
12
|
+
*
|
|
13
|
+
* - `pointermove`, `pointerup`, `pointercancel` subscribe on the
|
|
14
|
+
* `contextWindow` (defaults to `window`), NOT the source element. This
|
|
15
|
+
* keeps the gesture alive even when the pointer leaves the element's
|
|
16
|
+
* bounds during a fast swipe — the original element is only used for
|
|
17
|
+
* the initial `pointerdown` and for scroll-compensation tracking.
|
|
18
|
+
*
|
|
19
|
+
* - `distanceThreshold` (default `3`px) gates the `onStart` callback so
|
|
20
|
+
* a steady press without movement doesn't fire a pan. `onSessionStart`
|
|
21
|
+
* fires immediately on pointerdown for setup work.
|
|
22
|
+
*
|
|
23
|
+
* - Per-frame throttling via `frame.update(updatePoint, true)` so a flood
|
|
24
|
+
* of pointermove events doesn't run handlers more than once per render
|
|
25
|
+
* frame. On top of that, individual handlers are routed onto motion-dom's
|
|
26
|
+
* step lanes (see `wrapUpdate` / `wrapPostRender` above):
|
|
27
|
+
* `onSessionStart` / `onStart` / `onMove` land on `update`,
|
|
28
|
+
* `onEnd` / `onSessionEnd` on `postRender`. Matches upstream's
|
|
29
|
+
* `asyncHandler` + `frame.postRender` split byte-for-byte.
|
|
30
|
+
*
|
|
31
|
+
* - `getPanInfo` returns `{ point, delta, offset, velocity }` — identical
|
|
32
|
+
* shape to motion-dom's `DragInfo` / framer-motion's `PanInfo`.
|
|
33
|
+
*
|
|
34
|
+
* - Velocity uses a 100ms history window with the "skip the pointer-down
|
|
35
|
+
* origin if it's too stale" tweak upstream added for hold-then-flick
|
|
36
|
+
* gestures.
|
|
37
|
+
*/
|
|
38
|
+
import type { DragInfo } from '../types';
|
|
39
|
+
export interface PanHandlers {
|
|
40
|
+
/** Fires on `pointerdown` regardless of whether movement follows. */
|
|
41
|
+
onSessionStart?: (event: PointerEvent, info: DragInfo) => void;
|
|
42
|
+
/** Fires the first time pointer offset crosses `distanceThreshold`. */
|
|
43
|
+
onStart?: (event: PointerEvent, info: DragInfo) => void;
|
|
44
|
+
/** Fires on every per-frame-throttled pointermove past threshold. */
|
|
45
|
+
onMove?: (event: PointerEvent, info: DragInfo) => void;
|
|
46
|
+
/** Fires on `pointerup` / `pointercancel` if `onStart` ever fired. */
|
|
47
|
+
onEnd?: (event: PointerEvent, info: DragInfo) => void;
|
|
48
|
+
/** Fires on `pointerup` / `pointercancel` always (paired with `onSessionStart`). */
|
|
49
|
+
onSessionEnd?: (event: PointerEvent, info: DragInfo) => void;
|
|
50
|
+
}
|
|
51
|
+
export interface AttachPanOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Movement distance (in pixels) required before `onStart`/`onMove`
|
|
54
|
+
* fire. Default `3` — same as framer-motion. A steady press with
|
|
55
|
+
* sub-threshold drift is reported via `onSessionStart` / `onSessionEnd`
|
|
56
|
+
* only.
|
|
57
|
+
*/
|
|
58
|
+
distanceThreshold?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Window to attach the move/up/cancel listeners to. Defaults to the
|
|
61
|
+
* source element's owner window. Override for iframe / shadow-root
|
|
62
|
+
* scenarios.
|
|
63
|
+
*/
|
|
64
|
+
contextWindow?: Window | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Cleanup function returned by `attachPan`. Carries an `update` method
|
|
68
|
+
* that hot-swaps the live handler set without tearing down the active
|
|
69
|
+
* `PanSession` — call this when a consumer's `onPan` reference changes
|
|
70
|
+
* mid-gesture (the canonical Svelte 5 pattern of inline arrow handlers
|
|
71
|
+
* passes a fresh closure every render). Without this, the host
|
|
72
|
+
* `$effect` would have to teardown + re-attach and the user's in-flight
|
|
73
|
+
* pan would silently die.
|
|
74
|
+
*/
|
|
75
|
+
export type AttachPanCleanup = (() => void) & {
|
|
76
|
+
update: (next: PanHandlers) => void;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Attach a pan gesture session to `el`. Returns a cleanup function that
|
|
80
|
+
* tears down the pointerdown listener and ends any in-flight session,
|
|
81
|
+
* with a `.update(next)` method for hot-swapping handlers mid-gesture.
|
|
82
|
+
*
|
|
83
|
+
* Internally a fresh `PanSession` spawns on each pointerdown — the
|
|
84
|
+
* outer attachment just keeps the pointerdown listener alive across the
|
|
85
|
+
* element's lifetime.
|
|
86
|
+
*
|
|
87
|
+
* SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
|
|
88
|
+
* `$effect` consumer never fires on the server anyway, but defending the
|
|
89
|
+
* boundary lets the module load cleanly in node-only test runners.
|
|
90
|
+
*
|
|
91
|
+
* Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
|
|
92
|
+
* session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
|
|
93
|
+
* BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
|
|
94
|
+
* (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
|
|
95
|
+
* revert logic inside the user-supplied `onEnd` and rely on it firing
|
|
96
|
+
* exactly once per gesture — whether the user released or the host
|
|
97
|
+
* forced teardown.
|
|
98
|
+
*
|
|
99
|
+
* @param el Target element to bind `pointerdown` on. Move/up/cancel
|
|
100
|
+
* events are listened for on the element's owning window so a fast
|
|
101
|
+
* swipe past the element's bounds keeps the gesture alive.
|
|
102
|
+
* @param handlers Pan lifecycle handlers. Any subset of
|
|
103
|
+
* `onSessionStart` (fires on pointerdown), `onStart` (fires the first
|
|
104
|
+
* time the cumulative offset crosses `distanceThreshold`), `onMove`
|
|
105
|
+
* (per-frame-throttled on every pointermove past threshold), `onEnd`
|
|
106
|
+
* (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
|
|
107
|
+
* (fires on every pointerup/cancel where a pointermove occurred).
|
|
108
|
+
* @param options Per-session config. `distanceThreshold` (default 3px)
|
|
109
|
+
* gates the start callback; `contextWindow` overrides the owning
|
|
110
|
+
* window (use for shadow-root / iframe scenarios).
|
|
111
|
+
* @returns A cleanup function with an attached `.update(next)` method.
|
|
112
|
+
* Calling the cleanup ends the session + removes the pointerdown
|
|
113
|
+
* listener. Calling `.update(next)` swaps handlers in place on the
|
|
114
|
+
* live session without rebuilding it — the canonical Svelte pattern
|
|
115
|
+
* for inline arrow handlers that change identity each render.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const cleanup = attachPan(node, {
|
|
120
|
+
* onStart: (_event, info) => console.log('start', info.offset),
|
|
121
|
+
* onMove: (_event, info) => x.set(info.offset.x),
|
|
122
|
+
* onEnd: (_event, info) => {
|
|
123
|
+
* if (Math.abs(info.velocity.x) > 600) commit()
|
|
124
|
+
* else animate(x, 0, { type: 'spring' })
|
|
125
|
+
* }
|
|
126
|
+
* })
|
|
127
|
+
*
|
|
128
|
+
* // Later, swap handlers without ending the live gesture:
|
|
129
|
+
* cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
|
|
130
|
+
*
|
|
131
|
+
* // On unmount:
|
|
132
|
+
* cleanup()
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export declare const attachPan: (el: HTMLElement, handlers: PanHandlers, options?: AttachPanOptions) => AttachPanCleanup;
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { cancelFrame, frame, frameData, isPrimaryPointer } from 'motion-dom';
|
|
2
|
+
/**
|
|
3
|
+
* Brand we stamp on already-wrapped handlers so passing them back through
|
|
4
|
+
* `wrapHandlers` (e.g. by a future middleware layer) doesn't double-defer
|
|
5
|
+
* — that would compound frame latency invisibly per wrap depth. Symbol
|
|
6
|
+
* scoping keeps it private to this module.
|
|
7
|
+
*/
|
|
8
|
+
const WRAPPED_BRAND = Symbol('svelte-motion:pan:wrapped');
|
|
9
|
+
const wrapUpdate = (handler, isAlive) => {
|
|
10
|
+
if (!handler)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (handler[WRAPPED_BRAND])
|
|
13
|
+
return handler;
|
|
14
|
+
const wrapped = (event, info) => {
|
|
15
|
+
frame.update(() => {
|
|
16
|
+
// `isAlive` flips false on teardown; any frame.update closure
|
|
17
|
+
// queued before teardown but not yet flushed will see this and
|
|
18
|
+
// short-circuit — that's our cancellation path for the
|
|
19
|
+
// otherwise-uncancellable anonymous closures `frame.update`
|
|
20
|
+
// accepts.
|
|
21
|
+
if (!isAlive())
|
|
22
|
+
return;
|
|
23
|
+
handler(event, info);
|
|
24
|
+
}, false, true);
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
|
|
27
|
+
return wrapped;
|
|
28
|
+
};
|
|
29
|
+
const wrapPostRender = (handler, isAlive) => {
|
|
30
|
+
if (!handler)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (handler[WRAPPED_BRAND])
|
|
33
|
+
return handler;
|
|
34
|
+
const wrapped = (event, info) => {
|
|
35
|
+
frame.postRender(() => {
|
|
36
|
+
if (!isAlive())
|
|
37
|
+
return;
|
|
38
|
+
handler(event, info);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
|
|
42
|
+
return wrapped;
|
|
43
|
+
};
|
|
44
|
+
const wrapHandlers = (handlers, isAlive) => ({
|
|
45
|
+
onSessionStart: wrapUpdate(handlers.onSessionStart, isAlive),
|
|
46
|
+
onStart: wrapUpdate(handlers.onStart, isAlive),
|
|
47
|
+
onMove: wrapUpdate(handlers.onMove, isAlive),
|
|
48
|
+
onEnd: wrapPostRender(handlers.onEnd, isAlive),
|
|
49
|
+
onSessionEnd: wrapPostRender(handlers.onSessionEnd, isAlive)
|
|
50
|
+
});
|
|
51
|
+
const overflowStyles = new Set(['auto', 'scroll']);
|
|
52
|
+
const millisecondsToSeconds = (ms) => ms / 1000;
|
|
53
|
+
const secondsToMilliseconds = (s) => s * 1000;
|
|
54
|
+
const subtractPoint = (a, b) => ({
|
|
55
|
+
x: a.x - b.x,
|
|
56
|
+
y: a.y - b.y
|
|
57
|
+
});
|
|
58
|
+
const distance2D = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
|
|
59
|
+
/**
|
|
60
|
+
* Compute velocity (px/s) from the history of timestamped points,
|
|
61
|
+
* looking back `timeDelta` seconds for stability. Matches upstream's
|
|
62
|
+
* `getVelocity` including the hold-then-flick safeguard (skip
|
|
63
|
+
* history[0] if it's > 2× timeDelta old AND there are alternatives).
|
|
64
|
+
*/
|
|
65
|
+
const getVelocity = (history, timeDelta) => {
|
|
66
|
+
if (history.length < 2)
|
|
67
|
+
return { x: 0, y: 0 };
|
|
68
|
+
let i = history.length - 1;
|
|
69
|
+
let timestampedPoint = null;
|
|
70
|
+
const lastPoint = history[history.length - 1];
|
|
71
|
+
while (i >= 0) {
|
|
72
|
+
timestampedPoint = history[i];
|
|
73
|
+
if (lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta)) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
i--;
|
|
77
|
+
}
|
|
78
|
+
if (!timestampedPoint)
|
|
79
|
+
return { x: 0, y: 0 };
|
|
80
|
+
if (timestampedPoint === history[0] &&
|
|
81
|
+
history.length > 2 &&
|
|
82
|
+
lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta) * 2) {
|
|
83
|
+
timestampedPoint = history[1];
|
|
84
|
+
}
|
|
85
|
+
const time = millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp);
|
|
86
|
+
if (time === 0)
|
|
87
|
+
return { x: 0, y: 0 };
|
|
88
|
+
const v = {
|
|
89
|
+
x: (lastPoint.x - timestampedPoint.x) / time,
|
|
90
|
+
y: (lastPoint.y - timestampedPoint.y) / time
|
|
91
|
+
};
|
|
92
|
+
if (v.x === Infinity)
|
|
93
|
+
v.x = 0;
|
|
94
|
+
if (v.y === Infinity)
|
|
95
|
+
v.y = 0;
|
|
96
|
+
return v;
|
|
97
|
+
};
|
|
98
|
+
const getPanInfo = (point, history) => ({
|
|
99
|
+
point,
|
|
100
|
+
delta: subtractPoint(point, history[history.length - 1]),
|
|
101
|
+
offset: subtractPoint(point, history[0]),
|
|
102
|
+
velocity: getVelocity(history, 0.1)
|
|
103
|
+
});
|
|
104
|
+
const extractEventPoint = (event) => ({
|
|
105
|
+
x: event.pageX,
|
|
106
|
+
y: event.pageY
|
|
107
|
+
});
|
|
108
|
+
/**
|
|
109
|
+
* Attach a pan gesture session to `el`. Returns a cleanup function that
|
|
110
|
+
* tears down the pointerdown listener and ends any in-flight session,
|
|
111
|
+
* with a `.update(next)` method for hot-swapping handlers mid-gesture.
|
|
112
|
+
*
|
|
113
|
+
* Internally a fresh `PanSession` spawns on each pointerdown — the
|
|
114
|
+
* outer attachment just keeps the pointerdown listener alive across the
|
|
115
|
+
* element's lifetime.
|
|
116
|
+
*
|
|
117
|
+
* SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
|
|
118
|
+
* `$effect` consumer never fires on the server anyway, but defending the
|
|
119
|
+
* boundary lets the module load cleanly in node-only test runners.
|
|
120
|
+
*
|
|
121
|
+
* Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
|
|
122
|
+
* session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
|
|
123
|
+
* BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
|
|
124
|
+
* (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
|
|
125
|
+
* revert logic inside the user-supplied `onEnd` and rely on it firing
|
|
126
|
+
* exactly once per gesture — whether the user released or the host
|
|
127
|
+
* forced teardown.
|
|
128
|
+
*
|
|
129
|
+
* @param el Target element to bind `pointerdown` on. Move/up/cancel
|
|
130
|
+
* events are listened for on the element's owning window so a fast
|
|
131
|
+
* swipe past the element's bounds keeps the gesture alive.
|
|
132
|
+
* @param handlers Pan lifecycle handlers. Any subset of
|
|
133
|
+
* `onSessionStart` (fires on pointerdown), `onStart` (fires the first
|
|
134
|
+
* time the cumulative offset crosses `distanceThreshold`), `onMove`
|
|
135
|
+
* (per-frame-throttled on every pointermove past threshold), `onEnd`
|
|
136
|
+
* (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
|
|
137
|
+
* (fires on every pointerup/cancel where a pointermove occurred).
|
|
138
|
+
* @param options Per-session config. `distanceThreshold` (default 3px)
|
|
139
|
+
* gates the start callback; `contextWindow` overrides the owning
|
|
140
|
+
* window (use for shadow-root / iframe scenarios).
|
|
141
|
+
* @returns A cleanup function with an attached `.update(next)` method.
|
|
142
|
+
* Calling the cleanup ends the session + removes the pointerdown
|
|
143
|
+
* listener. Calling `.update(next)` swaps handlers in place on the
|
|
144
|
+
* live session without rebuilding it — the canonical Svelte pattern
|
|
145
|
+
* for inline arrow handlers that change identity each render.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* const cleanup = attachPan(node, {
|
|
150
|
+
* onStart: (_event, info) => console.log('start', info.offset),
|
|
151
|
+
* onMove: (_event, info) => x.set(info.offset.x),
|
|
152
|
+
* onEnd: (_event, info) => {
|
|
153
|
+
* if (Math.abs(info.velocity.x) > 600) commit()
|
|
154
|
+
* else animate(x, 0, { type: 'spring' })
|
|
155
|
+
* }
|
|
156
|
+
* })
|
|
157
|
+
*
|
|
158
|
+
* // Later, swap handlers without ending the live gesture:
|
|
159
|
+
* cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
|
|
160
|
+
*
|
|
161
|
+
* // On unmount:
|
|
162
|
+
* cleanup()
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export const attachPan = (el, handlers, options = {}) => {
|
|
166
|
+
if (typeof window === 'undefined') {
|
|
167
|
+
const noop = () => { };
|
|
168
|
+
return Object.assign(noop, { update: () => { } });
|
|
169
|
+
}
|
|
170
|
+
const contextWindow = options.contextWindow ?? el.ownerDocument?.defaultView ?? window;
|
|
171
|
+
const distanceThreshold = options.distanceThreshold ?? 3;
|
|
172
|
+
let session = null;
|
|
173
|
+
let rawHandlers = handlers;
|
|
174
|
+
// Liveness flag the wrapped handler closures consult before invoking
|
|
175
|
+
// the user callback. Flips false at teardown so any frame.update /
|
|
176
|
+
// frame.postRender callbacks queued before teardown — but not yet
|
|
177
|
+
// flushed — see the flag and skip dispatch. This is our only way to
|
|
178
|
+
// cancel the anonymous closures the wrappers schedule (frame.update
|
|
179
|
+
// doesn't return a handle we can store per call).
|
|
180
|
+
let isAlive = true;
|
|
181
|
+
const aliveGuard = () => isAlive;
|
|
182
|
+
// Frame-scheduled mirror of the live handlers — onSessionStart / onStart /
|
|
183
|
+
// onMove are queued onto motion-dom's `update` step, onEnd / onSessionEnd
|
|
184
|
+
// onto `postRender`. This is the wrap upstream applies via `asyncHandler`
|
|
185
|
+
// + `frame.postRender` in PanGesture.createPanHandlers; see the
|
|
186
|
+
// wrapUpdate / wrapPostRender helpers at the top of this file for the
|
|
187
|
+
// rationale. PanSession itself stays scheduler-unaware (and synchronous,
|
|
188
|
+
// for testability) — the scheduling lives at the `attachPan` boundary.
|
|
189
|
+
let liveHandlers = wrapHandlers(handlers, aliveGuard);
|
|
190
|
+
const onPointerDown = (event) => {
|
|
191
|
+
// Match upstream: ignore non-primary pointers (multi-touch, right-click).
|
|
192
|
+
if (!isPrimaryPointer(event))
|
|
193
|
+
return;
|
|
194
|
+
// Defensively end any prior session before overwriting the reference.
|
|
195
|
+
// Without this, a second primary pointerdown that arrives before the
|
|
196
|
+
// first pointerup orphans the prior session's contextWindow listeners.
|
|
197
|
+
session?.end();
|
|
198
|
+
session = new PanSession(event, liveHandlers, {
|
|
199
|
+
distanceThreshold,
|
|
200
|
+
contextWindow,
|
|
201
|
+
element: el
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
el.addEventListener('pointerdown', onPointerDown);
|
|
205
|
+
const update = (next) => {
|
|
206
|
+
rawHandlers = next;
|
|
207
|
+
liveHandlers = wrapHandlers(next, aliveGuard);
|
|
208
|
+
session?.updateHandlers(liveHandlers);
|
|
209
|
+
};
|
|
210
|
+
const teardown = () => {
|
|
211
|
+
// Synthesize the gesture's terminal lifecycle BEFORE flipping
|
|
212
|
+
// `isAlive`, so a host that tears us down mid-pan (effect re-run,
|
|
213
|
+
// component unmount) still sees a balanced onPanEnd / onPanSessionEnd
|
|
214
|
+
// pair. Dispatched against the *raw* (unwrapped) handlers so the
|
|
215
|
+
// delivery is synchronous — the wrapped lane would otherwise queue
|
|
216
|
+
// the callbacks onto frame.postRender just for them to be cancelled
|
|
217
|
+
// by the `isAlive = false` line immediately below.
|
|
218
|
+
if (session) {
|
|
219
|
+
session.dispatchTerminal(rawHandlers);
|
|
220
|
+
session.end();
|
|
221
|
+
session = null;
|
|
222
|
+
}
|
|
223
|
+
isAlive = false;
|
|
224
|
+
el.removeEventListener('pointerdown', onPointerDown);
|
|
225
|
+
};
|
|
226
|
+
return Object.assign(teardown, { update });
|
|
227
|
+
};
|
|
228
|
+
class PanSession {
|
|
229
|
+
history = [];
|
|
230
|
+
startEvent = null;
|
|
231
|
+
lastMoveEvent = null;
|
|
232
|
+
lastMovePoint = null;
|
|
233
|
+
handlers = {};
|
|
234
|
+
contextWindow = window;
|
|
235
|
+
distanceThreshold = 3;
|
|
236
|
+
element = null;
|
|
237
|
+
scrollPositions = new Map();
|
|
238
|
+
/**
|
|
239
|
+
* Idempotency flag — set the first time the gesture's terminal
|
|
240
|
+
* lifecycle pair (`onEnd` + `onSessionEnd`) fires. Both
|
|
241
|
+
* `handlePointerUp` (the natural release path) and
|
|
242
|
+
* `dispatchTerminal` (the forced-teardown path called by
|
|
243
|
+
* `attachPan.teardown`) check this and bail if already dispatched.
|
|
244
|
+
* Without it, a normal pointerup followed by a host-side teardown
|
|
245
|
+
* (e.g. `$effect` cleanup, component unmount) would replay
|
|
246
|
+
* `onEnd`/`onSessionEnd` against handlers that already saw them.
|
|
247
|
+
*/
|
|
248
|
+
terminalDispatched = false;
|
|
249
|
+
removeScrollListeners = null;
|
|
250
|
+
removeListeners = null;
|
|
251
|
+
constructor(event, handlers, opts) {
|
|
252
|
+
// Bail on non-primary pointers. Properties keep their declared
|
|
253
|
+
// defaults so TypeScript sees them initialized regardless of which
|
|
254
|
+
// constructor branch ran.
|
|
255
|
+
if (!isPrimaryPointer(event))
|
|
256
|
+
return;
|
|
257
|
+
this.handlers = handlers;
|
|
258
|
+
this.contextWindow = opts.contextWindow;
|
|
259
|
+
this.distanceThreshold = opts.distanceThreshold;
|
|
260
|
+
this.element = opts.element;
|
|
261
|
+
const point = extractEventPoint(event);
|
|
262
|
+
this.history = [{ ...point, timestamp: frameData.timestamp }];
|
|
263
|
+
this.handlers.onSessionStart?.(event, getPanInfo(point, this.history));
|
|
264
|
+
const moveHandler = (e) => this.handlePointerMove(e);
|
|
265
|
+
const upHandler = (e) => this.handlePointerUp(e);
|
|
266
|
+
this.contextWindow.addEventListener('pointermove', moveHandler);
|
|
267
|
+
this.contextWindow.addEventListener('pointerup', upHandler);
|
|
268
|
+
this.contextWindow.addEventListener('pointercancel', upHandler);
|
|
269
|
+
this.removeListeners = () => {
|
|
270
|
+
this.contextWindow.removeEventListener('pointermove', moveHandler);
|
|
271
|
+
this.contextWindow.removeEventListener('pointerup', upHandler);
|
|
272
|
+
this.contextWindow.removeEventListener('pointercancel', upHandler);
|
|
273
|
+
};
|
|
274
|
+
if (this.element)
|
|
275
|
+
this.startScrollTracking(this.element);
|
|
276
|
+
}
|
|
277
|
+
updateHandlers(handlers) {
|
|
278
|
+
this.handlers = handlers;
|
|
279
|
+
}
|
|
280
|
+
end() {
|
|
281
|
+
this.removeListeners?.();
|
|
282
|
+
this.removeListeners = null;
|
|
283
|
+
this.removeScrollListeners?.();
|
|
284
|
+
this.removeScrollListeners = null;
|
|
285
|
+
this.scrollPositions.clear();
|
|
286
|
+
cancelFrame(this.updatePoint);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Synthesize the gesture's terminal lifecycle pair (`onEnd` then
|
|
290
|
+
* `onSessionEnd`) against the supplied *raw* (unwrapped) handlers,
|
|
291
|
+
* using the last observed event + point as the synthetic terminal
|
|
292
|
+
* sample. Called by `attachPan.teardown` when a host kills the
|
|
293
|
+
* session mid-gesture — without this, an `$effect` re-run that
|
|
294
|
+
* tears down the attachment silently strands the consumer's state
|
|
295
|
+
* machine in an "in-progress" state (whilePan keyframes never
|
|
296
|
+
* revert, threshold-based commit decisions never run).
|
|
297
|
+
*
|
|
298
|
+
* Bypasses the frame-loop wrappers deliberately: the wrapped
|
|
299
|
+
* handlers would queue to `frame.postRender` only for the
|
|
300
|
+
* about-to-flip `isAlive` flag in attachPan to cancel them. Raw
|
|
301
|
+
* dispatch keeps the lifecycle synchronous with teardown.
|
|
302
|
+
*
|
|
303
|
+
* No-op when no pointermove ever fired — matches the
|
|
304
|
+
* `handlePointerUp` no-movement contract upstream uses.
|
|
305
|
+
*/
|
|
306
|
+
dispatchTerminal(rawHandlers) {
|
|
307
|
+
if (this.terminalDispatched)
|
|
308
|
+
return;
|
|
309
|
+
if (!(this.lastMoveEvent && this.lastMovePoint))
|
|
310
|
+
return;
|
|
311
|
+
const info = getPanInfo(this.lastMovePoint, this.history);
|
|
312
|
+
if (this.startEvent)
|
|
313
|
+
rawHandlers.onEnd?.(this.lastMoveEvent, info);
|
|
314
|
+
rawHandlers.onSessionEnd?.(this.lastMoveEvent, info);
|
|
315
|
+
this.terminalDispatched = true;
|
|
316
|
+
}
|
|
317
|
+
handlePointerMove = (event) => {
|
|
318
|
+
this.lastMoveEvent = event;
|
|
319
|
+
this.lastMovePoint = extractEventPoint(event);
|
|
320
|
+
// Per-frame throttle so a 1000hz mouse doesn't drown handlers.
|
|
321
|
+
frame.update(this.updatePoint, true);
|
|
322
|
+
};
|
|
323
|
+
handlePointerUp = (event) => {
|
|
324
|
+
this.end();
|
|
325
|
+
if (this.terminalDispatched)
|
|
326
|
+
return;
|
|
327
|
+
if (!(this.lastMoveEvent && this.lastMovePoint)) {
|
|
328
|
+
// No pointermove ever fired — match upstream framer-motion
|
|
329
|
+
// (`packages/framer-motion/src/gestures/pan/PanSession.ts`
|
|
330
|
+
// ~line 320) and return WITHOUT firing onEnd / onSessionEnd.
|
|
331
|
+
// Consumers that want a "tap" signal should use the press /
|
|
332
|
+
// tap gesture instead. This prevents a spurious
|
|
333
|
+
// onPanSessionStart → onPanSessionEnd pair on every plain
|
|
334
|
+
// click of a pan-enabled element.
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const finalPoint = event.type === 'pointercancel' ? this.lastMovePoint : extractEventPoint(event);
|
|
338
|
+
const info = getPanInfo(finalPoint, this.history);
|
|
339
|
+
if (this.startEvent)
|
|
340
|
+
this.handlers.onEnd?.(event, info);
|
|
341
|
+
this.handlers.onSessionEnd?.(event, info);
|
|
342
|
+
// Mark idempotent so a later forced teardown via
|
|
343
|
+
// `dispatchTerminal` doesn't replay this pair.
|
|
344
|
+
this.terminalDispatched = true;
|
|
345
|
+
};
|
|
346
|
+
updatePoint = () => {
|
|
347
|
+
if (!(this.lastMoveEvent && this.lastMovePoint))
|
|
348
|
+
return;
|
|
349
|
+
const info = getPanInfo(this.lastMovePoint, this.history);
|
|
350
|
+
const panAlreadyStarted = this.startEvent !== null;
|
|
351
|
+
const pastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold;
|
|
352
|
+
if (!panAlreadyStarted && !pastThreshold)
|
|
353
|
+
return;
|
|
354
|
+
this.history.push({ ...this.lastMovePoint, timestamp: frameData.timestamp });
|
|
355
|
+
if (!panAlreadyStarted) {
|
|
356
|
+
this.handlers.onStart?.(this.lastMoveEvent, info);
|
|
357
|
+
this.startEvent = this.lastMoveEvent;
|
|
358
|
+
}
|
|
359
|
+
this.handlers.onMove?.(this.lastMoveEvent, info);
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* Track scrollable ancestors so we can compensate for scroll deltas
|
|
363
|
+
* during the gesture — mirrors upstream's `startScrollTracking`.
|
|
364
|
+
* For element scrolls: adjust `history[0]` so offset stays sane
|
|
365
|
+
* (pageX/pageY unaffected by element scroll). For window scrolls:
|
|
366
|
+
* adjust `lastMovePoint` (pageX/pageY shift with window scroll).
|
|
367
|
+
*/
|
|
368
|
+
startScrollTracking(element) {
|
|
369
|
+
let current = element.parentElement;
|
|
370
|
+
while (current) {
|
|
371
|
+
const style = getComputedStyle(current);
|
|
372
|
+
if (overflowStyles.has(style.overflowX) || overflowStyles.has(style.overflowY)) {
|
|
373
|
+
this.scrollPositions.set(current, {
|
|
374
|
+
x: current.scrollLeft,
|
|
375
|
+
y: current.scrollTop
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
current = current.parentElement;
|
|
379
|
+
}
|
|
380
|
+
this.scrollPositions.set(this.contextWindow, {
|
|
381
|
+
x: this.contextWindow.scrollX,
|
|
382
|
+
y: this.contextWindow.scrollY
|
|
383
|
+
});
|
|
384
|
+
const onElementScroll = (event) => {
|
|
385
|
+
this.handleScroll(event.target);
|
|
386
|
+
};
|
|
387
|
+
const onWindowScroll = () => {
|
|
388
|
+
this.handleScroll(this.contextWindow);
|
|
389
|
+
};
|
|
390
|
+
this.contextWindow.addEventListener('scroll', onElementScroll, { capture: true });
|
|
391
|
+
this.contextWindow.addEventListener('scroll', onWindowScroll);
|
|
392
|
+
this.removeScrollListeners = () => {
|
|
393
|
+
this.contextWindow.removeEventListener('scroll', onElementScroll, {
|
|
394
|
+
capture: true
|
|
395
|
+
});
|
|
396
|
+
this.contextWindow.removeEventListener('scroll', onWindowScroll);
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
handleScroll(target) {
|
|
400
|
+
const initial = this.scrollPositions.get(target);
|
|
401
|
+
if (!initial)
|
|
402
|
+
return;
|
|
403
|
+
const isWindow = target === this.contextWindow;
|
|
404
|
+
const current = isWindow
|
|
405
|
+
? { x: this.contextWindow.scrollX, y: this.contextWindow.scrollY }
|
|
406
|
+
: {
|
|
407
|
+
x: target.scrollLeft,
|
|
408
|
+
y: target.scrollTop
|
|
409
|
+
};
|
|
410
|
+
const delta = { x: current.x - initial.x, y: current.y - initial.y };
|
|
411
|
+
if (delta.x === 0 && delta.y === 0)
|
|
412
|
+
return;
|
|
413
|
+
if (isWindow) {
|
|
414
|
+
if (this.lastMovePoint) {
|
|
415
|
+
this.lastMovePoint.x += delta.x;
|
|
416
|
+
this.lastMovePoint.y += delta.y;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else if (this.history.length > 0) {
|
|
420
|
+
this.history[0].x -= delta.x;
|
|
421
|
+
this.history[0].y -= delta.y;
|
|
422
|
+
}
|
|
423
|
+
this.scrollPositions.set(target, current);
|
|
424
|
+
frame.update(this.updatePoint, true);
|
|
425
|
+
}
|
|
426
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-motion",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values. The drop-in Framer Motion alternative for Svelte and SvelteKit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"@eslint/js": "^10.0.1",
|
|
105
105
|
"@playwright/test": "^1.60.0",
|
|
106
106
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
107
|
-
"@sveltejs/kit": "^2.
|
|
107
|
+
"@sveltejs/kit": "^2.61.1",
|
|
108
108
|
"@sveltejs/package": "^2.5.7",
|
|
109
109
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
110
110
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"prettier": "^3.8.3",
|
|
132
132
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
133
133
|
"prettier-plugin-sort-json": "^4.2.0",
|
|
134
|
-
"prettier-plugin-svelte": "^
|
|
134
|
+
"prettier-plugin-svelte": "^4.0.1",
|
|
135
135
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
136
136
|
"publint": "^0.3.21",
|
|
137
137
|
"runed": "0.37.1",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"tailwindcss-animate": "^1.0.7",
|
|
145
145
|
"tsx": "^4.22.3",
|
|
146
146
|
"typescript": "^6.0.3",
|
|
147
|
-
"typescript-eslint": "^8.
|
|
147
|
+
"typescript-eslint": "^8.60.0",
|
|
148
148
|
"vite": "^8.0.14",
|
|
149
149
|
"vite-tsconfig-paths": "^6.1.1",
|
|
150
150
|
"vitest": "^4.1.7"
|