motion-dom
Version:
2,059 lines • 94.3 kB
JavaScript
import { SubscriptionManager, clamp } from 'motion-utils';
import { animateSingleValue } from '../../animation/animate/single-value.mjs';
import { spring } from '../../animation/generators/spring.mjs';
import { calcGeneratorDuration } from '../../animation/generators/utils/calc-duration.mjs';
import { notifyLayoutAnimationStart } from '../../animation/utils/notify-inspector.mjs';
import { getOptimisedAppearId } from '../../animation/optimized-appear/get-appear-id.mjs';
import { getValueTransition } from '../../animation/utils/get-value-transition.mjs';
import { microtask } from '../../frameloop/microtask.mjs';
import { time } from '../../frameloop/sync-time.mjs';
import { scaleCorrectors } from '../styles/scale-correction.mjs';
import { statsBuffer } from '../../stats/buffer.mjs';
import { delay } from '../../utils/delay.mjs';
import { isRefObject } from '../../utils/is-ref-object.mjs';
import { isSVGElement } from '../../utils/is-svg-element.mjs';
import { isSVGSVGElement } from '../../utils/is-svg-svg-element.mjs';
import { mixNumber } from '../../utils/mix/number.mjs';
import { motionValue } from '../../value/index.mjs';
import { resolveMotionValue } from '../../value/utils/resolve-motion-value.mjs';
import { mixValues } from '../animation/mix-values.mjs';
import { pointToProgressVelocity, resolveAutoMass } from '../animation/layout-velocity.mjs';
import { transformGroups } from '../../animation/waapi/transforms/groups.mjs';
import { copyBoxInto, copyAxisDeltaInto, copyAxisInto } from '../geometry/copy.mjs';
import { translateAxis, transformBox, applyBoxDelta, applyTreeDeltas } from '../geometry/delta-apply.mjs';
import { calcLength, calcRelativeBox, calcRelativePosition, calcBoxDelta, isNear } from '../geometry/delta-calc.mjs';
import { removeBoxTransforms } from '../geometry/delta-remove.mjs';
import { roundBox } from '../geometry/round.mjs';
import { createBox, createDelta } from '../geometry/models.mjs';
import { boxEqualsRounded, isDeltaZero, axisDeltaEquals, boxEquals, aspectRatio } from '../geometry/utils.mjs';
import { NodeStack } from '../shared/stack.mjs';
import { defaultScaleCorrectors } from '../styles/default-scale-correctors.mjs';
import { buildProjectionTransform } from '../styles/transform.mjs';
import { eachAxis } from '../utils/each-axis.mjs';
import { FlatTree } from '../utils/flat-tree.mjs';
import { hasTransform, hasScale, has2DTranslate, hasDistortingTransform, angleTransforms } from '../utils/has-transform.mjs';
import { globalProjectionState } from './state.mjs';
import { frame, cancelFrame, frameSteps } from '../../frameloop/frame.mjs';
import { frameData } from '../../frameloop/frame-data.mjs';
/**
* Transforms that distort an element's layout, so must be reset before measuring.
*/
const distortingTransforms = ["z", "skew", "skewZ"];
/**
* We use 1000 as the animation target as 0-1000 maps better to pixels than 0-1
* which has a noticeable difference in spring animations
*/
const animationTarget = 1000;
let id = 0;
function resetDistortingTransform(key, visualElement, values, sharedAnimationValues) {
const { latestValues } = visualElement;
// Record the distorting transform and then temporarily set it to 0
if (latestValues[key]) {
values[key] = latestValues[key];
visualElement.setStaticValue(key, 0);
if (sharedAnimationValues) {
sharedAnimationValues[key] = 0;
}
}
}
/**
* Move accelerated transforms back to the main thread. The WAAPI animation
* hid startUpdate()'s reset of rotations, skews and z, and moving off it
* writes the whole transform back, so reset them again before this node
* is measured.
*/
const demoteTransforms = (node) => node.instance &&
transformGroups.get(node.instance)?.demote() &&
node.resetSkewAndRotation();
function cancelTreeOptimisedTransformAnimations(projectionNode) {
projectionNode.hasCheckedOptimisedAppear = true;
if (projectionNode.root === projectionNode)
return;
const { visualElement } = projectionNode.options;
if (!visualElement)
return;
const appearId = getOptimisedAppearId(visualElement);
if (window.MotionHasOptimisedAnimation(appearId, "transform")) {
const { layout, layoutId } = projectionNode.options;
window.MotionCancelOptimisedAnimation(appearId, "transform", frame, !(layout || layoutId));
}
const { parent } = projectionNode;
if (parent && !parent.hasCheckedOptimisedAppear) {
cancelTreeOptimisedTransformAnimations(parent);
}
}
function getClosestProjectingNode(visualElement) {
if (!visualElement)
return undefined;
return visualElement.options.allowProjection !== false
? visualElement.projection
: getClosestProjectingNode(visualElement.parent);
}
function createProjectionNode({ attachResizeListener, defaultParent, measureScroll, checkIsScrollRoot, resetTransform, }) {
/**
* Correctors registered via addScaleCorrector before projection loaded
* (e.g. with an async LazyMotion bundle) take precedence over the defaults.
* Applied here rather than at module scope so bundlers can drop projection
* when it's unused.
*/
Object.assign(scaleCorrectors, {
...defaultScaleCorrectors,
...scaleCorrectors,
});
return class ProjectionNode {
/**
* Create the projection node of a `motion` component's VisualElement,
* from its props. This runs while the component renders, before its
* children. It's here rather than in the component so React
* bundles only include it with the features that supply a
* ProjectionNode. Vanilla bundles that use the projection node
* (drag(), animateLayout()) include it too, as a class static
* can't be tree-shaken.
*/
static create(visualElement, props, initialPromotionConfig) {
const { layoutId, layout, drag, dragConstraints, layoutScroll, layoutRoot, layoutAnchor, layoutCrossfade, } = props;
const projection = (visualElement.projection = new this(visualElement.latestValues, props["data-framer-portal-id"]
? undefined
: getClosestProjectingNode(visualElement.parent)));
projection.setOptions({
layoutId,
layout,
alwaysMeasureLayout: Boolean(drag) ||
(dragConstraints && isRefObject(dragConstraints)),
visualElement,
/**
* TODO: Update options in an effect. This could be tricky as it'll be too late
* to update by the time layout animations run.
* We also need to fix this safeToRemove by linking it up to the one returned by usePresence,
* ensuring it gets called if there's no potential layout animations.
*
*/
animationType: typeof layout === "string" ? layout : "both",
initialPromotionConfig,
crossfade: layoutCrossfade,
layoutScroll,
layoutRoot,
layoutAnchor,
});
/**
* This node will take over from its layoutId's current lead, so snapshot
* the lead now, while rendering. By the time the lead unmounts (or this
* node mounts) React may already have committed DOM changes that come
* before it in the tree, like a sibling changing height, and a snapshot
* taken then starts the animation from a position never seen on screen.
*
* If React discards this render (e.g. it suspends), nothing else
* completes the update, so check it after the next frame as an
* unmounting node does.
*/
const lead = projection.getStack()?.lead;
if (lead && !projection.root.isUpdateBlocked()) {
lead.willUpdate();
lead.scheduleCheckAfterUnmount();
}
}
constructor(latestValues = {}, parent = defaultParent?.()) {
/**
* A unique ID generated for every projection node.
*/
this.id = id++;
/**
* An id that represents a unique session instigated by startUpdate.
*/
this.animationId = 0;
this.animationCommitId = 0;
/**
* A Set containing all this component's children. This is used to iterate
* through the children.
*
* TODO: This could be faster to iterate as a flat array stored on the root node.
*/
this.children = new Set();
/**
* Options for the node. We use this to configure what kind of layout animations
* we should perform (if any).
*/
this.options = {};
/**
* We use this to detect when its safe to shut down part of a projection tree.
* We have to keep projecting children for scale correction and relative projection
* until all their parents stop performing layout animations.
*/
this.isTreeAnimating = false;
this.isAnimationBlocked = false;
/**
* Flag to true if we think this layout has been changed. We can't always know this,
* currently we set it to true every time a component renders, or if it has a layoutDependency
* if that has changed between renders. Additionally, components can be grouped by LayoutGroup
* and if one node is dirtied, they all are.
*/
this.isLayoutDirty = false;
/**
* Flag to true if we think the projection calculations for this node needs
* recalculating as a result of an updated transform or layout animation.
*/
this.isProjectionDirty = false;
/**
* Flag to true if the layout *or* transform has changed. This then gets propagated
* throughout the projection tree, forcing any element below to recalculate on the next frame.
*/
this.isSharedProjectionDirty = false;
/**
* Flag transform dirty. This gets propagated throughout the whole tree but is only
* respected by shared nodes.
*/
this.isTransformDirty = false;
/**
* Block layout updates for instant layout transitions throughout the tree.
*/
this.updateManuallyBlocked = false;
this.updateBlockedByResize = false;
/**
* Set to true between the start of the first `willUpdate` call and the end of the `didUpdate`
* call.
*/
this.isUpdating = false;
/**
* If this is an SVG element we currently disable projection transforms
*/
this.isSVG = false;
/**
* Flag to true (during promotion) if a node doing an instant layout transition needs to reset
* its projection styles.
*/
this.needsReset = false;
/**
* Flags whether this node should have its transform reset prior to measuring.
*/
this.shouldResetTransform = false;
/**
* Store whether this node has been checked for optimised appear animations. As
* effects fire bottom-up, and we want to look up the tree for appear animations,
* this makes sure we only check each path once, stopping at nodes that
* have already been checked.
*/
this.hasCheckedOptimisedAppear = false;
/**
* An object representing the calculated contextual/accumulated/tree scale.
* This will be used to scale calculated projection transforms, as these are
* calculated in screen-space but need to be scaled for elements to visually
* make it to their calculated destinations.
*
* TODO: Lazy-init
*/
this.treeScale = { x: 1, y: 1 };
/**
*
*/
this.eventHandlers = new Map();
this.hasTreeAnimated = false;
// Note: Currently only running on root node
this.updateScheduled = false;
this.scheduleUpdate = () => this.update();
this.projectionUpdateScheduled = false;
this.checkUpdateFailed = () => {
if (this.isUpdating) {
this.isUpdating = false;
this.clearAllSnapshots();
}
};
/**
* This is a multi-step process as shared nodes might be of different depths. Nodes
* are sorted by depth order, so we need to resolve the entire tree before moving to
* the next step.
*/
this.updateProjection = () => {
this.projectionUpdateScheduled = false;
this.nodes.forEach(propagateDirtyNodes);
this.nodes.forEach(resolveTargetDelta);
this.nodes.forEach(calcProjection);
this.nodes.forEach(cleanDirtyNodes);
/**
* Record this frame's debug counts, if recordStats() is running.
*/
statsBuffer.countProjection?.();
};
/**
* Frame calculations
*/
this.resolvedRelativeTargetAt = 0.0;
this.hasProjected = false;
this.isVisible = true;
this.animationProgress = 0;
/**
* Shared layout
*/
// TODO Only running on root node
this.sharedNodes = new Map();
this.latestValues = latestValues;
this.root = parent ? parent.root || parent : this;
this.path = parent ? [...parent.path, parent] : [];
this.parent = parent;
this.depth = parent ? parent.depth + 1 : 0;
for (let i = 0; i < this.path.length; i++) {
this.path[i].shouldResetTransform = true;
}
if (this.root === this)
this.nodes = new FlatTree();
}
addEventListener(name, handler) {
if (!this.eventHandlers.has(name)) {
this.eventHandlers.set(name, new SubscriptionManager());
}
return this.eventHandlers.get(name).add(handler);
}
notifyListeners(name, ...args) {
this.eventHandlers.get(name)?.notify(...args);
}
hasListeners(name) {
return this.eventHandlers.has(name);
}
/**
* Lifecycles
*/
mount(instance) {
if (this.instance)
return;
this.isSVG = isSVGElement(instance) && !isSVGSVGElement(instance);
this.instance = instance;
const { layoutId, layout, visualElement } = this.options;
if (visualElement && !visualElement.current) {
visualElement.mount(instance);
}
this.root.nodes.add(this);
this.parent?.children.add(this);
if (this.root.hasTreeAnimated && (layout || layoutId)) {
this.isLayoutDirty = true;
}
if (attachResizeListener) {
let cancelDelay;
let innerWidth = 0;
const resizeUnblockUpdate = () => (this.root.updateBlockedByResize = false);
// Set initial innerWidth in a frame.read callback to batch the read
frame.read(() => {
innerWidth = window.innerWidth;
});
attachResizeListener(instance, () => {
const newInnerWidth = window.innerWidth;
if (newInnerWidth === innerWidth)
return;
innerWidth = newInnerWidth;
this.root.updateBlockedByResize = true;
cancelDelay?.();
cancelDelay = delay(resizeUnblockUpdate, 250);
if (globalProjectionState.hasAnimatedSinceResize) {
globalProjectionState.hasAnimatedSinceResize = false;
this.nodes.forEach(finishAnimation);
}
});
}
if (layoutId) {
this.root.registerSharedNode(layoutId, this);
}
this.addLayoutListener();
}
/**
* Only register the layout animation handler if this node requires
* layout animation. This is called on mount, and again whenever options
* change, as `layout` or `layoutId` may be enabled after mount.
*/
addLayoutListener() {
const { layoutId, layout, visualElement, animate } = this.options;
if (!this.instance ||
this.hasLayoutListener ||
animate === false ||
!visualElement ||
!(layoutId || layout))
return;
this.hasLayoutListener = true;
this.addEventListener("didUpdate", ({ delta, hasLayoutChanged, hasRelativeLayoutChanged, layout: newLayout, }) => {
if (this.isAnimationBlocked) {
this.target = undefined;
this.relativeTarget = undefined;
return;
}
// TODO: Check here if an animation exists
const layoutTransition = this.options.transition ||
visualElement.getDefaultTransition() ||
defaultLayoutTransition;
const { onLayoutAnimationStart, onLayoutAnimationComplete, } = visualElement.getProps();
/**
* The target layout of the element might stay the same,
* but its position relative to its parent has changed.
*/
const hasTargetChanged = !this.targetLayout ||
!boxEqualsRounded(this.targetLayout, newLayout);
/*
* Note: Disabled to fix relative animations always triggering new
* layout animations. If this causes further issues, we can try
* a different approach to detecting relative target changes.
*/
// || hasRelativeLayoutChanged
/**
* If the layout hasn't seemed to have changed, it might be that the
* element is visually in the same place in the document but its position
* relative to its parent has indeed changed. So here we check for that.
*/
const hasOnlyRelativeTargetChanged = !hasLayoutChanged && hasRelativeLayoutChanged;
if (this.options.layoutRoot ||
this.resumeFrom ||
hasOnlyRelativeTargetChanged ||
(hasLayoutChanged &&
(hasTargetChanged || !this.currentAnimation))) {
if (this.resumeFrom) {
this.resumingFrom = this.resumeFrom;
this.resumingFrom.resumingFrom = undefined;
}
const animationOptions = {
...getValueTransition(layoutTransition, "layout"),
onPlay: onLayoutAnimationStart,
onComplete: onLayoutAnimationComplete,
};
if (visualElement.shouldReduceMotion ||
this.options.layoutRoot) {
animationOptions.delay = 0;
animationOptions.type = false;
}
this.startAnimation(animationOptions);
/**
* Set animation origin after starting animation to avoid layout jump
* caused by stopping previous layout animation
*/
this.setAnimationOrigin(delta, hasOnlyRelativeTargetChanged, animationOptions.path);
}
else {
/**
* If the layout hasn't changed and we have an animation that hasn't started yet,
* finish it immediately. Otherwise it will be animating from a location
* that was probably never committed to screen and look like a jumpy box.
*/
if (!hasLayoutChanged) {
finishAnimation(this);
}
this.isLead() && this.options.onExitComplete?.();
}
this.targetLayout = newLayout;
});
}
unmount() {
this.options.layoutId && this.willUpdate();
this.root.nodes.remove(this);
this.getStack()?.remove(this);
this.parent?.children.delete(this);
this.instance = undefined;
this.eventHandlers.clear();
this.hasLayoutListener = false;
cancelFrame(this.updateProjection);
}
// only on the root
blockUpdate() {
this.updateManuallyBlocked = true;
}
unblockUpdate() {
this.updateManuallyBlocked = false;
}
isUpdateBlocked() {
return this.updateManuallyBlocked || this.updateBlockedByResize;
}
// Note: currently only running on root node
startUpdate() {
if (this.isUpdateBlocked())
return;
this.isUpdating = true;
this.nodes?.forEach(resetSkewAndRotation);
this.animationId++;
}
getTransformTemplate() {
const { visualElement } = this.options;
return visualElement && visualElement.getProps().transformTemplate;
}
willUpdate(shouldNotifyListeners = true) {
this.root.hasTreeAnimated = true;
if (this.root.isUpdateBlocked()) {
this.options.onExitComplete?.();
return;
}
/**
* If we're running optimised appear animations then these must be
* cancelled before measuring the DOM. This is so we can measure
* the true layout of the element rather than the WAAPI animation
* which will be unaffected by the resetSkewAndRotate step.
*
* Note: This is a DOM write. Worst case scenario is this is sandwiched
* between other snapshot reads which will cause unnecessary style recalculations.
* This has to happen here though, as we don't yet know which nodes will need
* snapshots in startUpdate(), but we only want to cancel optimised animations
* if a layout animation measurement is actually going to be affected by them.
*/
if (window.MotionCancelOptimisedAnimation &&
!this.hasCheckedOptimisedAppear) {
cancelTreeOptimisedTransformAnimations(this);
}
!this.root.isUpdating && this.root.startUpdate();
if (this.isLayoutDirty)
return;
/**
* Accelerated transforms aren't reflected in latestValues or
* removed by resetTransform, so move them back to the main
* thread before this node, or its ancestors, are measured.
*/
demoteTransforms(this);
this.isLayoutDirty = true;
for (let i = 0; i < this.path.length; i++) {
const node = this.path[i];
node.shouldResetTransform = true;
demoteTransforms(node);
/**
* Percentage translates resolve against layoutBox dimensions,
* so ancestors with them must be re-measured after transform reset.
*/
if (typeof node.latestValues.x === "string" ||
typeof node.latestValues.y === "string") {
node.isLayoutDirty = true;
}
node.updateScroll("snapshot");
if (node.options.layoutRoot) {
node.willUpdate(false);
}
/**
* Measure the nearest dragged ancestor before the DOM
* changes, so this node can follow its layout change.
*/
if (node.isAnimationBlocked) {
node.dragSnapshot || (node.dragSnapshot = node.measure());
this.dragAncestor = node;
}
}
const { layoutId, layout } = this.options;
if (layoutId === undefined && !layout)
return;
const transformTemplate = this.getTransformTemplate();
this.prevTransformTemplateValue = transformTemplate
? transformTemplate(this.latestValues, "")
: undefined;
this.updateSnapshot();
shouldNotifyListeners && this.notifyListeners("willUpdate");
}
update() {
this.updateScheduled = false;
const updateWasBlocked = this.isUpdateBlocked();
// When doing an instant transition, we skip the layout update,
// but should still clean up the measurements so that the next
// snapshot could be taken correctly.
if (updateWasBlocked) {
const wasBlockedByResize = this.updateBlockedByResize;
this.unblockUpdate();
this.updateBlockedByResize = false;
this.clearAllSnapshots();
/**
* When blocked by resize, still measure layouts so
* callbacks like onLayoutMeasure fire (e.g. Reorder).
* Skip notifyLayoutUpdate to prevent animations.
*/
if (wasBlockedByResize) {
this.nodes.forEach(forceLayoutMeasure);
}
this.nodes.forEach(clearMeasurements);
return;
}
/**
* If this is a repeat of didUpdate then ignore the animation.
*/
if (this.animationId <= this.animationCommitId) {
this.nodes.forEach(clearIsLayoutDirty);
return;
}
this.animationCommitId = this.animationId;
if (!this.isUpdating) {
this.nodes.forEach(clearIsLayoutDirty);
}
else {
this.isUpdating = false;
/**
* Ensure animation-blocked nodes (e.g. during drag)
* get measured even when memoized (willUpdate skipped).
*/
this.nodes.forEach(ensureDraggedNodesSnapshotted);
this.nodes.forEach(syncRelativeLayout);
/**
* Write
*/
this.nodes.forEach(resetTransformStyle);
/**
* Read ==================
*/
// Update layout measurements of updated children
this.nodes.forEach(updateLayout);
/**
* Write
*/
// Notify listeners that the layout is updated
this.nodes.forEach(notifyLayoutUpdate);
}
this.clearAllSnapshots();
/**
* Manually flush any pending updates. Ideally
* we could leave this to the following requestAnimationFrame but this seems
* to leave a flash of incorrectly styled content.
*/
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;
}
didUpdate() {
if (!this.updateScheduled) {
this.updateScheduled = true;
microtask.read(this.scheduleUpdate);
}
}
clearAllSnapshots() {
this.nodes.forEach(clearSnapshot);
/**
* A stack's last member can unmount while a replacement with the
* same layoutId mounts in the same commit, so the stack (and its
* lead) must outlive the unmount for the replacement to resume
* from it. By now that handoff has happened, so stacks that are
* still empty only retain unmounted nodes and can be released.
*/
this.sharedNodes.forEach((stack, layoutId) => stack.members.length
? stack.removeLeadSnapshot()
: this.sharedNodes.delete(layoutId));
}
scheduleUpdateProjection() {
if (!this.projectionUpdateScheduled) {
this.projectionUpdateScheduled = true;
frame.preRender(this.updateProjection, false, true);
}
}
scheduleCheckAfterUnmount() {
/**
* If the unmounting node is in a layoutGroup and did trigger a willUpdate,
* we manually call didUpdate to give a chance to the siblings to animate.
* Otherwise, cleanup all snapshots to prevents future nodes from reusing them.
*/
frame.postRender(() => {
if (this.isLayoutDirty) {
this.root.didUpdate();
}
else {
this.root.checkUpdateFailed();
}
});
}
/**
* Update measurements
*/
updateSnapshot() {
if (this.snapshot || !this.instance)
return;
this.snapshot = this.measure();
if (this.snapshot &&
!calcLength(this.snapshot.measuredBox.x) &&
!calcLength(this.snapshot.measuredBox.y)) {
this.snapshot = undefined;
}
/**
* Record how fast the element is moving when it's measured, so
* the next layout animation can start at the same speed.
*/
if (this.snapshot) {
const { visualElement } = this.options;
/**
* A paused or stopped animation hands on no velocity.
*/
const isRunning = this.currentAnimation?.state === "running";
const edges = isRunning && this.animationEdges;
/**
* A child moving inside its parent hands on only its
* velocity relative to the parent.
*/
if (isRunning && this.relativeTarget) {
this.snapshot.relativeVelocity =
this.relativeVelocity?.slice() || [];
}
const progress = (edges && this.motionValue?.getVelocity()) || 0;
const velocity = [0, 1, 2, 3, 4, 5].map((i) => (i < 4
? edges &&
(this.edgeValues
? this.edgeVelocity(i)
: (-edges[i] * progress) / 1000)
: visualElement
?.getValue(i < 5 ? "x" : "y")
?.getVelocity()) || 0);
if (velocity.some(Boolean))
this.snapshot.velocity = velocity;
}
}
updateLayout() {
if (!this.instance)
return;
this.updateScroll();
if (!(this.options.alwaysMeasureLayout && this.isLead()) &&
!this.isLayoutDirty) {
return;
}
/**
* When a node is mounted, it simply resumes from the prevLead's
* snapshot instead of taking a new one, but the ancestors scroll
* might have updated while the prevLead is unmounted. We need to
* update the scroll again to make sure the layout we measure is
* up to date.
*/
if (this.resumeFrom && !this.resumeFrom.instance) {
for (let i = 0; i < this.path.length; i++) {
const node = this.path[i];
node.updateScroll();
}
}
const prevLayout = this.layout;
this.layout = this.measure(false);
if (!this.layoutCorrected)
this.layoutCorrected = createBox();
this.isLayoutDirty = false;
this.projectionDelta = undefined;
this.notifyListeners("measure", this.layout.layoutBox);
const { visualElement } = this.options;
visualElement?.notify("LayoutMeasure", this.layout.layoutBox, prevLayout?.layoutBox);
}
updateScroll(phase = "measure") {
const { scroll, instance } = this;
const { animationId } = this.root;
if (this.options.layoutScroll &&
instance &&
!(scroll &&
scroll.animationId === animationId &&
scroll.phase === phase)) {
const isRoot = checkIsScrollRoot(instance);
this.scroll = {
animationId,
phase,
isRoot,
offset: measureScroll(instance),
wasRoot: scroll ? scroll.isRoot : isRoot,
};
}
}
resetTransform() {
if (!resetTransform)
return;
const isResetRequested = this.isLayoutDirty ||
this.shouldResetTransform ||
this.options.alwaysMeasureLayout;
isResetRequested && demoteTransforms(this);
const hasProjection = this.projectionDelta && !isDeltaZero(this.projectionDelta);
const transformTemplate = this.getTransformTemplate();
const transformTemplateValue = transformTemplate
? transformTemplate(this.latestValues, "")
: undefined;
const transformTemplateHasChanged = transformTemplateValue !== this.prevTransformTemplateValue;
if (isResetRequested &&
this.instance &&
(hasProjection ||
hasTransform(this.latestValues) ||
transformTemplateHasChanged)) {
resetTransform(this.instance, transformTemplateValue);
this.shouldResetTransform = false;
this.scheduleRender();
}
}
measure(removeTransform = true) {
const pageBox = this.measurePageBox();
let layoutBox = this.removeElementScroll(pageBox);
/**
* Measurements taken during the pre-render stage
* still have transforms applied so we remove them
* via calculation.
*/
if (removeTransform) {
layoutBox = this.removeTransform(layoutBox);
}
roundBox(layoutBox);
return {
animationId: this.root.animationId,
measuredBox: pageBox,
layoutBox,
latestValues: {},
source: this.id,
};
}
measurePageBox() {
const { visualElement } = this.options;
if (!visualElement)
return createBox();
const box = visualElement.measureViewportBox();
const wasInScrollRoot = this.scroll?.wasRoot || this.path.some(checkNodeWasScrollRoot);
if (!wasInScrollRoot) {
// Remove viewport scroll to give page-relative coordinates
const { scroll } = this.root;
if (scroll) {
translateAxis(box.x, scroll.offset.x);
translateAxis(box.y, scroll.offset.y);
}
}
return box;
}
removeElementScroll(box) {
const boxWithoutScroll = createBox();
copyBoxInto(boxWithoutScroll, box);
if (this.scroll?.wasRoot) {
return boxWithoutScroll;
}
/**
* Performance TODO: Keep a cumulative scroll offset down the tree
* rather than loop back up the path.
*/
for (let i = 0; i < this.path.length; i++) {
const node = this.path[i];
const { scroll, options } = node;
if (node !== this.root && scroll && options.layoutScroll) {
/**
* If this is a new scroll root, we want to remove all previous scrolls
* from the viewport box.
*/
if (scroll.wasRoot) {
copyBoxInto(boxWithoutScroll, box);
}
translateAxis(boxWithoutScroll.x, scroll.offset.x);
translateAxis(boxWithoutScroll.y, scroll.offset.y);
}
}
return boxWithoutScroll;
}
applyTransform(box, transformOnly = false, output, skipOwnTransform, start = 0) {
const withTransforms = output || createBox();
copyBoxInto(withTransforms, box);
for (let i = start; i < this.path.length; i++) {
const node = this.path[i];
if (!transformOnly &&
node.options.layoutScroll &&
node.scroll &&
node !== node.root) {
translateAxis(withTransforms.x, -node.scroll.offset.x);
translateAxis(withTransforms.y, -node.scroll.offset.y);
}
if (!hasTransform(node.latestValues))
continue;
transformBox(withTransforms, node.latestValues, node.layout?.layoutBox);
}
if (!skipOwnTransform && hasTransform(this.latestValues)) {
transformBox(withTransforms, this.latestValues, this.layout?.layoutBox);
}
return withTransforms;
}
removeTransform(box) {
const boxWithoutTransform = createBox();
copyBoxInto(boxWithoutTransform, box);
for (let i = 0; i < this.path.length; i++) {
const node = this.path[i];
if (!hasTransform(node.latestValues))
continue;
let sourceBox;
if (node.instance) {
hasScale(node.latestValues) && node.updateSnapshot();
sourceBox = createBox();
copyBoxInto(sourceBox, node.measurePageBox());
}
removeBoxTransforms(boxWithoutTransform, node.latestValues, node.snapshot?.layoutBox, sourceBox);
}
if (hasTransform(this.latestValues)) {
removeBoxTransforms(boxWithoutTransform, this.latestValues);
}
return boxWithoutTransform;
}
setTargetDelta(delta) {
this.targetDelta = delta;
this.root.scheduleUpdateProjection();
this.isProjectionDirty = true;
}
setOptions(options) {
this.options = {
...this.options,
...options,
crossfade: options.crossfade !== undefined ? options.crossfade : true,
};
this.addLayoutListener();
}
clearMeasurements() {
this.scroll = undefined;
this.layout = undefined;
this.snapshot = undefined;
this.prevTransformTemplateValue = undefined;
this.targetDelta = undefined;
this.target = undefined;
this.isLayoutDirty = false;
}
forceRelativeParentToResolveTarget() {
if (!this.relativeParent)
return;
/**
* If the parent target isn't up-to-date, force it to update.
* This is an unfortunate de-optimisation as it means any updating relative
* projection will cause all the relative parents to recalculate back
* up the tree.
*/
if (this.relativeParent.resolvedRelativeTargetAt !==
frameData.timestamp) {
this.relativeParent.resolveTargetDelta(true);
}
}
resolveTargetDelta(forceRecalculation = false) {
/**
* Once the dirty status of nodes has been spread through the tree, we also
* need to check if we have a shared node of a different depth that has itself
* been dirtied.
*/
const lead = this.getLead();
this.isProjectionDirty || (this.isProjectionDirty = lead.isProjectionDirty);
this.isTransformDirty || (this.isTransformDirty = lead.isTransformDirty);
this.isSharedProjectionDirty || (this.isSharedProjectionDirty = lead.isSharedProjectionDirty);
const isShared = Boolean(this.resumingFrom) || this !== lead;
/**
* We don't use transform for this step of processing so we don't
* need to check whether any nodes have changed transform.
*/
const canSkip = !(forceRecalculation ||
(isShared && this.isSharedProjectionDirty) ||
this.isProjectionDirty ||
this.parent?.isProjectionDirty ||
this.attemptToResolveRelativeTarget ||
this.root.updateBlockedByResize);
if (canSkip)
return;
const { layout, layoutId } = this.options;
/**
* If we have no layout, we can't perform projection, so early return
*/
if (!this.layout || !(layout || layoutId))
return;
this.resolvedRelativeTargetAt = frameData.timestamp;
const relativeParent = this.getClosestProjectingParent();
/**
* If we don't have a targetDelta but do have a layout, we can attempt to resolve
* a relativeParent. This will allow a component to perform scale correction
* even if no animation has started.
*/
if (!this.targetDelta && !this.relativeTarget) {
if (this.options.layoutAnchor !== false &&
relativeParent &&
relativeParent.layout) {
this.createRelativeTarget(relativeParent, this.layout.layoutBox, relativeParent.layout.layoutBox);
}
else {
this.removeRelativeTarget();
}
}
/**
* If we have no relative target or no target delta our target isn't valid
* for this frame.
*/
if (!this.relativeTarget && !this.targetDelta)
return;
/**
* Lazy-init target data structure
*/
if (!this.target) {
this.target = createBox();
this.targetWithTransforms = createBox();
}
/**
* If we've got a relative box for this component, resolve it into a target relative to the parent.
*/
if (this.relativeTarget &&
this.relativeTargetOrigin &&
this.relativeParent &&
this.relativeParent.target) {
this.forceRelativeParentToResolveTarget();
calcRelativeBox(this.target, this.relativeTarget, this.relativeParent.target, this.options.layoutAnchor || undefined);
/**
* If we've only got a targetDelta, resolve it into a target
*/
}
else if (this.targetDelta) {
if (Boolean(this.resumingFrom)) {
/**
* Own transforms are applied later, in applyTransformsToTarget,
* so only apply ancestor transforms here or they'd be doubled.
*/
this.applyTransform(this.layout.layoutBox, false, this.target, true);
}
else {
copyBoxInto(this.target, this.layout.layoutBox);
}
applyBoxDelta(this.target, this.targetDelta);
}
else {
/**
* If no target, use own layout as target
*/
copyBoxInto(this.target, this.layout.layoutBox);
}
/**
* If we've been told to attempt to resolve a relative target, do so.
*/
if (this.attemptToResolveRelativeTarget) {
this.attemptToResolveRelativeTarget = false;
if (this.options.layoutAnchor !== false &&
relativeParent &&
(!relativeParent.resumingFrom === !this.resumingFrom ||
/**
* Shared layout animations within a layoutRoot
* animate relative to it.
*/
(relativeParent.options.layoutRoot &&
!relativeParent.resumingFrom)) &&
!relativeParent.options.layoutScroll &&
relativeParent.target &&
this.animationProgress !== 1) {
this.createRelativeTarget(relativeParent, this.target, relativeParent.target);
}
else {
this.relativeParent = this.relativeTarget = undefined;
}
}
/**
* Increase debug counter for resolved target deltas
*/
statsBuffer.countProjection?.(1); // calculatedTargetDeltas
}
getClosestProjectingParent() {
if (!this.parent ||
hasScale(this.parent.latestValues) ||
has2DTranslate(this.parent.latestValues)) {
return undefined;
}
if (this.parent.isProjecting()) {
return this.parent;
}
else {
return this.parent.getClosestProjectingParent();
}
}
isProjecting() {
return Boolean((this.relativeTarget ||
this.targetDelta ||
this.options.layoutRoot) &&
this.layout);
}
createRelativeTarget(relativeParent, layout, parentLayout) {
this.relativeParent = relativeParent;
this.forceRelativeParentToResolveTarget();
this.relativeTarget = createBox();
this.relativeTargetOrigin = createBox();
calcRelativePosition(this.relativeTargetOrigin, layout, parentLayout, this.options.layoutAnchor || undefined);
copyBoxInto(this.relativeTarget, this.relativeTargetOrigin);
}
removeRelativeTarget() {
this.relativeParent = this.relativeTarget = undefined;
}
calcProjection() {
const lead = this.getLead();
const isShared = Boolean(this.resumingFrom) || this !== lead;
let canSkip = true;
/**
* If this is a normal layout animation and neither this node nor its nearest projecting
* is dirty then we can't skip.
*/
if (this.isProjectionDirty || this.parent?.isProjectionDirty) {
canSkip = false;
}
/**
* If this is a shared layout animation and this node's shared projection is dirty then
* we can't skip.
*/
if (isShared &&
(this.isSharedProjectionDirty || this.isTransformDirty)) {
canSkip = false;
}
/**
* If we have resolved the target this frame we must recalculate the
* projection to ensure it visually represents the internal calculations.
*/
if (this.resolvedRelativeTargetAt === frameData.timestamp) {
canSkip = false;
}
if (canSkip)
return;
const { layout, layoutId } = this.options;
/**
* If this section of the tree isn't animating we can
* delete our target sources for the following frame.
*/
this.isTreeAnimating = Boolean(this.parent?.isTreeAnimating ||
this.currentAnimation ||
this.pendingAnimation);
if (!this.isTreeAnimating) {
this.targetDelta = this.relativeTarget = undefined;
}
if (!this.layout || !(layout || layoutId))
return;
/**
* Reset the corrected box with the latest values from box, as we're then going
* to perform mutative operations on it.
*/
copyBoxInto(this.layoutCorrected, this.layout.layoutBox);
/**
* Record previous tree scales before updating.
*/
const prevTreeScaleX = this.treeScale.x;
const prevTreeScaleY = this.treeScale.y;
/**
* Apply all the parent deltas to this box to produce the corrected box. This
* is the layout box, as it will appear on screen as a result of the transforms of its parents.
*
* A shared target is usually in page space, so ancestor transforms are applied
* too. But a target relative to a parent that isn't itself shared (like a
* layoutRoot) is in that parent's layout space. So the transforms of the
* parent and its ancestors, which move both elements alike, are skipped,
* but those of other ancestors (between it and either element) apply.
*/
applyTreeDeltas(this.layoutCorrected, this.treeScale, this.path, isShared &&
(lead.relativeTarget && !lead.relativeParent.resumingFrom
? lead.relativeParent
: true));
/**
* If this layer needs to perform scale correction but doesn't have a target,
* use the layout as the target.
*/
if (lead.layout &&
!lead.target &&
(this.treeScale.x !== 1 || this.treeScale.y !== 1)) {
lead.target = lead.layout.layoutBox;
lead.targetWithTransforms = createBox();
}
const { target } = lead;
if (!target) {
/**
* If we don't have a target to project into, but we were previously
* projecting, we want to remove the stored transform and schedule
* a render to ensure the elements reflect the removed transform.
*/
if (this.prevProjectionDelta) {
this.createProjectionDeltas();
this.scheduleRender();
}
return;
}
if (!this.projectionDelta || !this.prevProjectionDelta) {
this.createProjectionDeltas();
}
else {
copyAxisDeltaInto(this.prevProjectionDelta.x, this.projectionDelta.x);
copyAxisDeltaInto(this.prevProjectionDelta.y, this.projectionDelta.y);
}
/**
* Update the delta between the corrected box and the target box before user-set transforms were applied.
* This will allow us to calculate the corrected borderRadius and boxShadow to compensate
* for our layout reprojection, but still allow them to be scaled correctly by the user.
* It might be that to simplify this we may want to accept that user-set scale is also corrected
* and we wouldn't have to keep and calc both deltas, OR we could support a user setting
* to allow people to choose whether these styles are corrected based on just the
* layout reprojection or the final bounding box.
*/
calcBoxDelta(this.projectionDelta, this.layoutCorrected, target, this.latestValues);
if (this.treeScale.x !== prevTreeScaleX ||
this.treeScale.y !== prevTreeScaleY ||
!axisDeltaEquals(this.projectionDelta.x, this.prevProjectionDelta.x) ||
!axisDeltaEquals(this.projectionDelta.y, this.prevProjectionDelta.y)) {
this.hasProjected = true;
this.scheduleRender();
this.notifyListeners("projectionUpdate", target);
}
/**
* Increase debug counter for recalculated projections
*/
statsBuffer.countProjection?.(2); // calculatedProjections
}
hide() {
this.isVisible = false;
// TODO: Schedule render
}
show() {
this.isVisible = true;
// TODO: Schedule render
}
scheduleRender(notifyAll = true) {
this.options.visualElement?.scheduleRender();
if (notifyAll) {
this.getStack()?.scheduleRender();
}
if (this.resumingFrom && !this.resumingFrom.instance) {
this.resumingFrom = undefined;
}
}
createProjectionDeltas() {
this.prevProjectionDelta = createDelta();
this.projectionDelta = createDelta();
this.projectionDeltaWithTransform = createDelta();
}
setAnimationOrigin(delta, hasOnlyRelativeTargetChanged = false, pathFn) {
const snapshot = this.snapshot;
const snapshotLatestValues = snapshot ? snapshot.latestValues : {};
const mixedValues = { ...this.latestValues };
const targetDelta = createDelta();
if (!this.relativeParent ||
!this.relativeParent.options.layoutRoot) {
this.relativeTarget = this.relativeTargetOrigin = undefined;
}
this.attemptToResolveRelativeTarget = !hasOnlyRelativeTargetChanged;
const relativeLayout = createBox();
const isSharedLayoutAnimation = snapshot?.source !== this.layout?.source;
const stack = this.getStack();
const isOnlyMember = !stack || stack.members.length <= 1;
const shouldCrossfadeOpacity = Boolean(isSharedLayoutAnimation &&
!isOnlyMember &&
this.options.crossfade === true &&
!this.path.some(hasOpacityCrossfade));
this.animationProgress = 0;
let prevRelativeTarget;
// The path decides whether the layout shift is worth curving
// (distance floor) and resolves the interpolator from the delta.
const interpolate = pathFn?.interpolateProjection(delta);
const box = this.layout?.layoutBox;
let edges = box &&
[box.x.min, box.x.max, box.y.min, box.y.max].map((edge, i) => {
const { translate, scale, originPoint } = delta[i < 2 ? "x" : "y"];
return (edge - originPoint) * (scale - 1) + translate;
});
/**
* A zero-length layout axis has no finite edge offsets.
*/
if (!edges?.every(isFinite))
edges = undefined;
this.animationEdges = edges;
this.edgeValues =
this.startVelocity =
this.impulse =
this.relativeVelocity =
this.relativeStart =
undefined;
/**
* A shared snapshot is measured with the old element's own
* transforms (like x/y from drag), so it hands their velocity on
* too. Otherwise those values keep animating themselves.
*/
const velocity = snapshot?.velocity;
/**
* A child that wasn't moving inside an animating parent before
* moved in page space, so that is its relative velocity.
*/
const relative = snapshot?.relativeVelocity || velocity;
if (relative?.slice(0, 4).some(Boolean) &&
this.getClosestProjectingParent()) {
this.relativeStart = relative;
}
if (velocity && edges) {
const v = velocity
.slice(0, 4)
.map((edge, i) => edge +
(isSharedLayoutAnimation
? velocity[4 + (i >> 1)]
: 0));
if (v.some(Boolean)) {
this.startVelocity = interpolate
? pointToProgressVelocity(delta.x.translate, delta.y.translate, { x: (v[0] + v[1]) / 2, y: (v[2] + v[3]) / 2 })
: v;
}
}
this.mixTargetDelta = (latest) => {
const progress = latest / 1000;
const point = interpolate?.(progress);
const values = this.edgeValues;
for (let i = 0; i < 2; i++) {
const axis = i ? "y" : "x";
values
? edgesToDelta(targetDelta[axis], delta[axis], this.layout.layoutBox[axis], values[i * 2], values[i * 2 + 1])
: mixAxisDeltaLinear(targetDelta[axis], delta[axis], progress);
}
if (point) {
targetDelta.x.translate = point.x;
targetDelta.y.translate = point.y;
}
this.setTargetDelta(targetDelta);
if (this.relativeTarget &&
this.relativeTargetOrigin &&
this.layout &&
this.relativeParent &&
this.relativeParent.layout) {
calcRelativePosition(relativeLayout, this.layout.layoutBox, this.relativeParent.layout.layoutBox, this.options.layoutAnchor || undefined);
const from = this.relativeTargetOrigin;
const to = relativeLayout;
/**
* A child mixes its place in its parent by progress,
* so a nested tree moves as one.
*/
mixBox(this.relativeTarget, from, to, progress);
/**
* Springs are linear, so the child's own velocity adds
* the impulse response on top of the mix.
*/
/**
* Only springs take velocity, so a tween skips this.
*/
if (this.isSpringLayout) {
const box = this.relativeTarget;
const impulse = this.impulse || 0;
const start = this.relativeStart;
const now = time.now();
const dt = (now - this.relativeTime) / 1000;
this.relativeTime = now;
const speeds = (this.relativeVelocity || (this.relativeVelocity = [0, 0, 0, 0]));
for (let i = 0; i < 4; i++) {
const axis = i < 2 ? "x" : "y";
const edge = i & 1 ? "max" : "min";
if (start)
box[axis][edge] += start[i] * impulse;
if (prevRelativeTarget && dt > 0) {
speeds[i] =
(box[axis][edge] -
prevRelativeTarget[axis][edge]) /
dt;
}
}
}
/**
* If this is an unchanged relative target we can consider the
* projection not dirty.
*/
if (prevRelativeTarget &&
boxEquals(this.relativeTarget, prevRelativeTarget)) {
this.isProjectionDirty = false;
}
if (!prevRelativeTarget)
prevRelativeTarget = createBox();
copyBoxInto(prevRelativeTarget, this.relativeTarget);
}
if (isSharedLayoutAnimation) {
this.animationValues = mixedValues;
mixValues(mixedValues, snapshotLatestValues, this.latestValues, progress, shouldCrossfadeOpacity, isOnlyMember);
}
if (point?.rotate !== undefined) {
// Dedicated `pathRotation` channel, not `rotate`, so an
// animating `rotate` is composed with, never clobbered.
if (!this.animationValues)
this.animationValues = mixedValues;
this.animationValues.pathRotation = point.rotate;
}
this.root.scheduleUpdateProjection();
this.scheduleRender();
this.animationProgress = progress;
};
this.mixTargetDelta(this.options.layoutRoot ? 1000 : 0);
}
startAnimation(options) {
this.notifyListeners("animationStart");
const isSpring = (this.isSpringLayout = options.type === "spring");
this.currentAnimation?.stop();
this.resumingFrom?.currentAnimation?.stop();
if (this.resumingFrom)
this.resumingFrom.edgeValues = undefined;
if (this.pendingAnimation) {
cancelFrame(this.pendingAnimation);
this.pendingAnimation = undefined;
}
/**
* Start the animation in the next frame to have a frame with progress 0,
* where the target is the same as when the animation started, so we can
* calculate the relative positions correctly for instant transitions.
*/
this.pendingAnimation = frame.update(() => {
globalProjectionState.hasAnimatedSinceResize = true;
this.motionValue || (this.motionValue = motionValue(0));
this.motionValue.jump(0, false);
/**
* A duration spring ignores velocity, like in animate(), so
* it ends on time.
*/
const velocity = this.startVelocity;
this.startVelocity = undefined;
let transition = options;
let mainVelocity = 0;
if (isSpring) {
/**
* With mass: "auto", mass comes from the element's own
* size.
*/
if (options.mass === "auto") {
const box = this.layout?.layoutBox;
transition = resolveAutoMass(options, box &&
Math.sqrt(calcLength(box.x) * calcLength(box.y)));
}
/**
* Only springs take a velocity. A tween keeps one
* driver, so its controls (pause, time) reach it all.
*/
const relative = this.relativeStart;
if ((velocity || relative) && !options.repeat) {
if (typeof velocity === "number") {
mainVelocity = velocity;
}
else {
/**
* Each edge (x min, x max, y min, y max) gets its
* own spring in pixels, with its own start
* velocity. An edge that doesn't move keeps
* still, and a thrown element keeps its size.
* One generator steps them all with the main
* spring, so one animation drives them and its
* controls reach them all.
*/
const edges = velocity && this.animationEdges;
transition = {
...transition,
type: (o) => {
const springs = [
o.velocity,
...(velocity || []),
].map((v, i) => spring({
...o,
keyframes: i
? [edges[i - 1], 0]
: o.keyframes,
velocity: v,
}));
const count = springs.length;
/**
* The response to 1000 px/s, for a
* child's velocity inside its parent.
* Coarse rest thresholds, because a
* small offset of it is far below 1px.
*/
relative &&
springs.push(spring({
...o,
keyframes: [0, 0],
velocity: 1000,
restDelta: 0.5,
restSpeed: 5,
}));
let time = 0;
const values = [];
const generator = {
calculatedDuration: null,
next: (t) => {
time = t;
const state = springs[0].next(t);
let done = state.done;
for (let i = 1; i < springs.length; i++) {
const { value, done: rest } = springs[i].next(t);
if (i < count) {
values[i - 1] = value;
}
else {
this.impulse = value / 1000;
}
done = done && rest;
}
if (edges)
this.edgeValues = values;
state.done = done;
return state;
},
};
/**
* Measure the duration now, then go back
* to the start, so a delay shows the
* start and not the end.
*/
generator.calculatedDuration =
calcGeneratorDuration(generator);
generator.next(0);
this.edgeVelocity = (i) => springs[i + 1].velocity(time);
return generator;
},
};
}
}
}
const main = animateSingleValue(this.motionValue, [0, 1000], {
...transition,
velocity: mainVelocity,
isSync: true,
onUpdate: (latest) => {
this.mixTargetDelta(latest);
options.onUpdate && options.onUpdate(latest);
},
onComplete: () => {
options.onComplete && options.onComplete();
this.completeAnimation();
},
});
this.currentAnimation = main;
notifyLayoutAnimationStart(this.currentAnimation, this);
if (this.resumingFrom) {
this.resumingFrom.currentAnimation = this.currentAnimation;
}
this.pendingAnimation = undefined;
});
}
completeAnimation() {
if (this.resumingFrom) {
this.resumingFrom.currentAnimation = undefined;
this.resumingFrom.preserveOpacity = undefined;
}
const stack = this.getStack();
stack && stack.exitAnimationComplete();
this.resumingFrom =
this.currentAnimation =
this.animationValues =
this.edgeValues =
this.edgeVelocity =
this.impulse =
undefined;
this.notifyListeners("animationComplete");
}
finishAnimation() {
if (this.currentAnimation) {
this.edgeValues = this.impulse = undefined;
this.mixTargetDelta?.(animationTarget);
this.currentAnimation.stop();
}
this.completeAnimation();
}
applyTransformsToTarget() {
const lead = this.getLead();
const { targetWithTransforms, layout, latestValues } = lead;
let { target } = lead;
if (!targetWithTransforms || !target || !layout)
return;
/**
* If we're only animating position, and this element isn't the lead element,
* then instead of projecting into the lead box we instead want to calculate
* a new target that aligns the two boxes but maintains the layout shape.
*/
if (this !== lead &&
this.layout &&
layout &&
shouldAnimatePositionOnly(this.options.animationType, this.layout.layoutBox, layout.layoutBox)) {
const { layoutBox } = this.layout;
const leadTarget = target;
target = this.target || createBox();
alignAxis(target.x, leadTarget.x, layoutBox.x);
alignAxis(target.y, leadTarget.y, layoutBox.y);
}
copyBoxInto(targetWithTransforms, target);
/**
* Apply the latest user-set transforms to the targetBox to produce the targetBoxFinal.
* This is the final box that we will then project into by calculating a transform delta and
* applying it to the corrected box.
*/
transformBox(targetWithTransforms, latestValues);
/**
* Update the delta between the corrected box and the final target box, after
* user-set transforms are applied to it. This will be used by the renderer to
* create a transform style that will reproject the element from its layout layout
* into the desired bounding box.
*/
calcBoxDelta(this.projectionDeltaWithTransform, this.layoutCorrected, targetWithTransforms, latestValues);
}
registerSharedNode(layoutId, node) {
if (!this.sharedNodes.has(layoutId)) {
this.sharedNodes.set(layoutId, new NodeStack());
}
const stack = this.sharedNodes.get(layoutId);
stack.add(node);
const config = node.options.initialPromotionConfig;
node.promote({
transition: config?.transition,
preserveFollowOpacity: config?.shouldPreserveFollowOpacity
? config.shouldPreserveFollowOpacity(node)
: undefined,
});
}
isLead() {
const stack = this.getStack();
return stack ? stack.lead === this : true;
}
getLead() {
const { layoutId } = this.options;
return layoutId ? this.getStack()?.lead || this : this;
}
getPrevLead() {
const { layoutId } = this.options;
return layoutId ? this.getStack()?.prevLead : undefined;
}
getStack() {
const { layoutId } = this.options;
if (layoutId)
return this.root.sharedNodes.get(layoutId);
}
promote({ needsReset, transition, preserveFollowOpacity, } = {}) {
this.getStack()?.promote(this, preserveFollowOpacity);
if (needsReset) {
this.projectionDelta = undefined;
this.needsReset = true;
}
if (transition)
this.setOptions({ transition });
}
relegate() {
return this.getStack()?.relegate(this) || false;
}
resetSkewAndRotation() {
const { visualElement } = this.options;
if (!visualElement)
return;
/**
* An unrolled check for rotation values. Most elements don't have any rotation and
* skipping the loop and new object creation is 50% faster.
* If there's no distorting values, we don't need to do any more.
*/
const { latestValues } = visualElement;
if (!hasDistortingTransform(latestValues))
return;
const resetValues = {};
// Check the skew and rotate value of all axes and reset to 0
for (let i = 0; i < distortingTransforms.length; i++) {
resetDistortingTransform(distortingTransforms[i], visualElement, resetValues, this.animationValues);
}
for (let i = 0; i < angleTransforms.length; i++) {
resetDistortingTransform(angleTransforms[i], visualElement, resetValues, this.animationValues);
}
// Force a render of this element to apply the transform with all skews and rotations
// set to 0.
visualElement.render();
// Put back all the values we reset
for (const key in resetValues) {
visualElement.setStaticValue(key, resetValues[key]);
if (this.animationValues) {
this.animationValues[key] = resetValues[key];
}
}
// Schedule a render for the next frame. This ensures we won't visually
// see the element with the reset rotate value applied.
visualElement.scheduleRender();
}
applyProjectionStyles(targetStyle, // CSSStyleDeclaration - doesn't allow numbers to be assigned to properties
styleProp) {
if (!this.instance || this.isSVG)
return;
const lead = this.getLead();
/**
* Without crossfade, a new lead hides the previous one, as it's
* usually unmounting. One that gave its layoutId away keeps
* rendering, so only hide nodes that follow another.
*/
if (!this.isVisible && lead !== this) {
targetStyle.visibility = "hidden";
return;
}
const transformTemplate = this.getTransformTemplate();
if (this.needsReset) {
this.needsReset = false;
targetStyle.visibility = "";
targetStyle.opacity = "";
targetStyle.pointerEvents =
resolveMotionValue(styleProp?.pointerEvents) || "";
targetStyle.transform = transformTemplate
? transformTemplate(this.latestValues, "")
: "none";
return;
}
if (!this.projectionDelta || !this.layout || !lead.target) {
if (this.options.layoutId || this.needsShow) {
this.needsShow = false;
targetStyle.visibility = "";
targetStyle.opacity =
this.latestValues.opacity !== undefined
? this.latestValues.opacity
: 1;
targetStyle.pointerEvents =
resolveMotionValue(styleProp?.pointerEvents) || "";
}
if (this.hasProjected && !hasTransform(this.latestValues)) {
targetStyle.transform = transformTemplate
? transformTemplate({}, "")
: "none";
this.hasProjected = false;
}
return;
}
targetStyle.visibility = "";
const valuesToRender = lead.animationValues || lead.latestValues;
this.applyTransformsToTarget();
let transform = buildProjectionTransform(this.projectionDeltaWithTransform, this.treeScale, valuesToRender);
if (transformTemplate) {
transform = transformTemplate(valuesToRender, transform);
}
targetStyle.transform = transform;
const { x, y } = this.projectionDelta;
targetStyle.transformOrigin = `${x.origin * 100}% ${y.origin * 100}% 0`;
if (lead.animationValues) {
/**
* If the lead component is animating, assign this either the entering/leaving
* opacity
*/
targetStyle.opacity =
lead === this
? valuesToRender.opacity ??
this.latestValues.opacity ??
1
: this.preserveOpacity
? this.latestValues.opacity
: valuesToRender.opacityExit;
}
else {
/**
* Or we're not animating at all, set the lead component to its layout
* opacity and other components to hidden.
*/
targetStyle.opacity =
lead === this
? valuesToRender.opacity !== undefined
? valuesToRender.opacity
: ""
: valuesToRender.opacityExit !== undefined
? valuesToRender.opacityExit
: 0;
}
/**
* Apply scale correction
*/
for (const key in scaleCorrectors) {
if (valuesToRender[key] === undefined)
continue;
const { correct, applyTo, isCSSVariable } = scaleCorrectors[key];
/**
* Only apply scale correction to the value if we have an
* active projection transform. Otherwise these values become
* vulnerable to distortion if the element changes size without
* a corresponding layout animation.
*/
const corrected = transform === "none"
? valuesToRender[key]
: correct(valuesToRender[key], lead);
if (applyTo) {
const num = applyTo.length;
for (let i = 0; i < num; i++) {
targetStyle[applyTo[i]] = corrected;
}
}
else {
// If this is a CSS variable, set it directly on the instance.
// Replacing this function from creating styles to setting them
// would be a good place to remove per frame object creation
if (isCSSVariable) {
this.options.visualElement.renderState.vars[key] = corrected;
}
else {
targetStyle[key] = corrected;
}
}
}
/**
* Disable pointer events on follow components. This is to ensure
* that if a follow component covers a lead component it doesn't block
* pointer events on the lead.
*/
if (this.options.layoutId) {
targetStyle.pointerEvents =
lead === this
? resolveMotionValue(styleProp?.pointerEvents) || ""
: "none";
}
}
clearSnapshot() {
this.resumeFrom =
this.snapshot =
this.dragSnapshot =
this.dragAncestor =
undefined;
}
// Only run on root
resetTree() {
this.root.nodes.forEach((node) => node.currentAnimation?.stop());
this.root.nodes.forEach(clearMeasurements);
this.root.sharedNodes.clear();
}
};
}
/**
* Nodes are depth-ordered, so a dragged ancestor's layout is updated before
* its descendants'.
*/
function updateLayout(node) {
node.updateLayout();
followDraggedAncestor(node);
}
function notifyLayoutUpdate(node) {
const snapshot = node.resumeFrom?.snapshot || node.snapshot;
if (node.isLead() &&
node.layout &&
snapshot &&
node.hasListeners("didUpdate")) {
const { layoutBox: layout, measuredBox: measuredLayout } = node.layout;
const { animationType } = node.options;
const isShared = snapshot.source !== node.layout.source;
// TODO Maybe we want to also resize the layout snapshot so we don't trigger
// animations for instance if layout="size" and an element has only changed position
const resizeSnapshot = (box, to) => {
if (animationType === "size") {
eachAxis((axis) => {
const length = calcLength(box[axis]);
box[axis].min = to[axis].min;
box[axis].max = box[axis].min + length;
});
}
else if (animationType === "x" || animationType === "y") {
const snapAxis = animationType === "x" ? "y" : "x";
copyAxisInto(box[snapAxis], to[snapAxis]);
}
else if (shouldAnimatePositionOnly(animationType, snapshot.layoutBox, layout)) {
eachAxis((axis) => {
const length = calcLength(to[axis]);
box[axis].max = box[axis].min + length;
/**
* Ensure relative target gets resized and rerendererd
*/
if (node.relativeTarget && !node.currentAnimation) {
node.isProjectionDirty = true;
node.relativeTarget[axis].max =
node.relativeTarget[axis].min + length;
}
});
}
};
const snapshotBox = isShared ? snapshot.measuredBox : snapshot.layoutBox;
resizeSnapshot(snapshotBox, layout);
const layoutDelta = createDelta();
calcBoxDelta(layoutDelta, layout, snapshot.layoutBox);
const visualDelta = createDelta();
calcBoxDelta(visualDelta, isShared ? node.applyTransform(measuredLayout, true) : layout, snapshotBox);
const hasLayoutChanged = !isDeltaZero(layoutDelta);
let hasRelativeLayoutChanged = false;
const relativeParent = node.getClosestProjectingParent();
/**
* A snapshot from another element is only relative to this node's
* relativeParent if it's a layoutRoot that contains both. A node can
* hold one without resumeFrom, when the element it came from changed
* layoutId but stayed mounted (see switchLayoutId), or a remix
* layout exit cleared it (see markLayoutExit).
*/
if (!isShared ||
(relativeParent?.options.layoutRoot &&
node.resumeFrom?.path.includes(relativeParent))) {
/**
* If the relativeParent is itself resuming from a different element then
* the relative snapshot is not relavent
*/
if (relativeParent && !relativeParent.resumeFrom) {
const { snapshot: parentSnapshot, layout: parentLayout } = relativeParent;
if (parentSnapshot && parentLayout) {
const anchor = node.options.layoutAnchor || undefined;
/**
* A shared snapshot includes the transforms of the old
* element's ancestors below the layoutRoot, as they
* aren't shared with the new element.
*/
const relativeSnapshot = createBox();
calcRelativePosition(relativeSnapshot, isShared
? node.resumeFrom.applyTransform(snapshot.layoutBox, true, undefined, true, relativeParent.depth + 1)
: snapshot.layoutBox, parentSnapshot.layoutBox, anchor);
const relativeLayout = createBox();
calcRelativePosition(relativeLayout, layout, parentLayout.layoutBox, anchor);
/**
* A shared snapshot is resized in page space, above, so
* resize it relative to the layoutRoot too.
*/
isShared && resizeSnapshot(relativeSnapshot, relativeLayout);
if (!boxEqualsRounded(relativeSnapshot, relativeLayout)) {
hasRelativeLayoutChanged = true;
}
if (relativeParent.options.layoutRoot) {
node.relativeTarget = relativeLayout;
node.relativeTargetOrigin = relativeSnapshot;
node.relativeParent = relativeParent;
}
}
}
}
node.notifyListeners("didUpdate", {
layout,
snapshot,
delta: visualDelta,
layoutDelta,
hasLayoutChanged,
hasRelativeLayoutChanged,
});
}
else if (node.isLead()) {
node.options.onExitComplete?.();
}
/**
* Clearing transition
* TODO: Investigate why this transition is being passed in as {type: false } from Framer
* and why we need it at all
*/
node.options.transition = undefined;
}
function propagateDirtyNodes(node) {
/**
* Increase debug counter for nodes encountered this frame
*/
statsBuffer.countProjection?.(0); // nodes
if (!node.parent)
return;
/**
* If this node isn't projecting, propagate isProjectionDirty. It will have
* no performance impact but it will allow the next child that *is* projecting
* but *isn't* dirty to just check its parent to see if *any* ancestor needs
* correcting.
*/
if (!node.isProjecting()) {
node.isProjectionDirty = node.parent.isProjectionDirty;
}
/**
* Propagate isSharedProjectionDirty and isTransformDirty
* throughout the whole tree. A future revision can take another look at
* this but for safety we still recalcualte shared nodes.
*/
node.isSharedProjectionDirty || (node.isSharedProjectionDirty = Boolean(node.isProjectionDirty ||
node.parent.isProjectionDirty ||
node.parent.isSharedProjectionDirty));
node.isTransformDirty || (node.isTransformDirty = node.parent.isTransformDirty);
}
function cleanDirtyNodes(node) {
node.isProjectionDirty =
node.isSharedProjectionDirty =
node.isTransformDirty =
false;
}
function clearSnapshot(node) {
node.clearSnapshot();
}
function clearMeasurements(node) {
node.clearMeasurements();
}
function forceLayoutMeasure(node) {
node.isLayoutDirty = true;
node.updateLayout();
}
function clearIsLayoutDirty(node) {
node.isLayoutDirty = false;
}
/**
* A dragged node doesn't layout animate, so it jumps to its new layout. Shift
* its descendants' snapshots by the same amount, so they only animate layout
* changes relative to it, rather than lagging behind.
*
* If its drag ended since the snapshot (e.g. drag disabled from onDrag), it
* layout animates again, so its descendants mustn't follow its jump.
*/
function followDraggedAncestor(node) {
const { snapshot, dragAncestor } = node;
const to = dragAncestor?.isAnimationBlocked && dragAncestor.layout;
if (!snapshot || !to || node.resumeFrom || node.isAnimationBlocked)
return;
const from = dragAncestor.dragSnapshot;
eachAxis((axis) => translateAxis(snapshot.layoutBox[axis], to.layoutBox[axis].min - from.layoutBox[axis].min));
}
/**
* When a node is animation-blocked (e.g. during drag) and its component
* didn't re-render (memoized), willUpdate() is never called so there's
* no snapshot. Use the previous layout as a snapshot and mark dirty so
* resetTransform/updateLayout/notifyLayoutUpdate process it normally.
*/
function ensureDraggedNodesSnapshotted(node) {
if (node.isAnimationBlocked && node.layout && !node.isLayoutDirty) {
node.snapshot = node.dragSnapshot || node.layout;
node.isLayoutDirty = true;
}
}
/**
* If a node's relative parent is about to be re-measured but the node isn't
* (e.g. it's in another LayoutGroup), its relative target is out of date.
* If it's layout animating, re-measure it too, so its relative target keeps
* resolving against current layouts. Otherwise its layout is still valid
* relative to its parent, so drop the relative target, and any targetDelta
* left over from a finished animation while an ancestor still animates, to be
* rebuilt from its layout.
*/
function syncRelativeLayout(node) {
if (node.relativeTarget && node.relativeParent?.isLayoutDirty) {
if (node.currentAnimation) {
node.isLayoutDirty = true;
}
else {
node.targetDelta = undefined;
node.removeRelativeTarget();
}
}
}
function resetTransformStyle(node) {
const { visualElement } = node.options;
if (visualElement?.getProps().onBeforeLayoutMeasure) {
visualElement.notify("BeforeLayoutMeasure");
}
node.resetTransform();
}
function finishAnimation(node) {
node.finishAnimation();
node.targetDelta = node.relativeTarget = node.target = undefined;
node.isProjectionDirty = true;
}
function resolveTargetDelta(node) {
node.resolveTargetDelta();
}
function calcProjection(node) {
node.calcProjection();
}
function resetSkewAndRotation(node) {
node.resetSkewAndRotation();
}
/**
* Build an axis delta that moves the layout's edges by these offsets (px).
*/
function edgesToDelta(output, delta, layout, min, max) {
const length = layout.max - layout.min;
const scale = length ? Math.max(0, (length + max - min) / length) : 1;
const origin = (output.originPoint = delta.originPoint);
output.origin = delta.origin;
output.scale = scale;
output.translate =
layout.min + min - (origin + (layout.min - origin) * scale);
}
function mixAxisDeltaLinear(output, delta, p) {
output.translate = mixNumber(delta.translate, 0, p);
output.scale = mixNumber(delta.scale, 1, p);
output.origin = delta.origin;
output.originPoint = delta.originPoint;
}
function mixAxis(output, from, to, p) {
output.min = mixNumber(from.min, to.min, p);
output.max = mixNumber(from.max, to.max, p);
}
function mixBox(output, from, to, p) {
mixAxis(output.x, from.x, to.x, p);
mixAxis(output.y, from.y, to.y, p);
}
function hasOpacityCrossfade(node) {
return node.animationValues?.opacityExit !== undefined;
}
const defaultLayoutTransition = {
duration: 0.45,
ease: [0.4, 0, 0.1, 1],
};
/**
* Align an axis to the start of another, keeping the length of a layout axis.
* The length is read first, as output can be the layout axis itself.
*/
function alignAxis(output, from, layout) {
output.max = calcLength(layout) + (output.min = from.min);
}
function shouldAnimatePositionOnly(animationType, snapshot, layout) {
return (animationType === "position" ||
(animationType === "preserve-aspect" &&
!isNear(aspectRatio(snapshot), aspectRatio(layout), 0.2)));
}
function checkNodeWasScrollRoot(node) {
return node !== node.root && node.scroll?.wasRoot;
}
export { cleanDirtyNodes, createProjectionNode, mixAxis, mixBox, propagateDirtyNodes };
//# sourceMappingURL=create-projection-node.mjs.map