@humanspeak/svelte-motion
Version:
Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values
863 lines (862 loc) • 38.9 kB
JavaScript
import { isPlaywrightEnv, pwLog, pwWarn } from './log';
/**
* Drag utilities
*
* This module implements low-level drag gesture handling that powers `motion.*` components.
* It intentionally avoids Svelte-specific APIs so it can be reused in action-like contexts.
*
* Troubleshooting tips when drag "doesn't move":
* - Ensure we are writing transforms via `animate(el, { x, y }, { duration: 0 })`.
* If computed style shows `transform: none`, another CSS rule may be overwriting the transform.
* - Confirm `axis` allows the intended direction (true | 'x' | 'y').
* - If using constraints as HTMLElement, verify both element and constraint refs are non-null and connected.
* - If movement stops immediately, direction lock may be engaged. Try disabling `directionLock`.
* - If post-release momentum never kicks in, velocity history may be empty (e.g., only pointerdown/up).
* Simulate a few `pointermove`s before releasing.
* - For nested drags, set `propagation` as needed to avoid parent-child contention.
*/
import { isDomElement } from './dom';
import { applyConstraints as applyFloatConstraints, parseMatrixTranslate } from './dragMath';
import { deriveBoundaryPhysics } from './dragParams';
import { computeHoverBaseline, splitHoverDefinition } from './hover';
import { createInertiaToBoundary } from './inertia';
import { animate } from 'motion';
/**
* Read an element's DOMRect with null-safety.
*/
const getRect = (el) => {
if (!el)
return null;
const r = el.getBoundingClientRect();
return {
top: r.top,
left: r.left,
right: r.right,
bottom: r.bottom,
width: r.width,
height: r.height
};
};
// const clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max)
/**
* Resolve drag constraints into pixel offsets relative to the dragged element's origin.
*
* - HTMLElement: Constrains the element to the bounding box of the provided element.
* - Pixel object: Direct pixel limits for top/left/right/bottom.
*/
/**
* Normalize constraints to pixel offsets relative to the dragged element's origin.
*
* HTMLElement constraints: allow moving within the container bounds (subtractive rect math).
* Pixel object: direct min/max per side.
*/
export const resolveConstraints = (el, constraints) => {
if (!constraints)
return null;
if (isDomElement(constraints)) {
if (!el)
return null;
const c = getRect(constraints);
const e = getRect(el);
if (!c || !e)
return null;
// Allow element to move within container bounds
return {
top: c.top - e.top,
left: c.left - e.left,
right: c.right - e.right,
bottom: c.bottom - e.bottom
};
}
const { top = -Infinity, left = -Infinity, right = Infinity, bottom = Infinity } = constraints;
return { top, left, right, bottom };
};
/**
* Apply elastic overflow outside of [min, max] using a linear ratio.
*/
/**
* Apply elastic overflow outside the [min, max] range.
* When beyond bounds, the extra distance is scaled linearly by `elastic`.
*/
export const applyElastic = (value, min, max, elastic) => {
if (value < min)
return min + (value - min) * Math.max(0, Math.min(1, elastic));
if (value > max)
return max + (value - max) * Math.max(0, Math.min(1, elastic));
return value;
};
/** Prefer high-resolution time in browser; fall back for SSR/tests. */
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
/** Sample windows for release-velocity inference (matches motion-dom values). */
const MAX_VELOCITY_DELTA_MS = 30;
const MIN_VELOCITY_INTERVAL_MS = 5;
/**
* Compute the release velocity for momentum from a pointer-history window.
*
* Mirrors motion-dom: walks back from the newest sample, including only
* samples within `MAX_VELOCITY_DELTA_MS` (30 ms) of newest, then divides
* the displacement by the elapsed time. Returns 0 if the newest sample is
* stale, the window has fewer than two samples, or the oldest-newest span
* is shorter than `MIN_VELOCITY_INTERVAL_MS` (5 ms — sub-frame).
*
* @param history Recent pointer samples ordered oldest → newest. Each
* sample is `{ x, y, t }` where `t` is `performance.now()` ms.
* @param nowMs Current `performance.now()` ms — used to discard a stale
* newest sample (finger lifted after a pause).
* @returns Inferred release velocity in pixels per second on each axis.
* @example
* const v = computeReleaseVelocity(
* [{ x: 0, y: 0, t: 1000 }, { x: 20, y: 0, t: 1020 }],
* 1020
* )
* // v ≈ { x: 1000, y: 0 } — 20 px over 20 ms → 1000 px/s
*/
const computeReleaseVelocity = (history, nowMs) => {
if (history.length < 2)
return { x: 0, y: 0 };
const newest = history[history.length - 1];
if (nowMs - newest.t > MAX_VELOCITY_DELTA_MS)
return { x: 0, y: 0 };
let oldestIdx = history.length - 1;
for (let i = history.length - 2; i >= 0; i--) {
if (newest.t - history[i].t > MAX_VELOCITY_DELTA_MS)
break;
oldestIdx = i;
}
if (oldestIdx === history.length - 1)
return { x: 0, y: 0 };
const oldest = history[oldestIdx];
const dtMs = newest.t - oldest.t;
if (dtMs < MIN_VELOCITY_INTERVAL_MS)
return { x: 0, y: 0 };
return {
x: ((newest.x - oldest.x) / dtMs) * 1000,
y: ((newest.y - oldest.y) / dtMs) * 1000
};
};
/**
* Attach a drag gesture to an element.
*
* Captures the pointer and updates x/y transforms with axis and optional
* direction lock, applies elastic overflow against constraints, emits
* lifecycle callbacks with `DragInfo`, and runs a momentum animation on
* release when enabled.
*
* Lifecycle:
* - pointerdown → capture pointer, snapshot origin, start velocity history, enter whileDrag
* - pointermove → compute deltas, direction lock, apply constraints + elastic, write x/y
* - pointerup/cancel → either run momentum decay to a target or settle/clamp instantly
*
* Invariant: `applied` tracks the currently applied x/y transform — it
* must stay in sync when writing transforms or finishing animations, or a
* second drag "jumps" from a stale origin (commonly a missed update after
* a non-zero-duration settle animation).
*
* @param el The element to make draggable.
* @param opts Drag options — `axis`, `constraints`, `elastic`,
* `momentum`, `whileDrag`, and the `onDrag*` lifecycle callbacks.
* @returns A callable cleanup handle ({@link AttachDragCleanup}): call it
* to detach the gesture's listeners (in-flight momentum is not
* cancelled — see the type docs), or call its `adjustOrigin(dx, dy)` to
* reposition the live gesture mid-drag.
* @example
* ```ts
* const cleanup = attachDrag(el, { axis: 'x', momentum: true })
* // …when a layout swap shifts the slot under the cursor mid-drag:
* cleanup.adjustOrigin(10, -5)
* // on teardown:
* cleanup()
* ```
*/
export const attachDrag = (el, opts) => {
const EL_ID = el.getAttribute('data-testid') || el.id || el.tagName;
pwLog('[drag] attach', {
el: EL_ID,
axis: opts.axis,
hasConstraints: !!opts.constraints,
momentum: opts.momentum,
elastic: opts.elastic,
directionLock: opts.directionLock,
listener: opts.listener,
hasControls: !!opts.controls,
snapToOrigin: opts.snapToOrigin,
propagation: opts.propagation
});
const axis = opts.axis;
const directionLock = !!opts.directionLock;
const listenerEnabled = opts.listener !== false;
const elastic = typeof opts.elastic === 'number' ? opts.elastic : 0.35;
const momentum = opts.momentum !== false;
const mergedTransition = (opts.mergedTransition ?? {});
let constraints = resolveConstraints(el, opts.constraints);
// Anchor constraints base:
// - Pixel object constraints are offsets from original origin (0,0)
// - HTMLElement constraints are measured from current applied transform at drag start
let constraintsBase = { x: 0, y: 0 };
// Track state
let dragging = false;
let lockAxis = null;
const lockThreshold = 4; // px to decide first-axis
let startPoint = { x: 0, y: 0 };
let lastPoint = { x: 0, y: 0 };
// Accumulated transform applied to element via Motion ('x'/'y')
const applied = { x: 0, y: 0 };
// Origin transform at the start of current drag
let origin = { x: 0, y: 0 };
let velocity = { x: 0, y: 0 };
// History for velocity smoothing (last N samples)
let history = [];
let whileDragBaseline = null;
let stopInertia = null;
const computeInfo = () => ({
point: { ...lastPoint },
delta: { x: lastPoint.x - startPoint.x, y: lastPoint.y - startPoint.y },
offset: {
x: origin.x + (lastPoint.x - startPoint.x),
y: origin.y + (lastPoint.y - startPoint.y)
},
velocity: { ...velocity }
});
/**
* Write absolute element-space translation using Motion's animate() with duration 0.
* Also update `applied` so subsequent drags have the correct origin.
*/
const setXY = (x, y) => {
pwLog('[drag] setXY → animate(0)', { el: EL_ID, x, y });
const payload = {};
if (axis === true || axis === 'x')
payload.x = x;
if (axis === true || axis === 'y')
payload.y = y;
// Skip no-op writes within a tiny epsilon to reduce layout churn
const EPS = 0.01;
const wantX = 'x' in payload;
const wantY = 'y' in payload;
const skipX = wantX && Math.abs(applied.x - x) < EPS;
const skipY = wantY && Math.abs(applied.y - y) < EPS;
if ((wantX ? skipX : true) && (wantY ? skipY : true)) {
return;
}
// duration: 0 to write instantly via Motion
animate(el, payload, { duration: 0 });
// Track applied transform for correct subsequent drag origins
if ('x' in payload)
applied.x = x;
if ('y' in payload)
applied.y = y;
// Playwright-only sanity check: confirm the transform actually
// landed on the element and retry once if not. Forces a style
// recalc via getComputedStyle, so we gate it behind the same
// playwright env flag pwLog uses so it never fires in prod.
if (isPlaywrightEnv()) {
let actualTransform = getComputedStyle(el).transform;
if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
pwWarn('⚠️ setXY transform missing; retrying write', { x, y });
animate(el, ('x' in payload || 'y' in payload
? payload
: { x, y }), { duration: 0 });
actualTransform = getComputedStyle(el).transform;
if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
pwWarn('⚠️ setXY second attempt still missing transform', { x, y });
}
}
}
};
/**
* Write absolute element-space translation by mutating
* `el.style.transform` DIRECTLY — no `animate()`, no epsilon skip,
* no Playwright retry. The translate channel is rewritten while any
* non-translate transform the element already carries (e.g. a
* `whileDrag` scale) is preserved as a suffix.
*
* Why this exists separately from `setXY`: routing through
* `animate(el, _, { duration: 0 })` defers the write to Motion's
* scheduler, costing ~1 frame before the new position paints. For
* the projection-driven origin compensation (where a layout swap
* must keep the dragged element under the cursor in the SAME frame
* the swap commits), that frame of lag manifests as a visible
* wobble. This path lands synchronously. See #379 / the
* `adjustOrigin` hook below.
*/
const setXYImmediate = (x, y) => {
const parts = [];
if (axis === true || axis === 'x')
parts.push(`translateX(${x}px)`);
if (axis === true || axis === 'y')
parts.push(`translateY(${y}px)`);
// Strip existing translate channels, keep the rest (scale/rotate/etc.).
const nonTranslate = el.style.transform.replace(/translate[XYZ3d]*\([^)]*\)/g, '').trim();
el.style.transform = [...parts, nonTranslate].filter(Boolean).join(' ');
if (axis === true || axis === 'x')
applied.x = x;
if (axis === true || axis === 'y')
applied.y = y;
};
/**
* Adjust the drag origin + visual offset by a layout-shift delta,
* mid-gesture, keeping the dragged element pinned under the cursor
* while its underlying layout slot moves.
*
* Direct port of framer-motion's projection `didUpdate` handler in
* `VisualElementDragControls.ts:742-758`:
*
* ```ts
* this.originPoint[axis] += delta[axis].translate
* motionValue.set(motionValue.get() + delta[axis].translate)
* ```
*
* We do the same two-write dance: shift `origin` (the gesture's
* reference zero) AND the applied visual transform by the same
* delta, so `lastPoint - startPoint + origin` continues to resolve
* to the correct on-screen position after the layout slot moved.
* Uses `setXYImmediate` so the compensation is visible the same
* frame as the layout change.
*
* Not wired to any projection node in this PR — exposed on the
* `attachDrag` return handle for the Reorder PR (#310) to call from
* its `ProjectionNode.didUpdate` listener.
*
* @param dx Layout delta on the x axis (px).
* @param dy Layout delta on the y axis (px).
*/
const adjustOrigin = (dx, dy) => {
if (!dragging)
return;
// Compensate the origin on BOTH axes unconditionally — upstream's
// didUpdate handler applies the delta per-axis via `eachAxis`
// regardless of the drag axis or direction lock, because a layout
// slot can shift on either axis.
origin.x += dx;
origin.y += dy;
// The VISUAL write is `setXYImmediate`, which only writes the axis
// this drag manages (`opts.axis`). For the dragged axis that pins
// the element same-frame; the cross-axis case (e.g. drag="x" + a
// y-shift) only updates `origin`, not the transform. Fully
// rendering cross-axis compensation needs to route through the
// Motion value the move path uses (a direct write here would be
// wiped by the next `setXY`), so it's finalized when this hook is
// wired in #310. The common Reorder case (drag axis === the shift
// axis) is fully compensated today.
setXYImmediate(applied.x + dx, applied.y + dy);
};
const startWhileDrag = () => {
if (!opts.whileDrag)
return;
// Baseline restore target: compute from sources
whileDragBaseline = computeHoverBaseline(el, {
initial: opts.baselineSources?.initial,
animate: opts.baselineSources?.animate,
whileHover: (opts.whileDrag ?? {})
});
const { keyframes, transition } = splitHoverDefinition(opts.whileDrag);
animate(el, keyframes, (transition ?? mergedTransition));
};
const endWhileDrag = () => {
if (!whileDragBaseline || Object.keys(whileDragBaseline).length === 0)
return;
animate(el, whileDragBaseline, mergedTransition);
whileDragBaseline = null;
};
const onPointerDown = (e) => {
if (!listenerEnabled)
return;
beginDrag(e);
};
/**
* Begin a drag sequence. Optionally rebase the origin under the cursor (`snapToCursor`).
* We capture the pointer to receive move/up/cancel regardless of hover state.
*/
const beginDrag = (e, snapToCursor = false) => {
pwLog('[drag] begin', {
el: EL_ID,
pointer: { id: e.pointerId, x: e.clientX, y: e.clientY },
snapToCursor
});
try {
if ('setPointerCapture' in el && typeof e.pointerId === 'number')
el.setPointerCapture(e.pointerId);
}
catch {
// ignore
}
// Cancel any ongoing inertia
if (stopInertia) {
stopInertia();
stopInertia = null;
}
// Recompute constraints in case bounding boxes changed since last drag
constraints = resolveConstraints(el, opts.constraints);
pwLog('[drag] constraints (px)', { el: EL_ID, constraints });
if (constraints) {
if (opts.constraints && !isDomElement(opts.constraints)) {
// Pixel constraints: always relative to original origin
constraintsBase = { x: 0, y: 0 };
}
else {
// HTMLElement constraints: recalc base each drag since layout may change
constraintsBase = { x: applied.x, y: applied.y };
}
pwLog('[drag] constraints-base', {
el: EL_ID,
base: { ...constraintsBase },
applied: { ...applied },
origin: { ...origin },
kind: opts.constraints && !isDomElement(opts.constraints)
? 'pixel'
: 'element'
});
}
dragging = true;
lockAxis = null;
// Start from current applied transform, not viewport rect
origin = { x: applied.x, y: applied.y };
startPoint = { x: e.clientX, y: e.clientY };
lastPoint = { ...startPoint };
velocity = { x: 0, y: 0 };
history = [{ x: e.clientX, y: e.clientY, t: now() }];
const applyXAxis = axis === true || axis === 'x';
const applyYAxis = axis === true || axis === 'y';
// For external dragControls we avoid snap-to-cursor to prevent teleports
const useSnapToCursor = snapToCursor && !opts.controls;
if (useSnapToCursor) {
const rect = el.getBoundingClientRect();
// Rebase to center under cursor while preserving accumulated transform frame
const desiredX = e.clientX - rect.width / 2;
const desiredY = e.clientY - rect.height / 2;
if (applyXAxis)
origin.x = desiredX;
if (applyYAxis)
origin.y = desiredY;
pwLog('[drag] snapToCursor origin', { el: EL_ID, origin });
}
startWhileDrag();
opts.callbacks?.onStart?.(e, computeInfo());
// Listen on element (to receive captured events) and window as fallback
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerup', onPointerUp);
el.addEventListener('pointercancel', onPointerCancel);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
};
/**
* Update drag on pointer move:
* - Track a small history for velocity smoothing
* - Compute dx/dy from initial pointerdown
* - Apply direction lock and constraints with elastic
* - Write absolute x/y
*/
const onPointerMove = (e) => {
if (!dragging)
return;
const t = now();
const nx = e.clientX;
const ny = e.clientY;
// Add to history and keep last 5 samples
history.push({ x: nx, y: ny, t });
if (history.length > 5)
history.shift();
// Calculate velocity from oldest to newest sample for smoothing
if (history.length >= 2) {
const oldest = history[0];
const newest = history[history.length - 1];
const dt = Math.max(1, newest.t - oldest.t);
const vx = ((newest.x - oldest.x) / dt) * 1000; // px/s
const vy = ((newest.y - oldest.y) / dt) * 1000;
velocity = { x: vx, y: vy };
}
lastPoint = { x: nx, y: ny };
const dx = nx - startPoint.x;
const dy = ny - startPoint.y;
pwLog('[drag] move', {
el: EL_ID,
pointer: { x: nx, y: ny },
deltas: { dx, dy },
origin,
applied,
vel: velocity
});
const applyX = axis === true || axis === 'x';
const applyY = axis === true || axis === 'y';
// Direction lock: only engage if both axes are enabled
if (directionLock && !lockAxis && Math.hypot(dx, dy) >= lockThreshold && axis === true) {
lockAxis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
pwLog('[drag] directionLock', { el: EL_ID, lockAxis });
opts.callbacks?.onDirectionLock?.(lockAxis);
}
let x = origin.x + (applyX ? dx : 0);
let y = origin.y + (applyY ? dy : 0);
const preClamp = { x, y };
// Respect direction lock (only relevant when axis === true)
if (lockAxis === 'x')
y = origin.y;
if (lockAxis === 'y')
x = origin.x;
// Convert to relative translation by clamping within constraints (float-safe)
if (constraints) {
const minX = constraintsBase.x + (constraints.left ?? -Infinity);
const maxX = constraintsBase.x + (constraints.right ?? Infinity);
const minY = constraintsBase.y + (constraints.top ?? -Infinity);
const maxY = constraintsBase.y + (constraints.bottom ?? Infinity);
pwLog('[drag] bounds', {
el: EL_ID,
base: { ...constraintsBase },
bounds: { minX, maxX, minY, maxY },
preClamp
});
x = applyFloatConstraints(x, { min: minX, max: maxX }, elastic);
y = applyFloatConstraints(y, { min: minY, max: maxY }, elastic);
pwLog('[drag] constrain+elastic', {
el: EL_ID,
preClamp,
base: { ...constraintsBase },
bounds: { minX, maxX, minY, maxY },
elastic,
out: { x, y }
});
}
// Apply absolute transform in element space
setXY(x, y);
opts.callbacks?.onMove?.(e, computeInfo());
};
const onPointerUp = (e) => {
pwLog('[drag] pointerup', {
el: EL_ID,
pointer: { id: e.pointerId, x: e.clientX, y: e.clientY },
dragging
});
if (!dragging)
return;
finishDrag(e);
};
const onPointerCancel = (e) => {
pwLog('[drag] pointercancel', {
el: EL_ID,
pointer: { id: e.pointerId, x: e.clientX, y: e.clientY },
dragging
});
if (!dragging)
return;
// Pointer was preempted (gesture-nav, palm rejection, scroll
// takeover). User did not release intentionally — skip the
// inertia/momentum path and force a no-momentum settle so the
// card clamps back into constraints without flinging.
finishDrag(e, true);
};
/**
* Finish a drag:
* - If momentum is enabled, decay towards a clamped target with exponential easing
* - Otherwise, animate back to a clamped position (or origin), then sync `applied`
*/
const finishDrag = (e, cancelled = false) => {
dragging = false;
velocity = computeReleaseVelocity(history, now());
pwLog('[drag] finish', {
el: EL_ID,
lastPoint,
startPoint,
origin,
applied,
momentum,
velocity
});
try {
if ('releasePointerCapture' in el && typeof e.pointerId === 'number')
el.releasePointerCapture(e.pointerId);
}
catch {
// ignore
}
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', onPointerUp);
el.removeEventListener('pointercancel', onPointerCancel);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
// Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary.
// Pointer-cancel forces a no-momentum settle (clamp into constraints, no fling) since the
// gesture was preempted rather than intentionally released.
if (momentum && !cancelled) {
pwLog('🚀 STARTING MOMENTUM', {
velocityX: velocity.x,
velocityY: velocity.y,
appliedX: applied.x,
appliedY: applied.y,
historyLength: history.length,
historyFirst: history[0],
historyLast: history[history.length - 1]
});
// If snapToOrigin, skip inertia and spring: use settle path to 0 for consistency
if (opts.snapToOrigin) {
pwLog('↩️ snapToOrigin: settle to (0,0)');
const applyX = axis === true || axis === 'x';
const applyY = axis === true || axis === 'y';
const controls = animate(el, {
...(applyX ? { x: 0 } : {}),
...(applyY ? { y: 0 } : {})
}, (mergedTransition ?? {}));
// Cancel hook so re-grab / controls.stop() interrupts the snap.
stopInertia = () => {
pwLog('❌ snapToOrigin cancelled');
controls.stop?.();
// Sync applied to wherever the snap reached so the next drag origin is correct.
const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
if (applyX)
applied.x = tx;
if (applyY)
applied.y = ty;
stopInertia = null;
};
Promise.resolve(controls.finished)
.then(() => {
// Sync internal applied transform so next drag uses the correct origin
if (applyX)
applied.x = 0;
if (applyY)
applied.y = 0;
pwLog('[drag] snapToOrigin finished → sync applied', {
el: EL_ID,
applied
});
if (stopInertia)
stopInertia = null;
})
.catch(() => { })
.finally(() => opts.callbacks?.onTransitionEnd?.());
return;
}
// Boundary min/max anchor to `constraintsBase` (the absolute
// pixel-constraint origin) so the inertia handoff snaps to the
// same edge pointermove's elastic clamping uses — not to a
// per-drag-shifted `origin` that would drift across drags.
const noConstraints = !constraints;
const huge = 1e6;
const minX = noConstraints
? applied.x - huge
: constraintsBase.x + (constraints?.left ?? -Infinity);
const maxX = noConstraints
? applied.x + huge
: constraintsBase.x + (constraints?.right ?? Infinity);
const minY = noConstraints
? applied.y - huge
: constraintsBase.y + (constraints?.top ?? -Infinity);
const maxY = noConstraints
? applied.y + huge
: constraintsBase.y + (constraints?.bottom ?? Infinity);
const { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping } = deriveBoundaryPhysics(elastic, opts.transition);
pwLog('⚙️ boundary-physics', {
timeConstantMs,
restDelta,
restSpeed,
bounceStiffness,
bounceDamping,
boundsX: { minX, maxX },
boundsY: { minY, maxY },
lockAxis,
axis
});
// Respect direction lock on release: only animate the locked axis
const applyX = (axis === true || axis === 'x') && lockAxis !== 'y';
const applyY = (axis === true || axis === 'y') && lockAxis !== 'x';
// Element-ref constraints can resize / reflow during inertia.
// Pixel constraints never change once set. We re-resolve only
// for element-ref each frame in the rAF loop below.
const isElementRefConstraint = isDomElement(opts.constraints);
const stepX = applyX
? createInertiaToBoundary({ value: applied.x, velocity: velocity.x }, { min: minX, max: maxX }, { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping })
: null;
const stepY = applyY
? createInertiaToBoundary({ value: applied.y, velocity: velocity.y }, { min: minY, max: maxY }, { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping })
: null;
let running = true;
const startTs = now();
let frameCount = 0;
const raf = () => {
if (!running) {
pwLog('🛑 RAF stopped (running = false)');
return;
}
const t = now() - startTs;
frameCount++;
// Use the precomputed step functions (built once per release)
const rx = stepX ? stepX(t) : { value: applied.x, done: true };
const ry = stepY ? stepY(t) : { value: applied.y, done: true };
// Element-ref constraints may have resized / reflowed since
// the steppers were built. Re-resolve and clamp the output
// so the card never lands outside the now-current container
// even if its boundary moved mid-spring. Pixel constraints
// don't move so we skip the work.
let nextX = rx.value;
let nextY = (axis === true || axis === 'y') && lockAxis !== 'x' ? ry.value : applied.y;
if (isElementRefConstraint) {
const freshConstraints = resolveConstraints(el, opts.constraints);
if (freshConstraints) {
constraintsBase = { x: applied.x, y: applied.y };
const freshMinX = constraintsBase.x + (freshConstraints.left ?? -Infinity);
const freshMaxX = constraintsBase.x + (freshConstraints.right ?? Infinity);
const freshMinY = constraintsBase.y + (freshConstraints.top ?? -Infinity);
const freshMaxY = constraintsBase.y + (freshConstraints.bottom ?? Infinity);
if (applyX)
nextX = Math.max(freshMinX, Math.min(freshMaxX, nextX));
if (applyY)
nextY = Math.max(freshMinY, Math.min(freshMaxY, nextY));
}
}
setXY(nextX, nextY);
if (frameCount <= 3 || frameCount % 10 === 0) {
pwLog(`🔄 FRAME ${frameCount}`, {
t: t.toFixed(0),
px: rx.value.toFixed?.(2) ?? rx.value,
py: ry.value.toFixed?.(2) ?? ry.value,
doneX: rx.done,
doneY: ry.done,
boundsX: { minX, maxX },
boundsY: { minY, maxY }
});
}
if ((rx.done || !stepX) && (ry.done || !stepY)) {
pwLog('✅ REST REACHED', {
frameCount,
finalX: nextX,
finalY: nextY,
timeConstantMs,
restDelta,
restSpeed
});
// Sync `applied` from the post-clamp frame values
// (nextX/nextY), not the raw stepper output. When
// element-ref constraints clamped this frame, raw
// rx.value sits outside the visible bounds and would
// desync the next-drag origin from the rendered transform.
const finalX = stepX ? nextX : applied.x;
const finalY = stepY ? nextY : applied.y;
if (axis === true || axis === 'x')
applied.x = finalX;
if (axis === true || axis === 'y')
applied.y = finalY;
running = false;
stopInertia = null;
opts.callbacks?.onTransitionEnd?.();
return;
}
requestAnimationFrame(raf);
};
stopInertia = () => {
pwLog('❌ MOMENTUM CANCELLED');
running = false;
// `applied` is already in sync with the last frame rendered
// by the rAF loop (setXY updates it on every frame). We
// intentionally do NOT call stepX/stepY again here —
// they're stateful (mutate lastT/springX/springV) and an
// extra call advances them past the visible state, leaving
// applied slightly out of sync with what the user sees.
pwLog('[drag] inertia cancelled → sync applied', {
el: EL_ID,
applied: { x: applied.x, y: applied.y }
});
stopInertia = null;
};
pwLog('🏁 QUEUING RAF');
requestAnimationFrame(raf);
}
else {
// No momentum: animate to clamped target or origin to resolve elastic overdrag
const applyX = axis === true || axis === 'x';
const applyY = axis === true || axis === 'y';
const dx = lastPoint.x - startPoint.x;
const dy = lastPoint.y - startPoint.y;
let x = origin.x + (applyX ? dx : 0);
let y = origin.y + (applyY ? dy : 0);
// Respect direction lock
if (lockAxis === 'x')
y = origin.y;
if (lockAxis === 'y')
x = origin.x;
if (opts.snapToOrigin) {
x = 0;
y = 0;
}
else if (constraints) {
const minX = constraintsBase.x + (constraints.left ?? -Infinity);
const maxX = constraintsBase.x + (constraints.right ?? Infinity);
const minY = constraintsBase.y + (constraints.top ?? -Infinity);
const maxY = constraintsBase.y + (constraints.bottom ?? Infinity);
pwLog('[drag] settle (no momentum) bounds', {
el: EL_ID,
base: { ...constraintsBase },
bounds: { minX, maxX, minY, maxY },
preClamp: { x, y }
});
x = applyFloatConstraints(x, { min: minX, max: maxX });
y = applyFloatConstraints(y, { min: minY, max: maxY });
pwLog('[drag] settle (no momentum) clamped', { el: EL_ID, out: { x, y } });
}
pwLog('[drag] settle (no momentum)', {
el: EL_ID,
target: { x, y },
origin,
applied,
dx,
dy,
elastic
});
// When elastic=0, the element is already at the clamped position during drag,
// so use instant settle (duration: 0) to avoid spring bounce.
// Otherwise use the merged transition for smooth settle animation.
const settleTransition = elastic === 0 ? { duration: 0 } : (mergedTransition ?? {});
// Animate with the merged transition so the settle feels consistent with other motion
const controls = animate(el, { ...(applyX ? { x } : {}), ...(applyY ? { y } : {}) }, settleTransition);
// Cancel hook so re-grab interrupts the settle animation cleanly.
stopInertia = () => {
pwLog('❌ settle (no momentum) cancelled');
controls.stop?.();
const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
if (applyX)
applied.x = tx;
if (applyY)
applied.y = ty;
stopInertia = null;
};
// Fire transition end once settled
Promise.resolve(controls.finished)
.then(() => {
// Sync internal applied transform so next drag uses the correct origin
if (applyX)
applied.x = x;
if (applyY)
applied.y = y;
pwLog('[drag] settle finished → sync applied', {
el: EL_ID,
applied
});
if (stopInertia)
stopInertia = null;
})
.catch(() => { })
.finally(() => opts.callbacks?.onTransitionEnd?.());
}
opts.callbacks?.onEnd?.(e, computeInfo());
endWhileDrag();
};
// Wire dragControls. The cancelInertia thunk reads the *current*
// stopInertia at call time so the latest in-flight animation is
// targeted (stopInertia is re-assigned per finishDrag).
if (opts.controls) {
const internal = opts.controls;
internal._bind?.(el, beginDrag, () => stopInertia?.());
pwLog('[drag] controls bound', { el: EL_ID });
}
el.addEventListener('pointerdown', onPointerDown);
pwLog('[drag] pointerdown listener attached', { el: EL_ID });
const teardown = () => {
pwLog('[drag] detach', { el: EL_ID });
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', onPointerUp);
el.removeEventListener('pointercancel', onPointerCancel);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
};
return Object.assign(teardown, { adjustOrigin });
};