UNPKG

@omicrxn/mercury

Version:

Mercury is a Svelte animation library powered by Motion. It simplifies complex animations with Svelte actions, provides layout and exit animations, and integrates seamlessly with Svelte features.

308 lines (307 loc) 12.2 kB
import { animate } from 'motion'; import { completeWaitExit, registerWaitExit, runAfterPendingExit, stopMercury } from './presence-coordinator.js'; import { mapTransitionToMotion } from './utils.js'; import { prefersReducedMotion } from './layout-anime.js'; const PRESENCE_STATE = Symbol('mercury:presence'); const WAIT_REGISTERED = Symbol('mercury:presence-wait'); const NATURAL_VALUES = Symbol('mercury:presence-natural'); const TRANSFORM_IDENTITY = { x: 0, y: 0, z: 0, translateX: 0, translateY: 0, translateZ: 0, rotate: 0, rotateX: 0, rotateY: 0, rotateZ: 0, skew: 0, skewX: 0, skewY: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 }; const getNaturalValue = (element, key) => { if (key in TRANSFORM_IDENTITY) return TRANSFORM_IDENTITY[key]; return getComputedStyle(element)[key] ?? ''; }; /** * "Natural" values are the element's settled styles — what an enter animates towards and a * reversed exit recovers to. They're snapshotted while the element is at rest, because * reading computed styles mid-animation would capture transient values. */ const snapshotNaturalValues = (element, keys) => { const el = element; const store = (el[NATURAL_VALUES] ??= {}); for (const key of keys) { if (!(key in store)) store[key] = getNaturalValue(element, key); } }; const naturalValueOf = (element, key) => element[NATURAL_VALUES]?.[key] ?? getNaturalValue(element, key); const resolveDurationMs = (controls, transition, fallbackMs) => { const motionMs = typeof controls.duration === 'number' && controls.duration > 0 ? controls.duration * 1000 : fallbackMs; if (transition?.type === 'spring') return Math.max(motionMs, fallbackMs); return motionMs; }; /** * Cancels any in-flight presence animation on the element and restores any inline * styles that were mutated (e.g. by popLayout). Called when a new presence/mercury * lifecycle starts on the same node so a reversed exit doesn't leave it stranded. */ export const clearPresence = (element) => { const el = element; const state = el[PRESENCE_STATE]; if (!state) return; state.controls.stop(); state.restore?.(); if (el[WAIT_REGISTERED]) { completeWaitExit(element); el[WAIT_REGISTERED] = undefined; } el[PRESENCE_STATE] = undefined; }; /** * Pops the element out of layout flow (mode: 'popLayout') so surrounding elements * reflow immediately while it animates out in place. * * Inset is left as `auto` so the browser keeps the static position after siblings * reflow. Width/height are frozen so the node doesn't collapse. */ const applyPopLayout = (element) => { const style = element.style; const saved = { position: style.position, width: style.width, height: style.height, boxSizing: style.boxSizing }; const rect = element.getBoundingClientRect(); let restoreParent; const parent = element.parentElement; if (parent && getComputedStyle(parent).position === 'static') { const savedParentPosition = parent.style.position; parent.style.position = 'relative'; restoreParent = () => { parent.style.position = savedParentPosition; }; } style.boxSizing = 'border-box'; style.width = `${rect.width}px`; style.height = `${rect.height}px`; style.position = 'absolute'; return () => { style.position = saved.position; style.width = saved.width; style.height = saved.height; style.boxSizing = saved.boxSizing; restoreParent?.(); }; }; /** * Splits a spec into animation targets and its (possibly shared) transition. * `transition` and `mode` keys are configuration, everything else is animated. */ const resolveSpec = (raw, shared) => { if (!raw) return undefined; const { transition, mode: _mode, ...targets } = raw; if (Object.keys(targets).length === 0) return undefined; return { targets, transition: transition ?? shared }; }; const finishOutWait = (element) => { const el = element; if (!el[WAIT_REGISTERED]) return; completeWaitExit(element); el[WAIT_REGISTERED] = undefined; }; /** * Svelte-native AnimatePresence. * * Used as a transition directive, so Svelte's semantics map directly onto motion/react's: * * - `transition:presence={{ initial, exit, mode, transition }}` — like a child of * `<AnimatePresence>`: on enter the element animates from `initial` to its settled * CSS styles, on removal it animates to `exit`. * - Local transitions don't run when an ancestor block mounts, which is exactly * `<AnimatePresence initial={false}>`. Use `transition:presence|global` to opt in. * - `mode: 'sync' | 'wait' | 'popLayout'` matches AnimatePresence's `mode`. * - Flat params (`out:presence={{ opacity: 0 }}`) describe the hidden state for both * directions: enters animate from it, exits animate to it. * * In `initial`, array values are treated as full keyframes (`opacity: [0, 0.5, 1]`). */ export const presence = (element, params, options) => { const el = element; const wasAnimating = el[PRESENCE_STATE] !== undefined; clearPresence(element); const { transition, mode: sharedMode, initial, exit, ...flat } = params; const structured = initial !== undefined || exit !== undefined; const enterSpec = resolveSpec(structured ? initial : flat, transition); const exitSpec = resolveSpec(structured ? exit : flat, transition); const mode = sharedMode ?? exit?.mode ?? 'sync'; if (prefersReducedMotion()) return { duration: 0 }; // This runs at play start, before any animation begins — refresh the settled-value // snapshot when the element is at rest, so reversals have reliable targets. if (!wasAnimating) el[NATURAL_VALUES] = undefined; snapshotNaturalValues(element, [ ...(enterSpec ? Object.keys(enterSpec.targets) : []), ...(exitSpec ? Object.keys(exitSpec.targets) : []) ]); // Wait-mode exits must register *synchronously*: Svelte creates the incoming branch // (whose enters check for pending exits) in the same flush, possibly before this play's // deferred config resolves. For `transition:` we can't know the direction yet, so the // registration is provisional — released as soon as the play resolves to an enter. let resolved = false; let waitToken; const releaseWait = () => { if (!waitToken || el[WAIT_REGISTERED] !== waitToken) return; el[WAIT_REGISTERED] = undefined; completeWaitExit(element); }; if (mode === 'wait' && exitSpec && options.direction !== 'in') { waitToken = {}; registerWaitExit(element); el[WAIT_REGISTERED] = waitToken; // Safety net: if the play is aborted before its deferred config ever resolves, // release the registration so enters in this scope aren't deferred forever. setTimeout(() => { if (!resolved) releaseWait(); }, 0); } // Both `popLayout` and `wait` take the exiting element out of layout flow: popLayout so // siblings reflow immediately, wait so the (already-mounted but deferred) incoming element // can occupy the slot without displacing the element that's still animating out. const popsLayout = mode === 'popLayout' || mode === 'wait'; const begin = (direction, keyframes, specTransition, onDone) => { const restore = direction === 'out' && popsLayout ? applyPopLayout(element) : undefined; const motionOptions = mapTransitionToMotion(specTransition); const userOnComplete = motionOptions.onComplete; let state; const controls = animate(element, keyframes, { ...motionOptions, onComplete: () => { userOnComplete?.(); if (el[PRESENCE_STATE] === state) el[PRESENCE_STATE] = undefined; onDone?.(); } }); state = { controls, direction, restore }; el[PRESENCE_STATE] = state; return controls; }; const runEnter = () => { // An exit may still be in flight if this enter reverses it (the block was resurrected // mid-outro). Animate from wherever the exit left off back to the settled state // instead of replaying the intro from its hidden keyframes. const reversesExit = el[PRESENCE_STATE]?.direction === 'out'; clearPresence(element); if (!enterSpec) { // Exit-only spec: don't leave a reversed exit frozen mid-animation — settle back. if (reversesExit && exitSpec) { const recovery = {}; for (const key of Object.keys(exitSpec.targets)) { recovery[key] = naturalValueOf(element, key); } begin('in', recovery, exitSpec.transition); } return; } const { targets } = enterSpec; const keyframes = {}; if (reversesExit) { for (const key of Object.keys(targets)) { const value = targets[key]; keyframes[key] = Array.isArray(value) ? value[value.length - 1] : naturalValueOf(element, key); } } else { const from = {}; for (const key of Object.keys(targets)) { const value = targets[key]; if (Array.isArray(value)) { keyframes[key] = value; from[key] = value[0]; } else { keyframes[key] = [value, naturalValueOf(element, key)]; from[key] = value; } } // Snap to the hidden state immediately so the element doesn't flash its settled // styles while a wait-mode exit defers the intro. animate(element, from, { duration: 0 }); } runAfterPendingExit(element, () => { if (!element.isConnected) return; begin('in', keyframes, enterSpec.transition); }); }; const recoverFromExit = () => { if (el[PRESENCE_STATE]?.direction !== 'out') return; runEnter(); }; const runExit = () => { if (!exitSpec) return { duration: 0 }; stopMercury(element); let finished = false; const finishOnce = () => { if (finished) return; finished = true; finishOutWait(element); }; const controls = begin('out', exitSpec.targets, exitSpec.transition, finishOnce); const fallbackMs = (exitSpec.transition?.duration ?? 0.3) * 1000; let sawProgress = false; return { delay: (exitSpec.transition?.delay ?? 0) * 1000, duration: resolveDurationMs(controls, exitSpec.transition, fallbackMs), tick: (t) => { // `out:`-only directives never get an 'in' play on reversal — Svelte aborts the // outro and emits a reset `tick(1, 0)` instead. Recover by animating back. if (t >= 1) { if (sawProgress) recoverFromExit(); return; } sawProgress = true; // Svelte's outro end is the reliable removal signal — Motion's onComplete may // not fire once the node leaves the document. if (t <= 0) finishOnce(); } }; }; return (opts) => { resolved = true; if ((opts?.direction ?? options.direction) === 'in') { releaseWait(); runEnter(); // Motion drives the visuals; a zero-length intro keeps Svelte's bookkeeping out of // the way (an interrupted intro would otherwise scale down the exit's duration). return { duration: 0 }; } return runExit(); }; };