@humanspeak/svelte-motion 0.5.2 → 0.5.3
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/projection.context.d.ts +22 -0
- package/dist/components/projection.context.js +56 -0
- package/dist/html/_MotionContainer.svelte +148 -21
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +59 -0
- package/dist/utils/drag.d.ts +42 -11
- package/dist/utils/drag.js +103 -12
- package/dist/utils/layout.d.ts +26 -2
- package/dist/utils/layout.js +13 -2
- package/dist/utils/projection.d.ts +287 -0
- package/dist/utils/projection.js +392 -0
- package/dist/utils/style.d.ts +23 -0
- package/dist/utils/style.js +27 -0
- package/dist/utils/variants.d.ts +26 -0
- package/dist/utils/variants.js +42 -0
- package/package.json +2 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProjectionNode } from '../utils/projection';
|
|
2
|
+
/**
|
|
3
|
+
* Publish a `ProjectionNode` as the projection parent for descendant
|
|
4
|
+
* motion components.
|
|
5
|
+
*
|
|
6
|
+
* @param node The current component's ProjectionNode, or `null` to
|
|
7
|
+
* explicitly clear (rarely needed — Svelte context auto-clears on
|
|
8
|
+
* component unmount).
|
|
9
|
+
*/
|
|
10
|
+
export declare const setProjectionParent: (node: ProjectionNode | null) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Read the ancestor `ProjectionNode` published by the nearest motion
|
|
13
|
+
* ancestor. Returns `null` when this component is at the root of the
|
|
14
|
+
* projection tree (or when no motion ancestor exists).
|
|
15
|
+
*
|
|
16
|
+
* Must be called BEFORE the current component publishes its own node
|
|
17
|
+
* via `setProjectionParent` — see the order-of-operations note in this
|
|
18
|
+
* file's header.
|
|
19
|
+
*
|
|
20
|
+
* @returns The parent ProjectionNode, or `null`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const getProjectionParent: () => ProjectionNode | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Svelte context plumbing for the projection tree.
|
|
4
|
+
*
|
|
5
|
+
* Every `motion.*` element creates a `ProjectionNode` at setup time and
|
|
6
|
+
* publishes it via `setProjectionParent(node)` so descendant motion
|
|
7
|
+
* elements can pick it up via `getProjectionParent()` and wire themselves
|
|
8
|
+
* as `node.parent` + register in `node.parent.children`. The tree shape
|
|
9
|
+
* mirrors the Svelte component tree exactly because Svelte's
|
|
10
|
+
* `setContext` propagates down through descendants in component-init
|
|
11
|
+
* order.
|
|
12
|
+
*
|
|
13
|
+
* This is the closest analog we have to framer-motion's depth-sorted
|
|
14
|
+
* FlatTree (`projection/node/create-projection-node.ts`), but without
|
|
15
|
+
* the global `root` registry — parent/child pointers are sufficient
|
|
16
|
+
* for the workflows this PR enables (drag↔swap origin compensation,
|
|
17
|
+
* ancestor-zeroing measure). A global root would only be needed once we
|
|
18
|
+
* port shared-element `layoutId` morphing onto projection nodes; the
|
|
19
|
+
* current `layoutId.ts` registry continues to handle that case
|
|
20
|
+
* independently.
|
|
21
|
+
*
|
|
22
|
+
* Order of operations inside a single `motion.*` component (CRITICAL):
|
|
23
|
+
* 1. Read parent via `getProjectionParent()` BEFORE creating own node.
|
|
24
|
+
* 2. Construct own node, set `node.parent = parent`.
|
|
25
|
+
* 3. Publish own node via `setProjectionParent(node)`.
|
|
26
|
+
* If steps 1 and 3 are reversed, the component sees ITS OWN node as
|
|
27
|
+
* the parent (because `setContext` shadows from the call site down)
|
|
28
|
+
* and the tree collapses to a chain of self-references. Same trap
|
|
29
|
+
* `layoutScroll.context.ts` documents — don't repeat it.
|
|
30
|
+
*/
|
|
31
|
+
const PROJECTION_CONTEXT_KEY = Symbol('svelte-motion:projection-parent');
|
|
32
|
+
/**
|
|
33
|
+
* Publish a `ProjectionNode` as the projection parent for descendant
|
|
34
|
+
* motion components.
|
|
35
|
+
*
|
|
36
|
+
* @param node The current component's ProjectionNode, or `null` to
|
|
37
|
+
* explicitly clear (rarely needed — Svelte context auto-clears on
|
|
38
|
+
* component unmount).
|
|
39
|
+
*/
|
|
40
|
+
export const setProjectionParent = (node) => {
|
|
41
|
+
setContext(PROJECTION_CONTEXT_KEY, node);
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Read the ancestor `ProjectionNode` published by the nearest motion
|
|
45
|
+
* ancestor. Returns `null` when this component is at the root of the
|
|
46
|
+
* projection tree (or when no motion ancestor exists).
|
|
47
|
+
*
|
|
48
|
+
* Must be called BEFORE the current component publishes its own node
|
|
49
|
+
* via `setProjectionParent` — see the order-of-operations note in this
|
|
50
|
+
* file's header.
|
|
51
|
+
*
|
|
52
|
+
* @returns The parent ProjectionNode, or `null`.
|
|
53
|
+
*/
|
|
54
|
+
export const getProjectionParent = () => {
|
|
55
|
+
return getContext(PROJECTION_CONTEXT_KEY) ?? null;
|
|
56
|
+
};
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
computeFlipTransforms,
|
|
40
40
|
runFlipAnimation,
|
|
41
41
|
setCompositorHints,
|
|
42
|
-
observeLayoutChanges
|
|
42
|
+
observeLayoutChanges,
|
|
43
|
+
type RectLike
|
|
43
44
|
} from '../utils/layout'
|
|
44
45
|
import type { SvelteHTMLElements } from 'svelte/elements'
|
|
45
|
-
import { mergeInlineStyles } from '../utils/style'
|
|
46
|
+
import { mergeInlineStyles, extractTransform } from '../utils/style'
|
|
46
47
|
import { isNativelyFocusable } from '../utils/a11y'
|
|
47
48
|
import {
|
|
48
49
|
getAnimatePresenceContext,
|
|
@@ -53,7 +54,15 @@
|
|
|
53
54
|
import { getInitialKeyframes } from '../utils/initial'
|
|
54
55
|
import { attachDrag } from '../utils/drag'
|
|
55
56
|
import { attachPan, type AttachPanCleanup } from '../utils/pan'
|
|
56
|
-
import {
|
|
57
|
+
import { ProjectionNode } from '../utils/projection'
|
|
58
|
+
import { getProjectionParent, setProjectionParent } from '../components/projection.context'
|
|
59
|
+
import {
|
|
60
|
+
resolveInitial,
|
|
61
|
+
resolveAnimate,
|
|
62
|
+
resolveExit,
|
|
63
|
+
resolveWhile,
|
|
64
|
+
resolveRestingValues
|
|
65
|
+
} from '../utils/variants'
|
|
57
66
|
import {
|
|
58
67
|
setVariantContext,
|
|
59
68
|
getVariantContext,
|
|
@@ -134,10 +143,17 @@
|
|
|
134
143
|
layout: layoutProp,
|
|
135
144
|
layoutId: layoutIdProp,
|
|
136
145
|
layoutScroll: layoutScrollProp,
|
|
146
|
+
onProjectionUpdate: onProjectionUpdateProp,
|
|
137
147
|
ref: element = $bindable(null),
|
|
138
148
|
...rest
|
|
139
149
|
}: Props = $props()
|
|
140
150
|
let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
|
|
151
|
+
// True once the enter/animate animation has COMPLETED. Until then the
|
|
152
|
+
// WAAPI animation owns the transform; flipping the inline baseline to
|
|
153
|
+
// the target mid-run causes a one-frame snap (the target shows through
|
|
154
|
+
// for the frame the inline changes). We therefore only apply the target
|
|
155
|
+
// as the inline style once settled — see the style derivation. (#377)
|
|
156
|
+
let enterAnimationSettled = $state(false)
|
|
141
157
|
let dataPath = $state<number>(-1)
|
|
142
158
|
const motionConfig = $derived(getMotionConfig())
|
|
143
159
|
const reducedMotionState = useReducedMotionConfig()
|
|
@@ -191,6 +207,46 @@
|
|
|
191
207
|
return refs.filter((el): el is HTMLElement => Boolean(el))
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
// Projection tree wiring (#379). Capture the parent node BEFORE
|
|
211
|
+
// publishing our own — same shadowing trap as layoutScroll above.
|
|
212
|
+
// The node measures through `resolveLayoutScrollAncestors` so its
|
|
213
|
+
// boxes share the FLIP coordinate space, and zeros ancestor
|
|
214
|
+
// transforms during measure so nested layout-animated parents don't
|
|
215
|
+
// corrupt a child's delta.
|
|
216
|
+
// The user-authored transform, sourced from the `style` prop rather
|
|
217
|
+
// than the live inline transform — the latter already carries any
|
|
218
|
+
// transform-type `initial`/`animate` keyframe by the time the node
|
|
219
|
+
// measures, which would be mistaken for the user's base.
|
|
220
|
+
const userBaseTransform = $derived(extractTransform(styleProp))
|
|
221
|
+
|
|
222
|
+
const projectionParent = getProjectionParent()
|
|
223
|
+
const projection = new ProjectionNode({
|
|
224
|
+
parent: projectionParent,
|
|
225
|
+
getScrollContainers: resolveLayoutScrollAncestors,
|
|
226
|
+
getBaseTransform: () => userBaseTransform
|
|
227
|
+
})
|
|
228
|
+
setProjectionParent(projection)
|
|
229
|
+
|
|
230
|
+
// Convert a projection `Box` (ancestor chain reset to base, self
|
|
231
|
+
// transform stripped, scroll containers compensated) to the
|
|
232
|
+
// `RectLike` shape `computeFlipTransforms` consumes.
|
|
233
|
+
const boxToRectLike = (box: {
|
|
234
|
+
x: { min: number; max: number }
|
|
235
|
+
y: { min: number; max: number }
|
|
236
|
+
}): RectLike => ({
|
|
237
|
+
left: box.x.min,
|
|
238
|
+
top: box.y.min,
|
|
239
|
+
width: box.x.max - box.x.min,
|
|
240
|
+
height: box.y.max - box.y.min
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// Ancestor-transform-invariant layout measurement for seeding the
|
|
244
|
+
// FLIP effect's first rect.
|
|
245
|
+
const measureLayoutRect = (): RectLike | null => {
|
|
246
|
+
const box = projection.measure()
|
|
247
|
+
return box ? boxToRectLike(box) : null
|
|
248
|
+
}
|
|
249
|
+
|
|
194
250
|
// Get current presence depth (0 = direct child of AnimatePresence, undefined = not in AnimatePresence)
|
|
195
251
|
const presenceDepth = getPresenceDepth()
|
|
196
252
|
|
|
@@ -478,6 +534,17 @@
|
|
|
478
534
|
)
|
|
479
535
|
)
|
|
480
536
|
|
|
537
|
+
// Reduced-motion-filtered animate values used as the inline-style
|
|
538
|
+
// baseline so an animated value (transforms included) persists after
|
|
539
|
+
// the WAAPI animation completes (#377). Filtered exactly like
|
|
540
|
+
// `initialKeyframes` so transforms are stripped under reduced motion.
|
|
541
|
+
const animateKeyframes = $derived(
|
|
542
|
+
filterReducedMotionKeyframes(
|
|
543
|
+
resolvedAnimate as Record<string, unknown> | undefined,
|
|
544
|
+
reducedMotion
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
|
|
481
548
|
// Derived attributes to keep both branches in sync (focusability, data flags, style, class)
|
|
482
549
|
const derivedAttrs = $derived<Record<string, unknown>>({
|
|
483
550
|
...(rest as Record<string, unknown>),
|
|
@@ -516,20 +583,33 @@
|
|
|
516
583
|
initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
|
|
517
584
|
? `${styleProp || ''};visibility:hidden`
|
|
518
585
|
: styleProp,
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
586
|
+
// The "from" slot: apply initialKeyframes as inline styles during
|
|
587
|
+
// the mounting/initial phases (before the WAAPI animation locks
|
|
588
|
+
// its from-value and we promote to 'ready' — see the lifecycle
|
|
589
|
+
// around the enter rAF). mergeInlineStyles prefers this slot when
|
|
590
|
+
// non-empty, so it wins over the animate slot below in these phases.
|
|
523
591
|
isLoaded === 'mounting' || isLoaded === 'initial'
|
|
524
592
|
? (initialKeyframes as unknown as Record<string, unknown>)
|
|
525
593
|
: undefined,
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
594
|
+
// The "target" slot. Only AFTER the enter animation completes does
|
|
595
|
+
// the target become the inline baseline, so the element holds it
|
|
596
|
+
// once WAAPI surrenders the property (default fill:'none' would
|
|
597
|
+
// otherwise leave transform:none). It must NOT be applied during
|
|
598
|
+
// the run: flipping the inline value to the target mid-animation
|
|
599
|
+
// shows the target for the one frame the inline changes (a visible
|
|
600
|
+
// snap), since WAAPI's composite doesn't override that exact frame.
|
|
601
|
+
// While the animation runs we keep the original behavior — initial
|
|
602
|
+
// keyframes own the inline (via the slot above), or, with no
|
|
603
|
+
// initial, the animate values seed the inline as the from. Resting
|
|
604
|
+
// values collapse keyframe arrays to their last element
|
|
605
|
+
// (animate={{x:[0,100,50]}} rests at 50). (#377)
|
|
606
|
+
enterAnimationSettled
|
|
607
|
+
? (resolveRestingValues(
|
|
608
|
+
animateKeyframes as DOMKeyframesDefinition | undefined
|
|
609
|
+
) as unknown as Record<string, unknown>)
|
|
610
|
+
: isNotEmpty(initialKeyframes)
|
|
611
|
+
? undefined
|
|
612
|
+
: (animateKeyframes as unknown as Record<string, unknown>)
|
|
533
613
|
),
|
|
534
614
|
class: classProp
|
|
535
615
|
})
|
|
@@ -807,12 +887,20 @@
|
|
|
807
887
|
transitionAnimate
|
|
808
888
|
})
|
|
809
889
|
|
|
890
|
+
// A fresh run owns the transform again until it completes.
|
|
891
|
+
enterAnimationSettled = false
|
|
810
892
|
animateWithLifecycle(
|
|
811
893
|
element,
|
|
812
894
|
payload as unknown as DOMKeyframesDefinition,
|
|
813
895
|
transitionAnimate as unknown as AnimationOptions,
|
|
814
896
|
(def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
|
|
815
|
-
(def) =>
|
|
897
|
+
(def) => {
|
|
898
|
+
// Now the target is the resting state — promote it to the
|
|
899
|
+
// inline baseline so it persists after WAAPI surrenders the
|
|
900
|
+
// property (default fill:'none'). (#377)
|
|
901
|
+
enterAnimationSettled = true
|
|
902
|
+
onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
|
|
903
|
+
}
|
|
816
904
|
)
|
|
817
905
|
}
|
|
818
906
|
|
|
@@ -930,26 +1018,65 @@
|
|
|
930
1018
|
: undefined
|
|
931
1019
|
)
|
|
932
1020
|
|
|
1021
|
+
// Projection node lifecycle + the `onProjectionUpdate` listener.
|
|
1022
|
+
// Mount once the element binds; seed the baseline layout; unmount on
|
|
1023
|
+
// cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
|
|
1024
|
+
// subscription lives in its own effect below so a change to the
|
|
1025
|
+
// callback's identity re-subscribes WITHOUT tearing the node down
|
|
1026
|
+
// (an unmount would clear latestLayout/children and re-seed the
|
|
1027
|
+
// first commit instead of emitting a real delta).
|
|
1028
|
+
$effect(() => {
|
|
1029
|
+
if (!element) return
|
|
1030
|
+
projection.mount(element)
|
|
1031
|
+
projection.measure() // seed latestLayout so the first commit can diff
|
|
1032
|
+
return () => {
|
|
1033
|
+
projection.unmount()
|
|
1034
|
+
}
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
// Subscribe the consumer's `onProjectionUpdate` callback. Separate
|
|
1038
|
+
// from the mount effect so re-subscribing on a callback-identity
|
|
1039
|
+
// change never unmounts the node.
|
|
1040
|
+
$effect(() => {
|
|
1041
|
+
if (!(element && onProjectionUpdateProp)) return
|
|
1042
|
+
const off = projection.addEventListener('didUpdate', (data) => onProjectionUpdateProp(data))
|
|
1043
|
+
return () => {
|
|
1044
|
+
off()
|
|
1045
|
+
}
|
|
1046
|
+
})
|
|
1047
|
+
|
|
933
1048
|
// Minimal layout animation using FLIP when `layout` is enabled.
|
|
934
1049
|
// When layout === 'position' we only translate.
|
|
935
1050
|
// When layout === true we also scale to smoothly interpolate size changes.
|
|
936
|
-
let lastRect:
|
|
1051
|
+
let lastRect: RectLike | null = null
|
|
937
1052
|
$effect(() => {
|
|
938
1053
|
if (!(element && layoutProp && isLoaded === 'ready')) return
|
|
939
1054
|
|
|
940
|
-
// Initialize last rect on first ready frame
|
|
941
|
-
|
|
1055
|
+
// Initialize last rect on first ready frame. We measure through the
|
|
1056
|
+
// projection node rather than `measureRect` directly so the rect is
|
|
1057
|
+
// ancestor-transform-invariant: the node resets the whole ancestor
|
|
1058
|
+
// chain to its mount-time base while reading, which strips any
|
|
1059
|
+
// motion-applied (FLIP/drag) transform up the tree. Without this a
|
|
1060
|
+
// child re-measures and FLIPs whenever an ancestor's transform
|
|
1061
|
+
// animates, even though the child's own layout never moved. (#379)
|
|
1062
|
+
lastRect = measureLayoutRect()
|
|
942
1063
|
// Hint compositor for smoother FLIP transforms
|
|
943
1064
|
setCompositorHints(element!, true)
|
|
944
1065
|
|
|
945
1066
|
let rafId: number | null = null
|
|
946
1067
|
const runFlip = () => {
|
|
947
|
-
|
|
1068
|
+
// commitLayoutChange does the ancestor-stripped measure, fans
|
|
1069
|
+
// the delta out to `onProjectionUpdate` listeners, AND returns
|
|
1070
|
+
// the freshly-measured box — so the FLIP reuses that single
|
|
1071
|
+
// measurement as `next` instead of walking + transform-resetting
|
|
1072
|
+
// the ancestor chain a second time per frame. (#379)
|
|
1073
|
+
const nextBox = projection.commitLayoutChange()
|
|
1074
|
+
if (!nextBox) return
|
|
1075
|
+
const next = boxToRectLike(nextBox)
|
|
948
1076
|
if (!lastRect) {
|
|
949
|
-
lastRect =
|
|
1077
|
+
lastRect = next
|
|
950
1078
|
return
|
|
951
1079
|
}
|
|
952
|
-
const next = measureRect(element!, scrollContainers)
|
|
953
1080
|
const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
|
|
954
1081
|
runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
|
|
955
1082
|
lastRect = next
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { motion } from './motion';
|
|
|
6
6
|
export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
|
|
7
7
|
export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
|
|
8
8
|
export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
|
|
9
|
-
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ReducedMotionConfig, Variants } from './types';
|
|
9
|
+
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionOnProjectionUpdate, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ProjectionUpdatePayload, ReducedMotionConfig, Variants } from './types';
|
|
10
10
|
export { useAnimate } from './utils/animate.svelte';
|
|
11
11
|
export type { AnimationScope } from './utils/animate.svelte';
|
|
12
12
|
export { useAnimationFrame } from './utils/animationFrame';
|
package/dist/types.d.ts
CHANGED
|
@@ -255,6 +255,59 @@ export type MotionOnPanSessionStart = ((event: PointerEvent, info: DragInfo) =>
|
|
|
255
255
|
export type MotionOnPanStart = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
256
256
|
export type MotionOnPan = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
257
257
|
export type MotionOnPanEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined;
|
|
258
|
+
/**
|
|
259
|
+
* Payload delivered to `onProjectionUpdate`. Re-declared structurally
|
|
260
|
+
* here (rather than imported from `projection.ts`) so `types.ts`
|
|
261
|
+
* stays dependency-free at the type layer. `delta.x/y.translate` is the
|
|
262
|
+
* px shift the element's layout box moved between the pre-change
|
|
263
|
+
* snapshot and the post-change measurement; `hasLayoutChanged` is false
|
|
264
|
+
* only when the delta is within a tight float epsilon (±0.01px
|
|
265
|
+
* translate), so even a sub-pixel real move is reported as a change.
|
|
266
|
+
*/
|
|
267
|
+
export type ProjectionUpdatePayload = {
|
|
268
|
+
layout: {
|
|
269
|
+
x: {
|
|
270
|
+
min: number;
|
|
271
|
+
max: number;
|
|
272
|
+
};
|
|
273
|
+
y: {
|
|
274
|
+
min: number;
|
|
275
|
+
max: number;
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
snapshot: {
|
|
279
|
+
x: {
|
|
280
|
+
min: number;
|
|
281
|
+
max: number;
|
|
282
|
+
};
|
|
283
|
+
y: {
|
|
284
|
+
min: number;
|
|
285
|
+
max: number;
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
delta: {
|
|
289
|
+
x: {
|
|
290
|
+
translate: number;
|
|
291
|
+
scale: number;
|
|
292
|
+
origin: number;
|
|
293
|
+
originPoint: number;
|
|
294
|
+
};
|
|
295
|
+
y: {
|
|
296
|
+
translate: number;
|
|
297
|
+
scale: number;
|
|
298
|
+
origin: number;
|
|
299
|
+
originPoint: number;
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
hasLayoutChanged: boolean;
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* Fires after each layout change to a `motion.*` element that has
|
|
306
|
+
* `layout` enabled, with the FLIP delta between the pre- and
|
|
307
|
+
* post-change layout boxes. Mirrors framer-motion's `onLayoutMeasure`
|
|
308
|
+
* surface. Wired through the element's internal `ProjectionNode`.
|
|
309
|
+
*/
|
|
310
|
+
export type MotionOnProjectionUpdate = ((data: ProjectionUpdatePayload) => void) | undefined;
|
|
258
311
|
export type DragAxis = boolean | 'x' | 'y';
|
|
259
312
|
export type DragConstraints = {
|
|
260
313
|
top?: number;
|
|
@@ -380,6 +433,12 @@ export type MotionProps = {
|
|
|
380
433
|
class?: string;
|
|
381
434
|
/** Enable FLIP layout animations; "position" limits to translation only */
|
|
382
435
|
layout?: boolean | 'position';
|
|
436
|
+
/**
|
|
437
|
+
* Fires after each `layout`-driven change with the FLIP delta from
|
|
438
|
+
* the element's internal projection node. Mirrors framer-motion's
|
|
439
|
+
* `onLayoutMeasure`. Requires `layout` to be enabled.
|
|
440
|
+
*/
|
|
441
|
+
onProjectionUpdate?: MotionOnProjectionUpdate;
|
|
383
442
|
/** Shared layout animation identifier. Elements with matching layoutId animate between positions. */
|
|
384
443
|
layoutId?: string;
|
|
385
444
|
/**
|
package/dist/utils/drag.d.ts
CHANGED
|
@@ -66,24 +66,55 @@ export declare const resolveConstraints: (el: HTMLElement | null, constraints: D
|
|
|
66
66
|
*/
|
|
67
67
|
export declare const applyElastic: (value: number, min: number, max: number, elastic: number) => number;
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
69
|
+
* Cleanup handle returned by {@link attachDrag}. Invoke it to detach the
|
|
70
|
+
* gesture's pointer/resize listeners. It does NOT cancel an in-flight
|
|
71
|
+
* momentum/settle animation — matching framer-motion, whose drag teardown
|
|
72
|
+
* removes listeners only and deliberately lets motion continue across an
|
|
73
|
+
* unmount/remount (e.g. reorder reconciliation); the animation is cleaned
|
|
74
|
+
* up by the element/motion-value lifecycle.
|
|
70
75
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
76
|
+
* The handle also carries `adjustOrigin(dx, dy)`, which shifts the LIVE
|
|
77
|
+
* gesture's origin + visual offset by a layout-shift delta mid-drag — used
|
|
78
|
+
* for projection-driven cursor pinning when a layout slot moves under the
|
|
79
|
+
* dragged element (#379 / #310). It is a no-op when not currently dragging
|
|
80
|
+
* and compensates on both axes (mirroring upstream's per-axis `eachAxis`
|
|
81
|
+
* compensation).
|
|
74
82
|
*/
|
|
83
|
+
export type AttachDragCleanup = (() => void) & {
|
|
84
|
+
adjustOrigin: (dx: number, dy: number) => void;
|
|
85
|
+
};
|
|
75
86
|
/**
|
|
76
|
-
* Attach a drag gesture to an
|
|
87
|
+
* Attach a drag gesture to an element.
|
|
88
|
+
*
|
|
89
|
+
* Captures the pointer and updates x/y transforms with axis and optional
|
|
90
|
+
* direction lock, applies elastic overflow against constraints, emits
|
|
91
|
+
* lifecycle callbacks with `DragInfo`, and runs a momentum animation on
|
|
92
|
+
* release when enabled.
|
|
77
93
|
*
|
|
78
94
|
* Lifecycle:
|
|
79
95
|
* - pointerdown → capture pointer, snapshot origin, start velocity history, enter whileDrag
|
|
80
96
|
* - pointermove → compute deltas, direction lock, apply constraints + elastic, write x/y
|
|
81
97
|
* - pointerup/cancel → either run momentum decay to a target or settle/clamp instantly
|
|
82
98
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
99
|
+
* Invariant: `applied` tracks the currently applied x/y transform — it
|
|
100
|
+
* must stay in sync when writing transforms or finishing animations, or a
|
|
101
|
+
* second drag "jumps" from a stale origin (commonly a missed update after
|
|
102
|
+
* a non-zero-duration settle animation).
|
|
103
|
+
*
|
|
104
|
+
* @param el The element to make draggable.
|
|
105
|
+
* @param opts Drag options — `axis`, `constraints`, `elastic`,
|
|
106
|
+
* `momentum`, `whileDrag`, and the `onDrag*` lifecycle callbacks.
|
|
107
|
+
* @returns A callable cleanup handle ({@link AttachDragCleanup}): call it
|
|
108
|
+
* to detach the gesture's listeners (in-flight momentum is not
|
|
109
|
+
* cancelled — see the type docs), or call its `adjustOrigin(dx, dy)` to
|
|
110
|
+
* reposition the live gesture mid-drag.
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* const cleanup = attachDrag(el, { axis: 'x', momentum: true })
|
|
114
|
+
* // …when a layout swap shifts the slot under the cursor mid-drag:
|
|
115
|
+
* cleanup.adjustOrigin(10, -5)
|
|
116
|
+
* // on teardown:
|
|
117
|
+
* cleanup()
|
|
118
|
+
* ```
|
|
88
119
|
*/
|
|
89
|
-
export declare const attachDrag: (el: HTMLElement, opts: AttachDragOptions) =>
|
|
120
|
+
export declare const attachDrag: (el: HTMLElement, opts: AttachDragOptions) => AttachDragCleanup;
|
package/dist/utils/drag.js
CHANGED
|
@@ -137,23 +137,36 @@ const computeReleaseVelocity = (history, nowMs) => {
|
|
|
137
137
|
/**
|
|
138
138
|
* Attach a drag gesture to an element.
|
|
139
139
|
*
|
|
140
|
-
* Captures the pointer
|
|
141
|
-
* applies elastic overflow against constraints, emits
|
|
142
|
-
* and runs a momentum animation on
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Attach a drag gesture to an HTMLElement.
|
|
140
|
+
* Captures the pointer and updates x/y transforms with axis and optional
|
|
141
|
+
* direction lock, applies elastic overflow against constraints, emits
|
|
142
|
+
* lifecycle callbacks with `DragInfo`, and runs a momentum animation on
|
|
143
|
+
* release when enabled.
|
|
146
144
|
*
|
|
147
145
|
* Lifecycle:
|
|
148
146
|
* - pointerdown → capture pointer, snapshot origin, start velocity history, enter whileDrag
|
|
149
147
|
* - pointermove → compute deltas, direction lock, apply constraints + elastic, write x/y
|
|
150
148
|
* - pointerup/cancel → either run momentum decay to a target or settle/clamp instantly
|
|
151
149
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
150
|
+
* Invariant: `applied` tracks the currently applied x/y transform — it
|
|
151
|
+
* must stay in sync when writing transforms or finishing animations, or a
|
|
152
|
+
* second drag "jumps" from a stale origin (commonly a missed update after
|
|
153
|
+
* a non-zero-duration settle animation).
|
|
154
|
+
*
|
|
155
|
+
* @param el The element to make draggable.
|
|
156
|
+
* @param opts Drag options — `axis`, `constraints`, `elastic`,
|
|
157
|
+
* `momentum`, `whileDrag`, and the `onDrag*` lifecycle callbacks.
|
|
158
|
+
* @returns A callable cleanup handle ({@link AttachDragCleanup}): call it
|
|
159
|
+
* to detach the gesture's listeners (in-flight momentum is not
|
|
160
|
+
* cancelled — see the type docs), or call its `adjustOrigin(dx, dy)` to
|
|
161
|
+
* reposition the live gesture mid-drag.
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* const cleanup = attachDrag(el, { axis: 'x', momentum: true })
|
|
165
|
+
* // …when a layout swap shifts the slot under the cursor mid-drag:
|
|
166
|
+
* cleanup.adjustOrigin(10, -5)
|
|
167
|
+
* // on teardown:
|
|
168
|
+
* cleanup()
|
|
169
|
+
* ```
|
|
157
170
|
*/
|
|
158
171
|
export const attachDrag = (el, opts) => {
|
|
159
172
|
const EL_ID = el.getAttribute('data-testid') || el.id || el.tagName;
|
|
@@ -249,6 +262,83 @@ export const attachDrag = (el, opts) => {
|
|
|
249
262
|
}
|
|
250
263
|
}
|
|
251
264
|
};
|
|
265
|
+
/**
|
|
266
|
+
* Write absolute element-space translation by mutating
|
|
267
|
+
* `el.style.transform` DIRECTLY — no `animate()`, no epsilon skip,
|
|
268
|
+
* no Playwright retry. The translate channel is rewritten while any
|
|
269
|
+
* non-translate transform the element already carries (e.g. a
|
|
270
|
+
* `whileDrag` scale) is preserved as a suffix.
|
|
271
|
+
*
|
|
272
|
+
* Why this exists separately from `setXY`: routing through
|
|
273
|
+
* `animate(el, _, { duration: 0 })` defers the write to Motion's
|
|
274
|
+
* scheduler, costing ~1 frame before the new position paints. For
|
|
275
|
+
* the projection-driven origin compensation (where a layout swap
|
|
276
|
+
* must keep the dragged element under the cursor in the SAME frame
|
|
277
|
+
* the swap commits), that frame of lag manifests as a visible
|
|
278
|
+
* wobble. This path lands synchronously. See #379 / the
|
|
279
|
+
* `adjustOrigin` hook below.
|
|
280
|
+
*/
|
|
281
|
+
const setXYImmediate = (x, y) => {
|
|
282
|
+
const parts = [];
|
|
283
|
+
if (axis === true || axis === 'x')
|
|
284
|
+
parts.push(`translateX(${x}px)`);
|
|
285
|
+
if (axis === true || axis === 'y')
|
|
286
|
+
parts.push(`translateY(${y}px)`);
|
|
287
|
+
// Strip existing translate channels, keep the rest (scale/rotate/etc.).
|
|
288
|
+
const nonTranslate = el.style.transform.replace(/translate[XYZ3d]*\([^)]*\)/g, '').trim();
|
|
289
|
+
el.style.transform = [...parts, nonTranslate].filter(Boolean).join(' ');
|
|
290
|
+
if (axis === true || axis === 'x')
|
|
291
|
+
applied.x = x;
|
|
292
|
+
if (axis === true || axis === 'y')
|
|
293
|
+
applied.y = y;
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Adjust the drag origin + visual offset by a layout-shift delta,
|
|
297
|
+
* mid-gesture, keeping the dragged element pinned under the cursor
|
|
298
|
+
* while its underlying layout slot moves.
|
|
299
|
+
*
|
|
300
|
+
* Direct port of framer-motion's projection `didUpdate` handler in
|
|
301
|
+
* `VisualElementDragControls.ts:742-758`:
|
|
302
|
+
*
|
|
303
|
+
* ```ts
|
|
304
|
+
* this.originPoint[axis] += delta[axis].translate
|
|
305
|
+
* motionValue.set(motionValue.get() + delta[axis].translate)
|
|
306
|
+
* ```
|
|
307
|
+
*
|
|
308
|
+
* We do the same two-write dance: shift `origin` (the gesture's
|
|
309
|
+
* reference zero) AND the applied visual transform by the same
|
|
310
|
+
* delta, so `lastPoint - startPoint + origin` continues to resolve
|
|
311
|
+
* to the correct on-screen position after the layout slot moved.
|
|
312
|
+
* Uses `setXYImmediate` so the compensation is visible the same
|
|
313
|
+
* frame as the layout change.
|
|
314
|
+
*
|
|
315
|
+
* Not wired to any projection node in this PR — exposed on the
|
|
316
|
+
* `attachDrag` return handle for the Reorder PR (#310) to call from
|
|
317
|
+
* its `ProjectionNode.didUpdate` listener.
|
|
318
|
+
*
|
|
319
|
+
* @param dx Layout delta on the x axis (px).
|
|
320
|
+
* @param dy Layout delta on the y axis (px).
|
|
321
|
+
*/
|
|
322
|
+
const adjustOrigin = (dx, dy) => {
|
|
323
|
+
if (!dragging)
|
|
324
|
+
return;
|
|
325
|
+
// Compensate the origin on BOTH axes unconditionally — upstream's
|
|
326
|
+
// didUpdate handler applies the delta per-axis via `eachAxis`
|
|
327
|
+
// regardless of the drag axis or direction lock, because a layout
|
|
328
|
+
// slot can shift on either axis.
|
|
329
|
+
origin.x += dx;
|
|
330
|
+
origin.y += dy;
|
|
331
|
+
// The VISUAL write is `setXYImmediate`, which only writes the axis
|
|
332
|
+
// this drag manages (`opts.axis`). For the dragged axis that pins
|
|
333
|
+
// the element same-frame; the cross-axis case (e.g. drag="x" + a
|
|
334
|
+
// y-shift) only updates `origin`, not the transform. Fully
|
|
335
|
+
// rendering cross-axis compensation needs to route through the
|
|
336
|
+
// Motion value the move path uses (a direct write here would be
|
|
337
|
+
// wiped by the next `setXY`), so it's finalized when this hook is
|
|
338
|
+
// wired in #310. The common Reorder case (drag axis === the shift
|
|
339
|
+
// axis) is fully compensated today.
|
|
340
|
+
setXYImmediate(applied.x + dx, applied.y + dy);
|
|
341
|
+
};
|
|
252
342
|
const startWhileDrag = () => {
|
|
253
343
|
if (!opts.whileDrag)
|
|
254
344
|
return;
|
|
@@ -758,7 +848,7 @@ export const attachDrag = (el, opts) => {
|
|
|
758
848
|
}
|
|
759
849
|
el.addEventListener('pointerdown', onPointerDown);
|
|
760
850
|
pwLog('[drag] pointerdown listener attached', { el: EL_ID });
|
|
761
|
-
|
|
851
|
+
const teardown = () => {
|
|
762
852
|
pwLog('[drag] detach', { el: EL_ID });
|
|
763
853
|
el.removeEventListener('pointerdown', onPointerDown);
|
|
764
854
|
el.removeEventListener('pointermove', onPointerMove);
|
|
@@ -768,4 +858,5 @@ export const attachDrag = (el, opts) => {
|
|
|
768
858
|
window.removeEventListener('pointerup', onPointerUp);
|
|
769
859
|
window.removeEventListener('pointercancel', onPointerCancel);
|
|
770
860
|
};
|
|
861
|
+
return Object.assign(teardown, { adjustOrigin });
|
|
771
862
|
};
|