import { GroupAnimation } from '../animation/GroupAnimation.mjs'; import { copyBoxInto } from '../projection/geometry/copy.mjs'; import { createBox } from '../projection/geometry/models.mjs'; import { HTMLProjectionNode } from '../projection/node/HTMLProjectionNode.mjs'; import { HTMLVisualElement } from '../render/html/HTMLVisualElement.mjs'; import { visualElementStore } from '../render/store.mjs'; import { resolveElements } from '../utils/resolve-elements.mjs'; import { frame } from '../frameloop/frame.mjs'; const layoutSelector = "[data-layout], [data-layout-id]"; const noop = () => { }; function snapshotFromTarget(projection) { const target = projection.targetWithTransforms || projection.target; if (!target) return undefined; const measuredBox = createBox(); const layoutBox = createBox(); copyBoxInto(measuredBox, target); copyBoxInto(layoutBox, target); return { animationId: projection.root?.animationId ?? 0, measuredBox, layoutBox, latestValues: projection.animationValues || projection.latestValues || {}, source: projection.id, }; } class LayoutAnimationBuilder { constructor(scope, updateDom, defaultOptions) { this.sharedTransitions = new Map(); this.notifyReady = noop; this.rejectReady = noop; this.scope = scope; this.updateDom = updateDom; this.defaultOptions = defaultOptions; this.readyPromise = new Promise((resolve, reject) => { this.notifyReady = resolve; this.rejectReady = reject; }); frame.postRender(() => { this.start().then(this.notifyReady).catch(this.rejectReady); }); } shared(id, transition) { this.sharedTransitions.set(id, transition); return this; } then(resolve, reject) { return this.readyPromise.then(resolve, reject); } async start() { const beforeElements = collectLayoutElements(this.scope); const beforeRecords = this.buildRecords(beforeElements); beforeRecords.forEach(({ projection }) => { const hasCurrentAnimation = Boolean(projection.currentAnimation); const isSharedLayout = Boolean(projection.options.layoutId); if (hasCurrentAnimation && isSharedLayout) { const snapshot = snapshotFromTarget(projection); if (snapshot) { projection.snapshot = snapshot; } else if (projection.snapshot) { projection.snapshot = undefined; } } else if (projection.snapshot && (projection.currentAnimation || projection.isProjecting())) { projection.snapshot = undefined; } projection.isPresent = true; projection.willUpdate(); }); await this.updateDom(); const afterElements = collectLayoutElements(this.scope); const afterRecords = this.buildRecords(afterElements); this.handleExitingElements(beforeRecords, afterRecords); afterRecords.forEach(({ projection }) => { const instance = projection.instance; const resumeFromInstance = projection.resumeFrom ?.instance; if (!instance || !resumeFromInstance) return; if (!("style" in instance)) return; const currentTransform = instance.style.transform; const resumeFromTransform = resumeFromInstance.style.transform; if (currentTransform && resumeFromTransform && currentTransform === resumeFromTransform) { instance.style.transform = ""; instance.style.transformOrigin = ""; } }); afterRecords.forEach(({ projection }) => { projection.isPresent = true; }); const root = getProjectionRoot(afterRecords, beforeRecords); root?.didUpdate(); await new Promise((resolve) => { frame.postRender(() => resolve()); }); const animations = collectAnimations(afterRecords); const animation = new GroupAnimation(animations); return animation; } buildRecords(elements) { const records = []; const recordMap = new Map(); for (const element of elements) { const parentRecord = findParentRecord(element, recordMap, this.scope); const { layout, layoutId } = readLayoutAttributes(element); const override = layoutId ? this.sharedTransitions.get(layoutId) : undefined; const transition = override || this.defaultOptions; const record = getOrCreateRecord(element, parentRecord?.projection, { layout, layoutId, animationType: typeof layout === "string" ? layout : "both", transition: transition, }); recordMap.set(element, record); records.push(record); } return records; } handleExitingElements(beforeRecords, afterRecords) { const afterElementsSet = new Set(afterRecords.map((record) => record.element)); beforeRecords.forEach((record) => { if (afterElementsSet.has(record.element)) return; // For shared layout elements, relegate to set up resumeFrom // so the remaining element animates from this position if (record.projection.options.layoutId) { record.projection.isPresent = false; record.projection.relegate(); } record.visualElement.unmount(); visualElementStore.delete(record.element); }); // Clear resumeFrom on EXISTING nodes that point to unmounted projections // This prevents crossfade animation when the source element was removed entirely // But preserve resumeFrom for NEW nodes so they can animate from the old position // Also preserve resumeFrom for lead nodes that were just promoted via relegate const beforeElementsSet = new Set(beforeRecords.map((record) => record.element)); afterRecords.forEach(({ element, projection }) => { if (beforeElementsSet.has(element) && projection.resumeFrom && !projection.resumeFrom.instance && !projection.isLead()) { projection.resumeFrom = undefined; projection.snapshot = undefined; } }); } } function parseAnimateLayoutArgs(scopeOrUpdateDom, updateDomOrOptions, options) { // animateLayout(updateDom) if (typeof scopeOrUpdateDom === "function") { return { scope: document, updateDom: scopeOrUpdateDom, defaultOptions: updateDomOrOptions, }; } // animateLayout(scope, updateDom, options?) const elements = resolveElements(scopeOrUpdateDom); const scope = elements[0] || document; return { scope, updateDom: updateDomOrOptions, defaultOptions: options, }; } function collectLayoutElements(scope) { const elements = Array.from(scope.querySelectorAll(layoutSelector)); if (scope instanceof Element && scope.matches(layoutSelector)) { if (!elements.includes(scope)) { elements.unshift(scope); } } return elements; } function readLayoutAttributes(element) { const layoutId = element.getAttribute("data-layout-id") || undefined; const rawLayout = element.getAttribute("data-layout"); let layout; if (rawLayout === "" || rawLayout === "true") { layout = true; } else if (rawLayout) { layout = rawLayout; } return { layout, layoutId, }; } function createVisualState() { return { latestValues: {}, renderState: { transform: {}, transformOrigin: {}, style: {}, vars: {}, }, }; } function getOrCreateRecord(element, parentProjection, projectionOptions) { const existing = visualElementStore.get(element); const visualElement = existing ?? new HTMLVisualElement({ props: {}, presenceContext: null, visualState: createVisualState(), }, { allowProjection: true }); if (!existing || !visualElement.projection) { visualElement.projection = new HTMLProjectionNode(visualElement.latestValues, parentProjection); } visualElement.projection.setOptions({ ...projectionOptions, visualElement, }); if (!visualElement.current) { visualElement.mount(element); } else if (!visualElement.projection.instance) { // Mount projection if VisualElement is already mounted but projection isn't // This happens when animate() was called before animateLayout() visualElement.projection.mount(element); } if (!existing) { visualElementStore.set(element, visualElement); } return { element, visualElement, projection: visualElement.projection, }; } function findParentRecord(element, recordMap, scope) { let parent = element.parentElement; while (parent) { const record = recordMap.get(parent); if (record) return record; if (parent === scope) break; parent = parent.parentElement; } return undefined; } function getProjectionRoot(afterRecords, beforeRecords) { const record = afterRecords[0] || beforeRecords[0]; return record?.projection.root; } function collectAnimations(afterRecords) { const animations = new Set(); afterRecords.forEach((record) => { const animation = record.projection.currentAnimation; if (animation) animations.add(animation); }); return Array.from(animations); } export { LayoutAnimationBuilder, parseAnimateLayoutArgs }; //# sourceMappingURL=LayoutAnimationBuilder.mjs.map