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

393 lines (392 loc) 17.1 kB
/** * Projection layout system — minimal foundation for cross-element layout * coordination during gestures (drag, layoutId, future shared-element). * * Direct port of the surface area framer-motion's projection system * exposes to consumers, slimmed down to what we actually need for the * acceptance criteria of #379: * * - Per-element `ProjectionNode`s with parent/child wiring through the * Svelte component tree (via `projection.context.ts`). * - `willUpdate()` / `didUpdate()` lifecycle around layout-mutating * operations: caller snapshots before the mutation, re-measures * after, fan-outs a `didUpdate` event with the computed delta. * - Transform-stripping `measure()` — the load-bearing read primitive. * A child's `getBoundingClientRect()` is contaminated by every * ancestor's transform, so while measuring we temporarily reset the * whole ancestor chain to each node's mount-time `baseTransform`, * then restore in reverse order. Resetting to the captured base * (rather than `'none'`) strips the motion-applied portion of each * transform while preserving the user-authored one — mirroring * framer-motion's `removeBoxTransforms`, which only subtracts * motion-tracked `latestValues`. Generalises what * `layout.ts:measureRect` does for the single-element case across * the projection chain. * * Math helpers (`createBox`, `createDelta`, `calcBoxDelta`, * `isDeltaZero`) are imported directly from `motion-dom` — no need to * re-port what upstream already re-exports. * * KNOWN_LIMITATIONS (deferred to follow-up PRs, see #379): * - No depth-sorted FlatTree / `path` array. We walk via `parent` * pointers from leaves; siblings under one parent is sufficient for * the Reorder use case and the rest of the projection tree workflows * this PR enables. * - No 4-phase tree walk (propagateDirty → resolveTarget → * calcProjection → cleanDirty). Projection-transform *inheritance* * (parent target deltas affecting child positioning) is not implemented * yet. The ancestor-zeroing measure is independent of this — it's a * read-time concern, not a projection-compose concern. * - No scale-correction utilities (border-radius / box-shadow). Visual * polish; defer. * - No `relativeTarget` / `projectionDelta` with transform inheritance. * Only matters for full shared-element morphing through nested * transforms; current `layoutId.ts` registry handles the simple case. * - No `layoutId` registry migration onto projection nodes. The * one-shot pattern in `layoutId.ts` keeps working; future PR can * route through projection nodes for richer coordination. */ import { measureRect } from './layout'; import { calcBoxDelta, createDelta, isDeltaZero } from 'motion-dom'; /** * Convert a `DOMRect` to our `Box` shape. Inline because `motion-dom`'s * `convertBoundingBoxToBox` works on a BoundingBox (already-derived * `top`/`bottom`/`left`/`right`) rather than the DOMRect we get from * `getBoundingClientRect`. Same math, just one less indirection. */ const rectToBox = (rect) => ({ x: { min: rect.left, max: rect.right }, y: { min: rect.top, max: rect.bottom } }); /** Deep-copy a Box so subsequent measurements don't mutate snapshots. */ const cloneBox = (box) => ({ x: { min: box.x.min, max: box.x.max }, y: { min: box.y.min, max: box.y.max } }); /** * Per-element node in the projection tree. Created at component setup * time in `_MotionContainer.svelte`, mounted when the element ref * binds, unmounted on cleanup. * * Lifecycle: * 1. `new ProjectionNode({ parent, getScrollContainers })` at setup. * 2. `node.mount(element)` once the element ref binds. * 3. `node.willUpdate()` before any layout-mutating state change (e.g. * a `values` reassign that reorders DOM children). * 4. State mutates → Svelte commits the DOM update. * 5. `node.didUpdate()` after the DOM update is flushed — fires * `didUpdate` listeners with the snapshot→current delta. * 6. `node.unmount()` on cleanup. */ export class ProjectionNode { /** The mounted element. `null` until `mount()` runs. */ element = null; /** * Parent node in the projection tree. Captured at construction * from the Svelte context. Set to `null` for root-level motion * elements that have no motion ancestor. */ parent = null; /** * Descendant nodes registered via `mount()`. Iterated when we * need to broadcast to the subtree (none in this PR; reserved * for follow-up work). */ children = new Set(); /** Most-recent post-mutation measurement, or `null` before first measure. */ latestLayout = null; /** * Pre-mutation snapshot captured by `willUpdate`. Cleared by * `didUpdate` after the delta has been computed. Idempotent for * repeat `willUpdate` calls in the same frame — only the first * snapshots; subsequent calls no-op so a parent broadcasting * `willUpdate` to its children doesn't clobber a child's own * earlier snapshot. */ snapshot = null; /** Whether `mount()` has been called and `unmount()` has not. */ isMounted = false; /** * Fallback user-authored base transform, captured from * `element.style.transform` at `mount()` time. Used only when no * `getBaseTransform` thunk was provided (e.g. unit tests that * construct a bare node and set the transform before mounting). * * For real `motion.*` elements the `getBaseTransform` thunk is * preferred: capturing at mount is unsafe because a transform-type * `initial` keyframe is serialized into the inline `style.transform` * BEFORE effects run, so the mount-time value can be a motion * transform rather than the user's. `resolveBaseTransform()` picks * the thunk first. * * `measure()` resets ancestors (and self, via `measureRect`) to this * base rather than to `'none'`, removing the motion-applied portion * while leaving the user-authored part intact — the same distinction * framer-motion draws by only subtracting motion-tracked * `latestValues` in `removeBoxTransforms`. */ baseTransform = ''; listeners = new Map(); getScrollContainers; getBaseTransform; constructor(options = {}) { this.parent = options.parent ?? null; this.getScrollContainers = options.getScrollContainers; this.getBaseTransform = options.getBaseTransform; } /** * The transform `measure()` resets this node's element to while * reading. Prefers the `getBaseTransform` thunk (the user's authored * `style` transform, motion-independent); falls back to the * mount-captured `baseTransform`. See `getBaseTransform` / * `baseTransform` for why the thunk is the safe source. */ resolveBaseTransform() { return this.getBaseTransform?.() ?? this.baseTransform; } /** * Register this node with its parent + bind to a DOM element. * Idempotent — calling `mount()` twice on the same element is a * no-op for the registration steps. * * Re-mounting onto a DIFFERENT element swaps the element in place * WITHOUT a full `unmount()`. A full unmount would `children.clear()`, * orphaning still-mounted descendants that registered themselves in * this node's `children` (they keep their `parent` pointer but the * set would never be repopulated). Listeners and children are * therefore preserved across an element swap. * * @param element The DOM element this node represents. */ mount(element) { if (this.isMounted && this.element === element) return; this.element = element; // Fallback base capture (the consumer's getBaseTransform thunk is // preferred — see `baseTransform`). this.baseTransform = element.style.transform; // Stale measurement from a previous element; refreshed on next measure. this.latestLayout = null; if (!this.isMounted) { this.isMounted = true; this.parent?.children.add(this); } } /** * Tear down. Detaches from parent, clears children references, * drops all listeners. Safe to call on a never-mounted node and * safe to call twice. */ unmount() { if (!this.isMounted) return; this.parent?.children.delete(this); this.children.clear(); this.listeners.clear(); this.element = null; this.latestLayout = null; this.snapshot = null; this.baseTransform = ''; this.isMounted = false; } /** * Read the element's layout box with every ancestor's * motion-applied transform temporarily removed, while preserving * each ancestor's user-authored base transform. * * Mechanism: * 1. Walk `this.parent` chain bottom-up, collecting every * mounted ancestor node (excludes self — `measureRect` * handles self's transform internally). * 2. Snapshot each ancestor's current `el.style.transform`. * 3. Set each to its node's resolved base transform (the * user-authored value, via `resolveBaseTransform`). This strips * any FLIP/drag/initial transform while keeping the user's static * one — see `getBaseTransform` / `baseTransform`. * 4. Delegate to `measureRect(self.element, scrollContainers, * self base)`, which applies self's base transform inside its own * try/finally and returns the scroll-compensated DOMRect. * 5. Restore ancestor transforms in reverse order inside a * `finally` block — guarantees restoration even if measure * throws. * 6. Convert DOMRect → Box and cache as `latestLayout`. * * Returns `null` when `element` is not mounted. */ measure() { if (!this.element) return null; // Collect ancestor nodes bottom-up. Skips ancestors that // haven't bound yet (`element === null`). const ancestors = []; let cursor = this.parent; while (cursor) { if (cursor.element) ancestors.push(cursor); cursor = cursor.parent; } // Snapshot current transform; reset each ancestor to its // user-authored base for the duration of the measure. const restoreList = ancestors.map((node) => ({ el: node.element, prev: node.element.style.transform, base: node.resolveBaseTransform() })); try { for (const { el, base } of restoreList) el.style.transform = base; // measureRect applies self's base transform + scroll-container offset. const rect = measureRect(this.element, this.getScrollContainers?.() ?? [], this.resolveBaseTransform(), true); const box = rectToBox(rect); this.latestLayout = box; // Clone for the event: `box` aliases `this.latestLayout`, so a // listener mutating it would corrupt the cached layout. this.notify('measure', cloneBox(box)); return box; } finally { // Reverse-order restore — important because ancestor // composition cascades from outer-most down; restoring // bottom-up matches the snapshot order. for (let i = restoreList.length - 1; i >= 0; i--) { restoreList[i].el.style.transform = restoreList[i].prev; } } } /** * Snapshot the current layout box for use by the next * `didUpdate()`. Caller's contract: invoke this BEFORE the * layout-mutating state change so the snapshot reflects the * pre-mutation position. * * Idempotent within a frame — once a snapshot exists, subsequent * `willUpdate()` calls are no-ops until `didUpdate()` consumes it. * This means a parent that broadcasts `willUpdate` to its * children before its own snapshot is fine: children snapshot * themselves first via their own willUpdate, parent's broadcast * is a no-op. */ willUpdate() { if (!this.element || this.snapshot) return; const measured = this.measure(); if (!measured) return; this.snapshot = cloneBox(measured); // Clone for the event: emitting `this.snapshot` by reference would // let a listener mutate the stored snapshot. this.notify('willUpdate', cloneBox(this.snapshot)); } /** * Re-measure post-mutation, compute the delta against the * snapshot, fire `didUpdate` listeners. No-op when there's no * snapshot (matches upstream — `willUpdate` MUST precede * `didUpdate` for the cycle to fire). * * Always clears the snapshot at the end so the next gesture's * `willUpdate`/`didUpdate` cycle starts fresh. */ didUpdate() { if (!this.element || !this.snapshot) { this.snapshot = null; return; } const layout = this.measure(); if (!layout) { this.snapshot = null; return; } const delta = createDelta(); calcBoxDelta(delta, this.snapshot, layout); const hasLayoutChanged = !isDeltaZero(delta); const payload = { // Clone the layout box: it aliases `this.latestLayout`, so // handing the live reference out would let a listener mutate // the node's cached layout and poison the next diff. layout: cloneBox(layout), snapshot: this.snapshot, delta, hasLayoutChanged }; this.snapshot = null; this.notify('didUpdate', payload); } /** * Observer-driven layout-change commit. Unlike the explicit * `willUpdate()` → mutate → `didUpdate()` cycle (used when a * consumer controls the exact mutation moment, e.g. Reorder.Group * before a `values` reassign), this is for the reactive path where * a layout change has ALREADY happened and we only learn about it * after the fact (the existing `observeLayoutChanges` FLIP loop in * `_MotionContainer`). * * Uses the cached `latestLayout` (the pre-change position from * mount or the previous commit) as the snapshot, re-measures the * post-change position, and fires `didUpdate` with the delta. * * First call after mount just seeds `latestLayout` (no prior * position to diff against) and fires nothing. * * Returns the freshly-measured `Box` (or `null` when unmounted) so * the caller can reuse it — the FLIP loop in `_MotionContainer` uses * this as its `next` rect instead of measuring a second time per * frame. * * The `didUpdate` fan-out is gated on a non-zero delta. The FLIP * animation this commit runs alongside writes its own inverse * `transform` to the element every frame, and those writes re-trigger * the same `observeLayoutChanges` signal that drives this method. * Those re-fires carry no real layout change (the ancestor-stripped * measure is identical to the previous one), so without the * `isDeltaZero` gate every animation frame would fan out a `delta: 0` * event and clobber the genuine delta from the originating change. */ commitLayoutChange() { if (!this.element) return null; const previous = this.latestLayout; const layout = this.measure(); if (!layout) return null; if (previous) { const delta = createDelta(); calcBoxDelta(delta, previous, layout); // Skip the no-op re-fires from our own FLIP transform writes. if (!isDeltaZero(delta)) { this.notify('didUpdate', { // Clone: `layout` aliases `this.latestLayout`. layout: cloneBox(layout), snapshot: previous, delta, hasLayoutChanged: true }); } } return layout; } /** * Subscribe to a projection event. Returns an unsubscribe * function. Safe to call after `unmount()` (becomes a no-op). */ addEventListener(name, cb) { let bucket = this.listeners.get(name); if (!bucket) { bucket = new Set(); this.listeners.set(name, bucket); } const wrapped = cb; bucket.add(wrapped); return () => { this.listeners.get(name)?.delete(wrapped); }; } notify(name, payload) { const bucket = this.listeners.get(name); if (!bucket || bucket.size === 0) return; // Snapshot the bucket so unsubscribes inside a listener don't // skip the next listener in the iteration. for (const cb of [...bucket]) cb(payload); } }