UNPKG

@omicrxn/mercury

Version:

Mercury is a Svelte animation library using AnimeJS under the hood. It simplifies complex animations with Svelte actions, provides layout and exit animations, and integrates seamlessly with Svelte features.

225 lines (224 loc) 9.37 kB
import { v4 as uuid } from 'uuid'; import { tick } from 'svelte'; import { useDebounce, useEventListener } from 'runed'; import { BasicProjectionNode, ProjectionNode } from '@layout-projection/core'; import { CompositeProjectionAnimator, createSnapshot, InPlaceMetadataManager, ProjectionAnimationHandler, ProjectionAnimator } from '@layout-projection/animation'; import { LayoutAnimationFramer, LayoutProjectionAnimationHandler, SKIP_SIZE } from '@layout-projection/animation/handlers'; // Global registry for shared layout snapshots const snapshots = new Map(); /** * Map of DOM elements to their projection nodes. */ export const nodeMap = new WeakMap(); /** * Checks if an HTMLElement has a direct text node child * @param {HTMLElement} element - The element to check * @returns {boolean} - True if element has a direct text node child, false otherwise */ function hasTextChild(element) { if (!element || !(element instanceof HTMLElement)) { return false; } return Array.from(element.childNodes).some((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim().length > 0); } /** * Attempt to find the nearest parent Projection Node for the given Projection * Node by looking upwards through the DOM. * @param projectionNode * @returns true if a parent Projection Node is found and attached */ function tryAttachToNearestParent(projectionNode) { let parentElement = projectionNode.element().parentElement; while (parentElement) { const parentProjectionNode = nodeMap.get(parentElement); if (parentProjectionNode) { projectionNode.attach(parentProjectionNode); return true; } parentElement = parentElement.parentElement; } return false; } /** * Utility function for creating a snapshot of a projection node */ function resetAndMeasure(projectionNode) { const isRoot = projectionNode.parent() === null; if (!isRoot) return; projectionNode.traverse((n) => { n.reset(); }); projectionNode.traverse((n) => { n.measure(); }); } /** * Handle snapshotting and animation for the projection tree */ function snapAndAnimate(projectionNode, animator, animationConfig) { const isRoot = projectionNode.parent() === null; if (!isRoot) return; const previousSnapshots = new Map(); // Create snapshots and store previous ones projectionNode.traverse((node) => { const previous = snapshots.get(node.identity()); const current = createSnapshot(node); if (!previous && !current) return; if (current && previous?.equals(current)) return; snapshots.set(node.identity(), current); previousSnapshots.set(node.identity(), previous); }); // Animate from previous to current snapshots projectionNode.traverse((node) => { const previous = previousSnapshots.get(node.identity()); const current = snapshots.get(node.identity()); if (!previous || !current) return; if (current && previous.equals(current)) return; animator.animate({ node: node, from: previous, to: current, duration: animationConfig.duration, easing: animationConfig.easing }); }); } function snap(projectionNode) { const isRoot = projectionNode.parent() === null; if (!isRoot) return; const previousSnapshots = new Map(); // Create snapshots and store previous ones projectionNode.traverse((node) => { const previous = snapshots.get(node.identity()); const current = createSnapshot(node); if (!previous && !current) return; if (current && previous?.equals(current)) return; snapshots.set(node.identity(), current); previousSnapshots.set(node.identity(), previous); }); } /** * Build the Projection Tree covering the given element and its child elements. * Create a Projection Node for the given element, and establish the parent-child * relationships with the existing child Projection Nodes without a parent. * This handles the case where child elements already have projection nodes from their own attachments. */ function buildProjectionTreeDownwards(element, layoutId, metadataManager) { // Check if projection node already exists (from another attachment) let projectionNode = nodeMap.get(element); if (projectionNode) { // If this element already has a projection node, just use it // This happens when multiple attachments are on the same element // or when a child attachment runs before a parent attachment return projectionNode; } // Create new projection node projectionNode = new BasicProjectionNode(element, layoutId); nodeMap.set(element, projectionNode); if (hasTextChild(element)) { metadataManager.define(projectionNode, SKIP_SIZE, true); } // Recursively build the tree with correct parent-child relationships buildTreeRecursive(element, projectionNode, metadataManager); return projectionNode; } /** * Recursively build projection tree ensuring correct parent-child relationships */ function buildTreeRecursive(element, parentProjectionNode, metadataManager) { for (const child of Array.from(element.children)) { if (!(child instanceof HTMLElement)) continue; let childProjectionNode = nodeMap.get(child); if (!childProjectionNode) { // Create new projection node for children without attachments childProjectionNode = new BasicProjectionNode(child, uuid()); nodeMap.set(child, childProjectionNode); if (hasTextChild(child)) { metadataManager.define(childProjectionNode, SKIP_SIZE, true); } } // Attach to correct parent if not already attached if (!childProjectionNode.parent()) { childProjectionNode.attach(parentProjectionNode); } // Recursively process children buildTreeRecursive(child, childProjectionNode, metadataManager); } } /** * Layout projection attachment factory * Returns an attachment function that handles layout animations */ export const layout = ({ layoutId, track, animationConfig = { duration: 300, easing: (progress) => progress } }) => { /** * Attachment function - called when element mounts * Recreated when dependencies (layoutId, track, animationConfig) change */ return (element) => { // Determine if this is a shared layout const finalLayoutId = layoutId ?? uuid(); // Set up animation infrastructure (shared among all nodes that might animate together) const layoutFramer = new LayoutAnimationFramer(); const metadataManager = new InPlaceMetadataManager(); const layoutHandler = new LayoutProjectionAnimationHandler(layoutFramer, metadataManager); const handlers = [layoutHandler]; const animator = new CompositeProjectionAnimator(handlers); // Build the projection tree downwards from this element const projectionNode = buildProjectionTreeDownwards(element, finalLayoutId, metadataManager); // Try to attach to nearest parent in the projection tree tryAttachToNearestParent(projectionNode); let isScrolling = $state(false); // wrap our “end scroll” logic const onScrollEnd = useDebounce(() => { isScrolling = false; // final animate when scroll stops snapAndAnimate(projectionNode, animator, animationConfig); }, 200); useEventListener(() => document, 'scroll', () => { isScrolling = true; resetAndMeasure(projectionNode); snap(projectionNode); onScrollEnd(); }, { capture: true, passive: true }); // Set up reactive tracking for layout changes $effect(() => { // Call the tracking function to establish dependencies track(); // Reset, measure, and animate from this root resetAndMeasure(projectionNode); if (!isScrolling) { snapAndAnimate(projectionNode, animator, animationConfig); } }); // Return cleanup function return () => { // Clean up snapshots for this subtree only if this is the root of the attachment projectionNode.traverse((n) => { const snapshot = snapshots.get(n.identity()); if (!snapshot) return; // Delete snapshot after next tick in case there's a shared element animation tick().then(() => { // Only delete if it's still the same snapshot (hasn't been replaced by shared animation) if (snapshots.get(n.identity()) === snapshot) { snapshots.delete(n.identity()); } }); }); // Simple cleanup: just remove our main element from nodeMap and dispose the projection node // The projection node will handle cleaning up its children appropriately nodeMap.delete(element); projectionNode.dispose(); }; }; };