UNPKG

@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

427 lines (426 loc) 18.3 kB
import { cancelFrame, frame, frameData, isPrimaryPointer } from 'motion-dom'; /** * Brand we stamp on already-wrapped handlers so passing them back through * `wrapHandlers` (e.g. by a future middleware layer) doesn't double-defer * — that would compound frame latency invisibly per wrap depth. Symbol * scoping keeps it private to this module. */ const WRAPPED_BRAND = Symbol('svelte-motion:pan:wrapped'); const wrapUpdate = (handler, isAlive) => { if (!handler) return undefined; if (handler[WRAPPED_BRAND]) return handler; const wrapped = (event, info) => { frame.update(() => { // `isAlive` flips false on teardown; any frame.update closure // queued before teardown but not yet flushed will see this and // short-circuit — that's our cancellation path for the // otherwise-uncancellable anonymous closures `frame.update` // accepts. if (!isAlive()) return; handler(event, info); }, false, true); }; Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true }); return wrapped; }; const wrapPostRender = (handler, isAlive) => { if (!handler) return undefined; if (handler[WRAPPED_BRAND]) return handler; const wrapped = (event, info) => { frame.postRender(() => { if (!isAlive()) return; handler(event, info); }); }; Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true }); return wrapped; }; const wrapHandlers = (handlers, isAlive) => ({ onSessionStart: wrapUpdate(handlers.onSessionStart, isAlive), onStart: wrapUpdate(handlers.onStart, isAlive), onMove: wrapUpdate(handlers.onMove, isAlive), onEnd: wrapPostRender(handlers.onEnd, isAlive), onSessionEnd: wrapPostRender(handlers.onSessionEnd, isAlive) }); const overflowStyles = new Set(['auto', 'scroll']); const millisecondsToSeconds = (ms) => ms / 1000; const secondsToMilliseconds = (s) => s * 1000; const subtractPoint = (a, b) => ({ x: a.x - b.x, y: a.y - b.y }); const distance2D = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); /** * Compute velocity (px/s) from the history of timestamped points, * looking back `timeDelta` seconds for stability. Matches upstream's * `getVelocity` including the hold-then-flick safeguard (skip * history[0] if it's > 2× timeDelta old AND there are alternatives). */ const getVelocity = (history, timeDelta) => { if (history.length < 2) return { x: 0, y: 0 }; let i = history.length - 1; let timestampedPoint = null; const lastPoint = history[history.length - 1]; while (i >= 0) { timestampedPoint = history[i]; if (lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta)) { break; } i--; } if (!timestampedPoint) return { x: 0, y: 0 }; if (timestampedPoint === history[0] && history.length > 2 && lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta) * 2) { timestampedPoint = history[1]; } const time = millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp); if (time === 0) return { x: 0, y: 0 }; const v = { x: (lastPoint.x - timestampedPoint.x) / time, y: (lastPoint.y - timestampedPoint.y) / time }; if (v.x === Infinity) v.x = 0; if (v.y === Infinity) v.y = 0; return v; }; const getPanInfo = (point, history) => ({ point, delta: subtractPoint(point, history[history.length - 1]), offset: subtractPoint(point, history[0]), velocity: getVelocity(history, 0.1) }); const extractEventPoint = (event) => ({ x: event.pageX, y: event.pageY }); /** * Attach a pan gesture session to `el`. Returns a cleanup function that * tears down the pointerdown listener and ends any in-flight session, * with a `.update(next)` method for hot-swapping handlers mid-gesture. * * Internally a fresh `PanSession` spawns on each pointerdown — the * outer attachment just keeps the pointerdown listener alive across the * element's lifetime. * * SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte * `$effect` consumer never fires on the server anyway, but defending the * boundary lets the module load cleanly in node-only test runners. * * Lifecycle guarantee: when the returned cleanup runs mid-gesture, the * session synthesizes `onEnd` + `onSessionEnd` against the raw handlers * BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts * (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan` * revert logic inside the user-supplied `onEnd` and rely on it firing * exactly once per gesture — whether the user released or the host * forced teardown. * * @param el Target element to bind `pointerdown` on. Move/up/cancel * events are listened for on the element's owning window so a fast * swipe past the element's bounds keeps the gesture alive. * @param handlers Pan lifecycle handlers. Any subset of * `onSessionStart` (fires on pointerdown), `onStart` (fires the first * time the cumulative offset crosses `distanceThreshold`), `onMove` * (per-frame-throttled on every pointermove past threshold), `onEnd` * (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd` * (fires on every pointerup/cancel where a pointermove occurred). * @param options Per-session config. `distanceThreshold` (default 3px) * gates the start callback; `contextWindow` overrides the owning * window (use for shadow-root / iframe scenarios). * @returns A cleanup function with an attached `.update(next)` method. * Calling the cleanup ends the session + removes the pointerdown * listener. Calling `.update(next)` swaps handlers in place on the * live session without rebuilding it — the canonical Svelte pattern * for inline arrow handlers that change identity each render. * * @example * ```ts * const cleanup = attachPan(node, { * onStart: (_event, info) => console.log('start', info.offset), * onMove: (_event, info) => x.set(info.offset.x), * onEnd: (_event, info) => { * if (Math.abs(info.velocity.x) > 600) commit() * else animate(x, 0, { type: 'spring' }) * } * }) * * // Later, swap handlers without ending the live gesture: * cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) }) * * // On unmount: * cleanup() * ``` */ export const attachPan = (el, handlers, options = {}) => { if (typeof window === 'undefined') { const noop = () => { }; return Object.assign(noop, { update: () => { } }); } const contextWindow = options.contextWindow ?? el.ownerDocument?.defaultView ?? window; const distanceThreshold = options.distanceThreshold ?? 3; let session = null; let rawHandlers = handlers; // Liveness flag the wrapped handler closures consult before invoking // the user callback. Flips false at teardown so any frame.update / // frame.postRender callbacks queued before teardown — but not yet // flushed — see the flag and skip dispatch. This is our only way to // cancel the anonymous closures the wrappers schedule (frame.update // doesn't return a handle we can store per call). let isAlive = true; const aliveGuard = () => isAlive; // Frame-scheduled mirror of the live handlers — onSessionStart / onStart / // onMove are queued onto motion-dom's `update` step, onEnd / onSessionEnd // onto `postRender`. This is the wrap upstream applies via `asyncHandler` // + `frame.postRender` in PanGesture.createPanHandlers; see the // wrapUpdate / wrapPostRender helpers at the top of this file for the // rationale. PanSession itself stays scheduler-unaware (and synchronous, // for testability) — the scheduling lives at the `attachPan` boundary. let liveHandlers = wrapHandlers(handlers, aliveGuard); const onPointerDown = (event) => { // Match upstream: ignore non-primary pointers (multi-touch, right-click). if (!isPrimaryPointer(event)) return; // Defensively end any prior session before overwriting the reference. // Without this, a second primary pointerdown that arrives before the // first pointerup orphans the prior session's contextWindow listeners. session?.end(); session = new PanSession(event, liveHandlers, { distanceThreshold, contextWindow, element: el }); }; el.addEventListener('pointerdown', onPointerDown); const update = (next) => { rawHandlers = next; liveHandlers = wrapHandlers(next, aliveGuard); session?.updateHandlers(liveHandlers); }; const teardown = () => { // Synthesize the gesture's terminal lifecycle BEFORE flipping // `isAlive`, so a host that tears us down mid-pan (effect re-run, // component unmount) still sees a balanced onPanEnd / onPanSessionEnd // pair. Dispatched against the *raw* (unwrapped) handlers so the // delivery is synchronous — the wrapped lane would otherwise queue // the callbacks onto frame.postRender just for them to be cancelled // by the `isAlive = false` line immediately below. if (session) { session.dispatchTerminal(rawHandlers); session.end(); session = null; } isAlive = false; el.removeEventListener('pointerdown', onPointerDown); }; return Object.assign(teardown, { update }); }; class PanSession { history = []; startEvent = null; lastMoveEvent = null; lastMovePoint = null; handlers = {}; contextWindow = window; distanceThreshold = 3; element = null; scrollPositions = new Map(); /** * Idempotency flag — set the first time the gesture's terminal * lifecycle pair (`onEnd` + `onSessionEnd`) fires. Both * `handlePointerUp` (the natural release path) and * `dispatchTerminal` (the forced-teardown path called by * `attachPan.teardown`) check this and bail if already dispatched. * Without it, a normal pointerup followed by a host-side teardown * (e.g. `$effect` cleanup, component unmount) would replay * `onEnd`/`onSessionEnd` against handlers that already saw them. */ terminalDispatched = false; removeScrollListeners = null; removeListeners = null; constructor(event, handlers, opts) { // Bail on non-primary pointers. Properties keep their declared // defaults so TypeScript sees them initialized regardless of which // constructor branch ran. if (!isPrimaryPointer(event)) return; this.handlers = handlers; this.contextWindow = opts.contextWindow; this.distanceThreshold = opts.distanceThreshold; this.element = opts.element; const point = extractEventPoint(event); this.history = [{ ...point, timestamp: frameData.timestamp }]; this.handlers.onSessionStart?.(event, getPanInfo(point, this.history)); const moveHandler = (e) => this.handlePointerMove(e); const upHandler = (e) => this.handlePointerUp(e); this.contextWindow.addEventListener('pointermove', moveHandler); this.contextWindow.addEventListener('pointerup', upHandler); this.contextWindow.addEventListener('pointercancel', upHandler); this.removeListeners = () => { this.contextWindow.removeEventListener('pointermove', moveHandler); this.contextWindow.removeEventListener('pointerup', upHandler); this.contextWindow.removeEventListener('pointercancel', upHandler); }; if (this.element) this.startScrollTracking(this.element); } updateHandlers(handlers) { this.handlers = handlers; } end() { this.removeListeners?.(); this.removeListeners = null; this.removeScrollListeners?.(); this.removeScrollListeners = null; this.scrollPositions.clear(); cancelFrame(this.updatePoint); } /** * Synthesize the gesture's terminal lifecycle pair (`onEnd` then * `onSessionEnd`) against the supplied *raw* (unwrapped) handlers, * using the last observed event + point as the synthetic terminal * sample. Called by `attachPan.teardown` when a host kills the * session mid-gesture — without this, an `$effect` re-run that * tears down the attachment silently strands the consumer's state * machine in an "in-progress" state (whilePan keyframes never * revert, threshold-based commit decisions never run). * * Bypasses the frame-loop wrappers deliberately: the wrapped * handlers would queue to `frame.postRender` only for the * about-to-flip `isAlive` flag in attachPan to cancel them. Raw * dispatch keeps the lifecycle synchronous with teardown. * * No-op when no pointermove ever fired — matches the * `handlePointerUp` no-movement contract upstream uses. */ dispatchTerminal(rawHandlers) { if (this.terminalDispatched) return; if (!(this.lastMoveEvent && this.lastMovePoint)) return; const info = getPanInfo(this.lastMovePoint, this.history); if (this.startEvent) rawHandlers.onEnd?.(this.lastMoveEvent, info); rawHandlers.onSessionEnd?.(this.lastMoveEvent, info); this.terminalDispatched = true; } handlePointerMove = (event) => { this.lastMoveEvent = event; this.lastMovePoint = extractEventPoint(event); // Per-frame throttle so a 1000hz mouse doesn't drown handlers. frame.update(this.updatePoint, true); }; handlePointerUp = (event) => { this.end(); if (this.terminalDispatched) return; if (!(this.lastMoveEvent && this.lastMovePoint)) { // No pointermove ever fired — match upstream framer-motion // (`packages/framer-motion/src/gestures/pan/PanSession.ts` // ~line 320) and return WITHOUT firing onEnd / onSessionEnd. // Consumers that want a "tap" signal should use the press / // tap gesture instead. This prevents a spurious // onPanSessionStart → onPanSessionEnd pair on every plain // click of a pan-enabled element. return; } const finalPoint = event.type === 'pointercancel' ? this.lastMovePoint : extractEventPoint(event); const info = getPanInfo(finalPoint, this.history); if (this.startEvent) this.handlers.onEnd?.(event, info); this.handlers.onSessionEnd?.(event, info); // Mark idempotent so a later forced teardown via // `dispatchTerminal` doesn't replay this pair. this.terminalDispatched = true; }; updatePoint = () => { if (!(this.lastMoveEvent && this.lastMovePoint)) return; const info = getPanInfo(this.lastMovePoint, this.history); const panAlreadyStarted = this.startEvent !== null; const pastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold; if (!panAlreadyStarted && !pastThreshold) return; this.history.push({ ...this.lastMovePoint, timestamp: frameData.timestamp }); if (!panAlreadyStarted) { this.handlers.onStart?.(this.lastMoveEvent, info); this.startEvent = this.lastMoveEvent; } this.handlers.onMove?.(this.lastMoveEvent, info); }; /** * Track scrollable ancestors so we can compensate for scroll deltas * during the gesture — mirrors upstream's `startScrollTracking`. * For element scrolls: adjust `history[0]` so offset stays sane * (pageX/pageY unaffected by element scroll). For window scrolls: * adjust `lastMovePoint` (pageX/pageY shift with window scroll). */ startScrollTracking(element) { let current = element.parentElement; while (current) { const style = getComputedStyle(current); if (overflowStyles.has(style.overflowX) || overflowStyles.has(style.overflowY)) { this.scrollPositions.set(current, { x: current.scrollLeft, y: current.scrollTop }); } current = current.parentElement; } this.scrollPositions.set(this.contextWindow, { x: this.contextWindow.scrollX, y: this.contextWindow.scrollY }); const onElementScroll = (event) => { this.handleScroll(event.target); }; const onWindowScroll = () => { this.handleScroll(this.contextWindow); }; this.contextWindow.addEventListener('scroll', onElementScroll, { capture: true }); this.contextWindow.addEventListener('scroll', onWindowScroll); this.removeScrollListeners = () => { this.contextWindow.removeEventListener('scroll', onElementScroll, { capture: true }); this.contextWindow.removeEventListener('scroll', onWindowScroll); }; } handleScroll(target) { const initial = this.scrollPositions.get(target); if (!initial) return; const isWindow = target === this.contextWindow; const current = isWindow ? { x: this.contextWindow.scrollX, y: this.contextWindow.scrollY } : { x: target.scrollLeft, y: target.scrollTop }; const delta = { x: current.x - initial.x, y: current.y - initial.y }; if (delta.x === 0 && delta.y === 0) return; if (isWindow) { if (this.lastMovePoint) { this.lastMovePoint.x += delta.x; this.lastMovePoint.y += delta.y; } } else if (this.history.length > 0) { this.history[0].x -= delta.x; this.history[0].y -= delta.y; } this.scrollPositions.set(target, current); frame.update(this.updatePoint, true); } }