@humanspeak/svelte-motion 0.3.4 → 0.3.6
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/components/PresenceChild.svelte +124 -0
- package/dist/components/PresenceChild.svelte.d.ts +8 -0
- package/dist/html/_MotionContainer.svelte +15 -7
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/utils/drag.js +155 -48
- package/dist/utils/dragMath.d.ts +18 -0
- package/dist/utils/dragMath.js +21 -0
- package/dist/utils/inertia.js +4 -3
- package/dist/utils/presence.d.ts +38 -11
- package/dist/utils/presence.js +80 -54
- package/dist/utils/usePresence.d.ts +71 -0
- package/dist/utils/usePresence.js +74 -0
- package/dist/vite.js +176 -7
- package/package.json +58 -45
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import {
|
|
4
|
+
getAnimatePresenceContext,
|
|
5
|
+
getPresenceDepth,
|
|
6
|
+
setPresenceChildContext,
|
|
7
|
+
setPresenceDepth
|
|
8
|
+
} from '../utils/presence'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Holds its `children` snippet rendered until the consumer signals the
|
|
12
|
+
* exit is complete via `safeToRemove()`. Lets a child run its own (non-
|
|
13
|
+
* `motion.*`) exit animation — fade via CSS transition, canvas effect,
|
|
14
|
+
* GSAP, etc — while still participating in `AnimatePresence`'s
|
|
15
|
+
* `onExitComplete` accounting and `mode='wait'` enter blocking.
|
|
16
|
+
*
|
|
17
|
+
* Children read `useIsPresent()` or `usePresence()` to observe the
|
|
18
|
+
* exit-phase flip.
|
|
19
|
+
*
|
|
20
|
+
* @prop present Bind to the same boolean that conditionally rendered this
|
|
21
|
+
* wrapper. When it flips to `false`, the wrapper holds children
|
|
22
|
+
* mounted with `isPresent = false` until `safeToRemove` fires.
|
|
23
|
+
* @prop children Snippet rendered while present or held.
|
|
24
|
+
*/
|
|
25
|
+
const { present = true, children } = $props<{
|
|
26
|
+
present?: boolean
|
|
27
|
+
children?: Snippet
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const animatePresence = getAnimatePresenceContext()
|
|
31
|
+
const presenceDepth = getPresenceDepth()
|
|
32
|
+
|
|
33
|
+
// Descendants of PresenceChild are no longer at depth 0, so any motion.*
|
|
34
|
+
// children inside don't trip the "direct children of AnimatePresence need
|
|
35
|
+
// a key" check in _MotionContainer.
|
|
36
|
+
if (presenceDepth !== undefined) {
|
|
37
|
+
setPresenceDepth(presenceDepth + 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Three-phase exit lifecycle:
|
|
41
|
+
// 'idle' — no exit in progress; render iff `present`.
|
|
42
|
+
// 'holding' — `present` flipped false; we're still rendering with
|
|
43
|
+
// isPresent=false, waiting for the consumer to call
|
|
44
|
+
// safeToRemove.
|
|
45
|
+
// 'completed' — consumer signaled completion. Stop rendering. Returning
|
|
46
|
+
// to 'idle' only happens if `present` flips back to true.
|
|
47
|
+
type ExitPhase = 'idle' | 'holding' | 'completed'
|
|
48
|
+
let phase = $state<ExitPhase>('idle')
|
|
49
|
+
// Track the prior `present` value so we only start an exit on a true→false
|
|
50
|
+
// transition, not when mounted with `present=false` (a no-op steady state).
|
|
51
|
+
// Using a closure variable inside $effect.pre below — see initial value
|
|
52
|
+
// capture there.
|
|
53
|
+
let prevPresent: boolean | undefined = undefined
|
|
54
|
+
|
|
55
|
+
// Each exit start mints a fresh `safeToRemove` closure bound to the cycle
|
|
56
|
+
// it was minted for. Re-entry / completion invalidates older closures so a
|
|
57
|
+
// captured-then-late-fired callback (setTimeout, external lib, stray
|
|
58
|
+
// listener after cleanup) from cycle A cannot complete cycle B.
|
|
59
|
+
let currentSafeToRemove: () => void = noopSafeToRemove
|
|
60
|
+
|
|
61
|
+
function noopSafeToRemove() {}
|
|
62
|
+
|
|
63
|
+
function mintSafeToRemove(): () => void {
|
|
64
|
+
// Closure compares against its own identity (`self`) to detect
|
|
65
|
+
// whether it's still the active cycle's callback. After re-entry or
|
|
66
|
+
// completion, `currentSafeToRemove` no longer points at `self`, so
|
|
67
|
+
// this branch no-ops — a stale capture cannot complete a later exit.
|
|
68
|
+
const self: () => void = () => {
|
|
69
|
+
if (currentSafeToRemove !== self || phase !== 'holding') return
|
|
70
|
+
phase = 'completed'
|
|
71
|
+
currentSafeToRemove = noopSafeToRemove
|
|
72
|
+
animatePresence?.notifyExitComplete()
|
|
73
|
+
}
|
|
74
|
+
return self
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isPresent = $derived(present && phase === 'idle')
|
|
78
|
+
|
|
79
|
+
setPresenceChildContext({
|
|
80
|
+
get isPresent() {
|
|
81
|
+
return isPresent
|
|
82
|
+
},
|
|
83
|
+
get safeToRemove() {
|
|
84
|
+
return currentSafeToRemove
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// $effect.pre runs before DOM updates so the phase transition lands in
|
|
89
|
+
// the same render pass as the `present` prop flip — otherwise the {#if}
|
|
90
|
+
// below re-evaluates with stale phase and unmounts/remounts the children,
|
|
91
|
+
// which (a) breaks any CSS transition the consumer relies on for exit and
|
|
92
|
+
// (b) makes `bind:this` references go stale.
|
|
93
|
+
$effect.pre(() => {
|
|
94
|
+
// Read `present` reactively first; assignments to module locals
|
|
95
|
+
// happen inside the branches below.
|
|
96
|
+
const current = present
|
|
97
|
+
// First run: just record the steady state. Don't fire any exit/enter
|
|
98
|
+
// signal — there's no transition to react to yet.
|
|
99
|
+
if (prevPresent === undefined) {
|
|
100
|
+
prevPresent = current
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (prevPresent && !current && phase === 'idle') {
|
|
104
|
+
phase = 'holding'
|
|
105
|
+
currentSafeToRemove = mintSafeToRemove()
|
|
106
|
+
animatePresence?.notifyExitStart()
|
|
107
|
+
} else if (!prevPresent && current && phase === 'holding') {
|
|
108
|
+
// Re-entry mid-hold cancels the exit accounting. Replacing the
|
|
109
|
+
// slot invalidates the cycle's `safeToRemove` for any consumer
|
|
110
|
+
// that captured it.
|
|
111
|
+
phase = 'idle'
|
|
112
|
+
currentSafeToRemove = noopSafeToRemove
|
|
113
|
+
animatePresence?.notifyExitComplete()
|
|
114
|
+
} else if (!prevPresent && current && phase === 'completed') {
|
|
115
|
+
// Re-mounted after a previous exit fully completed.
|
|
116
|
+
phase = 'idle'
|
|
117
|
+
}
|
|
118
|
+
prevPresent = current
|
|
119
|
+
})
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
{#if present || phase === 'holding'}
|
|
123
|
+
{@render children?.()}
|
|
124
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
present?: boolean;
|
|
4
|
+
children?: Snippet;
|
|
5
|
+
};
|
|
6
|
+
declare const PresenceChild: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type PresenceChild = ReturnType<typeof PresenceChild>;
|
|
8
|
+
export default PresenceChild;
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import { isNativelyFocusable } from '../utils/a11y'
|
|
43
43
|
import {
|
|
44
44
|
getAnimatePresenceContext,
|
|
45
|
+
getPresenceChildContext,
|
|
45
46
|
getPresenceDepth,
|
|
46
47
|
setPresenceDepth
|
|
47
48
|
} from '../utils/presence'
|
|
@@ -127,6 +128,12 @@
|
|
|
127
128
|
|
|
128
129
|
// Get presence context to check if we're inside AnimatePresence
|
|
129
130
|
const context = getAnimatePresenceContext()
|
|
131
|
+
// Inside a <PresenceChild>, the wrapper drives the exit. Skip the
|
|
132
|
+
// clone-based exit registration on this element so we don't double-fire
|
|
133
|
+
// (custom exit, then clone of a node the wrapper already let go).
|
|
134
|
+
// Enter-side coordination (shouldAnimateEnter, mode='wait' blocking)
|
|
135
|
+
// remains active so the element still slots into the outer presence flow.
|
|
136
|
+
const inPresenceChild = !!getPresenceChildContext()
|
|
130
137
|
|
|
131
138
|
// Get layoutId registry (provided by AnimatePresence or a parent LayoutGroup)
|
|
132
139
|
const layoutIdRegistry = getLayoutIdRegistry()
|
|
@@ -168,9 +175,8 @@
|
|
|
168
175
|
)
|
|
169
176
|
|
|
170
177
|
// Register onDestroy at component level (guaranteed to work in Svelte 5)
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
if (context) {
|
|
178
|
+
// — getContext()/onDestroy() must run during component initialization.
|
|
179
|
+
if (context && !inPresenceChild) {
|
|
174
180
|
onDestroy(() => {
|
|
175
181
|
pwLog('[presence] onDestroy triggered', { key: presenceKey })
|
|
176
182
|
context.unregisterChild(presenceKey)
|
|
@@ -181,8 +187,9 @@
|
|
|
181
187
|
// from the correct visual state. Without this, interrupting an enter animation
|
|
182
188
|
// causes the exit to snap (the element is disconnected before onDestroy, so
|
|
183
189
|
// getAnimations()/commitStyles() can't work at clone time).
|
|
190
|
+
// Skipped inside <PresenceChild>: the wrapper drives exit, no clone path.
|
|
184
191
|
$effect(() => {
|
|
185
|
-
if (!(element && context)) return
|
|
192
|
+
if (!(element && context) || inPresenceChild) return
|
|
186
193
|
let rafId: number
|
|
187
194
|
const capture = () => {
|
|
188
195
|
if (element && element.isConnected && element.getAnimations().length > 0) {
|
|
@@ -227,7 +234,7 @@
|
|
|
227
234
|
|
|
228
235
|
// Reactively update registration when element/exit/transition props change
|
|
229
236
|
$effect(() => {
|
|
230
|
-
if (element && context && resolvedExit) {
|
|
237
|
+
if (element && context && !inPresenceChild && resolvedExit) {
|
|
231
238
|
const filteredExit = filterReducedMotionKeyframes(
|
|
232
239
|
resolvedExit as Record<string, unknown>,
|
|
233
240
|
reducedMotion
|
|
@@ -241,9 +248,10 @@
|
|
|
241
248
|
}
|
|
242
249
|
})
|
|
243
250
|
|
|
244
|
-
// Update presence context with current state when element is ready and has size
|
|
251
|
+
// Update presence context with current state when element is ready and has size.
|
|
252
|
+
// Skipped inside <PresenceChild> — the rect/style snapshot only feeds the clone path.
|
|
245
253
|
$effect(() => {
|
|
246
|
-
if (!(context && element && isLoaded === 'ready')) return
|
|
254
|
+
if (!(context && element && isLoaded === 'ready') || inPresenceChild) return
|
|
247
255
|
|
|
248
256
|
let lastWidth = 0
|
|
249
257
|
let lastHeight = 0
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AnimatePresence from './components/AnimatePresence.svelte';
|
|
2
2
|
import MotionConfig from './components/MotionConfig.svelte';
|
|
3
|
+
import PresenceChild from './components/PresenceChild.svelte';
|
|
3
4
|
export { motion } from './motion';
|
|
4
5
|
export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
|
|
5
6
|
export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
|
|
@@ -21,6 +22,8 @@ export { useReducedMotion } from './utils/reducedMotion';
|
|
|
21
22
|
export { useReducedMotionConfig } from './utils/reducedMotionConfig';
|
|
22
23
|
export { useScroll } from './utils/scroll';
|
|
23
24
|
export { useSpring } from './utils/spring';
|
|
25
|
+
export { useIsPresent, usePresence } from './utils/usePresence';
|
|
26
|
+
export type { UsePresenceState } from './utils/usePresence';
|
|
24
27
|
export { useVelocity } from './utils/velocity';
|
|
25
28
|
/**
|
|
26
29
|
* @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
|
|
@@ -29,7 +32,7 @@ export { stringifyStyleObject } from './utils/styleObject';
|
|
|
29
32
|
export { styleString } from './utils/styleObject.svelte';
|
|
30
33
|
export { useTime } from './utils/time';
|
|
31
34
|
export { useTransform } from './utils/transform';
|
|
32
|
-
export { AnimatePresence, MotionConfig };
|
|
35
|
+
export { AnimatePresence, MotionConfig, PresenceChild };
|
|
33
36
|
export { default as MotionA } from './html/A.svelte';
|
|
34
37
|
export { default as MotionAbbr } from './html/Abbr.svelte';
|
|
35
38
|
export { default as MotionAddress } from './html/Address.svelte';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AnimatePresence from './components/AnimatePresence.svelte';
|
|
2
2
|
import MotionConfig from './components/MotionConfig.svelte';
|
|
3
|
+
import PresenceChild from './components/PresenceChild.svelte';
|
|
3
4
|
export { motion } from './motion';
|
|
4
5
|
// Re-export core animation functions from motion
|
|
5
6
|
export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
|
|
@@ -19,6 +20,7 @@ export { useReducedMotion } from './utils/reducedMotion';
|
|
|
19
20
|
export { useReducedMotionConfig } from './utils/reducedMotionConfig';
|
|
20
21
|
export { useScroll } from './utils/scroll';
|
|
21
22
|
export { useSpring } from './utils/spring';
|
|
23
|
+
export { useIsPresent, usePresence } from './utils/usePresence';
|
|
22
24
|
export { useVelocity } from './utils/velocity';
|
|
23
25
|
/**
|
|
24
26
|
* @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
|
|
@@ -27,7 +29,7 @@ export { stringifyStyleObject } from './utils/styleObject';
|
|
|
27
29
|
export { styleString } from './utils/styleObject.svelte';
|
|
28
30
|
export { useTime } from './utils/time';
|
|
29
31
|
export { useTransform } from './utils/transform';
|
|
30
|
-
export { AnimatePresence, MotionConfig };
|
|
32
|
+
export { AnimatePresence, MotionConfig, PresenceChild };
|
|
31
33
|
// Named component exports — tree-shakeable alternative to the `motion` object
|
|
32
34
|
export { default as MotionA } from './html/A.svelte';
|
|
33
35
|
export { default as MotionAbbr } from './html/Abbr.svelte';
|
package/dist/utils/drag.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pwLog, pwWarn } from './log';
|
|
1
|
+
import { isPlaywrightEnv, pwLog, pwWarn } from './log';
|
|
2
2
|
/**
|
|
3
3
|
* Drag utilities
|
|
4
4
|
*
|
|
@@ -16,7 +16,7 @@ import { pwLog, pwWarn } from './log';
|
|
|
16
16
|
* - For nested drags, set `propagation` as needed to avoid parent-child contention.
|
|
17
17
|
*/
|
|
18
18
|
import { isDomElement } from './dom';
|
|
19
|
-
import { applyConstraints as applyFloatConstraints } from './dragMath';
|
|
19
|
+
import { applyConstraints as applyFloatConstraints, parseMatrixTranslate } from './dragMath';
|
|
20
20
|
import { deriveBoundaryPhysics } from './dragParams';
|
|
21
21
|
import { computeHoverBaseline, splitHoverDefinition } from './hover';
|
|
22
22
|
import { createInertiaToBoundary } from './inertia';
|
|
@@ -87,6 +87,53 @@ export const applyElastic = (value, min, max, elastic) => {
|
|
|
87
87
|
};
|
|
88
88
|
/** Prefer high-resolution time in browser; fall back for SSR/tests. */
|
|
89
89
|
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
90
|
+
/** Sample windows for release-velocity inference (matches motion-dom values). */
|
|
91
|
+
const MAX_VELOCITY_DELTA_MS = 30;
|
|
92
|
+
const MIN_VELOCITY_INTERVAL_MS = 5;
|
|
93
|
+
/**
|
|
94
|
+
* Compute the release velocity for momentum from a pointer-history window.
|
|
95
|
+
*
|
|
96
|
+
* Mirrors motion-dom: walks back from the newest sample, including only
|
|
97
|
+
* samples within `MAX_VELOCITY_DELTA_MS` (30 ms) of newest, then divides
|
|
98
|
+
* the displacement by the elapsed time. Returns 0 if the newest sample is
|
|
99
|
+
* stale, the window has fewer than two samples, or the oldest-newest span
|
|
100
|
+
* is shorter than `MIN_VELOCITY_INTERVAL_MS` (5 ms — sub-frame).
|
|
101
|
+
*
|
|
102
|
+
* @param history Recent pointer samples ordered oldest → newest. Each
|
|
103
|
+
* sample is `{ x, y, t }` where `t` is `performance.now()` ms.
|
|
104
|
+
* @param nowMs Current `performance.now()` ms — used to discard a stale
|
|
105
|
+
* newest sample (finger lifted after a pause).
|
|
106
|
+
* @returns Inferred release velocity in pixels per second on each axis.
|
|
107
|
+
* @example
|
|
108
|
+
* const v = computeReleaseVelocity(
|
|
109
|
+
* [{ x: 0, y: 0, t: 1000 }, { x: 20, y: 0, t: 1020 }],
|
|
110
|
+
* 1020
|
|
111
|
+
* )
|
|
112
|
+
* // v ≈ { x: 1000, y: 0 } — 20 px over 20 ms → 1000 px/s
|
|
113
|
+
*/
|
|
114
|
+
const computeReleaseVelocity = (history, nowMs) => {
|
|
115
|
+
if (history.length < 2)
|
|
116
|
+
return { x: 0, y: 0 };
|
|
117
|
+
const newest = history[history.length - 1];
|
|
118
|
+
if (nowMs - newest.t > MAX_VELOCITY_DELTA_MS)
|
|
119
|
+
return { x: 0, y: 0 };
|
|
120
|
+
let oldestIdx = history.length - 1;
|
|
121
|
+
for (let i = history.length - 2; i >= 0; i--) {
|
|
122
|
+
if (newest.t - history[i].t > MAX_VELOCITY_DELTA_MS)
|
|
123
|
+
break;
|
|
124
|
+
oldestIdx = i;
|
|
125
|
+
}
|
|
126
|
+
if (oldestIdx === history.length - 1)
|
|
127
|
+
return { x: 0, y: 0 };
|
|
128
|
+
const oldest = history[oldestIdx];
|
|
129
|
+
const dtMs = newest.t - oldest.t;
|
|
130
|
+
if (dtMs < MIN_VELOCITY_INTERVAL_MS)
|
|
131
|
+
return { x: 0, y: 0 };
|
|
132
|
+
return {
|
|
133
|
+
x: ((newest.x - oldest.x) / dtMs) * 1000,
|
|
134
|
+
y: ((newest.y - oldest.y) / dtMs) * 1000
|
|
135
|
+
};
|
|
136
|
+
};
|
|
90
137
|
/**
|
|
91
138
|
* Attach a drag gesture to an element.
|
|
92
139
|
*
|
|
@@ -184,25 +231,21 @@ export const attachDrag = (el, opts) => {
|
|
|
184
231
|
applied.x = x;
|
|
185
232
|
if ('y' in payload)
|
|
186
233
|
applied.y = y;
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
y,
|
|
194
|
-
transform: actualTransform
|
|
195
|
-
});
|
|
196
|
-
animate(el, ('x' in payload || 'y' in payload ? payload : { x, y }), {
|
|
197
|
-
duration: 0
|
|
198
|
-
});
|
|
199
|
-
actualTransform = getComputedStyle(el).transform;
|
|
234
|
+
// Playwright-only sanity check: confirm the transform actually
|
|
235
|
+
// landed on the element and retry once if not. Forces a style
|
|
236
|
+
// recalc via getComputedStyle, so we gate it behind the same
|
|
237
|
+
// playwright env flag pwLog uses so it never fires in prod.
|
|
238
|
+
if (isPlaywrightEnv()) {
|
|
239
|
+
let actualTransform = getComputedStyle(el).transform;
|
|
200
240
|
if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
|
|
201
|
-
pwWarn('⚠️ setXY
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
241
|
+
pwWarn('⚠️ setXY transform missing; retrying write', { x, y });
|
|
242
|
+
animate(el, ('x' in payload || 'y' in payload
|
|
243
|
+
? payload
|
|
244
|
+
: { x, y }), { duration: 0 });
|
|
245
|
+
actualTransform = getComputedStyle(el).transform;
|
|
246
|
+
if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
|
|
247
|
+
pwWarn('⚠️ setXY second attempt still missing transform', { x, y });
|
|
248
|
+
}
|
|
206
249
|
}
|
|
207
250
|
}
|
|
208
251
|
};
|
|
@@ -404,22 +447,28 @@ export const attachDrag = (el, opts) => {
|
|
|
404
447
|
});
|
|
405
448
|
if (!dragging)
|
|
406
449
|
return;
|
|
407
|
-
|
|
450
|
+
// Pointer was preempted (gesture-nav, palm rejection, scroll
|
|
451
|
+
// takeover). User did not release intentionally — skip the
|
|
452
|
+
// inertia/momentum path and force a no-momentum settle so the
|
|
453
|
+
// card clamps back into constraints without flinging.
|
|
454
|
+
finishDrag(e, true);
|
|
408
455
|
};
|
|
409
456
|
/**
|
|
410
457
|
* Finish a drag:
|
|
411
458
|
* - If momentum is enabled, decay towards a clamped target with exponential easing
|
|
412
459
|
* - Otherwise, animate back to a clamped position (or origin), then sync `applied`
|
|
413
460
|
*/
|
|
414
|
-
const finishDrag = (e) => {
|
|
461
|
+
const finishDrag = (e, cancelled = false) => {
|
|
415
462
|
dragging = false;
|
|
463
|
+
velocity = computeReleaseVelocity(history, now());
|
|
416
464
|
pwLog('[drag] finish', {
|
|
417
465
|
el: EL_ID,
|
|
418
466
|
lastPoint,
|
|
419
467
|
startPoint,
|
|
420
468
|
origin,
|
|
421
469
|
applied,
|
|
422
|
-
momentum
|
|
470
|
+
momentum,
|
|
471
|
+
velocity
|
|
423
472
|
});
|
|
424
473
|
try {
|
|
425
474
|
if ('releasePointerCapture' in el && typeof e.pointerId === 'number')
|
|
@@ -434,8 +483,10 @@ export const attachDrag = (el, opts) => {
|
|
|
434
483
|
window.removeEventListener('pointermove', onPointerMove);
|
|
435
484
|
window.removeEventListener('pointerup', onPointerUp);
|
|
436
485
|
window.removeEventListener('pointercancel', onPointerCancel);
|
|
437
|
-
// Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary
|
|
438
|
-
|
|
486
|
+
// Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary.
|
|
487
|
+
// Pointer-cancel forces a no-momentum settle (clamp into constraints, no fling) since the
|
|
488
|
+
// gesture was preempted rather than intentionally released.
|
|
489
|
+
if (momentum && !cancelled) {
|
|
439
490
|
pwLog('🚀 STARTING MOMENTUM', {
|
|
440
491
|
velocityX: velocity.x,
|
|
441
492
|
velocityY: velocity.y,
|
|
@@ -454,6 +505,18 @@ export const attachDrag = (el, opts) => {
|
|
|
454
505
|
...(applyX ? { x: 0 } : {}),
|
|
455
506
|
...(applyY ? { y: 0 } : {})
|
|
456
507
|
}, (mergedTransition ?? {}));
|
|
508
|
+
// Cancel hook so re-grab / controls.stop() interrupts the snap.
|
|
509
|
+
stopInertia = () => {
|
|
510
|
+
pwLog('❌ snapToOrigin cancelled');
|
|
511
|
+
controls.stop?.();
|
|
512
|
+
// Sync applied to wherever the snap reached so the next drag origin is correct.
|
|
513
|
+
const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
|
|
514
|
+
if (applyX)
|
|
515
|
+
applied.x = tx;
|
|
516
|
+
if (applyY)
|
|
517
|
+
applied.y = ty;
|
|
518
|
+
stopInertia = null;
|
|
519
|
+
};
|
|
457
520
|
Promise.resolve(controls.finished)
|
|
458
521
|
.then(() => {
|
|
459
522
|
// Sync internal applied transform so next drag uses the correct origin
|
|
@@ -465,26 +528,31 @@ export const attachDrag = (el, opts) => {
|
|
|
465
528
|
el: EL_ID,
|
|
466
529
|
applied
|
|
467
530
|
});
|
|
531
|
+
if (stopInertia)
|
|
532
|
+
stopInertia = null;
|
|
468
533
|
})
|
|
469
534
|
.catch(() => { })
|
|
470
535
|
.finally(() => opts.callbacks?.onTransitionEnd?.());
|
|
471
536
|
return;
|
|
472
537
|
}
|
|
473
|
-
//
|
|
538
|
+
// Boundary min/max anchor to `constraintsBase` (the absolute
|
|
539
|
+
// pixel-constraint origin) so the inertia handoff snaps to the
|
|
540
|
+
// same edge pointermove's elastic clamping uses — not to a
|
|
541
|
+
// per-drag-shifted `origin` that would drift across drags.
|
|
474
542
|
const noConstraints = !constraints;
|
|
475
543
|
const huge = 1e6;
|
|
476
544
|
const minX = noConstraints
|
|
477
545
|
? applied.x - huge
|
|
478
|
-
:
|
|
546
|
+
: constraintsBase.x + (constraints?.left ?? -Infinity);
|
|
479
547
|
const maxX = noConstraints
|
|
480
548
|
? applied.x + huge
|
|
481
|
-
:
|
|
549
|
+
: constraintsBase.x + (constraints?.right ?? Infinity);
|
|
482
550
|
const minY = noConstraints
|
|
483
551
|
? applied.y - huge
|
|
484
|
-
:
|
|
552
|
+
: constraintsBase.y + (constraints?.top ?? -Infinity);
|
|
485
553
|
const maxY = noConstraints
|
|
486
554
|
? applied.y + huge
|
|
487
|
-
:
|
|
555
|
+
: constraintsBase.y + (constraints?.bottom ?? Infinity);
|
|
488
556
|
const { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping } = deriveBoundaryPhysics(elastic, opts.transition);
|
|
489
557
|
pwLog('⚙️ boundary-physics', {
|
|
490
558
|
timeConstantMs,
|
|
@@ -500,6 +568,10 @@ export const attachDrag = (el, opts) => {
|
|
|
500
568
|
// Respect direction lock on release: only animate the locked axis
|
|
501
569
|
const applyX = (axis === true || axis === 'x') && lockAxis !== 'y';
|
|
502
570
|
const applyY = (axis === true || axis === 'y') && lockAxis !== 'x';
|
|
571
|
+
// Element-ref constraints can resize / reflow during inertia.
|
|
572
|
+
// Pixel constraints never change once set. We re-resolve only
|
|
573
|
+
// for element-ref each frame in the rAF loop below.
|
|
574
|
+
const isElementRefConstraint = isDomElement(opts.constraints);
|
|
503
575
|
const stepX = applyX
|
|
504
576
|
? createInertiaToBoundary({ value: applied.x, velocity: velocity.x }, { min: minX, max: maxX }, { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping })
|
|
505
577
|
: null;
|
|
@@ -519,9 +591,27 @@ export const attachDrag = (el, opts) => {
|
|
|
519
591
|
// Use the precomputed step functions (built once per release)
|
|
520
592
|
const rx = stepX ? stepX(t) : { value: applied.x, done: true };
|
|
521
593
|
const ry = stepY ? stepY(t) : { value: applied.y, done: true };
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
594
|
+
// Element-ref constraints may have resized / reflowed since
|
|
595
|
+
// the steppers were built. Re-resolve and clamp the output
|
|
596
|
+
// so the card never lands outside the now-current container
|
|
597
|
+
// even if its boundary moved mid-spring. Pixel constraints
|
|
598
|
+
// don't move so we skip the work.
|
|
599
|
+
let nextX = rx.value;
|
|
600
|
+
let nextY = (axis === true || axis === 'y') && lockAxis !== 'x' ? ry.value : applied.y;
|
|
601
|
+
if (isElementRefConstraint) {
|
|
602
|
+
const freshConstraints = resolveConstraints(el, opts.constraints);
|
|
603
|
+
if (freshConstraints) {
|
|
604
|
+
constraintsBase = { x: applied.x, y: applied.y };
|
|
605
|
+
const freshMinX = constraintsBase.x + (freshConstraints.left ?? -Infinity);
|
|
606
|
+
const freshMaxX = constraintsBase.x + (freshConstraints.right ?? Infinity);
|
|
607
|
+
const freshMinY = constraintsBase.y + (freshConstraints.top ?? -Infinity);
|
|
608
|
+
const freshMaxY = constraintsBase.y + (freshConstraints.bottom ?? Infinity);
|
|
609
|
+
if (applyX)
|
|
610
|
+
nextX = Math.max(freshMinX, Math.min(freshMaxX, nextX));
|
|
611
|
+
if (applyY)
|
|
612
|
+
nextY = Math.max(freshMinY, Math.min(freshMaxY, nextY));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
525
615
|
setXY(nextX, nextY);
|
|
526
616
|
if (frameCount <= 3 || frameCount % 10 === 0) {
|
|
527
617
|
pwLog(`🔄 FRAME ${frameCount}`, {
|
|
@@ -537,15 +627,19 @@ export const attachDrag = (el, opts) => {
|
|
|
537
627
|
if ((rx.done || !stepX) && (ry.done || !stepY)) {
|
|
538
628
|
pwLog('✅ REST REACHED', {
|
|
539
629
|
frameCount,
|
|
540
|
-
finalX:
|
|
541
|
-
finalY:
|
|
630
|
+
finalX: nextX,
|
|
631
|
+
finalY: nextY,
|
|
542
632
|
timeConstantMs,
|
|
543
633
|
restDelta,
|
|
544
634
|
restSpeed
|
|
545
635
|
});
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
636
|
+
// Sync `applied` from the post-clamp frame values
|
|
637
|
+
// (nextX/nextY), not the raw stepper output. When
|
|
638
|
+
// element-ref constraints clamped this frame, raw
|
|
639
|
+
// rx.value sits outside the visible bounds and would
|
|
640
|
+
// desync the next-drag origin from the rendered transform.
|
|
641
|
+
const finalX = stepX ? nextX : applied.x;
|
|
642
|
+
const finalY = stepY ? nextY : applied.y;
|
|
549
643
|
if (axis === true || axis === 'x')
|
|
550
644
|
applied.x = finalX;
|
|
551
645
|
if (axis === true || axis === 'y')
|
|
@@ -560,14 +654,12 @@ export const attachDrag = (el, opts) => {
|
|
|
560
654
|
stopInertia = () => {
|
|
561
655
|
pwLog('❌ MOMENTUM CANCELLED');
|
|
562
656
|
running = false;
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (axis === true || axis === 'y')
|
|
570
|
-
applied.y = finalY;
|
|
657
|
+
// `applied` is already in sync with the last frame rendered
|
|
658
|
+
// by the rAF loop (setXY updates it on every frame). We
|
|
659
|
+
// intentionally do NOT call stepX/stepY again here —
|
|
660
|
+
// they're stateful (mutate lastT/springX/springV) and an
|
|
661
|
+
// extra call advances them past the visible state, leaving
|
|
662
|
+
// applied slightly out of sync with what the user sees.
|
|
571
663
|
pwLog('[drag] inertia cancelled → sync applied', {
|
|
572
664
|
el: EL_ID,
|
|
573
665
|
applied: { x: applied.x, y: applied.y }
|
|
@@ -624,6 +716,17 @@ export const attachDrag = (el, opts) => {
|
|
|
624
716
|
const settleTransition = elastic === 0 ? { duration: 0 } : (mergedTransition ?? {});
|
|
625
717
|
// Animate with the merged transition so the settle feels consistent with other motion
|
|
626
718
|
const controls = animate(el, { ...(applyX ? { x } : {}), ...(applyY ? { y } : {}) }, settleTransition);
|
|
719
|
+
// Cancel hook so re-grab interrupts the settle animation cleanly.
|
|
720
|
+
stopInertia = () => {
|
|
721
|
+
pwLog('❌ settle (no momentum) cancelled');
|
|
722
|
+
controls.stop?.();
|
|
723
|
+
const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
|
|
724
|
+
if (applyX)
|
|
725
|
+
applied.x = tx;
|
|
726
|
+
if (applyY)
|
|
727
|
+
applied.y = ty;
|
|
728
|
+
stopInertia = null;
|
|
729
|
+
};
|
|
627
730
|
// Fire transition end once settled
|
|
628
731
|
Promise.resolve(controls.finished)
|
|
629
732
|
.then(() => {
|
|
@@ -636,6 +739,8 @@ export const attachDrag = (el, opts) => {
|
|
|
636
739
|
el: EL_ID,
|
|
637
740
|
applied
|
|
638
741
|
});
|
|
742
|
+
if (stopInertia)
|
|
743
|
+
stopInertia = null;
|
|
639
744
|
})
|
|
640
745
|
.catch(() => { })
|
|
641
746
|
.finally(() => opts.callbacks?.onTransitionEnd?.());
|
|
@@ -643,10 +748,12 @@ export const attachDrag = (el, opts) => {
|
|
|
643
748
|
opts.callbacks?.onEnd?.(e, computeInfo());
|
|
644
749
|
endWhileDrag();
|
|
645
750
|
};
|
|
646
|
-
// Wire dragControls
|
|
751
|
+
// Wire dragControls. The cancelInertia thunk reads the *current*
|
|
752
|
+
// stopInertia at call time so the latest in-flight animation is
|
|
753
|
+
// targeted (stopInertia is re-assigned per finishDrag).
|
|
647
754
|
if (opts.controls) {
|
|
648
755
|
const internal = opts.controls;
|
|
649
|
-
internal._bind?.(el, beginDrag);
|
|
756
|
+
internal._bind?.(el, beginDrag, () => stopInertia?.());
|
|
650
757
|
pwLog('[drag] controls bound', { el: EL_ID });
|
|
651
758
|
}
|
|
652
759
|
el.addEventListener('pointerdown', onPointerDown);
|
package/dist/utils/dragMath.d.ts
CHANGED
|
@@ -34,3 +34,21 @@ export type ConstraintElastic = {
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
export declare const applyConstraints: (point: number, range: ConstraintRange, elastic?: ConstraintElastic) => number;
|
|
37
|
+
/**
|
|
38
|
+
* Parse a CSS `matrix(a, b, c, d, tx, ty)` string into translate components.
|
|
39
|
+
*
|
|
40
|
+
* Used to read the rendered translate after Motion writes inline transforms
|
|
41
|
+
* during drag-cancel hooks.
|
|
42
|
+
*
|
|
43
|
+
* @param transform The computed `transform` style — typically `'none'` or
|
|
44
|
+
* `'matrix(1, 0, 0, 1, 10, 20)'`. `matrix3d(...)` is not supported.
|
|
45
|
+
* @returns The 2D translate components `{ tx, ty }`. Defaults to
|
|
46
|
+
* `{ tx: 0, ty: 0 }` for `'none'` or any non-matching input.
|
|
47
|
+
* @example
|
|
48
|
+
* parseMatrixTranslate('matrix(1, 0, 0, 1, 10, 20)') // → { tx: 10, ty: 20 }
|
|
49
|
+
* parseMatrixTranslate('none') // → { tx: 0, ty: 0 }
|
|
50
|
+
*/
|
|
51
|
+
export declare const parseMatrixTranslate: (transform: string) => {
|
|
52
|
+
tx: number;
|
|
53
|
+
ty: number;
|
|
54
|
+
};
|
package/dist/utils/dragMath.js
CHANGED
|
@@ -42,3 +42,24 @@ export const applyConstraints = (point, range, elastic) => {
|
|
|
42
42
|
}
|
|
43
43
|
return point;
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Parse a CSS `matrix(a, b, c, d, tx, ty)` string into translate components.
|
|
47
|
+
*
|
|
48
|
+
* Used to read the rendered translate after Motion writes inline transforms
|
|
49
|
+
* during drag-cancel hooks.
|
|
50
|
+
*
|
|
51
|
+
* @param transform The computed `transform` style — typically `'none'` or
|
|
52
|
+
* `'matrix(1, 0, 0, 1, 10, 20)'`. `matrix3d(...)` is not supported.
|
|
53
|
+
* @returns The 2D translate components `{ tx, ty }`. Defaults to
|
|
54
|
+
* `{ tx: 0, ty: 0 }` for `'none'` or any non-matching input.
|
|
55
|
+
* @example
|
|
56
|
+
* parseMatrixTranslate('matrix(1, 0, 0, 1, 10, 20)') // → { tx: 10, ty: 20 }
|
|
57
|
+
* parseMatrixTranslate('none') // → { tx: 0, ty: 0 }
|
|
58
|
+
*/
|
|
59
|
+
export const parseMatrixTranslate = (transform) => {
|
|
60
|
+
const m = transform.match(/matrix\(([^)]+)\)/);
|
|
61
|
+
if (!m)
|
|
62
|
+
return { tx: 0, ty: 0 };
|
|
63
|
+
const parts = m[1].split(',').map((s) => Number.parseFloat(s.trim()));
|
|
64
|
+
return { tx: parts[4] ?? 0, ty: parts[5] ?? 0 };
|
|
65
|
+
};
|
package/dist/utils/inertia.js
CHANGED
|
@@ -80,12 +80,13 @@ export const createInertiaToBoundary = (initial, bounds, opts) => {
|
|
|
80
80
|
let springX = x;
|
|
81
81
|
let springV = 0;
|
|
82
82
|
let boundaryTarget = null;
|
|
83
|
-
//
|
|
83
|
+
// Starting OOB: skip inertia and engage the spring. Drop the
|
|
84
|
+
// away-from-boundary velocity component so the spring's first frames
|
|
85
|
+
// don't continue moving the value outward.
|
|
84
86
|
if (x < min || x > max) {
|
|
85
87
|
mode = 'spring';
|
|
86
|
-
// Set spring start at current value and carry current velocity
|
|
87
88
|
springX = x;
|
|
88
|
-
springV = v;
|
|
89
|
+
springV = x < min ? Math.max(0, v) : Math.min(0, v);
|
|
89
90
|
boundaryTarget = x < min ? min : max;
|
|
90
91
|
}
|
|
91
92
|
// Precompute first crossing (if any) from initial state
|