animejs
Version:
JavaScript animation engine
1,440 lines (1,307 loc) • 56.1 kB
JavaScript
/**
* Anime.js - layout - CJS
* @version v4.3.6
* @license MIT
* @copyright 2026 - Julian Garnier
*/
'use strict';
var helpers = require('../core/helpers.cjs');
var targets = require('../core/targets.cjs');
var parser = require('../easings/eases/parser.cjs');
var values = require('../core/values.cjs');
var timeline = require('../timeline/timeline.cjs');
var waapi = require('../waapi/waapi.cjs');
var globals = require('../core/globals.cjs');
/**
* @import {
* AnimationParams,
* RenderableCallbacks,
* TickableCallbacks,
* TimelineParams,
* TimerParams,
* } from '../types/index.js'
*/
/**
* @import {
* ScrollObserver,
* } from '../events/scroll.js'
*/
/**
* @import {
* Timeline,
* } from '../timeline/timeline.js'
*/
/**
* @import {
* WAAPIAnimation
* } from '../waapi/waapi.js'
*/
/**
* @import {
* Spring,
} from '../easings/spring/index.js'
*/
/**
* @import {
* DOMTarget,
* DOMTargetSelector,
* FunctionValue,
* EasingParam,
} from '../types/index.js'
*/
/**
* @typedef {DOMTargetSelector|Array<DOMTargetSelector>} LayoutChildrenParam
*/
/**
* @typedef {Object} LayoutAnimationTimingsParams
* @property {Number|FunctionValue} [delay]
* @property {Number|FunctionValue} [duration]
* @property {EasingParam|FunctionValue} [ease]
*/
/**
* @typedef {Record<String, Number|String|FunctionValue>} LayoutStateAnimationProperties
*/
/**
* @typedef {LayoutStateAnimationProperties & LayoutAnimationTimingsParams} LayoutStateParams
*/
/**
* @typedef {Object} LayoutSpecificAnimationParams
* @property {Number|FunctionValue} [delay]
* @property {Number|FunctionValue} [duration]
* @property {EasingParam|FunctionValue} [ease]
* @property {EasingParam} [playbackEase]
* @property {LayoutStateParams} [swapAt]
* @property {LayoutStateParams} [enterFrom]
* @property {LayoutStateParams} [leaveTo]
*/
/**
* @typedef {LayoutSpecificAnimationParams & TimerParams & TickableCallbacks<Timeline> & RenderableCallbacks<Timeline>} LayoutAnimationParams
*/
/**
* @typedef {Object} LayoutOptions
* @property {LayoutChildrenParam} [children]
* @property {Array<String>} [properties]
*/
/**
* @typedef {LayoutAnimationParams & LayoutOptions} AutoLayoutParams
*/
/**
* @typedef {Record<String, Number|String|FunctionValue> & {
* transform: String,
* x: Number,
* y: Number,
* left: Number,
* top: Number,
* clientLeft: Number,
* clientTop: Number,
* width: Number,
* height: Number,
* }} LayoutNodeProperties
*/
/**
* @typedef {Object} LayoutNode
* @property {String} id
* @property {DOMTarget} $el
* @property {Number} index
* @property {Number} total
* @property {Number} delay
* @property {Number} duration
* @property {EasingParam} ease
* @property {DOMTarget} $measure
* @property {LayoutSnapshot} state
* @property {AutoLayout} layout
* @property {LayoutNode|null} parentNode
* @property {Boolean} isTarget
* @property {Boolean} isEntering
* @property {Boolean} isLeaving
* @property {Boolean} hasTransform
* @property {Array<String>} inlineStyles
* @property {String|null} inlineTransforms
* @property {String|null} inlineTransition
* @property {Boolean} branchAdded
* @property {Boolean} branchRemoved
* @property {Boolean} branchNotRendered
* @property {Boolean} sizeChanged
* @property {Boolean} isInlined
* @property {Boolean} hasVisibilitySwap
* @property {Boolean} hasDisplayNone
* @property {Boolean} hasVisibilityHidden
* @property {String|null} measuredInlineTransform
* @property {String|null} measuredInlineTransition
* @property {String|null} measuredDisplay
* @property {String|null} measuredVisibility
* @property {String|null} measuredPosition
* @property {Boolean} measuredHasDisplayNone
* @property {Boolean} measuredHasVisibilityHidden
* @property {Boolean} measuredIsVisible
* @property {Boolean} measuredIsRemoved
* @property {Boolean} measuredIsInsideRoot
* @property {LayoutNodeProperties} properties
* @property {LayoutNode|null} _head
* @property {LayoutNode|null} _tail
* @property {LayoutNode|null} _prev
* @property {LayoutNode|null} _next
*/
/**
* @callback LayoutNodeIterator
* @param {LayoutNode} node
* @param {Number} index
* @return {void}
*/
let layoutId = 0;
let nodeId = 0;
/**
* @param {DOMTarget} root
* @param {DOMTarget} $el
* @return {Boolean}
*/
const isElementInRoot = (root, $el) => {
if (!root || !$el) return false;
return root === $el || root.contains($el);
};
/**
* @param {DOMTarget|null} $el
* @return {String|null}
*/
const muteElementTransition = $el => {
if (!$el) return null;
const style = $el.style;
const transition = style.transition || '';
style.setProperty('transition', 'none', 'important');
return transition;
};
/**
* @param {DOMTarget|null} $el
* @param {String|null} transition
*/
const restoreElementTransition = ($el, transition) => {
if (!$el) return;
const style = $el.style;
if (transition) {
style.transition = transition;
} else {
style.removeProperty('transition');
}
};
/**
* @param {LayoutNode} node
*/
const muteNodeTransition = node => {
const store = node.layout.transitionMuteStore;
const $el = node.$el;
const $measure = node.$measure;
if ($el && !store.has($el)) store.set($el, muteElementTransition($el));
if ($measure && !store.has($measure)) store.set($measure, muteElementTransition($measure));
};
/**
* @param {Map<DOMTarget, String|null>} store
*/
const restoreLayoutTransition = store => {
store.forEach((value, $el) => restoreElementTransition($el, value));
store.clear();
};
const hiddenComputedStyle = /** @type {CSSStyleDeclaration} */({
display: 'none',
visibility: 'hidden',
opacity: '0',
transform: 'none',
position: 'static',
});
/**
* @param {LayoutNode|null} node
*/
const detachNode = node => {
if (!node) return;
const parent = node.parentNode;
if (!parent) return;
if (parent._head === node) parent._head = node._next;
if (parent._tail === node) parent._tail = node._prev;
if (node._prev) node._prev._next = node._next;
if (node._next) node._next._prev = node._prev;
node._prev = null;
node._next = null;
node.parentNode = null;
};
/**
* @param {DOMTarget} $el
* @param {LayoutNode|null} parentNode
* @param {LayoutSnapshot} state
* @param {LayoutNode} recycledNode
* @return {LayoutNode}
*/
const createNode = ($el, parentNode, state, recycledNode) => {
let dataId = $el.dataset.layoutId;
if (!dataId) dataId = $el.dataset.layoutId = `node-${nodeId++}`;
const node = recycledNode ? recycledNode : /** @type {LayoutNode} */({});
node.$el = $el;
node.$measure = $el;
node.id = dataId;
node.index = 0;
node.total = 1;
node.delay = 0;
node.duration = 0;
node.ease = null;
node.state = state;
node.layout = state.layout;
node.parentNode = parentNode || null;
node.isTarget = false;
node.isEntering = false;
node.isLeaving = false;
node.isInlined = false;
node.hasTransform = false;
node.inlineStyles = [];
node.inlineTransforms = null;
node.inlineTransition = null;
node.branchAdded = false;
node.branchRemoved = false;
node.branchNotRendered = false;
node.sizeChanged = false;
node.hasVisibilitySwap = false;
node.hasDisplayNone = false;
node.hasVisibilityHidden = false;
node.measuredInlineTransform = null;
node.measuredInlineTransition = null;
node.measuredDisplay = null;
node.measuredVisibility = null;
node.measuredPosition = null;
node.measuredHasDisplayNone = false;
node.measuredHasVisibilityHidden = false;
node.measuredIsVisible = false;
node.measuredIsRemoved = false;
node.measuredIsInsideRoot = false;
node.properties = /** @type {LayoutNodeProperties} */({
transform: 'none',
x: 0,
y: 0,
left: 0,
top: 0,
clientLeft: 0,
clientTop: 0,
width: 0,
height: 0,
});
node.layout.properties.forEach(prop => node.properties[prop] = 0);
node._head = null;
node._tail = null;
node._prev = null;
node._next = null;
return node;
};
/**
* @param {LayoutNode} node
* @param {DOMTarget} $measure
* @param {CSSStyleDeclaration} computedStyle
* @param {Boolean} skipMeasurements
* @return {LayoutNode}
*/
const recordNodeState = (node, $measure, computedStyle, skipMeasurements) => {
const $el = node.$el;
const root = node.layout.root;
const isRoot = root === $el;
const properties = node.properties;
const rootNode = node.state.rootNode;
const parentNode = node.parentNode;
const computedTransforms = computedStyle.transform;
const inlineTransforms = $el.style.transform;
const parentNotRendered = parentNode ? parentNode.measuredIsRemoved : false;
const position = computedStyle.position;
if (isRoot) node.layout.absoluteCoords = position === 'fixed' || position === 'absolute';
node.$measure = $measure;
node.inlineTransforms = inlineTransforms;
node.hasTransform = computedTransforms && computedTransforms !== 'none';
node.measuredIsInsideRoot = isElementInRoot(root, $measure);
node.measuredInlineTransform = null;
node.measuredDisplay = computedStyle.display;
node.measuredVisibility = computedStyle.visibility;
node.measuredPosition = position;
node.measuredHasDisplayNone = computedStyle.display === 'none';
node.measuredHasVisibilityHidden = computedStyle.visibility === 'hidden';
node.measuredIsVisible = !(node.measuredHasDisplayNone || node.measuredHasVisibilityHidden);
node.measuredIsRemoved = node.measuredHasDisplayNone || node.measuredHasVisibilityHidden || parentNotRendered;
// Check if element has adjacent text that would reflow when taken out of flow
let hasAdjacentText = false;
let s = $el.previousSibling;
while (s && (s.nodeType === Node.COMMENT_NODE || (s.nodeType === Node.TEXT_NODE && !s.textContent.trim()))) s = s.previousSibling;
if (s && s.nodeType === Node.TEXT_NODE) {
hasAdjacentText = true;
} else {
s = $el.nextSibling;
while (s && (s.nodeType === Node.COMMENT_NODE || (s.nodeType === Node.TEXT_NODE && !s.textContent.trim()))) s = s.nextSibling;
hasAdjacentText = s !== null && s.nodeType === Node.TEXT_NODE;
}
node.isInlined = hasAdjacentText;
// Mute transforms (and transition to avoid triggering an animation) before the position calculation
if (node.hasTransform && !skipMeasurements) {
const transitionMuteStore = node.layout.transitionMuteStore;
if (!transitionMuteStore.get($el)) node.inlineTransition = muteElementTransition($el);
if ($measure === $el) {
$el.style.transform = 'none';
} else {
if (!transitionMuteStore.get($measure)) node.measuredInlineTransition = muteElementTransition($measure);
node.measuredInlineTransform = $measure.style.transform;
$measure.style.transform = 'none';
}
}
let left = 0;
let top = 0;
let width = 0;
let height = 0;
if (!skipMeasurements) {
const rect = $measure.getBoundingClientRect();
left = rect.left;
top = rect.top;
width = rect.width;
height = rect.height;
}
for (let name in properties) {
const computedProp = name === 'transform' ? computedTransforms : computedStyle[name] || (computedStyle.getPropertyValue && computedStyle.getPropertyValue(name));
if (!helpers.isUnd(computedProp)) properties[name] = computedProp;
}
properties.left = left;
properties.top = top;
properties.clientLeft = skipMeasurements ? 0 : $measure.clientLeft;
properties.clientTop = skipMeasurements ? 0 : $measure.clientTop;
// Compute local x/y relative to parent
let absoluteLeft, absoluteTop;
if (isRoot) {
if (!node.layout.absoluteCoords) {
absoluteLeft = 0;
absoluteTop = 0;
} else {
absoluteLeft = left;
absoluteTop = top;
}
} else {
const p = parentNode || rootNode;
const parentLeft = p.properties.left;
const parentTop = p.properties.top;
const borderLeft = p.properties.clientLeft;
const borderTop = p.properties.clientTop;
if (!node.layout.absoluteCoords) {
if (p === rootNode) {
const rootLeft = rootNode.properties.left;
const rootTop = rootNode.properties.top;
const rootBorderLeft = rootNode.properties.clientLeft;
const rootBorderTop = rootNode.properties.clientTop;
absoluteLeft = left - rootLeft - rootBorderLeft;
absoluteTop = top - rootTop - rootBorderTop;
} else {
absoluteLeft = left - parentLeft - borderLeft;
absoluteTop = top - parentTop - borderTop;
}
} else {
absoluteLeft = left - parentLeft - borderLeft;
absoluteTop = top - parentTop - borderTop;
}
}
properties.x = absoluteLeft;
properties.y = absoluteTop;
properties.width = width;
properties.height = height;
return node;
};
/**
* @param {LayoutNode} node
* @param {LayoutStateAnimationProperties} [props]
*/
const updateNodeProperties = (node, props) => {
if (!props) return;
for (let name in props) {
node.properties[name] = props[name];
}
};
/**
* @param {LayoutNode} node
* @param {LayoutAnimationTimingsParams} params
*/
const updateNodeTimingParams = (node, params) => {
const easeFunctionResult = values.getFunctionValue(params.ease, node.$el, node.index, node.total);
const keyEasing = helpers.isFnc(easeFunctionResult) ? easeFunctionResult : params.ease;
const hasSpring = !helpers.isUnd(keyEasing) && !helpers.isUnd(/** @type {Spring} */(keyEasing).ease);
node.ease = hasSpring ? /** @type {Spring} */(keyEasing).ease : keyEasing;
node.duration = hasSpring ? /** @type {Spring} */(keyEasing).settlingDuration : values.getFunctionValue(params.duration, node.$el, node.index, node.total);
node.delay = values.getFunctionValue(params.delay, node.$el, node.index, node.total);
};
/**
* @param {LayoutNode} node
*/
const recordNodeInlineStyles = node => {
const style = node.$el.style;
const stylesStore = node.inlineStyles;
stylesStore.length = 0;
node.layout.recordedProperties.forEach(prop => {
stylesStore.push(prop, style[prop] || '');
});
};
/**
* @param {LayoutNode} node
*/
const restoreNodeInlineStyles = node => {
const style = node.$el.style;
const stylesStore = node.inlineStyles;
for (let i = 0, l = stylesStore.length; i < l; i += 2) {
const property = stylesStore[i];
const styleValue = stylesStore[i + 1];
if (styleValue && styleValue !== '') {
style[property] = styleValue;
} else {
style[property] = '';
style.removeProperty(property);
}
}
};
/**
* @param {LayoutNode} node
*/
const restoreNodeTransform = node => {
const inlineTransforms = node.inlineTransforms;
const nodeStyle = node.$el.style;
if (!node.hasTransform || !inlineTransforms || (node.hasTransform && nodeStyle.transform === 'none') || (inlineTransforms && inlineTransforms === 'none')) {
nodeStyle.removeProperty('transform');
} else if (inlineTransforms) {
nodeStyle.transform = inlineTransforms;
}
const $measure = node.$measure;
if (node.hasTransform && $measure !== node.$el) {
const measuredStyle = $measure.style;
const measuredInline = node.measuredInlineTransform;
if (measuredInline && measuredInline !== '') {
measuredStyle.transform = measuredInline;
} else {
measuredStyle.removeProperty('transform');
}
}
node.measuredInlineTransform = null;
if (node.inlineTransition !== null) {
restoreElementTransition(node.$el, node.inlineTransition);
node.inlineTransition = null;
}
if ($measure !== node.$el && node.measuredInlineTransition !== null) {
restoreElementTransition($measure, node.measuredInlineTransition);
node.measuredInlineTransition = null;
}
};
/**
* @param {LayoutNode} node
*/
const restoreNodeVisualState = node => {
if (node.measuredIsRemoved || node.hasVisibilitySwap) {
node.$el.style.removeProperty('display');
node.$el.style.removeProperty('visibility');
if (node.hasVisibilitySwap) {
node.$measure.style.removeProperty('display');
node.$measure.style.removeProperty('visibility');
}
}
// if (node.measuredIsRemoved) {
node.layout.pendingRemoval.delete(node.$el);
// }
};
/**
* @param {LayoutNode} node
* @param {LayoutNode} targetNode
* @param {LayoutSnapshot} newState
* @return {LayoutNode}
*/
const cloneNodeProperties = (node, targetNode, newState) => {
targetNode.properties = /** @type {LayoutNodeProperties} */({ ...node.properties });
targetNode.state = newState;
targetNode.isTarget = node.isTarget;
targetNode.hasTransform = node.hasTransform;
targetNode.inlineTransforms = node.inlineTransforms;
targetNode.measuredIsVisible = node.measuredIsVisible;
targetNode.measuredDisplay = node.measuredDisplay;
targetNode.measuredIsRemoved = node.measuredIsRemoved;
targetNode.measuredHasDisplayNone = node.measuredHasDisplayNone;
targetNode.measuredHasVisibilityHidden = node.measuredHasVisibilityHidden;
targetNode.hasDisplayNone = node.hasDisplayNone;
targetNode.isInlined = node.isInlined;
targetNode.hasVisibilityHidden = node.hasVisibilityHidden;
return targetNode;
};
class LayoutSnapshot {
/**
* @param {AutoLayout} layout
*/
constructor(layout) {
/** @type {AutoLayout} */
this.layout = layout;
/** @type {LayoutNode|null} */
this.rootNode = null;
/** @type {Set<LayoutNode>} */
this.rootNodes = new Set();
/** @type {Map<String, LayoutNode>} */
this.nodes = new Map();
/** @type {Number} */
this.scrollX = 0;
/** @type {Number} */
this.scrollY = 0;
}
/**
* @return {this}
*/
revert() {
this.forEachNode(node => {
this.layout.pendingRemoval.delete(node.$el);
node.$el.removeAttribute('data-layout-id');
node.$measure.removeAttribute('data-layout-id');
});
this.rootNode = null;
this.rootNodes.clear();
this.nodes.clear();
return this;
}
/**
* @param {DOMTarget} $el
* @return {LayoutNode}
*/
getNode($el) {
if (!$el || !$el.dataset) return;
return this.nodes.get($el.dataset.layoutId);
}
/**
* @param {DOMTarget} $el
* @param {String} prop
* @return {Number|String}
*/
getComputedValue($el, prop) {
const node = this.getNode($el);
if (!node) return;
return /** @type {Number|String} */(node.properties[prop]);
}
/**
* @param {LayoutNode|null} rootNode
* @param {LayoutNodeIterator} cb
*/
forEach(rootNode, cb) {
let node = rootNode;
let i = 0;
while (node) {
cb(node, i++);
if (node._head) {
node = node._head;
} else if (node._next) {
node = node._next;
} else {
while (node && !node._next) {
node = node.parentNode;
}
if (node) node = node._next;
}
}
}
/**
* @param {LayoutNodeIterator} cb
*/
forEachRootNode(cb) {
this.forEach(this.rootNode, cb);
}
/**
* @param {LayoutNodeIterator} cb
*/
forEachNode(cb) {
for (const rootNode of this.rootNodes) {
this.forEach(rootNode, cb);
}
}
/**
* @param {DOMTarget} $el
* @param {LayoutNode|null} parentNode
* @return {LayoutNode|null}
*/
registerElement($el, parentNode) {
if (!$el || $el.nodeType !== 1) return null;
if (!this.layout.transitionMuteStore.has($el)) this.layout.transitionMuteStore.set($el, muteElementTransition($el));
/** @type {Array<DOMTarget|LayoutNode|null>} */
const stack = [$el, parentNode];
const root = this.layout.root;
let firstNode = null;
while (stack.length) {
/** @type {LayoutNode|null} */
const $parent = /** @type {LayoutNode|null} */(stack.pop());
/** @type {DOMTarget|null} */
const $current = /** @type {DOMTarget|null} */(stack.pop());
if (!$current || $current.nodeType !== 1 || helpers.isSvg($current)) continue;
const skipMeasurements = $parent ? $parent.measuredIsRemoved : false;
const computedStyle = skipMeasurements ? hiddenComputedStyle : getComputedStyle($current);
const hasDisplayNone = skipMeasurements ? true : computedStyle.display === 'none';
const hasVisibilityHidden = skipMeasurements ? true : computedStyle.visibility === 'hidden';
const isVisible = !hasDisplayNone && !hasVisibilityHidden;
const existingId = $current.dataset.layoutId;
const isInsideRoot = isElementInRoot(root, $current);
let node = existingId ? this.nodes.get(existingId) : null;
if (node && node.$el !== $current) {
const nodeInsideRoot = isElementInRoot(root, node.$el);
const measuredVisible = node.measuredIsVisible;
const shouldReassignNode = !nodeInsideRoot && (isInsideRoot || (!isInsideRoot && !measuredVisible && isVisible));
const shouldReuseMeasurements = nodeInsideRoot && !measuredVisible && isVisible;
// Rebind nodes that move into the root or whose detached twin just became visible
if (shouldReassignNode) {
detachNode(node);
node = createNode($current, $parent, this, node);
// for hidden element with in-root sibling, keep the hidden node but borrow measurements from its visible in-root twin element
} else if (shouldReuseMeasurements) {
recordNodeState(node, $current, computedStyle, skipMeasurements);
let $child = $current.lastElementChild;
while ($child) {
stack.push(/** @type {DOMTarget} */($child), node);
$child = $child.previousElementSibling;
}
if (!firstNode) firstNode = node;
continue;
// No reassignment needed so keep walking descendants under the current parent
} else {
let $child = $current.lastElementChild;
while ($child) {
stack.push(/** @type {DOMTarget} */($child), $parent);
$child = $child.previousElementSibling;
}
if (!firstNode) firstNode = node;
continue;
}
} else {
node = createNode($current, $parent, this, node);
}
node.branchAdded = false;
node.branchRemoved = false;
node.branchNotRendered = false;
node.isTarget = false;
node.sizeChanged = false;
node.hasVisibilityHidden = hasVisibilityHidden;
node.hasDisplayNone = hasDisplayNone;
node.hasVisibilitySwap = (hasVisibilityHidden && !node.measuredHasVisibilityHidden) || (hasDisplayNone && !node.measuredHasDisplayNone);
this.nodes.set(node.id, node);
node.parentNode = $parent || null;
node._prev = null;
node._next = null;
if ($parent) {
this.rootNodes.delete(node);
if (!$parent._head) {
$parent._head = node;
$parent._tail = node;
} else {
$parent._tail._next = node;
node._prev = $parent._tail;
$parent._tail = node;
}
} else {
// Each disconnected subtree becomes its own root in the snapshot graph
this.rootNodes.add(node);
}
recordNodeState(node, node.$el, computedStyle, skipMeasurements);
let $child = $current.lastElementChild;
while ($child) {
stack.push(/** @type {DOMTarget} */($child), node);
$child = $child.previousElementSibling;
}
if (!firstNode) firstNode = node;
}
return firstNode;
}
/**
* @param {DOMTarget} $el
* @param {Set<DOMTarget>} candidates
* @return {LayoutNode|null}
*/
ensureDetachedNode($el, candidates) {
if (!$el || $el === this.layout.root) return null;
const existingId = $el.dataset.layoutId;
const existingNode = existingId ? this.nodes.get(existingId) : null;
if (existingNode && existingNode.$el === $el) return existingNode;
let parentNode = null;
let $ancestor = $el.parentElement;
while ($ancestor && $ancestor !== this.layout.root) {
if (candidates.has($ancestor)) {
parentNode = this.ensureDetachedNode($ancestor, candidates);
break;
}
$ancestor = $ancestor.parentElement;
}
return this.registerElement($el, parentNode);
}
/**
* @return {this}
*/
record() {
const layout = this.layout;
const children = layout.children;
const root = layout.root;
const toParse = helpers.isArr(children) ? children : [children];
const scoped = [];
const scopeRoot = children === '*' ? root : globals.scope.root;
// Mute transition and transforms of root ancestors before recording the state
/** @type {Array<DOMTarget|String|null>} */
const rootAncestorTransformStore = [];
let $ancestor = root.parentElement;
while ($ancestor && $ancestor.nodeType === 1) {
const computedStyle = getComputedStyle($ancestor);
if (computedStyle.transform && computedStyle.transform !== 'none') {
const inlineTransform = $ancestor.style.transform || '';
const inlineTransition = muteElementTransition($ancestor);
rootAncestorTransformStore.push($ancestor, inlineTransform, inlineTransition);
$ancestor.style.transform = 'none';
}
$ancestor = $ancestor.parentElement;
}
for (let i = 0, l = toParse.length; i < l; i++) {
const child = toParse[i];
scoped[i] = helpers.isStr(child) ? scopeRoot.querySelectorAll(child) : child;
}
const parsedChildren = targets.registerTargets(scoped);
this.nodes.clear();
this.rootNodes.clear();
const rootNode = this.registerElement(root, null);
// Root node are always targets
rootNode.isTarget = true;
this.rootNode = rootNode;
const inRootNodeIds = new Set();
// Update index and total for inital timing calculation
let index = 0, total = this.nodes.size;
this.nodes.forEach((node, id) => {
node.index = index++;
node.total = total;
// Track ids of nodes that belong to the current root to filter detached matches
if (node && node.measuredIsInsideRoot) {
inRootNodeIds.add(id);
}
});
// Elements with a layout id outside the root that match the children selector
const detachedElementsLookup = new Set();
const orderedDetachedElements = [];
for (let i = 0, l = parsedChildren.length; i < l; i++) {
const $el = parsedChildren[i];
if (!$el || $el.nodeType !== 1 || $el === root) continue;
const insideRoot = isElementInRoot(root, $el);
if (!insideRoot) {
const layoutNodeId = $el.dataset.layoutId;
if (!layoutNodeId || !inRootNodeIds.has(layoutNodeId)) continue;
}
if (!detachedElementsLookup.has($el)) {
detachedElementsLookup.add($el);
orderedDetachedElements.push($el);
}
}
for (let i = 0, l = orderedDetachedElements.length; i < l; i++) {
this.ensureDetachedNode(orderedDetachedElements[i], detachedElementsLookup);
}
for (let i = 0, l = parsedChildren.length; i < l; i++) {
const $el = parsedChildren[i];
const node = this.getNode($el);
if (node) {
let cur = node;
while (cur) {
if (cur.isTarget) break;
cur.isTarget = true;
cur = cur.parentNode;
}
}
}
this.scrollX = window.scrollX;
this.scrollY = window.scrollY;
this.forEachNode(restoreNodeTransform);
// Restore transition and transforms of root ancestors
for (let i = 0, l = rootAncestorTransformStore.length; i < l; i += 3) {
const $el = /** @type {DOMTarget} */(rootAncestorTransformStore[i]);
const inlineTransform = /** @type {String} */(rootAncestorTransformStore[i + 1]);
const inlineTransition = /** @type {String|null} */(rootAncestorTransformStore[i + 2]);
if (inlineTransform && inlineTransform !== '') {
$el.style.transform = inlineTransform;
} else {
$el.style.removeProperty('transform');
}
restoreElementTransition($el, inlineTransition);
}
return this;
}
}
/**
* @param {LayoutStateParams} params
* @return {[LayoutStateAnimationProperties, LayoutAnimationTimingsParams]}
*/
function splitPropertiesFromParams(params) {
/** @type {LayoutStateAnimationProperties} */
const properties = {};
/** @type {LayoutAnimationTimingsParams} */
const parameters = {};
for (let name in params) {
const value = params[name];
const isEase = name === 'ease';
const isTiming = name === 'duration' || name === 'delay';
if (isTiming || isEase) {
if (isEase) {
parameters[name] = /** @type {EasingParam} */(value);
} else {
parameters[name] = /** @type {Number|FunctionValue} */(value);
}
} else {
properties[name] = /** @type {Number|String} */(value);
}
}
return [properties, parameters];
}
class AutoLayout {
/**
* @param {DOMTargetSelector} root
* @param {AutoLayoutParams} [params]
*/
constructor(root, params = {}) {
if (globals.scope.current) globals.scope.current.register(this);
const swapAtSplitParams = splitPropertiesFromParams(params.swapAt);
const enterFromSplitParams = splitPropertiesFromParams(params.enterFrom);
const leaveToSplitParams = splitPropertiesFromParams(params.leaveTo);
const transitionProperties = params.properties;
/** @type {Number|FunctionValue} */
params.duration = values.setValue(params.duration, 350);
/** @type {Number|FunctionValue} */
params.delay = values.setValue(params.delay, 0);
/** @type {EasingParam|FunctionValue} */
params.ease = values.setValue(params.ease, 'inOut(3.5)');
/** @type {AutoLayoutParams} */
this.params = params;
/** @type {DOMTarget} */
this.root = /** @type {DOMTarget} */(targets.registerTargets(root)[0]);
/** @type {Number} */
this.id = layoutId++;
/** @type {LayoutChildrenParam} */
this.children = params.children || '*';
/** @type {Boolean} */
this.absoluteCoords = false;
/** @type {LayoutStateParams} */
this.swapAtParams = helpers.mergeObjects(params.swapAt || { opacity: 0 }, { ease: 'inOut(1.75)' });
/** @type {LayoutStateParams} */
this.enterFromParams = params.enterFrom || { opacity: 0 };
/** @type {LayoutStateParams} */
this.leaveToParams = params.leaveTo || { opacity: 0 };
/** @type {Set<String>} */
this.properties = new Set([
'opacity',
'fontSize',
'color',
'backgroundColor',
'borderRadius',
'border',
'filter',
'clipPath',
]);
if (swapAtSplitParams[0]) for (let name in swapAtSplitParams[0]) this.properties.add(name);
if (enterFromSplitParams[0]) for (let name in enterFromSplitParams[0]) this.properties.add(name);
if (leaveToSplitParams[0]) for (let name in leaveToSplitParams[0]) this.properties.add(name);
if (transitionProperties) for (let i = 0, l = transitionProperties.length; i < l; i++) this.properties.add(transitionProperties[i]);
/** @type {Set<String>} */
this.recordedProperties = new Set([
'display',
'visibility',
'translate',
'position',
'left',
'top',
'marginLeft',
'marginTop',
'width',
'height',
'maxWidth',
'maxHeight',
'minWidth',
'minHeight',
]);
this.properties.forEach(prop => this.recordedProperties.add(prop));
/** @type {WeakSet<DOMTarget>} */
this.pendingRemoval = new WeakSet();
/** @type {Map<DOMTarget, String|null>} */
this.transitionMuteStore = new Map();
/** @type {LayoutSnapshot} */
this.oldState = new LayoutSnapshot(this);
/** @type {LayoutSnapshot} */
this.newState = new LayoutSnapshot(this);
/** @type {Timeline} */
this.timeline = null;
/** @type {WAAPIAnimation} */
this.transformAnimation = null;
/** @type {Array<DOMTarget>} */
this.animating = [];
/** @type {Array<DOMTarget>} */
this.swapping = [];
/** @type {Array<DOMTarget>} */
this.leaving = [];
/** @type {Array<DOMTarget>} */
this.entering = [];
// Record the current state as the old state to init the data attributes and allow imediate .animate()
this.oldState.record();
// And all layout transition muted during the record
restoreLayoutTransition(this.transitionMuteStore);
}
/**
* @return {this}
*/
revert() {
this.root.classList.remove('is-animated');
if (this.timeline) {
this.timeline.complete();
this.timeline = null;
}
if (this.transformAnimation) {
this.transformAnimation.complete();
this.transformAnimation = null;
}
this.animating.length = this.swapping.length = this.leaving.length = this.entering.length = 0;
this.oldState.revert();
this.newState.revert();
requestAnimationFrame(() => restoreLayoutTransition(this.transitionMuteStore));
return this;
}
/**
* @return {this}
*/
record() {
// Commit transforms before measuring
if (this.transformAnimation) {
this.transformAnimation.cancel();
this.transformAnimation = null;
}
// Record the old state
this.oldState.record();
// Cancel any running timeline
if (this.timeline) {
this.timeline.cancel();
this.timeline = null;
}
// Restore previously captured inline styles
this.newState.forEachRootNode(restoreNodeInlineStyles);
return this;
}
/**
* @param {LayoutAnimationParams} [params]
* @return {Timeline}
*/
animate(params = {}) {
/** @type { LayoutAnimationTimingsParams } */
const animationTimings = {
ease: values.setValue(params.ease, this.params.ease),
delay: values.setValue(params.delay, this.params.delay),
duration: values.setValue(params.duration, this.params.duration),
};
/** @type {TimelineParams} */
const tlParams = {};
const onComplete = values.setValue(params.onComplete, this.params.onComplete);
const onPause = values.setValue(params.onPause, this.params.onPause);
for (let name in globals.defaults) {
if (name !== 'ease' && name !== 'duration' && name !== 'delay') {
if (!helpers.isUnd(params[name])) {
tlParams[name] = params[name];
} else if (!helpers.isUnd(this.params[name])) {
tlParams[name] = this.params[name];
}
}
}
tlParams.onComplete = () => {
const ap = /** @type {ScrollObserver} */(params.autoplay);
const isScrollControled = ap && ap.linked;
if (isScrollControled) {
if (onComplete) onComplete(this.timeline);
return;
}
// Make sure to call .cancel() after restoreNodeInlineStyles(node); otehrwise the commited styles get reverted
if (this.transformAnimation) this.transformAnimation.cancel();
newState.forEachRootNode(node => {
restoreNodeVisualState(node);
restoreNodeInlineStyles(node);
});
for (let i = 0, l = transformed.length; i < l; i++) {
const $el = transformed[i];
$el.style.transform = newState.getComputedValue($el, 'transform');
}
if (this.root.classList.contains('is-animated')) {
this.root.classList.remove('is-animated');
if (onComplete) onComplete(this.timeline);
}
// Avoid CSS transitions at the end of the animation by restoring them on the next frame
requestAnimationFrame(() => {
if (this.root.classList.contains('is-animated')) return;
restoreLayoutTransition(this.transitionMuteStore);
});
};
tlParams.onPause = () => {
const ap = /** @type {ScrollObserver} */(params.autoplay);
const isScrollControled = ap && ap.linked;
if (isScrollControled) {
if (onComplete) onComplete(this.timeline);
if (onPause) onPause(this.timeline);
return;
}
if (!this.root.classList.contains('is-animated')) return;
if (this.transformAnimation) this.transformAnimation.cancel();
newState.forEachRootNode(restoreNodeVisualState);
this.root.classList.remove('is-animated');
if (onComplete) onComplete(this.timeline);
if (onPause) onPause(this.timeline);
};
tlParams.composition = false;
const swapAtParams = helpers.mergeObjects(helpers.mergeObjects(params.swapAt || {}, this.swapAtParams), animationTimings);
const enterFromParams = helpers.mergeObjects(helpers.mergeObjects(params.enterFrom || {}, this.enterFromParams), animationTimings);
const leaveToParams = helpers.mergeObjects(helpers.mergeObjects(params.leaveTo || {}, this.leaveToParams), animationTimings);
const [ swapAtProps, swapAtTimings ] = splitPropertiesFromParams(swapAtParams);
const [ enterFromProps, enterFromTimings ] = splitPropertiesFromParams(enterFromParams);
const [ leaveToProps, leaveToTimings ] = splitPropertiesFromParams(leaveToParams);
const oldState = this.oldState;
const newState = this.newState;
const animating = this.animating;
const swapping = this.swapping;
const entering = this.entering;
const leaving = this.leaving;
const pendingRemoval = this.pendingRemoval;
animating.length = swapping.length = entering.length = leaving.length = 0;
// Mute old state CSS transitions to prevent wrong properties calculation
oldState.forEachRootNode(muteNodeTransition);
// Capture the new state before animation
newState.record();
newState.forEachRootNode(recordNodeInlineStyles);
const targets = [];
const animated = [];
const transformed = [];
const animatedSwap = [];
const rootNode = newState.rootNode;
const $root = rootNode.$el;
newState.forEachRootNode(node => {
const $el = node.$el;
const id = node.id;
const parent = node.parentNode;
const parentAdded = parent ? parent.branchAdded : false;
const parentRemoved = parent ? parent.branchRemoved : false;
const parentNotRendered = parent ? parent.branchNotRendered : false;
let oldStateNode = oldState.nodes.get(id);
const hasNoOldState = !oldStateNode;
if (hasNoOldState) {
oldStateNode = cloneNodeProperties(node, /** @type {LayoutNode} */({}), oldState);
oldState.nodes.set(id, oldStateNode);
oldStateNode.measuredIsRemoved = true;
} else if (oldStateNode.measuredIsRemoved && !node.measuredIsRemoved) {
cloneNodeProperties(node, oldStateNode, oldState);
oldStateNode.measuredIsRemoved = true;
}
const oldParentNode = oldStateNode.parentNode;
const oldParentId = oldParentNode ? oldParentNode.id : null;
const newParentId = parent ? parent.id : null;
const parentChanged = oldParentId !== newParentId;
const elementChanged = oldStateNode.$el !== node.$el;
const wasRemovedBefore = oldStateNode.measuredIsRemoved;
const isRemovedNow = node.measuredIsRemoved;
// Recalculate postion relative to their parent for elements that have been moved
if (!oldStateNode.measuredIsRemoved && !isRemovedNow && !hasNoOldState && (parentChanged || elementChanged)) {
const oldAbsoluteLeft = oldStateNode.properties.left;
const oldAbsoluteTop = oldStateNode.properties.top;
const newParent = parent || newState.rootNode;
const oldParent = newParent.id ? oldState.nodes.get(newParent.id) : null;
const parentLeft = oldParent ? oldParent.properties.left : newParent.properties.left;
const parentTop = oldParent ? oldParent.properties.top : newParent.properties.top;
const borderLeft = oldParent ? oldParent.properties.clientLeft : newParent.properties.clientLeft;
const borderTop = oldParent ? oldParent.properties.clientTop : newParent.properties.clientTop;
oldStateNode.properties.x = oldAbsoluteLeft - parentLeft - borderLeft;
oldStateNode.properties.y = oldAbsoluteTop - parentTop - borderTop;
}
if (node.hasVisibilitySwap) {
if (node.hasVisibilityHidden) {
node.$el.style.visibility = 'visible';
node.$measure.style.visibility = 'hidden';
}
if (node.hasDisplayNone) {
node.$el.style.display = oldStateNode.measuredDisplay || node.measuredDisplay || '';
// Setting visibility 'hidden' instead of display none to avoid calculation issues
node.$measure.style.visibility = 'hidden';
// @TODO: check why setting display here can cause calculation issues
// node.$measure.style.display = 'none';
}
}
const wasPendingRemoval = pendingRemoval.has($el);
const wasVisibleBefore = oldStateNode.measuredIsVisible;
const isVisibleNow = node.measuredIsVisible;
const becomeVisible = !wasVisibleBefore && isVisibleNow && !parentNotRendered;
const topLevelAdded = !isRemovedNow && (wasRemovedBefore || wasPendingRemoval) && !parentAdded;
const newlyRemoved = isRemovedNow && !wasRemovedBefore && !parentRemoved;
const topLevelRemoved = newlyRemoved || isRemovedNow && wasPendingRemoval && !parentRemoved;
node.branchAdded = parentAdded || topLevelAdded;
node.branchRemoved = parentRemoved || topLevelRemoved;
node.branchNotRendered = parentNotRendered || isRemovedNow;
if (isRemovedNow && wasVisibleBefore) {
node.$el.style.display = oldStateNode.measuredDisplay;
node.$el.style.visibility = 'visible';
cloneNodeProperties(oldStateNode, node, newState);
}
// Node is leaving
if (newlyRemoved) {
if (node.isTarget) {
leaving.push($el);
node.isLeaving = true;
}
pendingRemoval.add($el);
} else if (!isRemovedNow && wasPendingRemoval) {
pendingRemoval.delete($el);
}
// Node is entering
if ((topLevelAdded && !parentNotRendered) || becomeVisible) {
updateNodeProperties(oldStateNode, enterFromProps);
if (node.isTarget) {
entering.push($el);
node.isEntering = true;
}
// Node is leaving
} else if (topLevelRemoved && !parentNotRendered) {
updateNodeProperties(node, leaveToProps);
}
// Node is animating
// The animating array is used only to calculate delays and duration on root children
if (node !== rootNode && node.isTarget && !node.isEntering && !node.isLeaving) {
animating.push($el);
}
targets.push($el);
});
let enteringIndex = 0;
let leavingIndex = 0;
let animatingIndex = 0;
newState.forEachRootNode(node => {
const $el = node.$el;
const parent = node.parentNode;
const oldStateNode = oldState.nodes.get(node.id);
const nodeProperties = node.properties;
const oldStateNodeProperties = oldStateNode.properties;
// Use closest animated parent index and total values so that children staggered delays are in sync with their parent
let animatedParent = parent !== rootNode && parent;
while (animatedParent && !animatedParent.isTarget && animatedParent !== rootNode) {
animatedParent = animatedParent.parentNode;
}
const animatingTotal = animating.length;
// Root is always animated first in sync with the first child (animating.length is the total of children)
if (node === rootNode) {
node.index = 0;
node.total = animatingTotal;
updateNodeTimingParams(node, animationTimings);
} else if (node.isEntering) {
node.index = animatedParent ? animatedParent.index : enteringIndex;
node.total = animatedParent ? animatingTotal : entering.length;
updateNodeTimingParams(node, enterFromTimings);
enteringIndex++;
} else if (node.isLeaving) {
node.index = animatedParent ? animatedParent.index : leavingIndex;
node.total = animatedParent ? animatingTotal : leaving.length;
leavingIndex++;
updateNodeTimingParams(node, leaveToTimings);
} else if (node.isTarget) {
node.index = animatingIndex++;
node.total = animatingTotal;
updateNodeTimingParams(node, animationTimings);
} else {
node.index = animatedParent ? animatedParent.index : 0;
node.total = animatingTotal;
updateNodeTimingParams(node, swapAtTimings);
}
// Make sure the old state node has its inex and total values up to date for valid "from" function values calculation
oldStateNode.index = node.index;
oldStateNode.total = node.total;
// Computes all values up front so we can check for changes and we don't have to re-compute them inside the animation props
for (let prop in nodeProperties) {
nodeProperties[prop] = values.getFunctionValue(nodeProperties[prop], $el, node.index, node.total);
oldStateNodeProperties[prop] = values.getFunctionValue(oldStateNodeProperties[prop], $el, oldStateNode.index, oldStateNode.total);
}
// Use a 1px tolerance to detect dimensions changes to prevent width / height animations on barelly visible elements
const sizeTolerance = 1;
const widthChanged = Math.abs(nodeProperties.width - oldStateNodeProperties.width) > sizeTolerance;
const heightChanged = Math.abs(nodeProperties.height - oldStateNodeProperties.height) > sizeTolerance;
node.sizeChanged = (widthChanged || heightChanged);
// const hiddenStateChanged = (topLevelAdded || newlyRemoved) && wasRemovedBefore !== isRemovedNow;
if (node.isTarget && (!node.measuredIsRemoved && oldStateNode.measuredIsVisible || node.measuredIsRemoved && node.measuredIsVisible)) {
if (nodeProperties.transform !== 'none' || oldStateNodeProperties.transform !== 'none') {
node.hasTransform = true;
transformed.push($el);
}
for (let prop in nodeProperties) {
// if (prop !== 'transform' && (nodeProperties[prop] !== oldStateNodeProperties[prop] || hiddenStateChanged)) {
if (prop !== 'transform' && (nodeProperties[prop] !== oldStateNodeProperties[prop])) {
animated.push($el);
break;
}
}
}
if (!node.isTarget) {
swapping.push($el);
if (node.sizeChanged && parent && parent.isTarget && parent.sizeChanged) {
if (swapAtProps.transform) {
node.hasTransform = true;
transformed.push($el);
}
animatedSwap.push($el);
}
}
});
const timingParams = {
delay: (/** @type {HTMLElement} */$el) => newState.getNode($el).delay,
duration: (/** @type {HTMLElement} */$el) => newState.getNode($el).duration,
ease: (/** @type {HTMLElement} */$el) => newState.getNode($el).ease,
};
tlParams.defaults = timingParams;
this.timeline = timeline.createTimeline(tlParams);
// Imediatly return the timeline if no layout changes detected
if (!animated.length && !transformed.length && !swapping.length) {
// Make sure to restore all CSS transition if no animation
restoreLayoutTransition(this.transitionMuteStore);
return this.timeline.complete();
}
if (targets.length) {
this.root.classList.add('is-animated');
for (let i = 0, l = targets.length; i < l; i++) {
const $el = targets[i];
const id = $el.dataset.layoutId;
const oldNode = oldState.nodes.get(id);
const newNode = newState.nodes.get(id);
const oldNodeState = oldNode.properties;
// muteNodeTransition(newNode);
// Don't animate positions of inlined elements (to avoid text reflow)
if (!newNode.isInlined) {
// Display grid can mess with the absolute positioning, so set it to block during transition
if (oldNode.measuredDisplay === 'grid' || newNode.measuredDisplay === 'grid') $el.style.setProperty('display', 'block', 'important');
// All children must be in position absolute or fixed
if ($el !== $root || this.absoluteCoords) {
$el.style.position = this.absoluteCoords ? 'fixed' : 'absolute';
$el.style.left = '0px';
$el.style.top = '0px';
$el.style.marginLeft = '0px';
$el.style.marginTop = '0px';
$el.style.translate = `${oldNodeState.x}px ${oldNodeState.y}px`;
}
if ($el === $root && newNode.measuredPosition === 'static') {
$el.style.position = 'relative';
// Cancel left / trop in case the static element had muted values now activated by potision relative
$el.style.left = '0px';
$el.style.top = '0px';
}
}
// Animate dimensions for all elements (including inlined)
$el.style.width = `${oldNodeState.width}px`;
$el.style.height = `${oldNodeState.height}px`;
// Overrides user defined min and max to prevents width and height clamping
$el.style.minWidth = `auto`;
$el.style.minHeight = `auto`;
$el.style.maxWidth = `none`;
$el.style.maxHeight = `none`;
}
// Restore the scroll position if the oldState differs from the current state
if (oldState.scrollX !== window.scrollX || oldState.scrollY !== window.scrollY) {
// Restoring in the next frame avoids race conditions if for example a waapi animation commit styles that affect the root height
requestAnimationFrame(() => window.scrollTo(oldState.scrollX, oldState.scrollY));
}
for (let i = 0, l = animated.length; i < l; i++) {
const $el = animated[i];
const id = $el.dataset.layoutId;
const oldNode = oldState.nodes.get(id);
const newNode = newState.nodes.get(id);
const oldNodeState = oldNode.properties;
const newNodeState = newNode.properties;
let nodeHasChanged = false;
/** @type {AnimationParams} */
const animatedProps = {
composition: 'none',
};
if (oldNodeState.width !== newNodeState.width) {
animatedProps.width = [oldNodeState.width, newNodeState.width];
nodeHasChanged = true;
}
if (oldNodeState.height !== newNodeState.height) {
animatedProps.height =