Files
boc/web-v2/node_modules/motion-dom/dist/es/layout/LayoutAnimationBuilder.mjs
T

355 lines
13 KiB
JavaScript

import { clamp } from 'motion-utils';
import { GroupAnimation } from '../animation/GroupAnimation.mjs';
import { microtask } from '../frameloop/microtask.mjs';
import { time } from '../frameloop/sync-time.mjs';
import { HTMLProjectionNode } from '../projection/node/HTMLProjectionNode.mjs';
import { HTMLVisualElement } from '../render/html/HTMLVisualElement.mjs';
import { visualElementStore } from '../render/store.mjs';
import { hasTransform } from '../projection/utils/has-transform.mjs';
import { resolveElements } from '../utils/resolve-elements.mjs';
import { frameData, frameSteps } from '../frameloop/frame.mjs';
const layoutSelector = "[data-layout],[data-layout-id]";
/**
* All imperatively-created projection nodes live in one persistent tree,
* shared across animateLayout() calls (and with any React-created nodes,
* via the singleton document root). Keyed by element for reuse.
*/
const layoutNodes = new WeakMap();
/**
* Builders created within the same synchronous tick are flushed together
* as a single "commit": every node is snapshotted before any updateDom
* runs, mirroring React batching renders from different parts of the tree.
*/
let pendingBuilders;
function collectLayoutElements(scope) {
const elements = [];
if (scope instanceof HTMLElement && scope.matches(layoutSelector)) {
elements.push(scope);
}
scope.querySelectorAll(layoutSelector).forEach((element) => {
if (element instanceof HTMLElement)
elements.push(element);
});
return elements;
}
/**
* Process any work scheduled on the frameloop now. A previous animation
* may have been seeked while paused (controls.time = x) without a frame
* having rendered it - we must materialise that state into the DOM
* before taking snapshots.
*/
function flushPendingFrame() {
if (frameData.isProcessing)
return;
const now = time.now();
frameData.delta = clamp(0, 1000 / 60, now - frameData.timestamp);
frameData.timestamp = now;
frameData.isProcessing = true;
frameSteps.update.process(frameData);
frameSteps.preRender.process(frameData);
frameSteps.render.process(frameData);
frameData.isProcessing = false;
}
function getProjectionParent(element) {
let ancestor = element.parentElement;
while (ancestor) {
const node = layoutNodes.get(ancestor);
if (node && node.instance)
return node;
ancestor = ancestor.parentElement;
}
return undefined;
}
function createVisualElement() {
return new HTMLVisualElement({
props: {},
presenceContext: null,
visualState: {
latestValues: {},
renderState: {
transform: {},
transformOrigin: {},
style: {},
vars: {},
},
},
}, { allowProjection: true });
}
function readNodeOptions(element, transition) {
const layoutAttr = element.getAttribute("data-layout");
const layoutId = element.getAttribute("data-layout-id") ?? undefined;
return {
layoutId,
layout: layoutAttr !== null ? true : undefined,
animationType: (!layoutAttr || layoutAttr === "true"
? "both"
: layoutAttr),
transition,
};
}
function prepareNode(element, transition) {
let node = layoutNodes.get(element);
if (!node) {
let visualElement = visualElementStore.get(element);
if (!visualElement)
visualElement = createVisualElement();
/**
* A first-time element may carry a projection transform in its
* inline style (e.g. it was cloned from an element mid-animation).
* That transform isn't tracked in latestValues so the engine can't
* reset it before measuring - clear it now so the first layout
* measurement isn't inflated.
*/
if (element.style.transform &&
!hasTransform(visualElement.latestValues)) {
element.style.transform = "";
}
node = new HTMLProjectionNode(visualElement.latestValues, getProjectionParent(element));
visualElement.projection = node;
node.setOptions({
...readNodeOptions(element, transition),
visualElement,
});
node.mount(element);
layoutNodes.set(element, node);
}
else {
node.setOptions(readNodeOptions(element, transition));
}
node.isPresent = true;
if (node.options.onExitComplete) {
node.setOptions({ onExitComplete: undefined });
}
return node;
}
function sortDocumentOrder(elements) {
return [...elements].sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
}
function dropNode(element, node) {
node.setOptions({ onExitComplete: undefined });
/**
* Stop any lingering animation so it can't leak into future updates.
* A follow node can share its currentAnimation with a surviving lead
* (via resumingFrom), in which case it isn't ours to stop.
*/
const stack = node.getStack();
if (!stack || node.isLead())
node.currentAnimation?.stop();
node.unmount();
layoutNodes.delete(element);
}
function flushPendingBuilders() {
const builders = pendingBuilders;
pendingBuilders = undefined;
flushPendingFrame();
/**
* Discover and mount every node across all builders before snapshotting
* any of them. Mounting during an active update flags isLayoutDirty,
* which would make that node's own willUpdate skip its snapshot.
* Document order guarantees ancestors mount before descendants, even
* when they're discovered by different builders.
*/
const targets = new Map();
for (const builder of builders) {
for (const element of builder.collectTargets()) {
const owners = targets.get(element);
owners ? owners.push(builder) : targets.set(element, [builder]);
}
}
const union = new Map();
for (const element of sortDocumentOrder(targets.keys())) {
const owners = targets.get(element);
const node = prepareNode(element, owners[owners.length - 1].transitionFor(element));
for (const owner of owners)
owner.adopt(element, node);
union.set(element, node);
}
union.forEach((node) => {
node.isLayoutDirty = false;
node.willUpdate();
});
const updatePromises = [];
for (const builder of builders) {
const result = builder.runUpdate();
if (result)
updatePromises.push(result);
}
const commit = () => {
/**
* Process all additions before any removals so that, even across
* builders, a removed member knows whether a replacement with the
* same layoutId was added in this commit.
*/
const newMemberIds = new Set();
for (const builder of builders) {
builder.reconcileAdditions(newMemberIds);
}
for (const builder of builders) {
builder.reconcileRemovals(newMemberIds);
}
let root;
union.forEach((node) => (root || (root = node.root)));
for (const builder of builders)
root || (root = builder.getRoot());
root?.didUpdate();
/**
* The root flushes the update on a microtask, synchronously
* processing the frame that creates the layout animations. Collect
* them in a later microtask step of the same pass.
*/
microtask.render(() => {
for (const builder of builders)
builder.finalize();
});
};
updatePromises.length ? Promise.all(updatePromises).then(commit) : commit();
}
class LayoutAnimationBuilder {
constructor(scope, updateDom, defaultOptions) {
this.scope = scope;
this.updateDom = updateDom;
this.defaultOptions = defaultOptions;
this.sharedTransitions = new Map();
this.notifyReady = () => { };
this.rejectReady = () => { };
this.tracked = new Map();
this.restorePoints = new Map();
this.readyPromise = new Promise((resolve, reject) => {
this.notifyReady = resolve;
this.rejectReady = reject;
});
if (!pendingBuilders) {
pendingBuilders = [];
queueMicrotask(flushPendingBuilders);
}
pendingBuilders.push(this);
}
shared(id, transition) {
this.sharedTransitions.set(id, transition);
return this;
}
then(resolve, reject) {
return this.readyPromise.then(resolve, reject);
}
transitionFor(element) {
const layoutId = element.getAttribute("data-layout-id");
return ((layoutId && this.sharedTransitions.get(layoutId)) ||
this.defaultOptions);
}
adopt(element, node) {
this.tracked.set(element, node);
this.restorePoints.set(element, {
parent: element.parentElement,
next: element.nextSibling,
});
}
collectTargets() {
return collectLayoutElements(this.scope);
}
runUpdate() {
try {
const result = this.updateDom();
if (result && typeof result.then === "function") {
return result.then(undefined, (error) => {
this.updateError = error;
});
}
}
catch (error) {
this.updateError = error;
}
return undefined;
}
reconcileAdditions(newMemberIds) {
for (const element of collectLayoutElements(this.scope)) {
if (this.tracked.has(element))
continue;
const node = prepareNode(element, this.transitionFor(element));
this.adopt(element, node);
node.options.layoutId && newMemberIds.add(node.options.layoutId);
}
}
reconcileRemovals(newMemberIds) {
this.tracked.forEach((node, element) => {
if (element.isConnected)
return;
const restore = this.restorePoints.get(element);
this.restorePoints.delete(element);
const { layoutId } = node.options;
const stack = node.getStack();
const hasSurvivor = stack &&
stack.members.some((member) => member !== node &&
member.instance
?.isConnected);
/**
* A removed lead with a surviving stack member - and no
* replacement member added this commit - runs an exit
* crossfade: restore the element to its old position in the
* DOM, relegate it and let the survivor take over. It's
* removed again once the animation completes.
*/
if (layoutId &&
node.isLead() &&
hasSurvivor &&
!newMemberIds.has(layoutId)) {
if (restore && restore.parent.isConnected) {
restore.parent.insertBefore(element, restore.next && restore.next.parentNode === restore.parent
? restore.next
: null);
node.isPresent = false;
node.setOptions({
onExitComplete: () => {
element.remove();
dropNode(element, node);
},
});
if (node.relegate())
return;
element.remove();
}
}
dropNode(element, node);
this.tracked.delete(element);
});
}
getRoot() {
let root;
this.tracked.forEach((node) => (root || (root = node.root)));
return root;
}
finalize() {
if (this.updateError) {
this.rejectReady(this.updateError);
return;
}
const animations = new Set();
this.tracked.forEach((node) => {
if (node.instance && node.currentAnimation) {
animations.add(node.currentAnimation);
}
});
this.notifyReady(new GroupAnimation([...animations]));
}
}
function parseAnimateLayoutArgs(scopeOrUpdateDom, updateDomOrOptions, options) {
if (typeof scopeOrUpdateDom === "function") {
return {
scope: document,
updateDom: scopeOrUpdateDom,
defaultOptions: updateDomOrOptions,
};
}
const scope = scopeOrUpdateDom instanceof Document
? scopeOrUpdateDom
: resolveElements(scopeOrUpdateDom)[0] ?? document;
return {
scope,
updateDom: updateDomOrOptions,
defaultOptions: options,
};
}
export { LayoutAnimationBuilder, parseAnimateLayoutArgs };
//# sourceMappingURL=LayoutAnimationBuilder.mjs.map