solidjs-motion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/dist/src/default-values.d.ts +6 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/motion-config.d.ts +14 -0
- package/dist/src/motion-proxy.d.ts +103 -0
- package/dist/src/presence-context.d.ts +4 -0
- package/dist/src/presence.d.ts +95 -0
- package/dist/src/primitives/createDrag.d.ts +16 -0
- package/dist/src/primitives/createDragControls.d.ts +30 -0
- package/dist/src/primitives/createGestures.d.ts +8 -0
- package/dist/src/primitives/createInView.d.ts +51 -0
- package/dist/src/primitives/createMotion.d.ts +82 -0
- package/dist/src/primitives/createPan.d.ts +83 -0
- package/dist/src/primitives/createScroll.d.ts +40 -0
- package/dist/src/primitives/gesture-state.d.ts +108 -0
- package/dist/src/primitives/motion-value.d.ts +111 -0
- package/dist/src/primitives/value-registry.d.ts +34 -0
- package/dist/src/reduced-motion.d.ts +29 -0
- package/dist/src/style.d.ts +79 -0
- package/dist/src/types.d.ts +374 -0
- package/dist/src/use-motion.d.ts +35 -0
- package/dist/src/variants.d.ts +64 -0
- package/package.json +78 -0
- package/src/default-values.ts +52 -0
- package/src/index.ts +60 -0
- package/src/motion-config.tsx +37 -0
- package/src/motion-proxy.tsx +377 -0
- package/src/presence-context.ts +32 -0
- package/src/presence.tsx +466 -0
- package/src/primitives/createDrag.ts +670 -0
- package/src/primitives/createDragControls.ts +101 -0
- package/src/primitives/createGestures.ts +145 -0
- package/src/primitives/createInView.ts +124 -0
- package/src/primitives/createMotion.ts +638 -0
- package/src/primitives/createPan.ts +338 -0
- package/src/primitives/createScroll.ts +101 -0
- package/src/primitives/gesture-state.ts +772 -0
- package/src/primitives/motion-value.ts +328 -0
- package/src/primitives/value-registry.ts +114 -0
- package/src/reduced-motion.ts +51 -0
- package/src/style.ts +266 -0
- package/src/types.ts +538 -0
- package/src/use-motion.tsx +412 -0
- package/src/variants.ts +134 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { type AnimationPlaybackControls, animate } from "motion"
|
|
2
|
+
import { HTMLVisualElement, type MotionValue, time, visualElementStore } from "motion-dom"
|
|
3
|
+
import { createEffect, onCleanup } from "solid-js"
|
|
4
|
+
import type {
|
|
5
|
+
DragConstraints,
|
|
6
|
+
DragControls,
|
|
7
|
+
DragControlsStartOptions,
|
|
8
|
+
MotionOptions,
|
|
9
|
+
PanInfo,
|
|
10
|
+
} from "../types"
|
|
11
|
+
import { DRAG_CONTROLS_REGISTER } from "./createDragControls"
|
|
12
|
+
import { createPan } from "./createPan"
|
|
13
|
+
import type { SetActive } from "./gesture-state"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// createDrag — pointer-driven drag with motion-dom VisualElement composition
|
|
17
|
+
// (Q5/C-lean + Q15).
|
|
18
|
+
//
|
|
19
|
+
// Architecture:
|
|
20
|
+
// - Layered on top of createPan (Q11/D3). Pan handles the pointer session
|
|
21
|
+
// (down → threshold-gated start → moves → up/cancel) and exposes its
|
|
22
|
+
// PanInfo via callbacks. Drag adds the side-effects on top: transform
|
|
23
|
+
// writes, body/touchAction styling, pointer capture, state-machine
|
|
24
|
+
// activation, and (Stage 4) momentum.
|
|
25
|
+
// - Translation flows through motion-dom's VisualElement system: drag writes
|
|
26
|
+
// to the element's `x`/`y` MotionValues, and motion's render pipeline
|
|
27
|
+
// composes the final transform string. This is why `whileDrag: { scale }`
|
|
28
|
+
// works for free — the scale animation writes a sibling MV and the VE
|
|
29
|
+
// composes all transform-class values into one output (Q5/C-lean).
|
|
30
|
+
//
|
|
31
|
+
// State machine integration:
|
|
32
|
+
// - On pan-start (only if drag is enabled), `setActive("whileDrag", true)`
|
|
33
|
+
// fires. The state machine's `winners` memo then claims whileDrag's
|
|
34
|
+
// target keys, EXCEPT x and y which are filtered (drag owns them).
|
|
35
|
+
// - On pan-end, `setActive("whileDrag", false)`. Momentum (Stage 4) runs
|
|
36
|
+
// AFTER whileDrag deactivates — visual gesture state ends with the
|
|
37
|
+
// pointerup, not when the animation settles.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get or create a motion-dom VisualElement for an HTMLElement. Required
|
|
42
|
+
* because we write to the VE's `x`/`y` MotionValues during drag, and motion
|
|
43
|
+
* only auto-creates the VE inside `animate(el, target)` calls — if a user
|
|
44
|
+
* configures drag without any animate target, no VE would exist.
|
|
45
|
+
*
|
|
46
|
+
* Mirrors framer-motion's `createDOMVisualElement` (which isn't reachable
|
|
47
|
+
* from a non-React context — framer-motion's main entry requires React).
|
|
48
|
+
* The options shape and `mount` + `visualElementStore.set` calls match the
|
|
49
|
+
* upstream implementation. SVG support is omitted for v0.1 — drag on SVG
|
|
50
|
+
* is an unusual case.
|
|
51
|
+
*/
|
|
52
|
+
function ensureVisualElement(el: HTMLElement): InstanceType<typeof HTMLVisualElement> {
|
|
53
|
+
const existing = visualElementStore.get(el)
|
|
54
|
+
if (existing) return existing as InstanceType<typeof HTMLVisualElement>
|
|
55
|
+
|
|
56
|
+
// motion-dom's VisualElement options type expects more fields than we
|
|
57
|
+
// can sensibly provide without React-flavored MotionProps. The runtime
|
|
58
|
+
// needs only the visualState shape and an empty props bag.
|
|
59
|
+
const options = {
|
|
60
|
+
presenceContext: null,
|
|
61
|
+
props: {},
|
|
62
|
+
visualState: {
|
|
63
|
+
renderState: {
|
|
64
|
+
transform: {},
|
|
65
|
+
transformOrigin: {},
|
|
66
|
+
style: {},
|
|
67
|
+
vars: {},
|
|
68
|
+
attrs: {},
|
|
69
|
+
},
|
|
70
|
+
latestValues: {},
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: VisualElement options type expects React-flavored MotionProps we can't supply.
|
|
74
|
+
const ve = new HTMLVisualElement(options as any)
|
|
75
|
+
ve.mount(el)
|
|
76
|
+
visualElementStore.set(el, ve)
|
|
77
|
+
return ve as InstanceType<typeof HTMLVisualElement>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute the `touch-action` CSS value for an element being dragged.
|
|
82
|
+
* Disabling touch-action prevents the browser from interpreting the gesture
|
|
83
|
+
* as a scroll. Axis-locked drags leave the unused axis available for scroll
|
|
84
|
+
* (so a horizontally-draggable card can still be scrolled vertically by the
|
|
85
|
+
* surrounding page).
|
|
86
|
+
*/
|
|
87
|
+
function touchActionFor(drag: MotionOptions["drag"]): string {
|
|
88
|
+
if (drag === "x") return "pan-y"
|
|
89
|
+
if (drag === "y") return "pan-x"
|
|
90
|
+
return "none"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolved drag bounds expressed as absolute MotionValue bounds (Q5/C-lean —
|
|
95
|
+
* drag writes absolute values, so we clamp in MV-space, not offset-space).
|
|
96
|
+
* `Infinity` / `-Infinity` represent "unbounded in that direction."
|
|
97
|
+
*/
|
|
98
|
+
type ResolvedBounds = {
|
|
99
|
+
minX: number
|
|
100
|
+
maxX: number
|
|
101
|
+
minY: number
|
|
102
|
+
maxY: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a {@link DragConstraints} value into absolute MotionValue bounds at
|
|
107
|
+
* drag-start. Two input shapes (Q8):
|
|
108
|
+
*
|
|
109
|
+
* - **Numeric** (`{ top, left, right, bottom }`): bounds are absolute MV
|
|
110
|
+
* values. `left: -100` means x cannot go below -100.
|
|
111
|
+
* - **HTMLElement or `() => HTMLElement | null`**: container that the
|
|
112
|
+
* dragged element must stay inside. Bounds are computed from the
|
|
113
|
+
* container's bounding rect vs the dragged element's current rect, then
|
|
114
|
+
* re-centered around the current MV values.
|
|
115
|
+
*
|
|
116
|
+
* The element form is resolved ONCE at drag-start (current viewport rects).
|
|
117
|
+
* Reactive constraint changes mid-drag aren't honored in v0.1 — they'd
|
|
118
|
+
* require re-measuring on each pointermove. Acceptable corner case.
|
|
119
|
+
*
|
|
120
|
+
* Returns `null` when constraints are unset or the accessor returns null —
|
|
121
|
+
* caller treats as "no clamping, no elastic resistance."
|
|
122
|
+
*/
|
|
123
|
+
function resolveConstraints(
|
|
124
|
+
constraints: DragConstraints | undefined,
|
|
125
|
+
el: HTMLElement,
|
|
126
|
+
dragStartX: number,
|
|
127
|
+
dragStartY: number,
|
|
128
|
+
): ResolvedBounds | null {
|
|
129
|
+
if (!constraints) return null
|
|
130
|
+
|
|
131
|
+
// Discriminate variants with `instanceof HTMLElement` first — this rules
|
|
132
|
+
// out HTMLElement so the `typeof === "function"` check below narrows
|
|
133
|
+
// cleanly to the accessor variant. (TS gets confused if we test the
|
|
134
|
+
// function form first; HTMLElement instances have many methods on their
|
|
135
|
+
// prototype, which can muddle TS's narrowing logic.)
|
|
136
|
+
let container: HTMLElement | null = null
|
|
137
|
+
if (constraints instanceof HTMLElement) {
|
|
138
|
+
container = constraints
|
|
139
|
+
} else if (typeof constraints === "function") {
|
|
140
|
+
container = constraints()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (container) {
|
|
144
|
+
// Element-form: compute offset bounds from rects, then add dragStart
|
|
145
|
+
// so we get absolute MV bounds. Subtle but important: the element's
|
|
146
|
+
// current rect already includes any applied transform (including the
|
|
147
|
+
// current dragStart translation), so we add dragStart back to convert
|
|
148
|
+
// "allowable offset from current position" to "allowable absolute MV
|
|
149
|
+
// value."
|
|
150
|
+
const containerRect = container.getBoundingClientRect()
|
|
151
|
+
const elementRect = el.getBoundingClientRect()
|
|
152
|
+
return {
|
|
153
|
+
minX: dragStartX + (containerRect.left - elementRect.left),
|
|
154
|
+
maxX: dragStartX + (containerRect.right - elementRect.right),
|
|
155
|
+
minY: dragStartY + (containerRect.top - elementRect.top),
|
|
156
|
+
maxY: dragStartY + (containerRect.bottom - elementRect.bottom),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Numeric form. Missing keys → unbounded on that side.
|
|
161
|
+
const numeric = constraints as { top?: number; left?: number; right?: number; bottom?: number }
|
|
162
|
+
return {
|
|
163
|
+
minX: numeric.left ?? -Infinity,
|
|
164
|
+
maxX: numeric.right ?? Infinity,
|
|
165
|
+
minY: numeric.top ?? -Infinity,
|
|
166
|
+
maxY: numeric.bottom ?? Infinity,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Apply elastic resistance past a boundary (Q15c — linear).
|
|
172
|
+
*
|
|
173
|
+
* Within bounds: `value` passes through unchanged. Past a bound by `Δ`: the
|
|
174
|
+
* displayed value is `boundary + elastic × Δ`. With `elastic: 0` the value
|
|
175
|
+
* clamps hard at the boundary; with `elastic: 1` resistance vanishes
|
|
176
|
+
* (motion's default is `0.5`, halving the overflow).
|
|
177
|
+
*
|
|
178
|
+
* The function is symmetric — overflow on either side resists with the
|
|
179
|
+
* same coefficient.
|
|
180
|
+
*/
|
|
181
|
+
function applyElastic(value: number, min: number, max: number, elastic: number): number {
|
|
182
|
+
if (value < min) return min + (value - min) * elastic
|
|
183
|
+
if (value > max) return max + (value - max) * elastic
|
|
184
|
+
return value
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const DEFAULT_ELASTIC = 0.5
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Default `dragTransition` (Q15d — matches motion's inertia preset).
|
|
191
|
+
*
|
|
192
|
+
* `type: "inertia"` decays from the release point using `velocity`, with
|
|
193
|
+
* spring physics at `min`/`max` boundaries. The defaults are the values
|
|
194
|
+
* the user signed off on during Phase 2 grilling; passing a custom
|
|
195
|
+
* `dragTransition` shallow-merges over these.
|
|
196
|
+
*/
|
|
197
|
+
const DEFAULT_DRAG_TRANSITION = {
|
|
198
|
+
type: "inertia" as const,
|
|
199
|
+
power: 0.8,
|
|
200
|
+
timeConstant: 750,
|
|
201
|
+
bounceStiffness: 500,
|
|
202
|
+
bounceDamping: 10,
|
|
203
|
+
restDelta: 1,
|
|
204
|
+
restSpeed: 10,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Bind pointer-driven drag to an element. Layers on top of createPan for the
|
|
209
|
+
* pointer session; adds transform writes, body styles, pointer capture, and
|
|
210
|
+
* state-machine activation.
|
|
211
|
+
*
|
|
212
|
+
* Drag is enabled when `opts.drag` is truthy (`true`, `"x"`, or `"y"`).
|
|
213
|
+
* createDrag always wires the pointer session — the enable check is per-
|
|
214
|
+
* gesture-start, so toggling `opts.drag` on/off doesn't churn listeners.
|
|
215
|
+
*
|
|
216
|
+
* Phase 2 Commit 6 — Stage 2 scope: VE bootstrap, translation, axis lock,
|
|
217
|
+
* body/pointer styles, callbacks, cleanup. Constraints + elastic resistance
|
|
218
|
+
* land in Stage 3; momentum + dragSnapToOrigin in Stage 4.
|
|
219
|
+
*/
|
|
220
|
+
export function createDrag(
|
|
221
|
+
el: HTMLElement,
|
|
222
|
+
getOpts: () => MotionOptions,
|
|
223
|
+
setActive: SetActive,
|
|
224
|
+
): void {
|
|
225
|
+
// Drag-session state. These are reset on each pan-start; null between
|
|
226
|
+
// sessions. Holding references in the closure is fine — the createPan
|
|
227
|
+
// handlers below close over them.
|
|
228
|
+
let xMV: MotionValue<number> | null = null
|
|
229
|
+
let yMV: MotionValue<number> | null = null
|
|
230
|
+
/** Drag-start position of the x/y MotionValues (the values that existed
|
|
231
|
+
* before the user grabbed). offsets from PanInfo accumulate from this. */
|
|
232
|
+
let dragStartX = 0
|
|
233
|
+
let dragStartY = 0
|
|
234
|
+
/** Resolved bounds for this session — computed once at drag-start and
|
|
235
|
+
* reused across all pointermoves to avoid repeat layout reads. `null`
|
|
236
|
+
* means no constraints. */
|
|
237
|
+
let sessionBounds: ResolvedBounds | null = null
|
|
238
|
+
/** Saved before applying drag's `user-select` / `touch-action` overrides
|
|
239
|
+
* so we can restore them exactly on session end. */
|
|
240
|
+
let savedUserSelect = ""
|
|
241
|
+
let savedTouchAction = ""
|
|
242
|
+
let capturedPointerId: number | null = null
|
|
243
|
+
/** In-flight momentum animations (one per axis when active). Stopped on
|
|
244
|
+
* owner disposal AND on a fresh pointerdown (to interrupt a settling
|
|
245
|
+
* momentum if the user grabs again mid-decay). */
|
|
246
|
+
let momentumControls: AnimationPlaybackControls[] = []
|
|
247
|
+
|
|
248
|
+
function isDragEnabled(): boolean {
|
|
249
|
+
return Boolean(getOpts().drag)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function restoreBodyAndElementStyles(): void {
|
|
253
|
+
document.body.style.userSelect = savedUserSelect
|
|
254
|
+
el.style.touchAction = savedTouchAction
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function releasePointerCaptureSafely(): void {
|
|
258
|
+
if (capturedPointerId === null) return
|
|
259
|
+
try {
|
|
260
|
+
el.releasePointerCapture(capturedPointerId)
|
|
261
|
+
} catch {
|
|
262
|
+
// jsdom doesn't fully implement setPointerCapture; tolerate.
|
|
263
|
+
}
|
|
264
|
+
capturedPointerId = null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stopMomentum(): void {
|
|
268
|
+
for (const ctrl of momentumControls) ctrl.stop()
|
|
269
|
+
momentumControls = []
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Stable handler references — they close over getOpts so reactive opts
|
|
273
|
+
// are read at event time. Hoisted out of the createPan call so the
|
|
274
|
+
// function-form options below doesn't re-allocate them per getOpts() call.
|
|
275
|
+
const handlePanStart = (event: PointerEvent, info: PanInfo) => {
|
|
276
|
+
// The pan session always fires onPanStart once movement crosses the
|
|
277
|
+
// threshold. Drag's enable check is here, not at the createPan-setup
|
|
278
|
+
// site, so toggling `drag` off mid-life immediately stops drag
|
|
279
|
+
// engagement without re-attaching pointer listeners.
|
|
280
|
+
if (!isDragEnabled()) return
|
|
281
|
+
|
|
282
|
+
// If momentum from a previous drag is still settling, cancel it now —
|
|
283
|
+
// the user has grabbed again, and they expect the element to follow
|
|
284
|
+
// their pointer from its current position, not continue decaying.
|
|
285
|
+
stopMomentum()
|
|
286
|
+
|
|
287
|
+
const ve = ensureVisualElement(el)
|
|
288
|
+
xMV = ve.getValue("x", 0) as MotionValue<number>
|
|
289
|
+
yMV = ve.getValue("y", 0) as MotionValue<number>
|
|
290
|
+
dragStartX = xMV.get()
|
|
291
|
+
dragStartY = yMV.get()
|
|
292
|
+
|
|
293
|
+
// Resolve constraints once per session. Reading layout rects mid-drag
|
|
294
|
+
// would cost a forced reflow per pointermove; one read at drag-start
|
|
295
|
+
// is enough for v0.1 (reactive constraint changes during a drag are
|
|
296
|
+
// a rare corner case — they re-apply on the NEXT session).
|
|
297
|
+
sessionBounds = resolveConstraints(getOpts().dragConstraints, el, dragStartX, dragStartY)
|
|
298
|
+
|
|
299
|
+
// Body + touch-action overrides — saved so the exact prior values
|
|
300
|
+
// restore on session end (don't assume defaults).
|
|
301
|
+
savedUserSelect = document.body.style.userSelect
|
|
302
|
+
savedTouchAction = el.style.touchAction
|
|
303
|
+
document.body.style.userSelect = "none"
|
|
304
|
+
el.style.touchAction = touchActionFor(getOpts().drag)
|
|
305
|
+
|
|
306
|
+
// Pointer capture keeps move events flowing to the element even when
|
|
307
|
+
// the pointer leaves it during a fast drag. setPointerCapture can
|
|
308
|
+
// throw in some browsers (e.g., already captured); swallow.
|
|
309
|
+
try {
|
|
310
|
+
el.setPointerCapture(event.pointerId)
|
|
311
|
+
capturedPointerId = event.pointerId
|
|
312
|
+
} catch {
|
|
313
|
+
// Safe — window listeners in createPan continue to fire regardless.
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
setActive("whileDrag", true)
|
|
317
|
+
getOpts().onDragStart?.(event, info)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const handlePan = (event: PointerEvent, info: PanInfo) => {
|
|
321
|
+
if (!isDragEnabled() || !xMV || !yMV) return
|
|
322
|
+
|
|
323
|
+
const axis = getOpts().drag
|
|
324
|
+
// Axis lock: when drag is "x" or "y", we SKIP writes to the locked axis
|
|
325
|
+
// entirely — matches motion/react's per-axis shouldDrag short-circuit.
|
|
326
|
+
// Writing dragStartY+0 to yMV when y is locked would generate
|
|
327
|
+
// no-op-but-non-empty writes that consumers and tests both observe.
|
|
328
|
+
const writeX = axis !== "y"
|
|
329
|
+
const writeY = axis !== "x"
|
|
330
|
+
const elastic = getOpts().dragElastic ?? DEFAULT_ELASTIC
|
|
331
|
+
|
|
332
|
+
if (writeX) {
|
|
333
|
+
const candidateX = dragStartX + info.offset.x
|
|
334
|
+
const finalX = sessionBounds
|
|
335
|
+
? applyElastic(candidateX, sessionBounds.minX, sessionBounds.maxX, elastic)
|
|
336
|
+
: candidateX
|
|
337
|
+
xMV.set(finalX)
|
|
338
|
+
}
|
|
339
|
+
if (writeY) {
|
|
340
|
+
const candidateY = dragStartY + info.offset.y
|
|
341
|
+
const finalY = sessionBounds
|
|
342
|
+
? applyElastic(candidateY, sessionBounds.minY, sessionBounds.maxY, elastic)
|
|
343
|
+
: candidateY
|
|
344
|
+
yMV.set(finalY)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
getOpts().onDrag?.(event, info)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const handlePanEnd = (event: PointerEvent, info: PanInfo) => {
|
|
351
|
+
if (!isDragEnabled() || !xMV || !yMV) return
|
|
352
|
+
|
|
353
|
+
// Visual gesture state ends with the pointerup; momentum is a separate
|
|
354
|
+
// animation that continues after whileDrag deactivates. This matches
|
|
355
|
+
// motion/react semantic: `whileDrag: { scale: 1.05 }` un-scales at
|
|
356
|
+
// release, while the position settles independently.
|
|
357
|
+
setActive("whileDrag", false)
|
|
358
|
+
restoreBodyAndElementStyles()
|
|
359
|
+
releasePointerCaptureSafely()
|
|
360
|
+
|
|
361
|
+
getOpts().onDragEnd?.(event, info)
|
|
362
|
+
|
|
363
|
+
// Capture refs locally — the closure clears xMV/yMV below before the
|
|
364
|
+
// momentum promise can resolve, but the inertia animation needs stable
|
|
365
|
+
// references through its lifetime.
|
|
366
|
+
const xRef = xMV
|
|
367
|
+
const yRef = yMV
|
|
368
|
+
const boundsRef = sessionBounds
|
|
369
|
+
const opts = getOpts()
|
|
370
|
+
const snapToOrigin = opts.dragSnapToOrigin ?? false
|
|
371
|
+
const momentum = opts.dragMomentum ?? true
|
|
372
|
+
const userTransition = opts.dragTransition ?? {}
|
|
373
|
+
|
|
374
|
+
// Axis lock: the release path must mirror the same write-gate the drag
|
|
375
|
+
// loop uses (handlePan). Without this, pointer velocity on the locked
|
|
376
|
+
// axis feeds an inertia animation on that axis's MV — drifting the
|
|
377
|
+
// element along an axis the user explicitly locked. The cursor still
|
|
378
|
+
// has Y velocity when `drag: "x"` (any non-perfectly-horizontal motion
|
|
379
|
+
// moves the pointer through Y), so this matters in practice.
|
|
380
|
+
const dragAxis = opts.drag
|
|
381
|
+
const releaseX = dragAxis !== "y"
|
|
382
|
+
const releaseY = dragAxis !== "x"
|
|
383
|
+
|
|
384
|
+
// Reset the tracked momentum array — we'll push 1 or 2 controls below
|
|
385
|
+
// depending on axis. (handlePanStart's stopMomentum already cleared any
|
|
386
|
+
// prior session's controls, but we re-initialize here for clarity since
|
|
387
|
+
// the per-axis branches below append rather than replace.)
|
|
388
|
+
momentumControls = []
|
|
389
|
+
|
|
390
|
+
// Q15c follow-up: couple bounce physics to `dragElastic`. With elastic
|
|
391
|
+
// 0 (hard clamp), inertia's spring-back at the boundary uses default
|
|
392
|
+
// stiffness/damping that visibly overshoots before settling — even
|
|
393
|
+
// though the drag itself is clamped. Mirror motion-react's pattern
|
|
394
|
+
// (VisualElementDragControls.startAnimation): overdamp the spring with
|
|
395
|
+
// very high stiffness + damping so the snap-back is effectively
|
|
396
|
+
// instantaneous. With elastic > 0 we keep the soft spring so the
|
|
397
|
+
// rubber-band feel is preserved.
|
|
398
|
+
//
|
|
399
|
+
// Numerical choices (200/40 soft, 1e6/1e7 hard) come from motion-react.
|
|
400
|
+
const elastic = opts.dragElastic ?? DEFAULT_ELASTIC
|
|
401
|
+
const bounceParams = elastic
|
|
402
|
+
? { bounceStiffness: 200, bounceDamping: 40 }
|
|
403
|
+
: { bounceStiffness: 1_000_000, bounceDamping: 10_000_000 }
|
|
404
|
+
|
|
405
|
+
// Flicker fix for elastic=0: motion-react's source acknowledges that
|
|
406
|
+
// overdamping the spring still computes one frame of overshoot before
|
|
407
|
+
// the snap-back. When the user releases AT a boundary with velocity
|
|
408
|
+
// pointing OUT of that boundary, there's nothing for inertia to
|
|
409
|
+
// usefully decay toward — feeding it the outward velocity produces
|
|
410
|
+
// exactly the visible flicker. Zero those release velocities and
|
|
411
|
+
// inertia settles silently at the bound. Inward velocities are
|
|
412
|
+
// preserved so a release moving back toward center still glides.
|
|
413
|
+
const xAtMax = boundsRef !== null && boundsRef.maxX !== Infinity && xRef.get() >= boundsRef.maxX
|
|
414
|
+
const xAtMin =
|
|
415
|
+
boundsRef !== null && boundsRef.minX !== -Infinity && xRef.get() <= boundsRef.minX
|
|
416
|
+
const yAtMax = boundsRef !== null && boundsRef.maxY !== Infinity && yRef.get() >= boundsRef.maxY
|
|
417
|
+
const yAtMin =
|
|
418
|
+
boundsRef !== null && boundsRef.minY !== -Infinity && yRef.get() <= boundsRef.minY
|
|
419
|
+
const xVelocity =
|
|
420
|
+
!elastic && ((xAtMax && info.velocity.x > 0) || (xAtMin && info.velocity.x < 0))
|
|
421
|
+
? 0
|
|
422
|
+
: info.velocity.x
|
|
423
|
+
const yVelocity =
|
|
424
|
+
!elastic && ((yAtMax && info.velocity.y > 0) || (yAtMin && info.velocity.y < 0))
|
|
425
|
+
? 0
|
|
426
|
+
: info.velocity.y
|
|
427
|
+
|
|
428
|
+
/** Fire onDragTransitionEnd via getOpts so reactive callback swaps see
|
|
429
|
+
* the latest value (the user may have swapped handlers between pan-end
|
|
430
|
+
* and momentum-settle). */
|
|
431
|
+
const fireTransitionEnd = () => getOpts().onDragTransitionEnd?.()
|
|
432
|
+
|
|
433
|
+
if (snapToOrigin) {
|
|
434
|
+
// Spring back to (0, 0). motion's pattern (Q15e): use the inertia
|
|
435
|
+
// transition but clamp min/max to 0 so the spring physics carries
|
|
436
|
+
// the value home from wherever the user released it.
|
|
437
|
+
const transitionX = {
|
|
438
|
+
...DEFAULT_DRAG_TRANSITION,
|
|
439
|
+
...bounceParams,
|
|
440
|
+
...userTransition,
|
|
441
|
+
velocity: xVelocity,
|
|
442
|
+
min: 0,
|
|
443
|
+
max: 0,
|
|
444
|
+
}
|
|
445
|
+
const transitionY = {
|
|
446
|
+
...DEFAULT_DRAG_TRANSITION,
|
|
447
|
+
...bounceParams,
|
|
448
|
+
...userTransition,
|
|
449
|
+
velocity: yVelocity,
|
|
450
|
+
min: 0,
|
|
451
|
+
max: 0,
|
|
452
|
+
}
|
|
453
|
+
const settles: Array<Promise<unknown> | AnimationPlaybackControls> = []
|
|
454
|
+
if (releaseX) {
|
|
455
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct. Target arg is a placeholder — inertia computes the actual settle point from velocity + min/max.
|
|
456
|
+
const ctrlX = animate(xRef, 0, transitionX as any)
|
|
457
|
+
momentumControls.push(ctrlX)
|
|
458
|
+
settles.push(ctrlX)
|
|
459
|
+
}
|
|
460
|
+
if (releaseY) {
|
|
461
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct.
|
|
462
|
+
const ctrlY = animate(yRef, 0, transitionY as any)
|
|
463
|
+
momentumControls.push(ctrlY)
|
|
464
|
+
settles.push(ctrlY)
|
|
465
|
+
}
|
|
466
|
+
if (settles.length > 0) Promise.all(settles).then(fireTransitionEnd)
|
|
467
|
+
else fireTransitionEnd()
|
|
468
|
+
} else {
|
|
469
|
+
// Inertia release path — runs regardless of `dragMomentum`. When
|
|
470
|
+
// momentum is true, we feed the (heuristic-clamped) release velocity
|
|
471
|
+
// so the element glides naturally. When momentum is false, we feed
|
|
472
|
+
// velocity 0 — there's no decay, but the bounce physics still spring
|
|
473
|
+
// the element back to the bound if elastic let it overshoot during
|
|
474
|
+
// the drag. Skipping the animate entirely (the prior behavior) left
|
|
475
|
+
// the user stranded outside the container with no way to reach the
|
|
476
|
+
// element. Matches motion-react's pattern (VisualElementDragControls
|
|
477
|
+
// .startAnimation always runs inertia, zeroing velocity when
|
|
478
|
+
// dragMomentum is false).
|
|
479
|
+
const releaseVelocityX = momentum ? xVelocity : 0
|
|
480
|
+
const releaseVelocityY = momentum ? yVelocity : 0
|
|
481
|
+
const transitionX = {
|
|
482
|
+
...DEFAULT_DRAG_TRANSITION,
|
|
483
|
+
...bounceParams,
|
|
484
|
+
...userTransition,
|
|
485
|
+
velocity: releaseVelocityX,
|
|
486
|
+
min: boundsRef?.minX,
|
|
487
|
+
max: boundsRef?.maxX,
|
|
488
|
+
}
|
|
489
|
+
const transitionY = {
|
|
490
|
+
...DEFAULT_DRAG_TRANSITION,
|
|
491
|
+
...bounceParams,
|
|
492
|
+
...userTransition,
|
|
493
|
+
velocity: releaseVelocityY,
|
|
494
|
+
min: boundsRef?.minY,
|
|
495
|
+
max: boundsRef?.maxY,
|
|
496
|
+
}
|
|
497
|
+
const settles: Array<Promise<unknown> | AnimationPlaybackControls> = []
|
|
498
|
+
if (releaseX) {
|
|
499
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct.
|
|
500
|
+
const ctrlX = animate(xRef, 0, transitionX as any)
|
|
501
|
+
momentumControls.push(ctrlX)
|
|
502
|
+
settles.push(ctrlX)
|
|
503
|
+
}
|
|
504
|
+
if (releaseY) {
|
|
505
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct.
|
|
506
|
+
const ctrlY = animate(yRef, 0, transitionY as any)
|
|
507
|
+
momentumControls.push(ctrlY)
|
|
508
|
+
settles.push(ctrlY)
|
|
509
|
+
}
|
|
510
|
+
if (settles.length > 0) Promise.all(settles).then(fireTransitionEnd)
|
|
511
|
+
else fireTransitionEnd()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
xMV = null
|
|
515
|
+
yMV = null
|
|
516
|
+
sessionBounds = null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Function-form options so createPan reads `panThreshold` reactively.
|
|
520
|
+
// Handler references are stable — only the threshold (and the wrapping
|
|
521
|
+
// object) is recreated per call from createPan.
|
|
522
|
+
createPan(
|
|
523
|
+
() => el,
|
|
524
|
+
() => ({
|
|
525
|
+
threshold: getOpts().panThreshold,
|
|
526
|
+
onPanStart: handlePanStart,
|
|
527
|
+
onPan: handlePan,
|
|
528
|
+
onPanEnd: handlePanEnd,
|
|
529
|
+
}),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
// ---------- External drag (Q9 — createDragControls integration) ----------
|
|
533
|
+
// When the user wires `dragControls: someControls` into MotionOptions, an
|
|
534
|
+
// external pointerdown elsewhere in the UI (a "drag handle" button) can
|
|
535
|
+
// start a drag on this element via `controls.start(event)`. Bypasses the
|
|
536
|
+
// threshold gate — the user explicitly said "drag," no hysteresis needed.
|
|
537
|
+
//
|
|
538
|
+
// We synthesize our own pan session here rather than re-using createPan's
|
|
539
|
+
// because:
|
|
540
|
+
// 1. The originating element is the drag handle, not `el`. createPan's
|
|
541
|
+
// pointerdown listener is bound to `el` and wouldn't see the event.
|
|
542
|
+
// 2. We need to skip threshold; createPan's threshold gate is private.
|
|
543
|
+
//
|
|
544
|
+
// The session-tracking logic (pointerId match, sample buffer for velocity,
|
|
545
|
+
// window listener attach/cleanup) is duplicated from createPan. A future
|
|
546
|
+
// refactor could extract a shared "pan session runner" if a third caller
|
|
547
|
+
// emerges; for v0.1 the duplication is contained.
|
|
548
|
+
function startExternalDrag(event: PointerEvent, options: DragControlsStartOptions): void {
|
|
549
|
+
if (!isDragEnabled()) return
|
|
550
|
+
|
|
551
|
+
// Snap-to-cursor (Q9b): move the element so its center sits under the
|
|
552
|
+
// pointer BEFORE the drag-start info captures dragStartX/Y. Otherwise
|
|
553
|
+
// the offset chain would start from the original position and the
|
|
554
|
+
// visible jump-to-cursor would be lost on first pointermove.
|
|
555
|
+
if (options.snapToCursor) {
|
|
556
|
+
const ve = ensureVisualElement(el)
|
|
557
|
+
const snapXMV = ve.getValue("x", 0) as MotionValue<number>
|
|
558
|
+
const snapYMV = ve.getValue("y", 0) as MotionValue<number>
|
|
559
|
+
const elRect = el.getBoundingClientRect()
|
|
560
|
+
const centerX = elRect.left + elRect.width / 2
|
|
561
|
+
const centerY = elRect.top + elRect.height / 2
|
|
562
|
+
const axis = getOpts().drag
|
|
563
|
+
if (axis !== "y") snapXMV.set(snapXMV.get() + (event.clientX - centerX))
|
|
564
|
+
if (axis !== "x") snapYMV.set(snapYMV.get() + (event.clientY - centerY))
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Fire handlePanStart with a synthesized initial PanInfo. This
|
|
568
|
+
// initializes xMV/yMV/dragStartX/Y, resolves bounds, sets body styles,
|
|
569
|
+
// captures pointer, activates whileDrag, and fires onDragStart.
|
|
570
|
+
const initialInfo: PanInfo = {
|
|
571
|
+
point: { x: event.clientX, y: event.clientY },
|
|
572
|
+
delta: { x: 0, y: 0 },
|
|
573
|
+
offset: { x: 0, y: 0 },
|
|
574
|
+
velocity: { x: 0, y: 0 },
|
|
575
|
+
}
|
|
576
|
+
handlePanStart(event, initialInfo)
|
|
577
|
+
|
|
578
|
+
// Track the session locally — these would normally live inside
|
|
579
|
+
// createPan's closure. Velocity samples use motion-dom's `time.now()`
|
|
580
|
+
// to stay frame-synchronous with the rest of the pipeline.
|
|
581
|
+
const sessionStartPoint = { x: event.clientX, y: event.clientY }
|
|
582
|
+
let sessionLastPoint = { ...sessionStartPoint }
|
|
583
|
+
const sessionPointerId = event.pointerId
|
|
584
|
+
const sessionSamples: Array<{ t: number; point: { x: number; y: number } }> = [
|
|
585
|
+
{ t: time.now(), point: { ...sessionStartPoint } },
|
|
586
|
+
]
|
|
587
|
+
|
|
588
|
+
function computeSessionVelocity(): { x: number; y: number } {
|
|
589
|
+
if (sessionSamples.length < 2) return { x: 0, y: 0 }
|
|
590
|
+
const first = sessionSamples[0]
|
|
591
|
+
const last = sessionSamples[sessionSamples.length - 1]
|
|
592
|
+
if (!first || !last) return { x: 0, y: 0 }
|
|
593
|
+
const dt = last.t - first.t
|
|
594
|
+
if (dt <= 0) return { x: 0, y: 0 }
|
|
595
|
+
return {
|
|
596
|
+
x: ((last.point.x - first.point.x) / dt) * 1000,
|
|
597
|
+
y: ((last.point.y - first.point.y) / dt) * 1000,
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function buildSessionInfo(e: PointerEvent): PanInfo {
|
|
602
|
+
const point = { x: e.clientX, y: e.clientY }
|
|
603
|
+
return {
|
|
604
|
+
point,
|
|
605
|
+
delta: { x: point.x - sessionLastPoint.x, y: point.y - sessionLastPoint.y },
|
|
606
|
+
offset: { x: point.x - sessionStartPoint.x, y: point.y - sessionStartPoint.y },
|
|
607
|
+
velocity: computeSessionVelocity(),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function onSessionMove(e: PointerEvent): void {
|
|
612
|
+
if (e.pointerId !== sessionPointerId) return
|
|
613
|
+
const point = { x: e.clientX, y: e.clientY }
|
|
614
|
+
const now = time.now()
|
|
615
|
+
sessionSamples.push({ t: now, point })
|
|
616
|
+
const cutoff = now - 200
|
|
617
|
+
while (sessionSamples.length > 1 && (sessionSamples[0]?.t ?? 0) < cutoff) {
|
|
618
|
+
sessionSamples.shift()
|
|
619
|
+
}
|
|
620
|
+
const info = buildSessionInfo(e)
|
|
621
|
+
sessionLastPoint = point
|
|
622
|
+
handlePan(e, info)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function onSessionEnd(e: PointerEvent): void {
|
|
626
|
+
if (e.pointerId !== sessionPointerId) return
|
|
627
|
+
const info = buildSessionInfo(e)
|
|
628
|
+
handlePanEnd(e, info)
|
|
629
|
+
window.removeEventListener("pointermove", onSessionMove)
|
|
630
|
+
window.removeEventListener("pointerup", onSessionEnd)
|
|
631
|
+
window.removeEventListener("pointercancel", onSessionEnd)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
window.addEventListener("pointermove", onSessionMove)
|
|
635
|
+
window.addEventListener("pointerup", onSessionEnd)
|
|
636
|
+
window.addEventListener("pointercancel", onSessionEnd)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Register with the controls instance whenever opts.dragControls changes.
|
|
640
|
+
// createEffect re-runs on swap; the previous registration unmounts via
|
|
641
|
+
// the symbol-keyed unregister function. Q9d — last mount wins; the
|
|
642
|
+
// unregister only nulls out if we're still the active handler.
|
|
643
|
+
createEffect(() => {
|
|
644
|
+
const controls = getOpts().dragControls as DragControls | undefined
|
|
645
|
+
if (!controls) return
|
|
646
|
+
const internal = controls as DragControls & {
|
|
647
|
+
[DRAG_CONTROLS_REGISTER]?: (
|
|
648
|
+
handler: (event: PointerEvent, options: DragControlsStartOptions) => void,
|
|
649
|
+
) => () => void
|
|
650
|
+
}
|
|
651
|
+
const register = internal[DRAG_CONTROLS_REGISTER]
|
|
652
|
+
if (!register) return
|
|
653
|
+
const unregister = register(startExternalDrag)
|
|
654
|
+
onCleanup(unregister)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// Owner-disposal cleanup. Three layers:
|
|
658
|
+
// 1. Stop any settling momentum animations (they hold MV references that
|
|
659
|
+
// keep ticking after disposal otherwise).
|
|
660
|
+
// 2. Restore the body/touch styles if we're in mid-drag.
|
|
661
|
+
// 3. Release any captured pointer.
|
|
662
|
+
// createPan's own onCleanup handles removing its listeners separately.
|
|
663
|
+
onCleanup(() => {
|
|
664
|
+
stopMomentum()
|
|
665
|
+
if (xMV || yMV) {
|
|
666
|
+
restoreBodyAndElementStyles()
|
|
667
|
+
releasePointerCaptureSafely()
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
}
|