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

291 lines (290 loc) 8.64 kB
import { HTMLProjectionNode, HTMLVisualElement, copyBoxInto, createBox, visualElementStore } from 'motion-dom'; const createVisualState = () => ({ latestValues: {}, renderState: { transform: {}, transformOrigin: {}, style: {}, vars: {} } }); const cloneMeasurements = (measurements) => { if (!measurements) return undefined; const measuredBox = createBox(); const layoutBox = createBox(); copyBoxInto(measuredBox, measurements.measuredBox); copyBoxInto(layoutBox, measurements.layoutBox); return { animationId: measurements.animationId, measuredBox, layoutBox, latestValues: { ...measurements.latestValues }, source: measurements.source }; }; const animationTypes = new Set([ 'position', 'x', 'y', 'size', 'both', 'preserve-aspect' ]); const animationTypeForLayout = (layout) => typeof layout === 'string' && animationTypes.has(layout) ? layout : 'both'; /** * Svelte lifecycle adapter for motion-dom's upstream projection node system. * * The public Svelte API stays unchanged (`layout`, `layoutId`, `transition`). * This adapter only translates those props into the same `HTMLProjectionNode` * and `HTMLVisualElement` internals Framer Motion uses. */ export class MotionDomProjectionAdapter { static adapters = new WeakMap(); visualElement; projection; element = null; layout; layoutId; transition; lastLayout; constructor(options = {}) { const parent = options.parent ?? null; this.visualElement = new HTMLVisualElement({ parent: parent?.visualElement, props: {}, presenceContext: null, visualState: createVisualState() }, { allowProjection: true }); this.projection = new HTMLProjectionNode(this.visualElement.latestValues, parent?.projection); this.visualElement.projection = this.projection; MotionDomProjectionAdapter.adapters.set(this.projection, this); } /** * Update projection options from current Svelte props. * * @param options Current layout-related motion props. * @returns Nothing. * * @example * ```ts * adapter.updateOptions({ layout, layoutId, transition, style }) * ``` */ updateOptions(options) { this.layout = options.layout; this.layoutId = options.layoutId; this.transition = options.transition; this.visualElement.update({ transition: options.transition, style: options.style }, null); this.projection.setOptions({ layout: options.layout, layoutId: options.layoutId, layoutScroll: options.layoutScroll, animationType: animationTypeForLayout(options.layout), transition: options.transition, visualElement: this.visualElement }); } /** * Mount the upstream projection node to an element and seed its layout. * * @param element Element represented by the current motion component. * @returns Nothing. * * @example * ```ts * adapter.mount(element) * ``` */ mount(element) { if (this.element === element) return; if (this.element) this.unmount(); this.element = element; MotionDomProjectionAdapter.adapters.set(this.projection, this); this.visualElement.mount(element); this.seedLayout(); } /** * Unmount the upstream projection node and clear its visual-element store. * * @returns Nothing. * * @example * ```ts * adapter.unmount() * ``` */ unmount() { if (!this.element) return; const element = this.element; this.projection.scheduleCheckAfterUnmount(); this.visualElement.unmount(); visualElementStore.delete(element); MotionDomProjectionAdapter.adapters.delete(this.projection); this.element = null; this.lastLayout = undefined; } /** * Capture the upstream "before" snapshot. * * @returns Nothing. * * @example * ```ts * adapter.willUpdate() * ``` */ willUpdate() { if (!this.element || !this.layout) return; this.projection.willUpdate(); } /** * Commit an upstream layout update after Svelte has patched the DOM. * * @returns Nothing. * * @example * ```ts * adapter.didUpdate() * ``` */ didUpdate() { if (!this.element || !this.layout) return; this.projection.root?.didUpdate(); this.refreshCachedLayout(); } /** * Seed the current layout without animating. * * @returns Nothing. * * @example * ```ts * adapter.seedLayout() * ``` */ seedLayout() { if (!this.element) return; this.updatePathScroll(); this.projection.isLayoutDirty = true; this.projection.updateLayout(); this.lastLayout = cloneMeasurements(this.projection.layout); } /** * Animate from the last cached layout to the current observed layout. * * This covers layout changes discovered after the mutation by observers. * Svelte runes mode doesn't expose the same component pre/post-update * hooks Framer Motion uses in React, so this adapter reuses upstream * projection while the Svelte component controls the snapshot timing. * * @returns Nothing. * * @example * ```ts * adapter.commitObservedLayoutChange() * ``` */ commitObservedLayoutChange() { if (!this.element || !this.layout) return; if (!this.lastLayout) { this.seedLayout(); return; } this.projection.root?.startUpdate(); this.seedCachedSnapshotsForSubtree(this.projection); this.projection.root?.didUpdate(); this.refreshCachedLayout(); } /** * Finish any active upstream layout animation in this subtree. * * @returns Nothing. * * @example * ```ts * adapter.finishAnimation() * ``` */ finishAnimation() { if (!this.element || !this.layout) return; this.finishAnimationForSubtree(this.projection); this.seedLayout(); } /** * Check whether this projection subtree has an active layout animation. * * @returns `true` when this projection subtree is currently animating. * * @example * ```ts * if (adapter.isAnimating()) adapter.finishAnimation() * ``` */ isAnimating() { return this.isAnimatingSubtree(this.projection); } seedCachedSnapshotsForSubtree(projection) { const adapter = MotionDomProjectionAdapter.adapters.get(projection); const snapshot = cloneMeasurements(adapter?.lastLayout); if (snapshot && projection.options.layout) { this.prepareSnapshotPath(projection); projection.snapshot = snapshot; projection.isLayoutDirty = true; } for (const child of projection.children) { this.seedCachedSnapshotsForSubtree(child); } } prepareSnapshotPath(projection) { projection.root.hasTreeAnimated = true; for (const node of projection.path) { node.shouldResetTransform = true; node.updateScroll('snapshot'); if (node.options.layoutRoot) { node.willUpdate(false); } } } finishAnimationForSubtree(projection) { projection.finishAnimation(); projection.targetDelta = projection.relativeTarget = projection.target = undefined; projection.isProjectionDirty = true; projection.scheduleRender(); for (const child of projection.children) { this.finishAnimationForSubtree(child); } } isAnimatingSubtree(projection) { if (projection.currentAnimation) return true; for (const child of projection.children) { if (this.isAnimatingSubtree(child)) return true; } return false; } updatePathScroll() { for (const node of this.projection.path) { node.updateScroll(); } } refreshCachedLayout() { requestAnimationFrame(() => { this.lastLayout = cloneMeasurements(this.projection.layout); }); } }