(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Projection = {})); })(this, (function (exports) { 'use strict'; const noop = (any) => any; class Queue { constructor() { this.order = []; this.scheduled = new Set(); } add(process) { if (!this.scheduled.has(process)) { this.scheduled.add(process); this.order.push(process); return true; } } remove(process) { const index = this.order.indexOf(process); if (index !== -1) { this.order.splice(index, 1); this.scheduled.delete(process); } } clear() { this.order.length = 0; this.scheduled.clear(); } } function createRenderStep(runNextFrame) { /** * We create and reuse two queues, one to queue jobs for the current frame * and one for the next. We reuse to avoid triggering GC after x frames. */ let thisFrame = new Queue(); let nextFrame = new Queue(); let numToRun = 0; /** * Track whether we're currently processing jobs in this step. This way * we can decide whether to schedule new jobs for this frame or next. */ let isProcessing = false; let flushNextFrame = false; /** * A set of processes which were marked keepAlive when scheduled. */ const toKeepAlive = new WeakSet(); const step = { /** * Schedule a process to run on the next frame. */ schedule: (callback, keepAlive = false, immediate = false) => { const addToCurrentFrame = immediate && isProcessing; const queue = addToCurrentFrame ? thisFrame : nextFrame; if (keepAlive) toKeepAlive.add(callback); if (queue.add(callback) && addToCurrentFrame && isProcessing) { // If we're adding it to the currently running queue, update its measured size numToRun = thisFrame.order.length; } return callback; }, /** * Cancel the provided callback from running on the next frame. */ cancel: (callback) => { nextFrame.remove(callback); toKeepAlive.delete(callback); }, /** * Execute all schedule callbacks. */ process: (frameData) => { /** * If we're already processing we've probably been triggered by a flushSync * inside an existing process. Instead of executing, mark flushNextFrame * as true and ensure we flush the following frame at the end of this one. */ if (isProcessing) { flushNextFrame = true; return; } isProcessing = true; [thisFrame, nextFrame] = [nextFrame, thisFrame]; // Clear the next frame queue nextFrame.clear(); // Execute this frame numToRun = thisFrame.order.length; if (numToRun) { for (let i = 0; i < numToRun; i++) { const callback = thisFrame.order[i]; callback(frameData); if (toKeepAlive.has(callback)) { step.schedule(callback); runNextFrame(); } } } isProcessing = false; if (flushNextFrame) { flushNextFrame = false; step.process(frameData); } }, }; return step; } const stepsOrder = [ "prepare", "read", "update", "preRender", "render", "postRender", ]; const maxElapsed = 40; function createRenderBatcher(scheduleNextBatch, allowKeepAlive) { let runNextFrame = false; let useDefaultElapsed = true; const state = { delta: 0, timestamp: 0, isProcessing: false, }; const steps = stepsOrder.reduce((acc, key) => { acc[key] = createRenderStep(() => (runNextFrame = true)); return acc; }, {}); const processStep = (stepId) => steps[stepId].process(state); const processBatch = () => { const timestamp = performance.now(); runNextFrame = false; state.delta = useDefaultElapsed ? 1000 / 60 : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1); state.timestamp = timestamp; state.isProcessing = true; stepsOrder.forEach(processStep); state.isProcessing = false; if (runNextFrame && allowKeepAlive) { useDefaultElapsed = false; scheduleNextBatch(processBatch); } }; const wake = () => { runNextFrame = true; useDefaultElapsed = true; if (!state.isProcessing) { scheduleNextBatch(processBatch); } }; const schedule = stepsOrder.reduce((acc, key) => { const step = steps[key]; acc[key] = (process, keepAlive = false, immediate = false) => { if (!runNextFrame) wake(); return step.schedule(process, keepAlive, immediate); }; return acc; }, {}); const cancel = (process) => stepsOrder.forEach((key) => steps[key].cancel(process)); return { schedule, cancel, state, steps }; } const { schedule: frame, cancel: cancelFrame, state: frameData, steps, } = createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true); function addUniqueItem(arr, item) { if (arr.indexOf(item) === -1) arr.push(item); } function removeItem(arr, item) { const index = arr.indexOf(item); if (index > -1) arr.splice(index, 1); } class SubscriptionManager { constructor() { this.subscriptions = []; } add(handler) { addUniqueItem(this.subscriptions, handler); return () => removeItem(this.subscriptions, handler); } notify(a, b, c) { const numSubscriptions = this.subscriptions.length; if (!numSubscriptions) return; if (numSubscriptions === 1) { /** * If there's only a single handler we can just call it without invoking a loop. */ this.subscriptions[0](a, b, c); } else { for (let i = 0; i < numSubscriptions; i++) { /** * Check whether the handler exists before firing as it's possible * the subscriptions were modified during this loop running. */ const handler = this.subscriptions[i]; handler && handler(a, b, c); } } } getSize() { return this.subscriptions.length; } clear() { this.subscriptions.length = 0; } } // Accepts an easing function and returns a new one that outputs mirrored values for // the second half of the animation. Turns easeIn into easeInOut. const mirrorEasing = (easing) => (p) => p <= 0.5 ? easing(2 * p) / 2 : (2 - easing(2 * (1 - p))) / 2; // Accepts an easing function and returns a new one that outputs reversed values. // Turns easeIn into easeOut. const reverseEasing = (easing) => (p) => 1 - easing(1 - p); const circIn = (p) => 1 - Math.sin(Math.acos(p)); const circOut = reverseEasing(circIn); const circInOut = mirrorEasing(circIn); /* Progress within given range Given a lower limit and an upper limit, we return the progress (expressed as a number 0-1) represented by the given value, and limit that progress to within 0-1. @param [number]: Lower limit @param [number]: Upper limit @param [number]: Value to find progress within given range @return [number]: Progress of value within range as expressed 0-1 */ const progress = (from, to, value) => { const toFromDifference = to - from; return toFromDifference === 0 ? 1 : (value - from) / toFromDifference; }; /* Value in range from progress Given a lower limit and an upper limit, we return the value within that range as expressed by progress (usually a number from 0 to 1) So progress = 0.5 would change from -------- to to from ---- to E.g. from = 10, to = 20, progress = 0.5 => 15 @param [number]: Lower limit of range @param [number]: Upper limit of range @param [number]: The progress between lower and upper limits expressed 0-1 @return [number]: Value as calculated from progress within range (not limited within range) */ const mix = (from, to, progress) => -progress * from + progress * to + from; /** * TODO: When we move from string as a source of truth to data models * everything in this folder should probably be referred to as models vs types */ // If this number is a decimal, make it just five decimal places // to avoid exponents const sanitize = (v) => Math.round(v * 100000) / 100000; const floatRegex = /(-)?([\d]*\.?[\d])+/g; const colorRegex = /(#[0-9a-f]{3,8}|(rgb|hsl)a?\((-?[\d\.]+%?[,\s]+){2}(-?[\d\.]+%?)\s*[\,\/]?\s*[\d\.]*%?\))/gi; const singleColorRegex = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\((-?[\d\.]+%?[,\s]+){2}(-?[\d\.]+%?)\s*[\,\/]?\s*[\d\.]*%?\))$/i; function isString(v) { return typeof v === "string"; } const createUnitType = (unit) => ({ test: (v) => isString(v) && v.endsWith(unit) && v.split(" ").length === 1, parse: parseFloat, transform: (v) => `${v}${unit}`, }); const degrees = createUnitType("deg"); const percent = createUnitType("%"); const px = createUnitType("px"); const vh = createUnitType("vh"); const vw = createUnitType("vw"); const progressPercentage = { ...percent, parse: (v) => percent.parse(v) / 100, transform: (v) => percent.transform(v * 100), }; const borders = ["TopLeft", "TopRight", "BottomLeft", "BottomRight"]; const numBorders = borders.length; const asNumber = (value) => typeof value === "string" ? parseFloat(value) : value; const isPx = (value) => typeof value === "number" || px.test(value); function mixValues(target, follow, lead, progress, shouldCrossfadeOpacity, isOnlyMember) { if (shouldCrossfadeOpacity) { target.opacity = mix(0, // TODO Reinstate this if only child lead.opacity !== undefined ? lead.opacity : 1, easeCrossfadeIn(progress)); target.opacityExit = mix(follow.opacity !== undefined ? follow.opacity : 1, 0, easeCrossfadeOut(progress)); } else if (isOnlyMember) { target.opacity = mix(follow.opacity !== undefined ? follow.opacity : 1, lead.opacity !== undefined ? lead.opacity : 1, progress); } /** * Mix border radius */ for (let i = 0; i < numBorders; i++) { const borderLabel = `border${borders[i]}Radius`; let followRadius = getRadius(follow, borderLabel); let leadRadius = getRadius(lead, borderLabel); if (followRadius === undefined && leadRadius === undefined) continue; followRadius || (followRadius = 0); leadRadius || (leadRadius = 0); const canMix = followRadius === 0 || leadRadius === 0 || isPx(followRadius) === isPx(leadRadius); if (canMix) { target[borderLabel] = Math.max(mix(asNumber(followRadius), asNumber(leadRadius), progress), 0); if (percent.test(leadRadius) || percent.test(followRadius)) { target[borderLabel] += "%"; } } else { target[borderLabel] = leadRadius; } } /** * Mix rotation */ if (follow.rotate || lead.rotate) { target.rotate = mix(follow.rotate || 0, lead.rotate || 0, progress); } } function getRadius(values, radiusName) { return values[radiusName] !== undefined ? values[radiusName] : values.borderRadius; } // /** // * We only want to mix the background color if there's a follow element // * that we're not crossfading opacity between. For instance with switch // * AnimateSharedLayout animations, this helps the illusion of a continuous // * element being animated but also cuts down on the number of paints triggered // * for elements where opacity is doing that work for us. // */ // if ( // !hasFollowElement && // latestLeadValues.backgroundColor && // latestFollowValues.backgroundColor // ) { // /** // * This isn't ideal performance-wise as mixColor is creating a new function every frame. // * We could probably create a mixer that runs at the start of the animation but // * the idea behind the crossfader is that it runs dynamically between two potentially // * changing targets (ie opacity or borderRadius may be animating independently via variants) // */ // leadState.backgroundColor = followState.backgroundColor = mixColor( // latestFollowValues.backgroundColor as string, // latestLeadValues.backgroundColor as string // )(p) // } const easeCrossfadeIn = compress(0, 0.5, circOut); const easeCrossfadeOut = compress(0.5, 0.95, noop); function compress(min, max, easing) { return (p) => { // Could replace ifs with clamp if (p < min) return 0; if (p > max) return 1; return easing(progress(min, max, p)); }; } /** * Reset an axis to the provided origin box. * * This is a mutative operation. */ function copyAxisInto(axis, originAxis) { axis.min = originAxis.min; axis.max = originAxis.max; } /** * Reset a box to the provided origin box. * * This is a mutative operation. */ function copyBoxInto(box, originBox) { copyAxisInto(box.x, originBox.x); copyAxisInto(box.y, originBox.y); } function isIdentityScale(scale) { return scale === undefined || scale === 1; } function hasScale({ scale, scaleX, scaleY }) { return (!isIdentityScale(scale) || !isIdentityScale(scaleX) || !isIdentityScale(scaleY)); } function hasTransform(values) { return (hasScale(values) || has2DTranslate(values) || values.z || values.rotate || values.rotateX || values.rotateY); } function has2DTranslate(values) { return is2DTranslate(values.x) || is2DTranslate(values.y); } function is2DTranslate(value) { return value && value !== "0%"; } /** * Scales a point based on a factor and an originPoint */ function scalePoint(point, scale, originPoint) { const distanceFromOrigin = point - originPoint; const scaled = scale * distanceFromOrigin; return originPoint + scaled; } /** * Applies a translate/scale delta to a point */ function applyPointDelta(point, translate, scale, originPoint, boxScale) { if (boxScale !== undefined) { point = scalePoint(point, boxScale, originPoint); } return scalePoint(point, scale, originPoint) + translate; } /** * Applies a translate/scale delta to an axis */ function applyAxisDelta(axis, translate = 0, scale = 1, originPoint, boxScale) { axis.min = applyPointDelta(axis.min, translate, scale, originPoint, boxScale); axis.max = applyPointDelta(axis.max, translate, scale, originPoint, boxScale); } /** * Applies a translate/scale delta to a box */ function applyBoxDelta(box, { x, y }) { applyAxisDelta(box.x, x.translate, x.scale, x.originPoint); applyAxisDelta(box.y, y.translate, y.scale, y.originPoint); } /** * Apply a tree of deltas to a box. We do this to calculate the effect of all the transforms * in a tree upon our box before then calculating how to project it into our desired viewport-relative box * * This is the final nested loop within updateLayoutDelta for future refactoring */ function applyTreeDeltas(box, treeScale, treePath, isSharedTransition = false) { const treeLength = treePath.length; if (!treeLength) return; // Reset the treeScale treeScale.x = treeScale.y = 1; let node; let delta; for (let i = 0; i < treeLength; i++) { node = treePath[i]; delta = node.projectionDelta; /** * TODO: Prefer to remove this, but currently we have motion components with * display: contents in Framer. */ const instance = node.instance; if (instance && instance.style && instance.style.display === "contents") { continue; } if (isSharedTransition && node.options.layoutScroll && node.scroll && node !== node.root) { transformBox(box, { x: -node.scroll.offset.x, y: -node.scroll.offset.y, }); } if (delta) { // Incoporate each ancestor's scale into a culmulative treeScale for this component treeScale.x *= delta.x.scale; treeScale.y *= delta.y.scale; // Apply each ancestor's calculated delta into this component's recorded layout box applyBoxDelta(box, delta); } if (isSharedTransition && hasTransform(node.latestValues)) { transformBox(box, node.latestValues); } } /** * Snap tree scale back to 1 if it's within a non-perceivable threshold. * This will help reduce useless scales getting rendered. */ treeScale.x = snapToDefault(treeScale.x); treeScale.y = snapToDefault(treeScale.y); } function snapToDefault(scale) { if (Number.isInteger(scale)) return scale; return scale > 1.0000000000001 || scale < 0.999999999999 ? scale : 1; } function translateAxis(axis, distance) { axis.min = axis.min + distance; axis.max = axis.max + distance; } /** * Apply a transform to an axis from the latest resolved motion values. * This function basically acts as a bridge between a flat motion value map * and applyAxisDelta */ function transformAxis(axis, transforms, [key, scaleKey, originKey]) { const axisOrigin = transforms[originKey] !== undefined ? transforms[originKey] : 0.5; const originPoint = mix(axis.min, axis.max, axisOrigin); // Apply the axis delta to the final axis applyAxisDelta(axis, transforms[key], transforms[scaleKey], originPoint, transforms.scale); } /** * The names of the motion values we want to apply as translation, scale and origin. */ const xKeys$1 = ["x", "scaleX", "originX"]; const yKeys$1 = ["y", "scaleY", "originY"]; /** * Apply a transform to a box from the latest resolved motion values. */ function transformBox(box, transform) { transformAxis(box.x, transform, xKeys$1); transformAxis(box.y, transform, yKeys$1); } function calcLength(axis) { return axis.max - axis.min; } function isNear(value, target = 0, maxDistance = 0.01) { return Math.abs(value - target) <= maxDistance; } function calcAxisDelta(delta, source, target, origin = 0.5) { delta.origin = origin; delta.originPoint = mix(source.min, source.max, delta.origin); delta.scale = calcLength(target) / calcLength(source); if (isNear(delta.scale, 1, 0.0001) || isNaN(delta.scale)) delta.scale = 1; delta.translate = mix(target.min, target.max, delta.origin) - delta.originPoint; if (isNear(delta.translate) || isNaN(delta.translate)) delta.translate = 0; } function calcBoxDelta(delta, source, target, origin) { calcAxisDelta(delta.x, source.x, target.x, origin ? origin.originX : undefined); calcAxisDelta(delta.y, source.y, target.y, origin ? origin.originY : undefined); } function calcRelativeAxis(target, relative, parent) { target.min = parent.min + relative.min; target.max = target.min + calcLength(relative); } function calcRelativeBox(target, relative, parent) { calcRelativeAxis(target.x, relative.x, parent.x); calcRelativeAxis(target.y, relative.y, parent.y); } function calcRelativeAxisPosition(target, layout, parent) { target.min = layout.min - parent.min; target.max = target.min + calcLength(layout); } function calcRelativePosition(target, layout, parent) { calcRelativeAxisPosition(target.x, layout.x, parent.x); calcRelativeAxisPosition(target.y, layout.y, parent.y); } /** * Remove a delta from a point. This is essentially the steps of applyPointDelta in reverse */ function removePointDelta(point, translate, scale, originPoint, boxScale) { point -= translate; point = scalePoint(point, 1 / scale, originPoint); if (boxScale !== undefined) { point = scalePoint(point, 1 / boxScale, originPoint); } return point; } /** * Remove a delta from an axis. This is essentially the steps of applyAxisDelta in reverse */ function removeAxisDelta(axis, translate = 0, scale = 1, origin = 0.5, boxScale, originAxis = axis, sourceAxis = axis) { if (percent.test(translate)) { translate = parseFloat(translate); const relativeProgress = mix(sourceAxis.min, sourceAxis.max, translate / 100); translate = relativeProgress - sourceAxis.min; } if (typeof translate !== "number") return; let originPoint = mix(originAxis.min, originAxis.max, origin); if (axis === originAxis) originPoint -= translate; axis.min = removePointDelta(axis.min, translate, scale, originPoint, boxScale); axis.max = removePointDelta(axis.max, translate, scale, originPoint, boxScale); } /** * Remove a transforms from an axis. This is essentially the steps of applyAxisTransforms in reverse * and acts as a bridge between motion values and removeAxisDelta */ function removeAxisTransforms(axis, transforms, [key, scaleKey, originKey], origin, sourceAxis) { removeAxisDelta(axis, transforms[key], transforms[scaleKey], transforms[originKey], transforms.scale, origin, sourceAxis); } /** * The names of the motion values we want to apply as translation, scale and origin. */ const xKeys = ["x", "scaleX", "originX"]; const yKeys = ["y", "scaleY", "originY"]; /** * Remove a transforms from an box. This is essentially the steps of applyAxisBox in reverse * and acts as a bridge between motion values and removeAxisDelta */ function removeBoxTransforms(box, transforms, originBox, sourceBox) { removeAxisTransforms(box.x, transforms, xKeys, originBox ? originBox.x : undefined, sourceBox ? sourceBox.x : undefined); removeAxisTransforms(box.y, transforms, yKeys, originBox ? originBox.y : undefined, sourceBox ? sourceBox.y : undefined); } const createAxisDelta = () => ({ translate: 0, scale: 1, origin: 0, originPoint: 0, }); const createDelta = () => ({ x: createAxisDelta(), y: createAxisDelta(), }); const createAxis = () => ({ min: 0, max: 0 }); const createBox = () => ({ x: createAxis(), y: createAxis(), }); /** * Decide whether a transition is defined on a given Transition. * This filters out orchestration options and returns true * if any options are left. */ function isTransitionDefined({ when, delay: _delay, delayChildren, staggerChildren, staggerDirection, repeat, repeatType, repeatDelay, from, elapsed, ...transition }) { return !!Object.keys(transition).length; } function getValueTransition(transition, key) { return transition[key] || transition["default"] || transition; } function isAxisDeltaZero(delta) { return delta.translate === 0 && delta.scale === 1; } function isDeltaZero(delta) { return isAxisDeltaZero(delta.x) && isAxisDeltaZero(delta.y); } function boxEquals(a, b) { return (a.x.min === b.x.min && a.x.max === b.x.max && a.y.min === b.y.min && a.y.max === b.y.max); } function boxEqualsRounded(a, b) { return (Math.round(a.x.min) === Math.round(b.x.min) && Math.round(a.x.max) === Math.round(b.x.max) && Math.round(a.y.min) === Math.round(b.y.min) && Math.round(a.y.max) === Math.round(b.y.max)); } function aspectRatio(box) { return calcLength(box.x) / calcLength(box.y); } class NodeStack { constructor() { this.members = []; } add(node) { addUniqueItem(this.members, node); node.scheduleRender(); } remove(node) { removeItem(this.members, node); if (node === this.prevLead) { this.prevLead = undefined; } if (node === this.lead) { const prevLead = this.members[this.members.length - 1]; if (prevLead) { this.promote(prevLead); } } } relegate(node) { const indexOfNode = this.members.findIndex((member) => node === member); if (indexOfNode === 0) return false; /** * Find the next projection node that is present */ let prevLead; for (let i = indexOfNode; i >= 0; i--) { const member = this.members[i]; if (member.isPresent !== false) { prevLead = member; break; } } if (prevLead) { this.promote(prevLead); return true; } else { return false; } } promote(node, preserveFollowOpacity) { const prevLead = this.lead; if (node === prevLead) return; this.prevLead = prevLead; this.lead = node; node.show(); if (prevLead) { prevLead.instance && prevLead.scheduleRender(); node.scheduleRender(); node.resumeFrom = prevLead; if (preserveFollowOpacity) { node.resumeFrom.preserveOpacity = true; } if (prevLead.snapshot) { node.snapshot = prevLead.snapshot; node.snapshot.latestValues = prevLead.animationValues || prevLead.latestValues; } if (node.root && node.root.isUpdating) { node.isLayoutDirty = true; } const { crossfade } = node.options; if (crossfade === false) { prevLead.hide(); } /** * TODO: * - Test border radius when previous node was deleted * - boxShadow mixing * - Shared between element A in scrolled container and element B (scroll stays the same or changes) * - Shared between element A in transformed container and element B (transform stays the same or changes) * - Shared between element A in scrolled page and element B (scroll stays the same or changes) * --- * - Crossfade opacity of root nodes * - layoutId changes after animation * - layoutId changes mid animation */ } } exitAnimationComplete() { this.members.forEach((node) => { const { options, resumingFrom } = node; options.onExitComplete && options.onExitComplete(); if (resumingFrom) { resumingFrom.options.onExitComplete && resumingFrom.options.onExitComplete(); } }); } scheduleRender() { this.members.forEach((node) => { node.instance && node.scheduleRender(false); }); } /** * Clear any leads that have been removed this render to prevent them from being * used in future animations and to prevent memory leaks */ removeLeadSnapshot() { if (this.lead && this.lead.snapshot) { this.lead.snapshot = undefined; } } } const scaleCorrectors = {}; function addScaleCorrector(correctors) { Object.assign(scaleCorrectors, correctors); } function buildProjectionTransform(delta, treeScale, latestTransform) { let transform = ""; /** * The translations we use to calculate are always relative to the viewport coordinate space. * But when we apply scales, we also scale the coordinate space of an element and its children. * For instance if we have a treeScale (the culmination of all parent scales) of 0.5 and we need * to move an element 100 pixels, we actually need to move it 200 in within that scaled space. */ const xTranslate = delta.x.translate / treeScale.x; const yTranslate = delta.y.translate / treeScale.y; if (xTranslate || yTranslate) { transform = `translate3d(${xTranslate}px, ${yTranslate}px, 0) `; } /** * Apply scale correction for the tree transform. * This will apply scale to the screen-orientated axes. */ if (treeScale.x !== 1 || treeScale.y !== 1) { transform += `scale(${1 / treeScale.x}, ${1 / treeScale.y}) `; } if (latestTransform) { const { rotate, rotateX, rotateY } = latestTransform; if (rotate) transform += `rotate(${rotate}deg) `; if (rotateX) transform += `rotateX(${rotateX}deg) `; if (rotateY) transform += `rotateY(${rotateY}deg) `; } /** * Apply scale to match the size of the element to the size we want it. * This will apply scale to the element-orientated axes. */ const elementScaleX = delta.x.scale * treeScale.x; const elementScaleY = delta.y.scale * treeScale.y; if (elementScaleX !== 1 || elementScaleY !== 1) { transform += `scale(${elementScaleX}, ${elementScaleY})`; } return transform || "none"; } function eachAxis(callback) { return [callback("x"), callback("y")]; } const compareByDepth = (a, b) => a.depth - b.depth; class FlatTree { constructor() { this.children = []; this.isDirty = false; } add(child) { addUniqueItem(this.children, child); this.isDirty = true; } remove(child) { removeItem(this.children, child); this.isDirty = true; } forEach(callback) { this.isDirty && this.children.sort(compareByDepth); this.isDirty = false; this.children.forEach(callback); } } const isKeyframesTarget = (v) => { return Array.isArray(v); }; const isCustomValue = (v) => { return Boolean(v && typeof v === "object" && v.mix && v.toValue); }; const isMotionValue = (value) => Boolean(value && value.getVelocity); /** * If the provided value is a MotionValue, this returns the actual value, otherwise just the value itself * * TODO: Remove and move to library */ function resolveMotionValue(value) { const unwrappedValue = isMotionValue(value) ? value.get() : value; return isCustomValue(unwrappedValue) ? unwrappedValue.toValue() : unwrappedValue; } /** * This should only ever be modified on the client otherwise it'll * persist through server requests. If we need instanced states we * could lazy-init via root. */ const globalProjectionState = { /** * Global flag as to whether the tree has animated since the last time * we resized the window */ hasAnimatedSinceResize: true, /** * We set this to true once, on the first update. Any nodes added to the tree beyond that * update will be given a `data-projection-id` attribute. */ hasEverUpdated: false, }; /** * Timeout defined in ms */ function delay(callback, timeout) { const start = performance.now(); const checkElapsed = ({ timestamp }) => { const elapsed = timestamp - start; if (elapsed >= timeout) { cancelFrame(checkElapsed); callback(elapsed - timeout); } }; frame.read(checkElapsed, true); return () => cancelFrame(checkElapsed); } function record(data) { if (window.MotionDebug) { window.MotionDebug.record(data); } } /* Convert velocity into velocity per second @param [number]: Unit per frame @param [number]: Frame duration in ms */ function velocityPerSecond(velocity, frameDuration) { return frameDuration ? velocity * (1000 / frameDuration) : 0; } const warned = new Set(); function warnOnce(condition, message, element) { if (condition || warned.has(message)) return; console.warn(message); if (element) console.warn(element); warned.add(message); } const isFloat = (value) => { return !isNaN(parseFloat(value)); }; /** * `MotionValue` is used to track the state and velocity of motion values. * * @public */ class MotionValue { /** * @param init - The initiating value * @param config - Optional configuration options * * - `transformer`: A function to transform incoming values with. * * @internal */ constructor(init, options = {}) { /** * This will be replaced by the build step with the latest version number. * When MotionValues are provided to motion components, warn if versions are mixed. */ this.version = "10.18.0"; /** * Duration, in milliseconds, since last updating frame. * * @internal */ this.timeDelta = 0; /** * Timestamp of the last time this `MotionValue` was updated. * * @internal */ this.lastUpdated = 0; /** * Tracks whether this value can output a velocity. Currently this is only true * if the value is numerical, but we might be able to widen the scope here and support * other value types. * * @internal */ this.canTrackVelocity = false; /** * An object containing a SubscriptionManager for each active event. */ this.events = {}; this.updateAndNotify = (v, render = true) => { this.prev = this.current; this.current = v; // Update timestamp const { delta, timestamp } = frameData; if (this.lastUpdated !== timestamp) { this.timeDelta = delta; this.lastUpdated = timestamp; frame.postRender(this.scheduleVelocityCheck); } // Update update subscribers if (this.prev !== this.current && this.events.change) { this.events.change.notify(this.current); } // Update velocity subscribers if (this.events.velocityChange) { this.events.velocityChange.notify(this.getVelocity()); } // Update render subscribers if (render && this.events.renderRequest) { this.events.renderRequest.notify(this.current); } }; /** * Schedule a velocity check for the next frame. * * This is an instanced and bound function to prevent generating a new * function once per frame. * * @internal */ this.scheduleVelocityCheck = () => frame.postRender(this.velocityCheck); /** * Updates `prev` with `current` if the value hasn't been updated this frame. * This ensures velocity calculations return `0`. * * This is an instanced and bound function to prevent generating a new * function once per frame. * * @internal */ this.velocityCheck = ({ timestamp }) => { if (timestamp !== this.lastUpdated) { this.prev = this.current; if (this.events.velocityChange) { this.events.velocityChange.notify(this.getVelocity()); } } }; this.hasAnimated = false; this.prev = this.current = init; this.canTrackVelocity = isFloat(this.current); this.owner = options.owner; } /** * Adds a function that will be notified when the `MotionValue` is updated. * * It returns a function that, when called, will cancel the subscription. * * When calling `onChange` inside a React component, it should be wrapped with the * `useEffect` hook. As it returns an unsubscribe function, this should be returned * from the `useEffect` function to ensure you don't add duplicate subscribers.. * * ```jsx * export const MyComponent = () => { * const x = useMotionValue(0) * const y = useMotionValue(0) * const opacity = useMotionValue(1) * * useEffect(() => { * function updateOpacity() { * const maxXY = Math.max(x.get(), y.get()) * const newOpacity = transform(maxXY, [0, 100], [1, 0]) * opacity.set(newOpacity) * } * * const unsubscribeX = x.on("change", updateOpacity) * const unsubscribeY = y.on("change", updateOpacity) * * return () => { * unsubscribeX() * unsubscribeY() * } * }, []) * * return * } * ``` * * @param subscriber - A function that receives the latest value. * @returns A function that, when called, will cancel this subscription. * * @deprecated */ onChange(subscription) { { warnOnce(false, `value.onChange(callback) is deprecated. Switch to value.on("change", callback).`); } return this.on("change", subscription); } on(eventName, callback) { if (!this.events[eventName]) { this.events[eventName] = new SubscriptionManager(); } const unsubscribe = this.events[eventName].add(callback); if (eventName === "change") { return () => { unsubscribe(); /** * If we have no more change listeners by the start * of the next frame, stop active animations. */ frame.read(() => { if (!this.events.change.getSize()) { this.stop(); } }); }; } return unsubscribe; } clearListeners() { for (const eventManagers in this.events) { this.events[eventManagers].clear(); } } /** * Attaches a passive effect to the `MotionValue`. * * @internal */ attach(passiveEffect, stopPassiveEffect) { this.passiveEffect = passiveEffect; this.stopPassiveEffect = stopPassiveEffect; } /** * Sets the state of the `MotionValue`. * * @remarks * * ```jsx * const x = useMotionValue(0) * x.set(10) * ``` * * @param latest - Latest value to set. * @param render - Whether to notify render subscribers. Defaults to `true` * * @public */ set(v, render = true) { if (!render || !this.passiveEffect) { this.updateAndNotify(v, render); } else { this.passiveEffect(v, this.updateAndNotify); } } setWithVelocity(prev, current, delta) { this.set(current); this.prev = prev; this.timeDelta = delta; } /** * Set the state of the `MotionValue`, stopping any active animations, * effects, and resets velocity to `0`. */ jump(v) { this.updateAndNotify(v); this.prev = v; this.stop(); if (this.stopPassiveEffect) this.stopPassiveEffect(); } /** * Returns the latest state of `MotionValue` * * @returns - The latest state of `MotionValue` * * @public */ get() { return this.current; } /** * @public */ getPrevious() { return this.prev; } /** * Returns the latest velocity of `MotionValue` * * @returns - The latest velocity of `MotionValue`. Returns `0` if the state is non-numerical. * * @public */ getVelocity() { // This could be isFloat(this.prev) && isFloat(this.current), but that would be wasteful return this.canTrackVelocity ? // These casts could be avoided if parseFloat would be typed better velocityPerSecond(parseFloat(this.current) - parseFloat(this.prev), this.timeDelta) : 0; } /** * Registers a new animation to control this `MotionValue`. Only one * animation can drive a `MotionValue` at one time. * * ```jsx * value.start() * ``` * * @param animation - A function that starts the provided animation * * @internal */ start(startAnimation) { this.stop(); return new Promise((resolve) => { this.hasAnimated = true; this.animation = startAnimation(resolve); if (this.events.animationStart) { this.events.animationStart.notify(); } }).then(() => { if (this.events.animationComplete) { this.events.animationComplete.notify(); } this.clearAnimation(); }); } /** * Stop the currently active animation. * * @public */ stop() { if (this.animation) { this.animation.stop(); if (this.events.animationCancel) { this.events.animationCancel.notify(); } } this.clearAnimation(); } /** * Returns `true` if this value is currently animating. * * @public */ isAnimating() { return !!this.animation; } clearAnimation() { delete this.animation; } /** * Destroy and clean up subscribers to this `MotionValue`. * * The `MotionValue` hooks like `useMotionValue` and `useTransform` automatically * handle the lifecycle of the returned `MotionValue`, so this method is only necessary if you've manually * created a `MotionValue` via the `motionValue` function. * * @public */ destroy() { this.clearListeners(); this.stop(); if (this.stopPassiveEffect) { this.stopPassiveEffect(); } } } function motionValue(init, options) { return new MotionValue(init, options); } let warning = noop; let invariant = noop; { warning = (check, message) => { if (!check && typeof console !== "undefined") { console.warn(message); } }; invariant = (check, message) => { if (!check) { throw new Error(message); } }; } const visualElementStore = new WeakMap(); function memo(callback) { let result; return () => { if (result === undefined) result = callback(); return result; }; } /** * Generate a list of every possible transform key. */ const transformPropOrder = [ "transformPerspective", "x", "y", "z", "translateX", "translateY", "translateZ", "scale", "scaleX", "scaleY", "rotate", "rotateX", "rotateY", "rotateZ", "skew", "skewX", "skewY", ]; /** * A quick lookup for transform props. */ const transformProps = new Set(transformPropOrder); /** * Converts seconds to milliseconds * * @param seconds - Time in seconds. * @return milliseconds - Converted time in milliseconds. */ const secondsToMilliseconds = (seconds) => seconds * 1000; const millisecondsToSeconds = (milliseconds) => milliseconds / 1000; const instantAnimationState = { current: false, }; const isBezierDefinition = (easing) => Array.isArray(easing) && typeof easing[0] === "number"; function isWaapiSupportedEasing(easing) { return Boolean(!easing || (typeof easing === "string" && supportedWaapiEasing[easing]) || isBezierDefinition(easing) || (Array.isArray(easing) && easing.every(isWaapiSupportedEasing))); } const cubicBezierAsString = ([a, b, c, d]) => `cubic-bezier(${a}, ${b}, ${c}, ${d})`; const supportedWaapiEasing = { linear: "linear", ease: "ease", easeIn: "ease-in", easeOut: "ease-out", easeInOut: "ease-in-out", circIn: cubicBezierAsString([0, 0.65, 0.55, 1]), circOut: cubicBezierAsString([0.55, 0, 1, 0.45]), backIn: cubicBezierAsString([0.31, 0.01, 0.66, -0.59]), backOut: cubicBezierAsString([0.33, 1.53, 0.69, 0.99]), }; function mapEasingToNativeEasing(easing) { if (!easing) return undefined; return isBezierDefinition(easing) ? cubicBezierAsString(easing) : Array.isArray(easing) ? easing.map(mapEasingToNativeEasing) : supportedWaapiEasing[easing]; } function animateStyle(element, valueName, keyframes, { delay = 0, duration, repeat = 0, repeatType = "loop", ease, times, } = {}) { const keyframeOptions = { [valueName]: keyframes }; if (times) keyframeOptions.offset = times; const easing = mapEasingToNativeEasing(ease); /** * If this is an easing array, apply to keyframes, not animation as a whole */ if (Array.isArray(easing)) keyframeOptions.easing = easing; return element.animate(keyframeOptions, { delay, duration, easing: !Array.isArray(easing) ? easing : "linear", fill: "both", iterations: repeat + 1, direction: repeatType === "reverse" ? "alternate" : "normal", }); } function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }) { const index = repeat && repeatType !== "loop" && repeat % 2 === 1 ? 0 : keyframes.length - 1; return keyframes[index]; } /* Bezier function generator This has been modified from GaĆ«tan Renaudeau's BezierEasing https://github.com/gre/bezier-easing/blob/master/src/index.js https://github.com/gre/bezier-easing/blob/master/LICENSE I've removed the newtonRaphsonIterate algo because in benchmarking it wasn't noticiably faster than binarySubdivision, indeed removing it usually improved times, depending on the curve. I also removed the lookup table, as for the added bundle size and loop we're only cutting ~4 or so subdivision iterations. I bumped the max iterations up to 12 to compensate and this still tended to be faster for no perceivable loss in accuracy. Usage const easeOut = cubicBezier(.17,.67,.83,.67); const x = easeOut(0.5); // returns 0.627... */ // Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2. const calcBezier = (t, a1, a2) => (((1.0 - 3.0 * a2 + 3.0 * a1) * t + (3.0 * a2 - 6.0 * a1)) * t + 3.0 * a1) * t; const subdivisionPrecision = 0.0000001; const subdivisionMaxIterations = 12; function binarySubdivide(x, lowerBound, upperBound, mX1, mX2) { let currentX; let currentT; let i = 0; do { currentT = lowerBound + (upperBound - lowerBound) / 2.0; currentX = calcBezier(currentT, mX1, mX2) - x; if (currentX > 0.0) { upperBound = currentT; } else { lowerBound = currentT; } } while (Math.abs(currentX) > subdivisionPrecision && ++i < subdivisionMaxIterations); return currentT; } function cubicBezier(mX1, mY1, mX2, mY2) { // If this is a linear gradient, return linear easing if (mX1 === mY1 && mX2 === mY2) return noop; const getTForX = (aX) => binarySubdivide(aX, 0, 1, mX1, mX2); // If animation is at start/end, return t without easing return (t) => t === 0 || t === 1 ? t : calcBezier(getTForX(t), mY1, mY2); } const easeIn = cubicBezier(0.42, 0, 1, 1); const easeOut = cubicBezier(0, 0, 0.58, 1); const easeInOut = cubicBezier(0.42, 0, 0.58, 1); const isEasingArray = (ease) => { return Array.isArray(ease) && typeof ease[0] !== "number"; }; const backOut = cubicBezier(0.33, 1.53, 0.69, 0.99); const backIn = reverseEasing(backOut); const backInOut = mirrorEasing(backIn); const anticipate = (p) => (p *= 2) < 1 ? 0.5 * backIn(p) : 0.5 * (2 - Math.pow(2, -10 * (p - 1))); const easingLookup = { linear: noop, easeIn, easeInOut, easeOut, circIn, circInOut, circOut, backIn, backInOut, backOut, anticipate, }; const easingDefinitionToFunction = (definition) => { if (Array.isArray(definition)) { // If cubic bezier definition, create bezier curve invariant(definition.length === 4, `Cubic bezier arrays must contain four numerical values.`); const [x1, y1, x2, y2] = definition; return cubicBezier(x1, y1, x2, y2); } else if (typeof definition === "string") { // Else lookup from table invariant(easingLookup[definition] !== undefined, `Invalid easing type '${definition}'`); return easingLookup[definition]; } return definition; }; const clamp = (min, max, v) => Math.min(Math.max(v, min), max); const number = { test: (v) => typeof v === "number", parse: parseFloat, transform: (v) => v, }; const alpha = { ...number, transform: (v) => clamp(0, 1, v), }; const scale = { ...number, default: 1, }; /** * Returns true if the provided string is a color, ie rgba(0,0,0,0) or #000, * but false if a number or multiple colors */ const isColorString = (type, testProp) => (v) => { return Boolean((isString(v) && singleColorRegex.test(v) && v.startsWith(type)) || (testProp && Object.prototype.hasOwnProperty.call(v, testProp))); }; const splitColor = (aName, bName, cName) => (v) => { if (!isString(v)) return v; const [a, b, c, alpha] = v.match(floatRegex); return { [aName]: parseFloat(a), [bName]: parseFloat(b), [cName]: parseFloat(c), alpha: alpha !== undefined ? parseFloat(alpha) : 1, }; }; const clampRgbUnit = (v) => clamp(0, 255, v); const rgbUnit = { ...number, transform: (v) => Math.round(clampRgbUnit(v)), }; const rgba = { test: isColorString("rgb", "red"), parse: splitColor("red", "green", "blue"), transform: ({ red, green, blue, alpha: alpha$1 = 1 }) => "rgba(" + rgbUnit.transform(red) + ", " + rgbUnit.transform(green) + ", " + rgbUnit.transform(blue) + ", " + sanitize(alpha.transform(alpha$1)) + ")", }; function parseHex(v) { let r = ""; let g = ""; let b = ""; let a = ""; // If we have 6 characters, ie #FF0000 if (v.length > 5) { r = v.substring(1, 3); g = v.substring(3, 5); b = v.substring(5, 7); a = v.substring(7, 9); // Or we have 3 characters, ie #F00 } else { r = v.substring(1, 2); g = v.substring(2, 3); b = v.substring(3, 4); a = v.substring(4, 5); r += r; g += g; b += b; a += a; } return { red: parseInt(r, 16), green: parseInt(g, 16), blue: parseInt(b, 16), alpha: a ? parseInt(a, 16) / 255 : 1, }; } const hex = { test: isColorString("#"), parse: parseHex, transform: rgba.transform, }; const hsla = { test: isColorString("hsl", "hue"), parse: splitColor("hue", "saturation", "lightness"), transform: ({ hue, saturation, lightness, alpha: alpha$1 = 1 }) => { return ("hsla(" + Math.round(hue) + ", " + percent.transform(sanitize(saturation)) + ", " + percent.transform(sanitize(lightness)) + ", " + sanitize(alpha.transform(alpha$1)) + ")"); }, }; const color = { test: (v) => rgba.test(v) || hex.test(v) || hsla.test(v), parse: (v) => { if (rgba.test(v)) { return rgba.parse(v); } else if (hsla.test(v)) { return hsla.parse(v); } else { return hex.parse(v); } }, transform: (v) => { return isString(v) ? v : v.hasOwnProperty("red") ? rgba.transform(v) : hsla.transform(v); }, }; // Adapted from https://gist.github.com/mjackson/5311256 function hueToRgb(p, q, t) { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; } function hslaToRgba({ hue, saturation, lightness, alpha }) { hue /= 360; saturation /= 100; lightness /= 100; let red = 0; let green = 0; let blue = 0; if (!saturation) { red = green = blue = lightness; } else { const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation; const p = 2 * lightness - q; red = hueToRgb(p, q, hue + 1 / 3); green = hueToRgb(p, q, hue); blue = hueToRgb(p, q, hue - 1 / 3); } return { red: Math.round(red * 255), green: Math.round(green * 255), blue: Math.round(blue * 255), alpha, }; } // Linear color space blending // Explained https://www.youtube.com/watch?v=LKnqECcg6Gw // Demonstrated http://codepen.io/osublake/pen/xGVVaN const mixLinearColor = (from, to, v) => { const fromExpo = from * from; return Math.sqrt(Math.max(0, v * (to * to - fromExpo) + fromExpo)); }; const colorTypes = [hex, rgba, hsla]; const getColorType = (v) => colorTypes.find((type) => type.test(v)); function asRGBA(color) { const type = getColorType(color); invariant(Boolean(type), `'${color}' is not an animatable color. Use the equivalent color code instead.`); let model = type.parse(color); if (type === hsla) { // TODO Remove this cast - needed since Framer Motion's stricter typing model = hslaToRgba(model); } return model; } const mixColor = (from, to) => { const fromRGBA = asRGBA(from); const toRGBA = asRGBA(to); const blended = { ...fromRGBA }; return (v) => { blended.red = mixLinearColor(fromRGBA.red, toRGBA.red, v); blended.green = mixLinearColor(fromRGBA.green, toRGBA.green, v); blended.blue = mixLinearColor(fromRGBA.blue, toRGBA.blue, v); blended.alpha = mix(fromRGBA.alpha, toRGBA.alpha, v); return rgba.transform(blended); }; }; /** * Pipe * Compose other transformers to run linearily * pipe(min(20), max(40)) * @param {...functions} transformers * @return {function} */ const combineFunctions = (a, b) => (v) => b(a(v)); const pipe = (...transformers) => transformers.reduce(combineFunctions); const checkStringStartsWith = (token) => (key) => typeof key === "string" && key.startsWith(token); const isCSSVariableName = checkStringStartsWith("--"); const isCSSVariableToken = checkStringStartsWith("var(--"); const cssVariableRegex = /var\s*\(\s*--[\w-]+(\s*,\s*(?:(?:[^)(]|\((?:[^)(]+|\([^)(]*\))*\))*)+)?\s*\)/g; function test(v) { var _a, _b; return (isNaN(v) && isString(v) && (((_a = v.match(floatRegex)) === null || _a === void 0 ? void 0 : _a.length) || 0) + (((_b = v.match(colorRegex)) === null || _b === void 0 ? void 0 : _b.length) || 0) > 0); } const cssVarTokeniser = { regex: cssVariableRegex, countKey: "Vars", token: "${v}", parse: noop, }; const colorTokeniser = { regex: colorRegex, countKey: "Colors", token: "${c}", parse: color.parse, }; const numberTokeniser = { regex: floatRegex, countKey: "Numbers", token: "${n}", parse: number.parse, }; function tokenise(info, { regex, countKey, token, parse }) { const matches = info.tokenised.match(regex); if (!matches) return; info["num" + countKey] = matches.length; info.tokenised = info.tokenised.replace(regex, token); info.values.push(...matches.map(parse)); } function analyseComplexValue(value) { const originalValue = value.toString(); const info = { value: originalValue, tokenised: originalValue, values: [], numVars: 0, numColors: 0, numNumbers: 0, }; if (info.value.includes("var(--")) tokenise(info, cssVarTokeniser); tokenise(info, colorTokeniser); tokenise(info, numberTokeniser); return info; } function parseComplexValue(v) { return analyseComplexValue(v).values; } function createTransformer(source) { const { values, numColors, numVars, tokenised } = analyseComplexValue(source); const numValues = values.length; return (v) => { let output = tokenised; for (let i = 0; i < numValues; i++) { if (i < numVars) { output = output.replace(cssVarTokeniser.token, v[i]); } else if (i < numVars + numColors) { output = output.replace(colorTokeniser.token, color.transform(v[i])); } else { output = output.replace(numberTokeniser.token, sanitize(v[i])); } } return output; }; } const convertNumbersToZero = (v) => typeof v === "number" ? 0 : v; function getAnimatableNone$1(v) { const parsed = parseComplexValue(v); const transformer = createTransformer(v); return transformer(parsed.map(convertNumbersToZero)); } const complex = { test, parse: parseComplexValue, createTransformer, getAnimatableNone: getAnimatableNone$1, }; const mixImmediate = (origin, target) => (p) => `${p > 0 ? target : origin}`; function getMixer(origin, target) { if (typeof origin === "number") { return (v) => mix(origin, target, v); } else if (color.test(origin)) { return mixColor(origin, target); } else { return origin.startsWith("var(") ? mixImmediate(origin, target) : mixComplex(origin, target); } } const mixArray = (from, to) => { const output = [...from]; const numValues = output.length; const blendValue = from.map((fromThis, i) => getMixer(fromThis, to[i])); return (v) => { for (let i = 0; i < numValues; i++) { output[i] = blendValue[i](v); } return output; }; }; const mixObject = (origin, target) => { const output = { ...origin, ...target }; const blendValue = {}; for (const key in output) { if (origin[key] !== undefined && target[key] !== undefined) { blendValue[key] = getMixer(origin[key], target[key]); } } return (v) => { for (const key in blendValue) { output[key] = blendValue[key](v); } return output; }; }; const mixComplex = (origin, target) => { const template = complex.createTransformer(target); const originStats = analyseComplexValue(origin); const targetStats = analyseComplexValue(target); const canInterpolate = originStats.numVars === targetStats.numVars && originStats.numColors === targetStats.numColors && originStats.numNumbers >= targetStats.numNumbers; if (canInterpolate) { return pipe(mixArray(originStats.values, targetStats.values), template); } else { warning(true, `Complex values '${origin}' and '${target}' too different to mix. Ensure all colors are of the same type, and that each contains the same quantity of number and color values. Falling back to instant transition.`); return mixImmediate(origin, target); } }; const mixNumber = (from, to) => (p) => mix(from, to, p); function detectMixerFactory(v) { if (typeof v === "number") { return mixNumber; } else if (typeof v === "string") { return color.test(v) ? mixColor : mixComplex; } else if (Array.isArray(v)) { return mixArray; } else if (typeof v === "object") { return mixObject; } return mixNumber; } function createMixers(output, ease, customMixer) { const mixers = []; const mixerFactory = customMixer || detectMixerFactory(output[0]); const numMixers = output.length - 1; for (let i = 0; i < numMixers; i++) { let mixer = mixerFactory(output[i], output[i + 1]); if (ease) { const easingFunction = Array.isArray(ease) ? ease[i] || noop : ease; mixer = pipe(easingFunction, mixer); } mixers.push(mixer); } return mixers; } /** * Create a function that maps from a numerical input array to a generic output array. * * Accepts: * - Numbers * - Colors (hex, hsl, hsla, rgb, rgba) * - Complex (combinations of one or more numbers or strings) * * ```jsx * const mixColor = interpolate([0, 1], ['#fff', '#000']) * * mixColor(0.5) // 'rgba(128, 128, 128, 1)' * ``` * * TODO Revist this approach once we've moved to data models for values, * probably not needed to pregenerate mixer functions. * * @public */ function interpolate(input, output, { clamp: isClamp = true, ease, mixer } = {}) { const inputLength = input.length; invariant(inputLength === output.length, "Both input and output ranges must be the same length"); /** * If we're only provided a single input, we can just make a function * that returns the output. */ if (inputLength === 1) return () => output[0]; // If input runs highest -> lowest, reverse both arrays if (input[0] > input[inputLength - 1]) { input = [...input].reverse(); output = [...output].reverse(); } const mixers = createMixers(output, ease, mixer); const numMixers = mixers.length; const interpolator = (v) => { let i = 0; if (numMixers > 1) { for (; i < input.length - 2; i++) { if (v < input[i + 1]) break; } } const progressInRange = progress(input[i], input[i + 1], v); return mixers[i](progressInRange); }; return isClamp ? (v) => interpolator(clamp(input[0], input[inputLength - 1], v)) : interpolator; } function fillOffset(offset, remaining) { const min = offset[offset.length - 1]; for (let i = 1; i <= remaining; i++) { const offsetProgress = progress(0, remaining, i); offset.push(mix(min, 1, offsetProgress)); } } function defaultOffset(arr) { const offset = [0]; fillOffset(offset, arr.length - 1); return offset; } function convertOffsetToTimes(offset, duration) { return offset.map((o) => o * duration); } function defaultEasing(values, easing) { return values.map(() => easing || easeInOut).splice(0, values.length - 1); } function keyframes({ duration = 300, keyframes: keyframeValues, times, ease = "easeInOut", }) { /** * Easing functions can be externally defined as strings. Here we convert them * into actual functions. */ const easingFunctions = isEasingArray(ease) ? ease.map(easingDefinitionToFunction) : easingDefinitionToFunction(ease); /** * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator * to reduce GC during animation. */ const state = { done: false, value: keyframeValues[0], }; /** * Create a times array based on the provided 0-1 offsets */ const absoluteTimes = convertOffsetToTimes( // Only use the provided offsets if they're the correct length // TODO Maybe we should warn here if there's a length mismatch times && times.length === keyframeValues.length ? times : defaultOffset(keyframeValues), duration); const mapTimeToKeyframe = interpolate(absoluteTimes, keyframeValues, { ease: Array.isArray(easingFunctions) ? easingFunctions : defaultEasing(keyframeValues, easingFunctions), }); return { calculatedDuration: duration, next: (t) => { state.value = mapTimeToKeyframe(t); state.done = t >= duration; return state; }, }; } const velocitySampleDuration = 5; // ms function calcGeneratorVelocity(resolveValue, t, current) { const prevT = Math.max(t - velocitySampleDuration, 0); return velocityPerSecond(current - resolveValue(prevT), t - prevT); } const safeMin = 0.001; const minDuration = 0.01; const maxDuration$1 = 10.0; const minDamping = 0.05; const maxDamping = 1; function findSpring({ duration = 800, bounce = 0.25, velocity = 0, mass = 1, }) { let envelope; let derivative; warning(duration <= secondsToMilliseconds(maxDuration$1), "Spring duration must be 10 seconds or less"); let dampingRatio = 1 - bounce; /** * Restrict dampingRatio and duration to within acceptable ranges. */ dampingRatio = clamp(minDamping, maxDamping, dampingRatio); duration = clamp(minDuration, maxDuration$1, millisecondsToSeconds(duration)); if (dampingRatio < 1) { /** * Underdamped spring */ envelope = (undampedFreq) => { const exponentialDecay = undampedFreq * dampingRatio; const delta = exponentialDecay * duration; const a = exponentialDecay - velocity; const b = calcAngularFreq(undampedFreq, dampingRatio); const c = Math.exp(-delta); return safeMin - (a / b) * c; }; derivative = (undampedFreq) => { const exponentialDecay = undampedFreq * dampingRatio; const delta = exponentialDecay * duration; const d = delta * velocity + velocity; const e = Math.pow(dampingRatio, 2) * Math.pow(undampedFreq, 2) * duration; const f = Math.exp(-delta); const g = calcAngularFreq(Math.pow(undampedFreq, 2), dampingRatio); const factor = -envelope(undampedFreq) + safeMin > 0 ? -1 : 1; return (factor * ((d - e) * f)) / g; }; } else { /** * Critically-damped spring */ envelope = (undampedFreq) => { const a = Math.exp(-undampedFreq * duration); const b = (undampedFreq - velocity) * duration + 1; return -safeMin + a * b; }; derivative = (undampedFreq) => { const a = Math.exp(-undampedFreq * duration); const b = (velocity - undampedFreq) * (duration * duration); return a * b; }; } const initialGuess = 5 / duration; const undampedFreq = approximateRoot(envelope, derivative, initialGuess); duration = secondsToMilliseconds(duration); if (isNaN(undampedFreq)) { return { stiffness: 100, damping: 10, duration, }; } else { const stiffness = Math.pow(undampedFreq, 2) * mass; return { stiffness, damping: dampingRatio * 2 * Math.sqrt(mass * stiffness), duration, }; } } const rootIterations = 12; function approximateRoot(envelope, derivative, initialGuess) { let result = initialGuess; for (let i = 1; i < rootIterations; i++) { result = result - envelope(result) / derivative(result); } return result; } function calcAngularFreq(undampedFreq, dampingRatio) { return undampedFreq * Math.sqrt(1 - dampingRatio * dampingRatio); } const durationKeys = ["duration", "bounce"]; const physicsKeys = ["stiffness", "damping", "mass"]; function isSpringType(options, keys) { return keys.some((key) => options[key] !== undefined); } function getSpringOptions(options) { let springOptions = { velocity: 0.0, stiffness: 100, damping: 10, mass: 1.0, isResolvedFromDuration: false, ...options, }; // stiffness/damping/mass overrides duration/bounce if (!isSpringType(options, physicsKeys) && isSpringType(options, durationKeys)) { const derived = findSpring(options); springOptions = { ...springOptions, ...derived, mass: 1.0, }; springOptions.isResolvedFromDuration = true; } return springOptions; } function spring({ keyframes, restDelta, restSpeed, ...options }) { const origin = keyframes[0]; const target = keyframes[keyframes.length - 1]; /** * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator * to reduce GC during animation. */ const state = { done: false, value: origin }; const { stiffness, damping, mass, duration, velocity, isResolvedFromDuration, } = getSpringOptions({ ...options, velocity: -millisecondsToSeconds(options.velocity || 0), }); const initialVelocity = velocity || 0.0; const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass)); const initialDelta = target - origin; const undampedAngularFreq = millisecondsToSeconds(Math.sqrt(stiffness / mass)); /** * If we're working on a granular scale, use smaller defaults for determining * when the spring is finished. * * These defaults have been selected emprically based on what strikes a good * ratio between feeling good and finishing as soon as changes are imperceptible. */ const isGranularScale = Math.abs(initialDelta) < 5; restSpeed || (restSpeed = isGranularScale ? 0.01 : 2); restDelta || (restDelta = isGranularScale ? 0.005 : 0.5); let resolveSpring; if (dampingRatio < 1) { const angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio); // Underdamped spring resolveSpring = (t) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); return (target - envelope * (((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / angularFreq) * Math.sin(angularFreq * t) + initialDelta * Math.cos(angularFreq * t))); }; } else if (dampingRatio === 1) { // Critically damped spring resolveSpring = (t) => target - Math.exp(-undampedAngularFreq * t) * (initialDelta + (initialVelocity + undampedAngularFreq * initialDelta) * t); } else { // Overdamped spring const dampedAngularFreq = undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1); resolveSpring = (t) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); // When performing sinh or cosh values can hit Infinity so we cap them here const freqForT = Math.min(dampedAngularFreq * t, 300); return (target - (envelope * ((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) * Math.sinh(freqForT) + dampedAngularFreq * initialDelta * Math.cosh(freqForT))) / dampedAngularFreq); }; } return { calculatedDuration: isResolvedFromDuration ? duration || null : null, next: (t) => { const current = resolveSpring(t); if (!isResolvedFromDuration) { let currentVelocity = initialVelocity; if (t !== 0) { /** * We only need to calculate velocity for under-damped springs * as over- and critically-damped springs can't overshoot, so * checking only for displacement is enough. */ if (dampingRatio < 1) { currentVelocity = calcGeneratorVelocity(resolveSpring, t, current); } else { currentVelocity = 0; } } const isBelowVelocityThreshold = Math.abs(currentVelocity) <= restSpeed; const isBelowDisplacementThreshold = Math.abs(target - current) <= restDelta; state.done = isBelowVelocityThreshold && isBelowDisplacementThreshold; } else { state.done = t >= duration; } state.value = state.done ? target : current; return state; }, }; } function inertia({ keyframes, velocity = 0.0, power = 0.8, timeConstant = 325, bounceDamping = 10, bounceStiffness = 500, modifyTarget, min, max, restDelta = 0.5, restSpeed, }) { const origin = keyframes[0]; const state = { done: false, value: origin, }; const isOutOfBounds = (v) => (min !== undefined && v < min) || (max !== undefined && v > max); const nearestBoundary = (v) => { if (min === undefined) return max; if (max === undefined) return min; return Math.abs(min - v) < Math.abs(max - v) ? min : max; }; let amplitude = power * velocity; const ideal = origin + amplitude; const target = modifyTarget === undefined ? ideal : modifyTarget(ideal); /** * If the target has changed we need to re-calculate the amplitude, otherwise * the animation will start from the wrong position. */ if (target !== ideal) amplitude = target - origin; const calcDelta = (t) => -amplitude * Math.exp(-t / timeConstant); const calcLatest = (t) => target + calcDelta(t); const applyFriction = (t) => { const delta = calcDelta(t); const latest = calcLatest(t); state.done = Math.abs(delta) <= restDelta; state.value = state.done ? target : latest; }; /** * Ideally this would resolve for t in a stateless way, we could * do that by always precalculating the animation but as we know * this will be done anyway we can assume that spring will * be discovered during that. */ let timeReachedBoundary; let spring$1; const checkCatchBoundary = (t) => { if (!isOutOfBounds(state.value)) return; timeReachedBoundary = t; spring$1 = spring({ keyframes: [state.value, nearestBoundary(state.value)], velocity: calcGeneratorVelocity(calcLatest, t, state.value), damping: bounceDamping, stiffness: bounceStiffness, restDelta, restSpeed, }); }; checkCatchBoundary(0); return { calculatedDuration: null, next: (t) => { /** * We need to resolve the friction to figure out if we need a * spring but we don't want to do this twice per frame. So here * we flag if we updated for this frame and later if we did * we can skip doing it again. */ let hasUpdatedFrame = false; if (!spring$1 && timeReachedBoundary === undefined) { hasUpdatedFrame = true; applyFriction(t); checkCatchBoundary(t); } /** * If we have a spring and the provided t is beyond the moment the friction * animation crossed the min/max boundary, use the spring. */ if (timeReachedBoundary !== undefined && t > timeReachedBoundary) { return spring$1.next(t - timeReachedBoundary); } else { !hasUpdatedFrame && applyFriction(t); return state; } }, }; } const frameloopDriver = (update) => { const passTimestamp = ({ timestamp }) => update(timestamp); return { start: () => frame.update(passTimestamp, true), stop: () => cancelFrame(passTimestamp), /** * If we're processing this frame we can use the * framelocked timestamp to keep things in sync. */ now: () => frameData.isProcessing ? frameData.timestamp : performance.now(), }; }; /** * Implement a practical max duration for keyframe generation * to prevent infinite loops */ const maxGeneratorDuration = 20000; function calcGeneratorDuration(generator) { let duration = 0; const timeStep = 50; let state = generator.next(duration); while (!state.done && duration < maxGeneratorDuration) { duration += timeStep; state = generator.next(duration); } return duration >= maxGeneratorDuration ? Infinity : duration; } const types = { decay: inertia, inertia, tween: keyframes, keyframes: keyframes, spring, }; /** * Animate a single value on the main thread. * * This function is written, where functionality overlaps, * to be largely spec-compliant with WAAPI to allow fungibility * between the two. */ function animateValue({ autoplay = true, delay = 0, driver = frameloopDriver, keyframes: keyframes$1, type = "keyframes", repeat = 0, repeatDelay = 0, repeatType = "loop", onPlay, onStop, onComplete, onUpdate, ...options }) { let speed = 1; let hasStopped = false; let resolveFinishedPromise; let currentFinishedPromise; /** * Resolve the current Promise every time we enter the * finished state. This is WAAPI-compatible behaviour. */ const updateFinishedPromise = () => { currentFinishedPromise = new Promise((resolve) => { resolveFinishedPromise = resolve; }); }; // Create the first finished promise updateFinishedPromise(); let animationDriver; const generatorFactory = types[type] || keyframes; /** * If this isn't the keyframes generator and we've been provided * strings as keyframes, we need to interpolate these. */ let mapNumbersToKeyframes; if (generatorFactory !== keyframes && typeof keyframes$1[0] !== "number") { { invariant(keyframes$1.length === 2, `Only two keyframes currently supported with spring and inertia animations. Trying to animate ${keyframes$1}`); } mapNumbersToKeyframes = interpolate([0, 100], keyframes$1, { clamp: false, }); keyframes$1 = [0, 100]; } const generator = generatorFactory({ ...options, keyframes: keyframes$1 }); let mirroredGenerator; if (repeatType === "mirror") { mirroredGenerator = generatorFactory({ ...options, keyframes: [...keyframes$1].reverse(), velocity: -(options.velocity || 0), }); } let playState = "idle"; let holdTime = null; let startTime = null; let cancelTime = null; /** * If duration is undefined and we have repeat options, * we need to calculate a duration from the generator. * * We set it to the generator itself to cache the duration. * Any timeline resolver will need to have already precalculated * the duration by this step. */ if (generator.calculatedDuration === null && repeat) { generator.calculatedDuration = calcGeneratorDuration(generator); } const { calculatedDuration } = generator; let resolvedDuration = Infinity; let totalDuration = Infinity; if (calculatedDuration !== null) { resolvedDuration = calculatedDuration + repeatDelay; totalDuration = resolvedDuration * (repeat + 1) - repeatDelay; } let currentTime = 0; const tick = (timestamp) => { if (startTime === null) return; /** * requestAnimationFrame timestamps can come through as lower than * the startTime as set by performance.now(). Here we prevent this, * though in the future it could be possible to make setting startTime * a pending operation that gets resolved here. */ if (speed > 0) startTime = Math.min(startTime, timestamp); if (speed < 0) startTime = Math.min(timestamp - totalDuration / speed, startTime); if (holdTime !== null) { currentTime = holdTime; } else { // Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 = // 2000.0000000000002. This is a problem when we are comparing the currentTime with the duration, for // example. currentTime = Math.round(timestamp - startTime) * speed; } // Rebase on delay const timeWithoutDelay = currentTime - delay * (speed >= 0 ? 1 : -1); const isInDelayPhase = speed >= 0 ? timeWithoutDelay < 0 : timeWithoutDelay > totalDuration; currentTime = Math.max(timeWithoutDelay, 0); /** * If this animation has finished, set the current time * to the total duration. */ if (playState === "finished" && holdTime === null) { currentTime = totalDuration; } let elapsed = currentTime; let frameGenerator = generator; if (repeat) { /** * Get the current progress (0-1) of the animation. If t is > * than duration we'll get values like 2.5 (midway through the * third iteration) */ const progress = Math.min(currentTime, totalDuration) / resolvedDuration; /** * Get the current iteration (0 indexed). For instance the floor of * 2.5 is 2. */ let currentIteration = Math.floor(progress); /** * Get the current progress of the iteration by taking the remainder * so 2.5 is 0.5 through iteration 2 */ let iterationProgress = progress % 1.0; /** * If iteration progress is 1 we count that as the end * of the previous iteration. */ if (!iterationProgress && progress >= 1) { iterationProgress = 1; } iterationProgress === 1 && currentIteration--; currentIteration = Math.min(currentIteration, repeat + 1); /** * Reverse progress if we're not running in "normal" direction */ const isOddIteration = Boolean(currentIteration % 2); if (isOddIteration) { if (repeatType === "reverse") { iterationProgress = 1 - iterationProgress; if (repeatDelay) { iterationProgress -= repeatDelay / resolvedDuration; } } else if (repeatType === "mirror") { frameGenerator = mirroredGenerator; } } elapsed = clamp(0, 1, iterationProgress) * resolvedDuration; } /** * If we're in negative time, set state as the initial keyframe. * This prevents delay: x, duration: 0 animations from finishing * instantly. */ const state = isInDelayPhase ? { done: false, value: keyframes$1[0] } : frameGenerator.next(elapsed); if (mapNumbersToKeyframes) { state.value = mapNumbersToKeyframes(state.value); } let { done } = state; if (!isInDelayPhase && calculatedDuration !== null) { done = speed >= 0 ? currentTime >= totalDuration : currentTime <= 0; } const isAnimationFinished = holdTime === null && (playState === "finished" || (playState === "running" && done)); if (onUpdate) { onUpdate(state.value); } if (isAnimationFinished) { finish(); } return state; }; const stopAnimationDriver = () => { animationDriver && animationDriver.stop(); animationDriver = undefined; }; const cancel = () => { playState = "idle"; stopAnimationDriver(); resolveFinishedPromise(); updateFinishedPromise(); startTime = cancelTime = null; }; const finish = () => { playState = "finished"; onComplete && onComplete(); stopAnimationDriver(); resolveFinishedPromise(); }; const play = () => { if (hasStopped) return; if (!animationDriver) animationDriver = driver(tick); const now = animationDriver.now(); onPlay && onPlay(); if (holdTime !== null) { startTime = now - holdTime; } else if (!startTime || playState === "finished") { startTime = now; } if (playState === "finished") { updateFinishedPromise(); } cancelTime = startTime; holdTime = null; /** * Set playState to running only after we've used it in * the previous logic. */ playState = "running"; animationDriver.start(); }; if (autoplay) { play(); } const controls = { then(resolve, reject) { return currentFinishedPromise.then(resolve, reject); }, get time() { return millisecondsToSeconds(currentTime); }, set time(newTime) { newTime = secondsToMilliseconds(newTime); currentTime = newTime; if (holdTime !== null || !animationDriver || speed === 0) { holdTime = newTime; } else { startTime = animationDriver.now() - newTime / speed; } }, get duration() { const duration = generator.calculatedDuration === null ? calcGeneratorDuration(generator) : generator.calculatedDuration; return millisecondsToSeconds(duration); }, get speed() { return speed; }, set speed(newSpeed) { if (newSpeed === speed || !animationDriver) return; speed = newSpeed; controls.time = millisecondsToSeconds(currentTime); }, get state() { return playState; }, play, pause: () => { playState = "paused"; holdTime = currentTime; }, stop: () => { hasStopped = true; if (playState === "idle") return; playState = "idle"; onStop && onStop(); cancel(); }, cancel: () => { if (cancelTime !== null) tick(cancelTime); cancel(); }, complete: () => { playState = "finished"; }, sample: (elapsed) => { startTime = 0; return tick(elapsed); }, }; return controls; } const supportsWaapi = memo(() => Object.hasOwnProperty.call(Element.prototype, "animate")); /** * A list of values that can be hardware-accelerated. */ const acceleratedValues = new Set([ "opacity", "clipPath", "filter", "transform", "backgroundColor", ]); /** * 10ms is chosen here as it strikes a balance between smooth * results (more than one keyframe per frame at 60fps) and * keyframe quantity. */ const sampleDelta = 10; //ms /** * Implement a practical max duration for keyframe generation * to prevent infinite loops */ const maxDuration = 20000; const requiresPregeneratedKeyframes = (valueName, options) => options.type === "spring" || valueName === "backgroundColor" || !isWaapiSupportedEasing(options.ease); function createAcceleratedAnimation(value, valueName, { onUpdate, onComplete, ...options }) { const canAccelerateAnimation = supportsWaapi() && acceleratedValues.has(valueName) && !options.repeatDelay && options.repeatType !== "mirror" && options.damping !== 0 && options.type !== "inertia"; if (!canAccelerateAnimation) return false; /** * TODO: Unify with js/index */ let hasStopped = false; let resolveFinishedPromise; let currentFinishedPromise; /** * Cancelling an animation will write to the DOM. For safety we want to defer * this until the next `update` frame lifecycle. This flag tracks whether we * have a pending cancel, if so we shouldn't allow animations to finish. */ let pendingCancel = false; /** * Resolve the current Promise every time we enter the * finished state. This is WAAPI-compatible behaviour. */ const updateFinishedPromise = () => { currentFinishedPromise = new Promise((resolve) => { resolveFinishedPromise = resolve; }); }; // Create the first finished promise updateFinishedPromise(); let { keyframes, duration = 300, ease, times } = options; /** * If this animation needs pre-generated keyframes then generate. */ if (requiresPregeneratedKeyframes(valueName, options)) { const sampleAnimation = animateValue({ ...options, repeat: 0, delay: 0, }); let state = { done: false, value: keyframes[0] }; const pregeneratedKeyframes = []; /** * Bail after 20 seconds of pre-generated keyframes as it's likely * we're heading for an infinite loop. */ let t = 0; while (!state.done && t < maxDuration) { state = sampleAnimation.sample(t); pregeneratedKeyframes.push(state.value); t += sampleDelta; } times = undefined; keyframes = pregeneratedKeyframes; duration = t - sampleDelta; ease = "linear"; } const animation = animateStyle(value.owner.current, valueName, keyframes, { ...options, duration, /** * This function is currently not called if ease is provided * as a function so the cast is safe. * * However it would be possible for a future refinement to port * in easing pregeneration from Motion One for browsers that * support the upcoming `linear()` easing function. */ ease: ease, times, }); const cancelAnimation = () => { pendingCancel = false; animation.cancel(); }; const safeCancel = () => { pendingCancel = true; frame.update(cancelAnimation); resolveFinishedPromise(); updateFinishedPromise(); }; /** * Prefer the `onfinish` prop as it's more widely supported than * the `finished` promise. * * Here, we synchronously set the provided MotionValue to the end * keyframe. If we didn't, when the WAAPI animation is finished it would * be removed from the element which would then revert to its old styles. */ animation.onfinish = () => { if (pendingCancel) return; value.set(getFinalKeyframe(keyframes, options)); onComplete && onComplete(); safeCancel(); }; /** * Animation interrupt callback. */ const controls = { then(resolve, reject) { return currentFinishedPromise.then(resolve, reject); }, attachTimeline(timeline) { animation.timeline = timeline; animation.onfinish = null; return noop; }, get time() { return millisecondsToSeconds(animation.currentTime || 0); }, set time(newTime) { animation.currentTime = secondsToMilliseconds(newTime); }, get speed() { return animation.playbackRate; }, set speed(newSpeed) { animation.playbackRate = newSpeed; }, get duration() { return millisecondsToSeconds(duration); }, play: () => { if (hasStopped) return; animation.play(); /** * Cancel any pending cancel tasks */ cancelFrame(cancelAnimation); }, pause: () => animation.pause(), stop: () => { hasStopped = true; if (animation.playState === "idle") return; /** * WAAPI doesn't natively have any interruption capabilities. * * Rather than read commited styles back out of the DOM, we can * create a renderless JS animation and sample it twice to calculate * its current value, "previous" value, and therefore allow * Motion to calculate velocity for any subsequent animation. */ const { currentTime } = animation; if (currentTime) { const sampleAnimation = animateValue({ ...options, autoplay: false, }); value.setWithVelocity(sampleAnimation.sample(currentTime - sampleDelta).value, sampleAnimation.sample(currentTime).value, sampleDelta); } safeCancel(); }, complete: () => { if (pendingCancel) return; animation.finish(); }, cancel: safeCancel, }; return controls; } function createInstantAnimation({ keyframes, delay, onUpdate, onComplete, }) { const setValue = () => { onUpdate && onUpdate(keyframes[keyframes.length - 1]); onComplete && onComplete(); /** * TODO: As this API grows it could make sense to always return * animateValue. This will be a bigger project as animateValue * is frame-locked whereas this function resolves instantly. * This is a behavioural change and also has ramifications regarding * assumptions within tests. */ return { time: 0, speed: 1, duration: 0, play: (noop), pause: (noop), stop: (noop), then: (resolve) => { resolve(); return Promise.resolve(); }, cancel: (noop), complete: (noop), }; }; return delay ? animateValue({ keyframes: [0, 1], duration: 0, delay, onComplete: setValue, }) : setValue(); } const underDampedSpring = { type: "spring", stiffness: 500, damping: 25, restSpeed: 10, }; const criticallyDampedSpring = (target) => ({ type: "spring", stiffness: 550, damping: target === 0 ? 2 * Math.sqrt(550) : 30, restSpeed: 10, }); const keyframesTransition = { type: "keyframes", duration: 0.8, }; /** * Default easing curve is a slightly shallower version of * the default browser easing curve. */ const ease = { type: "keyframes", ease: [0.25, 0.1, 0.35, 1], duration: 0.3, }; const getDefaultTransition = (valueKey, { keyframes }) => { if (keyframes.length > 2) { return keyframesTransition; } else if (transformProps.has(valueKey)) { return valueKey.startsWith("scale") ? criticallyDampedSpring(keyframes[1]) : underDampedSpring; } return ease; }; /** * Check if a value is animatable. Examples: * * āœ…: 100, "100px", "#fff" * āŒ: "block", "url(2.jpg)" * @param value * * @internal */ const isAnimatable = (key, value) => { // If the list of keys tat might be non-animatable grows, replace with Set if (key === "zIndex") return false; // If it's a number or a keyframes array, we can animate it. We might at some point // need to do a deep isAnimatable check of keyframes, or let Popmotion handle this, // but for now lets leave it like this for performance reasons if (typeof value === "number" || Array.isArray(value)) return true; if (typeof value === "string" && // It's animatable if we have a string (complex.test(value) || value === "0") && // And it contains numbers and/or colors !value.startsWith("url(") // Unless it starts with "url(" ) { return true; } return false; }; /** * Properties that should default to 1 or 100% */ const maxDefaults = new Set(["brightness", "contrast", "saturate", "opacity"]); function applyDefaultFilter(v) { const [name, value] = v.slice(0, -1).split("("); if (name === "drop-shadow") return v; const [number] = value.match(floatRegex) || []; if (!number) return v; const unit = value.replace(number, ""); let defaultValue = maxDefaults.has(name) ? 1 : 0; if (number !== value) defaultValue *= 100; return name + "(" + defaultValue + unit + ")"; } const functionRegex = /([a-z-]*)\(.*?\)/g; const filter = { ...complex, getAnimatableNone: (v) => { const functions = v.match(functionRegex); return functions ? functions.map(applyDefaultFilter).join(" ") : v; }, }; const int = { ...number, transform: Math.round, }; const numberValueTypes = { // Border props borderWidth: px, borderTopWidth: px, borderRightWidth: px, borderBottomWidth: px, borderLeftWidth: px, borderRadius: px, radius: px, borderTopLeftRadius: px, borderTopRightRadius: px, borderBottomRightRadius: px, borderBottomLeftRadius: px, // Positioning props width: px, maxWidth: px, height: px, maxHeight: px, size: px, top: px, right: px, bottom: px, left: px, // Spacing props padding: px, paddingTop: px, paddingRight: px, paddingBottom: px, paddingLeft: px, margin: px, marginTop: px, marginRight: px, marginBottom: px, marginLeft: px, // Transform props rotate: degrees, rotateX: degrees, rotateY: degrees, rotateZ: degrees, scale, scaleX: scale, scaleY: scale, scaleZ: scale, skew: degrees, skewX: degrees, skewY: degrees, distance: px, translateX: px, translateY: px, translateZ: px, x: px, y: px, z: px, perspective: px, transformPerspective: px, opacity: alpha, originX: progressPercentage, originY: progressPercentage, originZ: px, // Misc zIndex: int, // SVG fillOpacity: alpha, strokeOpacity: alpha, numOctaves: int, }; /** * A map of default value types for common values */ const defaultValueTypes = { ...numberValueTypes, // Color props color, backgroundColor: color, outlineColor: color, fill: color, stroke: color, // Border props borderColor: color, borderTopColor: color, borderRightColor: color, borderBottomColor: color, borderLeftColor: color, filter, WebkitFilter: filter, }; /** * Gets the default ValueType for the provided value key */ const getDefaultValueType = (key) => defaultValueTypes[key]; function getAnimatableNone(key, value) { let defaultValueType = getDefaultValueType(key); if (defaultValueType !== filter) defaultValueType = complex; // If value is not recognised as animatable, ie "none", create an animatable version origin based on the target return defaultValueType.getAnimatableNone ? defaultValueType.getAnimatableNone(value) : undefined; } /** * Check if the value is a zero value string like "0px" or "0%" */ const isZeroValueString = (v) => /^0[^.\s]+$/.test(v); function isNone(value) { if (typeof value === "number") { return value === 0; } else if (value !== null) { return value === "none" || value === "0" || isZeroValueString(value); } } function getKeyframes(value, valueName, target, transition) { const isTargetAnimatable = isAnimatable(valueName, target); let keyframes; if (Array.isArray(target)) { keyframes = [...target]; } else { keyframes = [null, target]; } const defaultOrigin = transition.from !== undefined ? transition.from : value.get(); let animatableTemplateValue = undefined; const noneKeyframeIndexes = []; for (let i = 0; i < keyframes.length; i++) { /** * Fill null/wildcard keyframes */ if (keyframes[i] === null) { keyframes[i] = i === 0 ? defaultOrigin : keyframes[i - 1]; } if (isNone(keyframes[i])) { noneKeyframeIndexes.push(i); } // TODO: Clean this conditional, it works for now if (typeof keyframes[i] === "string" && keyframes[i] !== "none" && keyframes[i] !== "0") { animatableTemplateValue = keyframes[i]; } } if (isTargetAnimatable && noneKeyframeIndexes.length && animatableTemplateValue) { for (let i = 0; i < noneKeyframeIndexes.length; i++) { const index = noneKeyframeIndexes[i]; keyframes[index] = getAnimatableNone(valueName, animatableTemplateValue); } } return keyframes; } const MotionGlobalConfig = { skipAnimations: false, }; const animateMotionValue = (valueName, value, target, transition = {}) => { return (onComplete) => { const valueTransition = getValueTransition(transition, valueName) || {}; /** * Most transition values are currently completely overwritten by value-specific * transitions. In the future it'd be nicer to blend these transitions. But for now * delay actually does inherit from the root transition if not value-specific. */ const delay = valueTransition.delay || transition.delay || 0; /** * Elapsed isn't a public transition option but can be passed through from * optimized appear effects in milliseconds. */ let { elapsed = 0 } = transition; elapsed = elapsed - secondsToMilliseconds(delay); const keyframes = getKeyframes(value, valueName, target, valueTransition); /** * Check if we're able to animate between the start and end keyframes, * and throw a warning if we're attempting to animate between one that's * animatable and another that isn't. */ const originKeyframe = keyframes[0]; const targetKeyframe = keyframes[keyframes.length - 1]; const isOriginAnimatable = isAnimatable(valueName, originKeyframe); const isTargetAnimatable = isAnimatable(valueName, targetKeyframe); warning(isOriginAnimatable === isTargetAnimatable, `You are trying to animate ${valueName} from "${originKeyframe}" to "${targetKeyframe}". ${originKeyframe} is not an animatable value - to enable this animation set ${originKeyframe} to a value animatable to ${targetKeyframe} via the \`style\` property.`); let options = { keyframes, velocity: value.getVelocity(), ease: "easeOut", ...valueTransition, delay: -elapsed, onUpdate: (v) => { value.set(v); valueTransition.onUpdate && valueTransition.onUpdate(v); }, onComplete: () => { onComplete(); valueTransition.onComplete && valueTransition.onComplete(); }, }; /** * If there's no transition defined for this value, we can generate * unqiue transition settings for this value. */ if (!isTransitionDefined(valueTransition)) { options = { ...options, ...getDefaultTransition(valueName, options), }; } /** * Both WAAPI and our internal animation functions use durations * as defined by milliseconds, while our external API defines them * as seconds. */ if (options.duration) { options.duration = secondsToMilliseconds(options.duration); } if (options.repeatDelay) { options.repeatDelay = secondsToMilliseconds(options.repeatDelay); } if (!isOriginAnimatable || !isTargetAnimatable || instantAnimationState.current || valueTransition.type === false || MotionGlobalConfig.skipAnimations) { /** * If we can't animate this value, or the global instant animation flag is set, * or this is simply defined as an instant transition, return an instant transition. */ return createInstantAnimation(instantAnimationState.current ? { ...options, delay: 0 } : options); } /** * Animate via WAAPI if possible. */ if ( /** * If this is a handoff animation, the optimised animation will be running via * WAAPI. Therefore, this animation must be JS to ensure it runs "under" the * optimised animation. */ !transition.isHandoff && value.owner && value.owner.current instanceof HTMLElement && /** * If we're outputting values to onUpdate then we can't use WAAPI as there's * no way to read the value from WAAPI every frame. */ !value.owner.getProps().onUpdate) { const acceleratedAnimation = createAcceleratedAnimation(value, valueName, options); if (acceleratedAnimation) return acceleratedAnimation; } /** * If we didn't create an accelerated animation, create a JS animation */ return animateValue(options); }; }; function isWillChangeMotionValue(value) { return Boolean(isMotionValue(value) && value.add); } /** * Check if value is a numerical string, ie a string that is purely a number eg "100" or "-100.1" */ const isNumericalString = (v) => /^\-?\d*\.?\d+$/.test(v); /** * Tests a provided value against a ValueType */ const testValueType = (v) => (type) => type.test(v); /** * ValueType for "auto" */ const auto = { test: (v) => v === "auto", parse: (v) => v, }; /** * A list of value types commonly used for dimensions */ const dimensionValueTypes = [number, px, percent, degrees, vw, vh, auto]; /** * Tests a dimensional value against the list of dimension ValueTypes */ const findDimensionValueType = (v) => dimensionValueTypes.find(testValueType(v)); /** * A list of all ValueTypes */ const valueTypes = [...dimensionValueTypes, color, complex]; /** * Tests a value against the list of ValueTypes */ const findValueType = (v) => valueTypes.find(testValueType(v)); function resolveVariantFromProps(props, definition, custom, currentValues = {}, currentVelocity = {}) { /** * If the variant definition is a function, resolve. */ if (typeof definition === "function") { definition = definition(custom !== undefined ? custom : props.custom, currentValues, currentVelocity); } /** * If the variant definition is a variant label, or * the function returned a variant label, resolve. */ if (typeof definition === "string") { definition = props.variants && props.variants[definition]; } /** * At this point we've resolved both functions and variant labels, * but the resolved variant label might itself have been a function. * If so, resolve. This can only have returned a valid target object. */ if (typeof definition === "function") { definition = definition(custom !== undefined ? custom : props.custom, currentValues, currentVelocity); } return definition; } function checkTargetForNewValues(visualElement, target, origin) { var _a, _b; const newValueKeys = Object.keys(target).filter((key) => !visualElement.hasValue(key)); const numNewValues = newValueKeys.length; if (!numNewValues) return; for (let i = 0; i < numNewValues; i++) { const key = newValueKeys[i]; const targetValue = target[key]; let value = null; /** * If the target is a series of keyframes, we can use the first value * in the array. If this first value is null, we'll still need to read from the DOM. */ if (Array.isArray(targetValue)) { value = targetValue[0]; } /** * If the target isn't keyframes, or the first keyframe was null, we need to * first check if an origin value was explicitly defined in the transition as "from", * if not read the value from the DOM. As an absolute fallback, take the defined target value. */ if (value === null) { value = (_b = (_a = origin[key]) !== null && _a !== void 0 ? _a : visualElement.readValue(key)) !== null && _b !== void 0 ? _b : target[key]; } /** * If value is still undefined or null, ignore it. Preferably this would throw, * but this was causing issues in Framer. */ if (value === undefined || value === null) continue; if (typeof value === "string" && (isNumericalString(value) || isZeroValueString(value))) { // If this is a number read as a string, ie "0" or "200", convert it to a number value = parseFloat(value); } else if (!findValueType(value) && complex.test(targetValue)) { value = getAnimatableNone(key, targetValue); } visualElement.addValue(key, motionValue(value, { owner: visualElement })); if (origin[key] === undefined) { origin[key] = value; } if (value !== null) visualElement.setBaseTarget(key, value); } } function getOriginFromTransition(key, transition) { if (!transition) return; const valueTransition = transition[key] || transition["default"] || transition; return valueTransition.from; } function getOrigin(target, transition, visualElement) { const origin = {}; for (const key in target) { const transitionOrigin = getOriginFromTransition(key, transition); if (transitionOrigin !== undefined) { origin[key] = transitionOrigin; } else { const value = visualElement.getValue(key); if (value) { origin[key] = value.get(); } } } return origin; } function isSVGElement(element) { return element instanceof SVGElement && element.tagName !== "svg"; } function isForcedMotionValue(key, { layout, layoutId }) { return (transformProps.has(key) || key.startsWith("origin") || ((layout || layoutId !== undefined) && (!!scaleCorrectors[key] || key === "opacity"))); } function scrapeMotionValuesFromProps(props, prevProps) { const { style } = props; const newValues = {}; for (const key in style) { if (isMotionValue(style[key]) || (prevProps.style && isMotionValue(prevProps.style[key])) || isForcedMotionValue(key, props)) { newValues[key] = style[key]; } } return newValues; } /** * Parse Framer's special CSS variable format into a CSS token and a fallback. * * ``` * `var(--foo, #fff)` => [`--foo`, '#fff'] * ``` * * @param current */ const splitCSSVariableRegex = /var\((--[a-zA-Z0-9-_]+),? ?([a-zA-Z0-9 ()%#.,-]+)?\)/; function parseCSSVariable(current) { const match = splitCSSVariableRegex.exec(current); if (!match) return [,]; const [, token, fallback] = match; return [token, fallback]; } const maxDepth = 4; function getVariableValue(current, element, depth = 1) { invariant(depth <= maxDepth, `Max CSS variable fallback depth detected in property "${current}". This may indicate a circular fallback dependency.`); const [token, fallback] = parseCSSVariable(current); // No CSS variable detected if (!token) return; // Attempt to read this CSS variable off the element const resolved = window.getComputedStyle(element).getPropertyValue(token); if (resolved) { const trimmed = resolved.trim(); return isNumericalString(trimmed) ? parseFloat(trimmed) : trimmed; } else if (isCSSVariableToken(fallback)) { // The fallback might itself be a CSS variable, in which case we attempt to resolve it too. return getVariableValue(fallback, element, depth + 1); } else { return fallback; } } /** * Resolve CSS variables from * * @internal */ function resolveCSSVariables(visualElement, { ...target }, transitionEnd) { const element = visualElement.current; if (!(element instanceof Element)) return { target, transitionEnd }; // If `transitionEnd` isn't `undefined`, clone it. We could clone `target` and `transitionEnd` // only if they change but I think this reads clearer and this isn't a performance-critical path. if (transitionEnd) { transitionEnd = { ...transitionEnd }; } // Go through existing `MotionValue`s and ensure any existing CSS variables are resolved visualElement.values.forEach((value) => { const current = value.get(); if (!isCSSVariableToken(current)) return; const resolved = getVariableValue(current, element); if (resolved) value.set(resolved); }); // Cycle through every target property and resolve CSS variables. Currently // we only read single-var properties like `var(--foo)`, not `calc(var(--foo) + 20px)` for (const key in target) { const current = target[key]; if (!isCSSVariableToken(current)) continue; const resolved = getVariableValue(current, element); if (!resolved) continue; // Clone target if it hasn't already been target[key] = resolved; if (!transitionEnd) transitionEnd = {}; // If the user hasn't already set this key on `transitionEnd`, set it to the unresolved // CSS variable. This will ensure that after the animation the component will reflect // changes in the value of the CSS variable. if (transitionEnd[key] === undefined) { transitionEnd[key] = current; } } return { target, transitionEnd }; } const isBrowser = typeof document !== "undefined"; const positionalKeys = new Set([ "width", "height", "top", "left", "right", "bottom", "x", "y", "translateX", "translateY", ]); const isPositionalKey = (key) => positionalKeys.has(key); const hasPositionalKey = (target) => { return Object.keys(target).some(isPositionalKey); }; const isNumOrPxType = (v) => v === number || v === px; const getPosFromMatrix = (matrix, pos) => parseFloat(matrix.split(", ")[pos]); const getTranslateFromMatrix = (pos2, pos3) => (_bbox, { transform }) => { if (transform === "none" || !transform) return 0; const matrix3d = transform.match(/^matrix3d\((.+)\)$/); if (matrix3d) { return getPosFromMatrix(matrix3d[1], pos3); } else { const matrix = transform.match(/^matrix\((.+)\)$/); if (matrix) { return getPosFromMatrix(matrix[1], pos2); } else { return 0; } } }; const transformKeys = new Set(["x", "y", "z"]); const nonTranslationalTransformKeys = transformPropOrder.filter((key) => !transformKeys.has(key)); function removeNonTranslationalTransform(visualElement) { const removedTransforms = []; nonTranslationalTransformKeys.forEach((key) => { const value = visualElement.getValue(key); if (value !== undefined) { removedTransforms.push([key, value.get()]); value.set(key.startsWith("scale") ? 1 : 0); } }); // Apply changes to element before measurement if (removedTransforms.length) visualElement.render(); return removedTransforms; } const positionalValues = { // Dimensions width: ({ x }, { paddingLeft = "0", paddingRight = "0" }) => x.max - x.min - parseFloat(paddingLeft) - parseFloat(paddingRight), height: ({ y }, { paddingTop = "0", paddingBottom = "0" }) => y.max - y.min - parseFloat(paddingTop) - parseFloat(paddingBottom), top: (_bbox, { top }) => parseFloat(top), left: (_bbox, { left }) => parseFloat(left), bottom: ({ y }, { top }) => parseFloat(top) + (y.max - y.min), right: ({ x }, { left }) => parseFloat(left) + (x.max - x.min), // Transform x: getTranslateFromMatrix(4, 13), y: getTranslateFromMatrix(5, 14), }; // Alias translate longform names positionalValues.translateX = positionalValues.x; positionalValues.translateY = positionalValues.y; const convertChangedValueTypes = (target, visualElement, changedKeys) => { const originBbox = visualElement.measureViewportBox(); const element = visualElement.current; const elementComputedStyle = getComputedStyle(element); const { display } = elementComputedStyle; const origin = {}; // If the element is currently set to display: "none", make it visible before // measuring the target bounding box if (display === "none") { visualElement.setStaticValue("display", target.display || "block"); } /** * Record origins before we render and update styles */ changedKeys.forEach((key) => { origin[key] = positionalValues[key](originBbox, elementComputedStyle); }); // Apply the latest values (as set in checkAndConvertChangedValueTypes) visualElement.render(); const targetBbox = visualElement.measureViewportBox(); changedKeys.forEach((key) => { // Restore styles to their **calculated computed style**, not their actual // originally set style. This allows us to animate between equivalent pixel units. const value = visualElement.getValue(key); value && value.jump(origin[key]); target[key] = positionalValues[key](targetBbox, elementComputedStyle); }); return target; }; const checkAndConvertChangedValueTypes = (visualElement, target, origin = {}, transitionEnd = {}) => { target = { ...target }; transitionEnd = { ...transitionEnd }; const targetPositionalKeys = Object.keys(target).filter(isPositionalKey); // We want to remove any transform values that could affect the element's bounding box before // it's measured. We'll reapply these later. let removedTransformValues = []; let hasAttemptedToRemoveTransformValues = false; const changedValueTypeKeys = []; targetPositionalKeys.forEach((key) => { const value = visualElement.getValue(key); if (!visualElement.hasValue(key)) return; let from = origin[key]; let fromType = findDimensionValueType(from); const to = target[key]; let toType; // TODO: The current implementation of this basically throws an error // if you try and do value conversion via keyframes. There's probably // a way of doing this but the performance implications would need greater scrutiny, // as it'd be doing multiple resize-remeasure operations. if (isKeyframesTarget(to)) { const numKeyframes = to.length; const fromIndex = to[0] === null ? 1 : 0; from = to[fromIndex]; fromType = findDimensionValueType(from); for (let i = fromIndex; i < numKeyframes; i++) { /** * Don't allow wildcard keyframes to be used to detect * a difference in value types. */ if (to[i] === null) break; if (!toType) { toType = findDimensionValueType(to[i]); invariant(toType === fromType || (isNumOrPxType(fromType) && isNumOrPxType(toType)), "Keyframes must be of the same dimension as the current value"); } else { invariant(findDimensionValueType(to[i]) === toType, "All keyframes must be of the same type"); } } } else { toType = findDimensionValueType(to); } if (fromType !== toType) { // If they're both just number or px, convert them both to numbers rather than // relying on resize/remeasure to convert (which is wasteful in this situation) if (isNumOrPxType(fromType) && isNumOrPxType(toType)) { const current = value.get(); if (typeof current === "string") { value.set(parseFloat(current)); } if (typeof to === "string") { target[key] = parseFloat(to); } else if (Array.isArray(to) && toType === px) { target[key] = to.map(parseFloat); } } else if ((fromType === null || fromType === void 0 ? void 0 : fromType.transform) && (toType === null || toType === void 0 ? void 0 : toType.transform) && (from === 0 || to === 0)) { // If one or the other value is 0, it's safe to coerce it to the // type of the other without measurement if (from === 0) { value.set(toType.transform(from)); } else { target[key] = fromType.transform(to); } } else { // If we're going to do value conversion via DOM measurements, we first // need to remove non-positional transform values that could affect the bbox measurements. if (!hasAttemptedToRemoveTransformValues) { removedTransformValues = removeNonTranslationalTransform(visualElement); hasAttemptedToRemoveTransformValues = true; } changedValueTypeKeys.push(key); transitionEnd[key] = transitionEnd[key] !== undefined ? transitionEnd[key] : target[key]; value.jump(to); } } }); if (changedValueTypeKeys.length) { const scrollY = changedValueTypeKeys.indexOf("height") >= 0 ? window.pageYOffset : null; const convertedTarget = convertChangedValueTypes(target, visualElement, changedValueTypeKeys); // If we removed transform values, reapply them before the next render if (removedTransformValues.length) { removedTransformValues.forEach(([key, value]) => { visualElement.getValue(key).set(value); }); } // Reapply original values visualElement.render(); // Restore scroll position if (isBrowser && scrollY !== null) { window.scrollTo({ top: scrollY }); } return { target: convertedTarget, transitionEnd }; } else { return { target, transitionEnd }; } }; /** * Convert value types for x/y/width/height/top/left/bottom/right * * Allows animation between `'auto'` -> `'100%'` or `0` -> `'calc(50% - 10vw)'` * * @internal */ function unitConversion(visualElement, target, origin, transitionEnd) { return hasPositionalKey(target) ? checkAndConvertChangedValueTypes(visualElement, target, origin, transitionEnd) : { target, transitionEnd }; } /** * Parse a DOM variant to make it animatable. This involves resolving CSS variables * and ensuring animations like "20%" => "calc(50vw)" are performed in pixels. */ const parseDomVariant = (visualElement, target, origin, transitionEnd) => { const resolved = resolveCSSVariables(visualElement, target, transitionEnd); target = resolved.target; transitionEnd = resolved.transitionEnd; return unitConversion(visualElement, target, origin, transitionEnd); }; function isRefObject(ref) { return (ref && typeof ref === "object" && Object.prototype.hasOwnProperty.call(ref, "current")); } // Does this device prefer reduced motion? Returns `null` server-side. const prefersReducedMotion = { current: null }; const hasReducedMotionListener = { current: false }; function initPrefersReducedMotion() { hasReducedMotionListener.current = true; if (!isBrowser) return; if (window.matchMedia) { const motionMediaQuery = window.matchMedia("(prefers-reduced-motion)"); const setReducedMotionPreferences = () => (prefersReducedMotion.current = motionMediaQuery.matches); motionMediaQuery.addListener(setReducedMotionPreferences); setReducedMotionPreferences(); } else { prefersReducedMotion.current = false; } } function isAnimationControls(v) { return (v !== null && typeof v === "object" && typeof v.start === "function"); } /** * Decides if the supplied variable is variant label */ function isVariantLabel(v) { return typeof v === "string" || Array.isArray(v); } const variantPriorityOrder = [ "animate", "whileInView", "whileFocus", "whileHover", "whileTap", "whileDrag", "exit", ]; const variantProps = ["initial", ...variantPriorityOrder]; function isControllingVariants(props) { return (isAnimationControls(props.animate) || variantProps.some((name) => isVariantLabel(props[name]))); } function isVariantNode(props) { return Boolean(isControllingVariants(props) || props.variants); } function updateMotionValuesFromProps(element, next, prev) { const { willChange } = next; for (const key in next) { const nextValue = next[key]; const prevValue = prev[key]; if (isMotionValue(nextValue)) { /** * If this is a motion value found in props or style, we want to add it * to our visual element's motion value map. */ element.addValue(key, nextValue); if (isWillChangeMotionValue(willChange)) { willChange.add(key); } /** * Check the version of the incoming motion value with this version * and warn against mismatches. */ { warnOnce(nextValue.version === "10.18.0", `Attempting to mix Framer Motion versions ${nextValue.version} with 10.18.0 may not work as expected.`); } } else if (isMotionValue(prevValue)) { /** * If we're swapping from a motion value to a static value, * create a new motion value from that */ element.addValue(key, motionValue(nextValue, { owner: element })); if (isWillChangeMotionValue(willChange)) { willChange.remove(key); } } else if (prevValue !== nextValue) { /** * If this is a flat value that has changed, update the motion value * or create one if it doesn't exist. We only want to do this if we're * not handling the value with our animation state. */ if (element.hasValue(key)) { const existingValue = element.getValue(key); // TODO: Only update values that aren't being animated or even looked at !existingValue.hasAnimated && existingValue.set(nextValue); } else { const latestValue = element.getStaticValue(key); element.addValue(key, motionValue(latestValue !== undefined ? latestValue : nextValue, { owner: element })); } } } // Handle removed values for (const key in prev) { if (next[key] === undefined) element.removeValue(key); } return next; } const featureProps = { animation: [ "animate", "variants", "whileHover", "whileTap", "exit", "whileInView", "whileFocus", "whileDrag", ], exit: ["exit"], drag: ["drag", "dragControls"], focus: ["whileFocus"], hover: ["whileHover", "onHoverStart", "onHoverEnd"], tap: ["whileTap", "onTap", "onTapStart", "onTapCancel"], pan: ["onPan", "onPanStart", "onPanSessionStart", "onPanEnd"], inView: ["whileInView", "onViewportEnter", "onViewportLeave"], layout: ["layout", "layoutId"], }; const featureDefinitions = {}; for (const key in featureProps) { featureDefinitions[key] = { isEnabled: (props) => featureProps[key].some((name) => !!props[name]), }; } const featureNames = Object.keys(featureDefinitions); const numFeatures = featureNames.length; const propEventHandlers = [ "AnimationStart", "AnimationComplete", "Update", "BeforeLayoutMeasure", "LayoutMeasure", "LayoutAnimationStart", "LayoutAnimationComplete", ]; const numVariantProps = variantProps.length; /** * A VisualElement is an imperative abstraction around UI elements such as * HTMLElement, SVGElement, Three.Object3D etc. */ class VisualElement { constructor({ parent, props, presenceContext, reducedMotionConfig, visualState, }, options = {}) { /** * A reference to the current underlying Instance, e.g. a HTMLElement * or Three.Mesh etc. */ this.current = null; /** * A set containing references to this VisualElement's children. */ this.children = new Set(); /** * Determine what role this visual element should take in the variant tree. */ this.isVariantNode = false; this.isControllingVariants = false; /** * Decides whether this VisualElement should animate in reduced motion * mode. * * TODO: This is currently set on every individual VisualElement but feels * like it could be set globally. */ this.shouldReduceMotion = null; /** * A map of all motion values attached to this visual element. Motion * values are source of truth for any given animated value. A motion * value might be provided externally by the component via props. */ this.values = new Map(); /** * Cleanup functions for active features (hover/tap/exit etc) */ this.features = {}; /** * A map of every subscription that binds the provided or generated * motion values onChange listeners to this visual element. */ this.valueSubscriptions = new Map(); /** * A reference to the previously-provided motion values as returned * from scrapeMotionValuesFromProps. We use the keys in here to determine * if any motion values need to be removed after props are updated. */ this.prevMotionValues = {}; /** * An object containing a SubscriptionManager for each active event. */ this.events = {}; /** * An object containing an unsubscribe function for each prop event subscription. * For example, every "Update" event can have multiple subscribers via * VisualElement.on(), but only one of those can be defined via the onUpdate prop. */ this.propEventSubscriptions = {}; this.notifyUpdate = () => this.notify("Update", this.latestValues); this.render = () => { if (!this.current) return; this.triggerBuild(); this.renderInstance(this.current, this.renderState, this.props.style, this.projection); }; this.scheduleRender = () => frame.render(this.render, false, true); const { latestValues, renderState } = visualState; this.latestValues = latestValues; this.baseTarget = { ...latestValues }; this.initialValues = props.initial ? { ...latestValues } : {}; this.renderState = renderState; this.parent = parent; this.props = props; this.presenceContext = presenceContext; this.depth = parent ? parent.depth + 1 : 0; this.reducedMotionConfig = reducedMotionConfig; this.options = options; this.isControllingVariants = isControllingVariants(props); this.isVariantNode = isVariantNode(props); if (this.isVariantNode) { this.variantChildren = new Set(); } this.manuallyAnimateOnMount = Boolean(parent && parent.current); /** * Any motion values that are provided to the element when created * aren't yet bound to the element, as this would technically be impure. * However, we iterate through the motion values and set them to the * initial values for this component. * * TODO: This is impure and we should look at changing this to run on mount. * Doing so will break some tests but this isn't neccessarily a breaking change, * more a reflection of the test. */ const { willChange, ...initialMotionValues } = this.scrapeMotionValuesFromProps(props, {}); for (const key in initialMotionValues) { const value = initialMotionValues[key]; if (latestValues[key] !== undefined && isMotionValue(value)) { value.set(latestValues[key], false); if (isWillChangeMotionValue(willChange)) { willChange.add(key); } } } } /** * This method takes React props and returns found MotionValues. For example, HTML * MotionValues will be found within the style prop, whereas for Three.js within attribute arrays. * * This isn't an abstract method as it needs calling in the constructor, but it is * intended to be one. */ scrapeMotionValuesFromProps(_props, _prevProps) { return {}; } mount(instance) { this.current = instance; visualElementStore.set(instance, this); if (this.projection && !this.projection.instance) { this.projection.mount(instance); } if (this.parent && this.isVariantNode && !this.isControllingVariants) { this.removeFromVariantTree = this.parent.addVariantChild(this); } this.values.forEach((value, key) => this.bindToMotionValue(key, value)); if (!hasReducedMotionListener.current) { initPrefersReducedMotion(); } this.shouldReduceMotion = this.reducedMotionConfig === "never" ? false : this.reducedMotionConfig === "always" ? true : prefersReducedMotion.current; { warnOnce(this.shouldReduceMotion !== true, "You have Reduced Motion enabled on your device. Animations may not appear as expected."); } if (this.parent) this.parent.children.add(this); this.update(this.props, this.presenceContext); } unmount() { visualElementStore.delete(this.current); this.projection && this.projection.unmount(); cancelFrame(this.notifyUpdate); cancelFrame(this.render); this.valueSubscriptions.forEach((remove) => remove()); this.removeFromVariantTree && this.removeFromVariantTree(); this.parent && this.parent.children.delete(this); for (const key in this.events) { this.events[key].clear(); } for (const key in this.features) { this.features[key].unmount(); } this.current = null; } bindToMotionValue(key, value) { const valueIsTransform = transformProps.has(key); const removeOnChange = value.on("change", (latestValue) => { this.latestValues[key] = latestValue; this.props.onUpdate && frame.update(this.notifyUpdate, false, true); if (valueIsTransform && this.projection) { this.projection.isTransformDirty = true; } }); const removeOnRenderRequest = value.on("renderRequest", this.scheduleRender); this.valueSubscriptions.set(key, () => { removeOnChange(); removeOnRenderRequest(); }); } sortNodePosition(other) { /** * If these nodes aren't even of the same type we can't compare their depth. */ if (!this.current || !this.sortInstanceNodePosition || this.type !== other.type) { return 0; } return this.sortInstanceNodePosition(this.current, other.current); } loadFeatures({ children, ...renderedProps }, isStrict, preloadedFeatures, initialLayoutGroupConfig) { let ProjectionNodeConstructor; let MeasureLayout; /** * If we're in development mode, check to make sure we're not rendering a motion component * as a child of LazyMotion, as this will break the file-size benefits of using it. */ if (preloadedFeatures && isStrict) { const strictMessage = "You have rendered a `motion` component within a `LazyMotion` component. This will break tree shaking. Import and render a `m` component instead."; renderedProps.ignoreStrict ? warning(false, strictMessage) : invariant(false, strictMessage); } for (let i = 0; i < numFeatures; i++) { const name = featureNames[i]; const { isEnabled, Feature: FeatureConstructor, ProjectionNode, MeasureLayout: MeasureLayoutComponent, } = featureDefinitions[name]; if (ProjectionNode) ProjectionNodeConstructor = ProjectionNode; if (isEnabled(renderedProps)) { if (!this.features[name] && FeatureConstructor) { this.features[name] = new FeatureConstructor(this); } if (MeasureLayoutComponent) { MeasureLayout = MeasureLayoutComponent; } } } if ((this.type === "html" || this.type === "svg") && !this.projection && ProjectionNodeConstructor) { this.projection = new ProjectionNodeConstructor(this.latestValues, this.parent && this.parent.projection); const { layoutId, layout, drag, dragConstraints, layoutScroll, layoutRoot, } = renderedProps; this.projection.setOptions({ layoutId, layout, alwaysMeasureLayout: Boolean(drag) || (dragConstraints && isRefObject(dragConstraints)), visualElement: this, scheduleRender: () => this.scheduleRender(), /** * 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: initialLayoutGroupConfig, layoutScroll, layoutRoot, }); } return MeasureLayout; } updateFeatures() { for (const key in this.features) { const feature = this.features[key]; if (feature.isMounted) { feature.update(); } else { feature.mount(); feature.isMounted = true; } } } triggerBuild() { this.build(this.renderState, this.latestValues, this.options, this.props); } /** * Measure the current viewport box with or without transforms. * Only measures axis-aligned boxes, rotate and skew must be manually * removed with a re-render to work. */ measureViewportBox() { return this.current ? this.measureInstanceViewportBox(this.current, this.props) : createBox(); } getStaticValue(key) { return this.latestValues[key]; } setStaticValue(key, value) { this.latestValues[key] = value; } /** * Make a target animatable by Popmotion. For instance, if we're * trying to animate width from 100px to 100vw we need to measure 100vw * in pixels to determine what we really need to animate to. This is also * pluggable to support Framer's custom value types like Color, * and CSS variables. */ makeTargetAnimatable(target, canMutate = true) { return this.makeTargetAnimatableFromInstance(target, this.props, canMutate); } /** * Update the provided props. Ensure any newly-added motion values are * added to our map, old ones removed, and listeners updated. */ update(props, presenceContext) { if (props.transformTemplate || this.props.transformTemplate) { this.scheduleRender(); } this.prevProps = this.props; this.props = props; this.prevPresenceContext = this.presenceContext; this.presenceContext = presenceContext; /** * Update prop event handlers ie onAnimationStart, onAnimationComplete */ for (let i = 0; i < propEventHandlers.length; i++) { const key = propEventHandlers[i]; if (this.propEventSubscriptions[key]) { this.propEventSubscriptions[key](); delete this.propEventSubscriptions[key]; } const listener = props["on" + key]; if (listener) { this.propEventSubscriptions[key] = this.on(key, listener); } } this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps), this.prevMotionValues); if (this.handleChildMotionValue) { this.handleChildMotionValue(); } } getProps() { return this.props; } /** * Returns the variant definition with a given name. */ getVariant(name) { return this.props.variants ? this.props.variants[name] : undefined; } /** * Returns the defined default transition on this component. */ getDefaultTransition() { return this.props.transition; } getTransformPagePoint() { return this.props.transformPagePoint; } getClosestVariantNode() { return this.isVariantNode ? this : this.parent ? this.parent.getClosestVariantNode() : undefined; } getVariantContext(startAtParent = false) { if (startAtParent) { return this.parent ? this.parent.getVariantContext() : undefined; } if (!this.isControllingVariants) { const context = this.parent ? this.parent.getVariantContext() || {} : {}; if (this.props.initial !== undefined) { context.initial = this.props.initial; } return context; } const context = {}; for (let i = 0; i < numVariantProps; i++) { const name = variantProps[i]; const prop = this.props[name]; if (isVariantLabel(prop) || prop === false) { context[name] = prop; } } return context; } /** * Add a child visual element to our set of children. */ addVariantChild(child) { const closestVariantNode = this.getClosestVariantNode(); if (closestVariantNode) { closestVariantNode.variantChildren && closestVariantNode.variantChildren.add(child); return () => closestVariantNode.variantChildren.delete(child); } } /** * Add a motion value and bind it to this visual element. */ addValue(key, value) { // Remove existing value if it exists if (value !== this.values.get(key)) { this.removeValue(key); this.bindToMotionValue(key, value); } this.values.set(key, value); this.latestValues[key] = value.get(); } /** * Remove a motion value and unbind any active subscriptions. */ removeValue(key) { this.values.delete(key); const unsubscribe = this.valueSubscriptions.get(key); if (unsubscribe) { unsubscribe(); this.valueSubscriptions.delete(key); } delete this.latestValues[key]; this.removeValueFromRenderState(key, this.renderState); } /** * Check whether we have a motion value for this key */ hasValue(key) { return this.values.has(key); } getValue(key, defaultValue) { if (this.props.values && this.props.values[key]) { return this.props.values[key]; } let value = this.values.get(key); if (value === undefined && defaultValue !== undefined) { value = motionValue(defaultValue, { owner: this }); this.addValue(key, value); } return value; } /** * If we're trying to animate to a previously unencountered value, * we need to check for it in our state and as a last resort read it * directly from the instance (which might have performance implications). */ readValue(key) { var _a; return this.latestValues[key] !== undefined || !this.current ? this.latestValues[key] : (_a = this.getBaseTargetFromProps(this.props, key)) !== null && _a !== void 0 ? _a : this.readValueFromInstance(this.current, key, this.options); } /** * Set the base target to later animate back to. This is currently * only hydrated on creation and when we first read a value. */ setBaseTarget(key, value) { this.baseTarget[key] = value; } /** * Find the base target for a value thats been removed from all animation * props. */ getBaseTarget(key) { var _a; const { initial } = this.props; const valueFromInitial = typeof initial === "string" || typeof initial === "object" ? (_a = resolveVariantFromProps(this.props, initial)) === null || _a === void 0 ? void 0 : _a[key] : undefined; /** * If this value still exists in the current initial variant, read that. */ if (initial && valueFromInitial !== undefined) { return valueFromInitial; } /** * Alternatively, if this VisualElement config has defined a getBaseTarget * so we can read the value from an alternative source, try that. */ const target = this.getBaseTargetFromProps(this.props, key); if (target !== undefined && !isMotionValue(target)) return target; /** * If the value was initially defined on initial, but it doesn't any more, * return undefined. Otherwise return the value as initially read from the DOM. */ return this.initialValues[key] !== undefined && valueFromInitial === undefined ? undefined : this.baseTarget[key]; } on(eventName, callback) { if (!this.events[eventName]) { this.events[eventName] = new SubscriptionManager(); } return this.events[eventName].add(callback); } notify(eventName, ...args) { if (this.events[eventName]) { this.events[eventName].notify(...args); } } } class DOMVisualElement extends VisualElement { sortInstanceNodePosition(a, b) { /** * compareDocumentPosition returns a bitmask, by using the bitwise & * we're returning true if 2 in that bitmask is set to true. 2 is set * to true if b preceeds a. */ return a.compareDocumentPosition(b) & 2 ? 1 : -1; } getBaseTargetFromProps(props, key) { return props.style ? props.style[key] : undefined; } removeValueFromRenderState(key, { vars, style }) { delete vars[key]; delete style[key]; } makeTargetAnimatableFromInstance({ transition, transitionEnd, ...target }, { transformValues }, isMounted) { let origin = getOrigin(target, transition || {}, this); /** * If Framer has provided a function to convert `Color` etc value types, convert them */ if (transformValues) { if (transitionEnd) transitionEnd = transformValues(transitionEnd); if (target) target = transformValues(target); if (origin) origin = transformValues(origin); } if (isMounted) { checkTargetForNewValues(this, target, origin); const parsed = parseDomVariant(this, target, origin, transitionEnd); transitionEnd = parsed.transitionEnd; target = parsed.target; } return { transition, transitionEnd, ...target, }; } } const translateAlias = { x: "translateX", y: "translateY", z: "translateZ", transformPerspective: "perspective", }; const numTransforms = transformPropOrder.length; /** * Build a CSS transform style from individual x/y/scale etc properties. * * This outputs with a default order of transforms/scales/rotations, this can be customised by * providing a transformTemplate function. */ function buildTransform(transform, { enableHardwareAcceleration = true, allowTransformNone = true, }, transformIsDefault, transformTemplate) { // The transform string we're going to build into. let transformString = ""; /** * Loop over all possible transforms in order, adding the ones that * are present to the transform string. */ for (let i = 0; i < numTransforms; i++) { const key = transformPropOrder[i]; if (transform[key] !== undefined) { const transformName = translateAlias[key] || key; transformString += `${transformName}(${transform[key]}) `; } } if (enableHardwareAcceleration && !transform.z) { transformString += "translateZ(0)"; } transformString = transformString.trim(); // If we have a custom `transform` template, pass our transform values and // generated transformString to that before returning if (transformTemplate) { transformString = transformTemplate(transform, transformIsDefault ? "" : transformString); } else if (allowTransformNone && transformIsDefault) { transformString = "none"; } return transformString; } /** * Provided a value and a ValueType, returns the value as that value type. */ const getValueAsType = (value, type) => { return type && typeof value === "number" ? type.transform(value) : value; }; function buildHTMLStyles(state, latestValues, options, transformTemplate) { const { style, vars, transform, transformOrigin } = state; // Track whether we encounter any transform or transformOrigin values. let hasTransform = false; let hasTransformOrigin = false; // Does the calculated transform essentially equal "none"? let transformIsNone = true; /** * Loop over all our latest animated values and decide whether to handle them * as a style or CSS variable. * * Transforms and transform origins are kept seperately for further processing. */ for (const key in latestValues) { const value = latestValues[key]; /** * If this is a CSS variable we don't do any further processing. */ if (isCSSVariableName(key)) { vars[key] = value; continue; } // Convert the value to its default value type, ie 0 -> "0px" const valueType = numberValueTypes[key]; const valueAsType = getValueAsType(value, valueType); if (transformProps.has(key)) { // If this is a transform, flag to enable further transform processing hasTransform = true; transform[key] = valueAsType; // If we already know we have a non-default transform, early return if (!transformIsNone) continue; // Otherwise check to see if this is a default transform if (value !== (valueType.default || 0)) transformIsNone = false; } else if (key.startsWith("origin")) { // If this is a transform origin, flag and enable further transform-origin processing hasTransformOrigin = true; transformOrigin[key] = valueAsType; } else { style[key] = valueAsType; } } if (!latestValues.transform) { if (hasTransform || transformTemplate) { style.transform = buildTransform(state.transform, options, transformIsNone, transformTemplate); } else if (style.transform) { /** * If we have previously created a transform but currently don't have any, * reset transform style to none. */ style.transform = "none"; } } /** * Build a transformOrigin style. Uses the same defaults as the browser for * undefined origins. */ if (hasTransformOrigin) { const { originX = "50%", originY = "50%", originZ = 0, } = transformOrigin; style.transformOrigin = `${originX} ${originY} ${originZ}`; } } function renderHTML(element, { style, vars }, styleProp, projection) { Object.assign(element.style, style, projection && projection.getProjectionStyles(styleProp)); // Loop over any CSS variables and assign those. for (const key in vars) { element.style.setProperty(key, vars[key]); } } /** * Bounding boxes tend to be defined as top, left, right, bottom. For various operations * it's easier to consider each axis individually. This function returns a bounding box * as a map of single-axis min/max values. */ function convertBoundingBoxToBox({ top, left, right, bottom, }) { return { x: { min: left, max: right }, y: { min: top, max: bottom }, }; } /** * Applies a TransformPoint function to a bounding box. TransformPoint is usually a function * provided by Framer to allow measured points to be corrected for device scaling. This is used * when measuring DOM elements and DOM event points. */ function transformBoxPoints(point, transformPoint) { if (!transformPoint) return point; const topLeft = transformPoint({ x: point.left, y: point.top }); const bottomRight = transformPoint({ x: point.right, y: point.bottom }); return { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x, }; } function measureViewportBox(instance, transformPoint) { return convertBoundingBoxToBox(transformBoxPoints(instance.getBoundingClientRect(), transformPoint)); } function getComputedStyle$1(element) { return window.getComputedStyle(element); } class HTMLVisualElement extends DOMVisualElement { constructor() { super(...arguments); this.type = "html"; } readValueFromInstance(instance, key) { if (transformProps.has(key)) { const defaultType = getDefaultValueType(key); return defaultType ? defaultType.default || 0 : 0; } else { const computedStyle = getComputedStyle$1(instance); const value = (isCSSVariableName(key) ? computedStyle.getPropertyValue(key) : computedStyle[key]) || 0; return typeof value === "string" ? value.trim() : value; } } measureInstanceViewportBox(instance, { transformPagePoint }) { return measureViewportBox(instance, transformPagePoint); } build(renderState, latestValues, options, props) { buildHTMLStyles(renderState, latestValues, options, props.transformTemplate); } scrapeMotionValuesFromProps(props, prevProps) { return scrapeMotionValuesFromProps(props, prevProps); } handleChildMotionValue() { if (this.childSubscription) { this.childSubscription(); delete this.childSubscription; } const { children } = this.props; if (isMotionValue(children)) { this.childSubscription = children.on("change", (latest) => { if (this.current) this.current.textContent = `${latest}`; }); } } renderInstance(instance, renderState, styleProp, projection) { renderHTML(instance, renderState, styleProp, projection); } } function animateSingleValue(value, keyframes, options) { const motionValue$1 = isMotionValue(value) ? value : motionValue(value); motionValue$1.start(animateMotionValue("", motionValue$1, keyframes, options)); return motionValue$1.animation; } const transformAxes = ["", "X", "Y", "Z"]; const hiddenVisibility = { visibility: "hidden" }; /** * 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; /** * Use a mutable data object for debug data so as to not create a new * object every frame. */ const projectionFrameData = { type: "projectionFrame", totalNodes: 0, resolvedTargetDeltas: 0, recalculatedProjection: 0, }; function createProjectionNode({ attachResizeListener, defaultParent, measureScroll, checkIsScrollRoot, resetTransform, }) { return class ProjectionNode { constructor(latestValues = {}, parent = defaultParent === null || defaultParent === void 0 ? void 0 : 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; /** * 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; /** * An object representing the calculated contextual/accumulated/tree scale. * This will be used to scale calculcated projection transforms, as these are * calculated in screen-space but need to be scaled for elements to layoutly * 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.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; /** * Reset debug counts. Manually resetting rather than creating a new * object each frame. */ projectionFrameData.totalNodes = projectionFrameData.resolvedTargetDeltas = projectionFrameData.recalculatedProjection = 0; this.nodes.forEach(propagateDirtyNodes); this.nodes.forEach(resolveTargetDelta); this.nodes.forEach(calcProjection); this.nodes.forEach(cleanDirtyNodes); record(projectionFrameData); }; 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) { const subscriptionManager = this.eventHandlers.get(name); subscriptionManager && subscriptionManager.notify(...args); } hasListeners(name) { return this.eventHandlers.has(name); } /** * Lifecycles */ mount(instance, isLayoutDirty = this.root.hasTreeAnimated) { if (this.instance) return; this.isSVG = isSVGElement(instance); this.instance = instance; const { layoutId, layout, visualElement } = this.options; if (visualElement && !visualElement.current) { visualElement.mount(instance); } this.root.nodes.add(this); this.parent && this.parent.children.add(this); if (isLayoutDirty && (layout || layoutId)) { this.isLayoutDirty = true; } if (attachResizeListener) { let cancelDelay; const resizeUnblockUpdate = () => (this.root.updateBlockedByResize = false); attachResizeListener(instance, () => { this.root.updateBlockedByResize = true; cancelDelay && cancelDelay(); cancelDelay = delay(resizeUnblockUpdate, 250); if (globalProjectionState.hasAnimatedSinceResize) { globalProjectionState.hasAnimatedSinceResize = false; this.nodes.forEach(finishAnimation); } }); } if (layoutId) { this.root.registerSharedNode(layoutId, this); } // Only register the handler if it requires layout animation if (this.options.animate !== false && visualElement && (layoutId || layout)) { this.addEventListener("didUpdate", ({ delta, hasLayoutChanged, hasRelativeTargetChanged, layout: newLayout, }) => { if (this.isTreeAnimationBlocked()) { 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 targetChanged = !this.targetLayout || !boxEqualsRounded(this.targetLayout, newLayout) || hasRelativeTargetChanged; /** * 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 && hasRelativeTargetChanged; if (this.options.layoutRoot || (this.resumeFrom && this.resumeFrom.instance) || hasOnlyRelativeTargetChanged || (hasLayoutChanged && (targetChanged || !this.currentAnimation))) { if (this.resumeFrom) { this.resumingFrom = this.resumeFrom; this.resumingFrom.resumingFrom = undefined; } this.setAnimationOrigin(delta, hasOnlyRelativeTargetChanged); const animationOptions = { ...getValueTransition(layoutTransition, "layout"), onPlay: onLayoutAnimationStart, onComplete: onLayoutAnimationComplete, }; if (visualElement.shouldReduceMotion || this.options.layoutRoot) { animationOptions.delay = 0; animationOptions.type = false; } this.startAnimation(animationOptions); } 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 commited to screen and look like a jumpy box. */ if (!hasLayoutChanged) { finishAnimation(this); } if (this.isLead() && this.options.onExitComplete) { this.options.onExitComplete(); } } this.targetLayout = newLayout; }); } } unmount() { this.options.layoutId && this.willUpdate(); this.root.nodes.remove(this); const stack = this.getStack(); stack && stack.remove(this); this.parent && this.parent.children.delete(this); this.instance = undefined; cancelFrame(this.updateProjection); } // only on the root blockUpdate() { this.updateManuallyBlocked = true; } unblockUpdate() { this.updateManuallyBlocked = false; } isUpdateBlocked() { return this.updateManuallyBlocked || this.updateBlockedByResize; } isTreeAnimationBlocked() { return (this.isAnimationBlocked || (this.parent && this.parent.isTreeAnimationBlocked()) || false); } // Note: currently only running on root node startUpdate() { if (this.isUpdateBlocked()) return; this.isUpdating = true; this.nodes && this.nodes.forEach(resetRotation); 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 && this.options.onExitComplete(); return; } !this.root.isUpdating && this.root.startUpdate(); if (this.isLayoutDirty) return; this.isLayoutDirty = true; for (let i = 0; i < this.path.length; i++) { const node = this.path[i]; node.shouldResetTransform = true; node.updateScroll("snapshot"); if (node.options.layoutRoot) { node.willUpdate(false); } } 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) { this.unblockUpdate(); this.clearAllSnapshots(); this.nodes.forEach(clearMeasurements); return; } if (!this.isUpdating) { this.nodes.forEach(clearIsLayoutDirty); } this.isUpdating = false; /** * 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 = performance.now(); frameData.delta = clamp(0, 1000 / 60, now - frameData.timestamp); frameData.timestamp = now; frameData.isProcessing = true; steps.update.process(frameData); steps.preRender.process(frameData); steps.render.process(frameData); frameData.isProcessing = false; } didUpdate() { if (!this.updateScheduled) { this.updateScheduled = true; queueMicrotask(() => this.update()); } } clearAllSnapshots() { this.nodes.forEach(clearSnapshot); this.sharedNodes.forEach(removeLeadSnapshots); } 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(); } updateLayout() { if (!this.instance) return; // TODO: Incorporate into a forwarded scroll offset 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); this.layoutCorrected = createBox(); this.isLayoutDirty = false; this.projectionDelta = undefined; this.notifyListeners("measure", this.layout.layoutBox); const { visualElement } = this.options; visualElement && visualElement.notify("LayoutMeasure", this.layout.layoutBox, prevLayout ? prevLayout.layoutBox : undefined); } updateScroll(phase = "measure") { let needsMeasurement = Boolean(this.options.layoutScroll && this.instance); if (this.scroll && this.scroll.animationId === this.root.animationId && this.scroll.phase === phase) { needsMeasurement = false; } if (needsMeasurement) { this.scroll = { animationId: this.root.animationId, phase, isRoot: checkIsScrollRoot(this.instance), offset: measureScroll(this.instance), }; } } resetTransform() { if (!resetTransform) return; const isResetRequested = this.isLayoutDirty || this.shouldResetTransform; 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 && (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(); // 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); /** * 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.isRoot) { copyBoxInto(boxWithoutScroll, box); const { scroll: rootScroll } = this.root; /** * Undo the application of page scroll that was originally added * to the measured bounding box. */ if (rootScroll) { translateAxis(boxWithoutScroll.x, -rootScroll.offset.x); translateAxis(boxWithoutScroll.y, -rootScroll.offset.y); } } translateAxis(boxWithoutScroll.x, scroll.offset.x); translateAxis(boxWithoutScroll.y, scroll.offset.y); } } return boxWithoutScroll; } applyTransform(box, transformOnly = false) { const withTransforms = createBox(); copyBoxInto(withTransforms, box); for (let i = 0; i < this.path.length; i++) { const node = this.path[i]; if (!transformOnly && node.options.layoutScroll && node.scroll && node !== node.root) { transformBox(withTransforms, { x: -node.scroll.offset.x, y: -node.scroll.offset.y, }); } if (!hasTransform(node.latestValues)) continue; transformBox(withTransforms, node.latestValues); } if (hasTransform(this.latestValues)) { transformBox(withTransforms, this.latestValues); } 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 (!node.instance) continue; if (!hasTransform(node.latestValues)) continue; hasScale(node.latestValues) && node.updateSnapshot(); const sourceBox = createBox(); const nodeBox = node.measurePageBox(); copyBoxInto(sourceBox, nodeBox); removeBoxTransforms(boxWithoutTransform, node.latestValues, node.snapshot ? node.snapshot.layoutBox : undefined, 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, }; } 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) { var _a; /** * 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 || ((_a = this.parent) === null || _a === void 0 ? void 0 : _a.isProjectionDirty) || this.attemptToResolveRelativeTarget); 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; /** * 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. */ // TODO If this is unsuccessful this currently happens every frame if (!this.targetDelta && !this.relativeTarget) { // TODO: This is a semi-repetition of further down this function, make DRY const relativeParent = this.getClosestProjectingParent(); if (relativeParent && relativeParent.layout && this.animationProgress !== 1) { this.relativeParent = relativeParent; this.forceRelativeParentToResolveTarget(); this.relativeTarget = createBox(); this.relativeTargetOrigin = createBox(); calcRelativePosition(this.relativeTargetOrigin, this.layout.layoutBox, relativeParent.layout.layoutBox); copyBoxInto(this.relativeTarget, this.relativeTargetOrigin); } else { this.relativeParent = this.relativeTarget = undefined; } } /** * 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); /** * If we've only got a targetDelta, resolve it into a target */ } else if (this.targetDelta) { if (Boolean(this.resumingFrom)) { // TODO: This is creating a new object every frame this.target = this.applyTransform(this.layout.layoutBox); } 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; const relativeParent = this.getClosestProjectingParent(); if (relativeParent && Boolean(relativeParent.resumingFrom) === Boolean(this.resumingFrom) && !relativeParent.options.layoutScroll && relativeParent.target && this.animationProgress !== 1) { this.relativeParent = relativeParent; this.forceRelativeParentToResolveTarget(); this.relativeTarget = createBox(); this.relativeTargetOrigin = createBox(); calcRelativePosition(this.relativeTargetOrigin, this.target, relativeParent.target); copyBoxInto(this.relativeTarget, this.relativeTargetOrigin); } else { this.relativeParent = this.relativeTarget = undefined; } } /** * Increase debug counter for resolved target deltas */ projectionFrameData.resolvedTargetDeltas++; } 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); } calcProjection() { var _a; 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 || ((_a = this.parent) === null || _a === void 0 ? void 0 : _a.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 && 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. */ applyTreeDeltas(this.layoutCorrected, this.treeScale, this.path, isShared); /** * 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; } 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.projectionTransform) { this.projectionDelta = createDelta(); this.projectionTransform = "none"; this.scheduleRender(); } return; } if (!this.projectionDelta) { this.projectionDelta = createDelta(); this.projectionDeltaWithTransform = createDelta(); } const prevProjectionTransform = this.projectionTransform; /** * 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); this.projectionTransform = buildProjectionTransform(this.projectionDelta, this.treeScale); if (this.projectionTransform !== prevProjectionTransform || this.treeScale.x !== prevTreeScaleX || this.treeScale.y !== prevTreeScaleY) { this.hasProjected = true; this.scheduleRender(); this.notifyListeners("projectionUpdate", target); } /** * Increase debug counter for recalculated projections */ projectionFrameData.recalculatedProjection++; } hide() { this.isVisible = false; // TODO: Schedule render } show() { this.isVisible = true; // TODO: Schedule render } scheduleRender(notifyAll = true) { this.options.scheduleRender && this.options.scheduleRender(); if (notifyAll) { const stack = this.getStack(); stack && stack.scheduleRender(); } if (this.resumingFrom && !this.resumingFrom.instance) { this.resumingFrom = undefined; } } setAnimationOrigin(delta, hasOnlyRelativeTargetChanged = false) { 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 snapshotSource = snapshot ? snapshot.source : undefined; const layoutSource = this.layout ? this.layout.source : undefined; const isSharedLayoutAnimation = snapshotSource !== layoutSource; 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; this.mixTargetDelta = (latest) => { const progress = latest / 1000; mixAxisDelta(targetDelta.x, delta.x, progress); mixAxisDelta(targetDelta.y, delta.y, progress); this.setTargetDelta(targetDelta); if (this.relativeTarget && this.relativeTargetOrigin && this.layout && this.relativeParent && this.relativeParent.layout) { calcRelativePosition(relativeLayout, this.layout.layoutBox, this.relativeParent.layout.layoutBox); mixBox(this.relativeTarget, this.relativeTargetOrigin, relativeLayout, progress); /** * 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); } this.root.scheduleUpdateProjection(); this.scheduleRender(); this.animationProgress = progress; }; this.mixTargetDelta(this.options.layoutRoot ? 1000 : 0); } startAnimation(options) { this.notifyListeners("animationStart"); this.currentAnimation && this.currentAnimation.stop(); if (this.resumingFrom && this.resumingFrom.currentAnimation) { this.resumingFrom.currentAnimation.stop(); } 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.currentAnimation = animateSingleValue(0, animationTarget, { ...options, onUpdate: (latest) => { this.mixTargetDelta(latest); options.onUpdate && options.onUpdate(latest); }, onComplete: () => { options.onComplete && options.onComplete(); this.completeAnimation(); }, }); 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 = undefined; this.notifyListeners("animationComplete"); } finishAnimation() { if (this.currentAnimation) { this.mixTargetDelta && this.mixTargetDelta(animationTarget); this.currentAnimation.stop(); } this.completeAnimation(); } applyTransformsToTarget() { const lead = this.getLead(); let { targetWithTransforms, target, layout, latestValues } = 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)) { target = this.target || createBox(); const xLength = calcLength(this.layout.layoutBox.x); target.x.min = lead.target.x.min; target.x.max = target.x.min + xLength; const yLength = calcLength(this.layout.layoutBox.y); target.y.min = lead.target.y.min; target.y.max = target.y.min + yLength; } 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 ? config.transition : undefined, preserveFollowOpacity: config && config.shouldPreserveFollowOpacity ? config.shouldPreserveFollowOpacity(node) : undefined, }); } isLead() { const stack = this.getStack(); return stack ? stack.lead === this : true; } getLead() { var _a; const { layoutId } = this.options; return layoutId ? ((_a = this.getStack()) === null || _a === void 0 ? void 0 : _a.lead) || this : this; } getPrevLead() { var _a; const { layoutId } = this.options; return layoutId ? (_a = this.getStack()) === null || _a === void 0 ? void 0 : _a.prevLead : undefined; } getStack() { const { layoutId } = this.options; if (layoutId) return this.root.sharedNodes.get(layoutId); } promote({ needsReset, transition, preserveFollowOpacity, } = {}) { const stack = this.getStack(); if (stack) stack.promote(this, preserveFollowOpacity); if (needsReset) { this.projectionDelta = undefined; this.needsReset = true; } if (transition) this.setOptions({ transition }); } relegate() { const stack = this.getStack(); if (stack) { return stack.relegate(this); } else { return false; } } resetRotation() { const { visualElement } = this.options; if (!visualElement) return; // If there's no detected rotation values, we can early return without a forced render. let hasRotate = false; /** * An unrolled check for rotation values. Most elements don't have any rotation and * skipping the nested loop and new object creation is 50% faster. */ const { latestValues } = visualElement; if (latestValues.rotate || latestValues.rotateX || latestValues.rotateY || latestValues.rotateZ) { hasRotate = true; } // If there's no rotation values, we don't need to do any more. if (!hasRotate) return; const resetValues = {}; // Check the rotate value of all axes and reset to 0 for (let i = 0; i < transformAxes.length; i++) { const key = "rotate" + transformAxes[i]; // Record the rotation and then temporarily set it to 0 if (latestValues[key]) { resetValues[key] = latestValues[key]; visualElement.setStaticValue(key, 0); } } // Force a render of this element to apply the transform with all rotations // set to 0. visualElement.render(); // Put back all the values we reset for (const key in resetValues) { visualElement.setStaticValue(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(); } getProjectionStyles(styleProp) { var _a, _b; if (!this.instance || this.isSVG) return undefined; if (!this.isVisible) { return hiddenVisibility; } const styles = { visibility: "", }; const transformTemplate = this.getTransformTemplate(); if (this.needsReset) { this.needsReset = false; styles.opacity = ""; styles.pointerEvents = resolveMotionValue(styleProp === null || styleProp === void 0 ? void 0 : styleProp.pointerEvents) || ""; styles.transform = transformTemplate ? transformTemplate(this.latestValues, "") : "none"; return styles; } const lead = this.getLead(); if (!this.projectionDelta || !this.layout || !lead.target) { const emptyStyles = {}; if (this.options.layoutId) { emptyStyles.opacity = this.latestValues.opacity !== undefined ? this.latestValues.opacity : 1; emptyStyles.pointerEvents = resolveMotionValue(styleProp === null || styleProp === void 0 ? void 0 : styleProp.pointerEvents) || ""; } if (this.hasProjected && !hasTransform(this.latestValues)) { emptyStyles.transform = transformTemplate ? transformTemplate({}, "") : "none"; this.hasProjected = false; } return emptyStyles; } const valuesToRender = lead.animationValues || lead.latestValues; this.applyTransformsToTarget(); styles.transform = buildProjectionTransform(this.projectionDeltaWithTransform, this.treeScale, valuesToRender); if (transformTemplate) { styles.transform = transformTemplate(valuesToRender, styles.transform); } const { x, y } = this.projectionDelta; styles.transformOrigin = `${x.origin * 100}% ${y.origin * 100}% 0`; if (lead.animationValues) { /** * If the lead component is animating, assign this either the entering/leaving * opacity */ styles.opacity = lead === this ? (_b = (_a = valuesToRender.opacity) !== null && _a !== void 0 ? _a : this.latestValues.opacity) !== null && _b !== void 0 ? _b : 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. */ styles.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 } = 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 = styles.transform === "none" ? valuesToRender[key] : correct(valuesToRender[key], lead); if (applyTo) { const num = applyTo.length; for (let i = 0; i < num; i++) { styles[applyTo[i]] = corrected; } } else { styles[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) { styles.pointerEvents = lead === this ? resolveMotionValue(styleProp === null || styleProp === void 0 ? void 0 : styleProp.pointerEvents) || "" : "none"; } return styles; } clearSnapshot() { this.resumeFrom = this.snapshot = undefined; } // Only run on root resetTree() { this.root.nodes.forEach((node) => { var _a; return (_a = node.currentAnimation) === null || _a === void 0 ? void 0 : _a.stop(); }); this.root.nodes.forEach(clearMeasurements); this.root.sharedNodes.clear(); } }; } function updateLayout(node) { node.updateLayout(); } function notifyLayoutUpdate(node) { var _a; const snapshot = ((_a = node.resumeFrom) === null || _a === void 0 ? void 0 : _a.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 if (animationType === "size") { eachAxis((axis) => { const axisSnapshot = isShared ? snapshot.measuredBox[axis] : snapshot.layoutBox[axis]; const length = calcLength(axisSnapshot); axisSnapshot.min = layout[axis].min; axisSnapshot.max = axisSnapshot.min + length; }); } else if (shouldAnimatePositionOnly(animationType, snapshot.layoutBox, layout)) { eachAxis((axis) => { const axisSnapshot = isShared ? snapshot.measuredBox[axis] : snapshot.layoutBox[axis]; const length = calcLength(layout[axis]); axisSnapshot.max = axisSnapshot.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 layoutDelta = createDelta(); calcBoxDelta(layoutDelta, layout, snapshot.layoutBox); const visualDelta = createDelta(); if (isShared) { calcBoxDelta(visualDelta, node.applyTransform(measuredLayout, true), snapshot.measuredBox); } else { calcBoxDelta(visualDelta, layout, snapshot.layoutBox); } const hasLayoutChanged = !isDeltaZero(layoutDelta); let hasRelativeTargetChanged = false; if (!node.resumeFrom) { const relativeParent = node.getClosestProjectingParent(); /** * 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 relativeSnapshot = createBox(); calcRelativePosition(relativeSnapshot, snapshot.layoutBox, parentSnapshot.layoutBox); const relativeLayout = createBox(); calcRelativePosition(relativeLayout, layout, parentLayout.layoutBox); if (!boxEqualsRounded(relativeSnapshot, relativeLayout)) { hasRelativeTargetChanged = true; } if (relativeParent.options.layoutRoot) { node.relativeTarget = relativeLayout; node.relativeTargetOrigin = relativeSnapshot; node.relativeParent = relativeParent; } } } } node.notifyListeners("didUpdate", { layout, snapshot, delta: visualDelta, layoutDelta, hasLayoutChanged, hasRelativeTargetChanged, }); } else if (node.isLead()) { const { onExitComplete } = node.options; onExitComplete && 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 */ projectionFrameData.totalNodes++; 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 clearIsLayoutDirty(node) { node.isLayoutDirty = false; } function resetTransformStyle(node) { const { visualElement } = node.options; if (visualElement && 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 resetRotation(node) { node.resetRotation(); } function removeLeadSnapshots(stack) { stack.removeLeadSnapshot(); } function mixAxisDelta(output, delta, p) { output.translate = mix(delta.translate, 0, p); output.scale = mix(delta.scale, 1, p); output.origin = delta.origin; output.originPoint = delta.originPoint; } function mixAxis(output, from, to, p) { output.min = mix(from.min, to.min, p); output.max = mix(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 && node.animationValues.opacityExit !== undefined); } const defaultLayoutTransition = { duration: 0.45, ease: [0.4, 0, 0.1, 1], }; const userAgentContains = (string) => typeof navigator !== "undefined" && navigator.userAgent.toLowerCase().includes(string); /** * Measured bounding boxes must be rounded in Safari and * left untouched in Chrome, otherwise non-integer layouts within scaled-up elements * can appear to jump. */ const roundPoint = userAgentContains("applewebkit/") && !userAgentContains("chrome/") ? Math.round : noop; function roundAxis(axis) { // Round to the nearest .5 pixels to support subpixel layouts axis.min = roundPoint(axis.min); axis.max = roundPoint(axis.max); } function roundBox(box) { roundAxis(box.x); roundAxis(box.y); } function shouldAnimatePositionOnly(animationType, snapshot, layout) { return (animationType === "position" || (animationType === "preserve-aspect" && !isNear(aspectRatio(snapshot), aspectRatio(layout), 0.2))); } function addDomEvent(target, eventName, handler, options = { passive: true }) { target.addEventListener(eventName, handler, options); return () => target.removeEventListener(eventName, handler); } const DocumentProjectionNode = createProjectionNode({ attachResizeListener: (ref, notify) => addDomEvent(ref, "resize", notify), measureScroll: () => ({ x: document.documentElement.scrollLeft || document.body.scrollLeft, y: document.documentElement.scrollTop || document.body.scrollTop, }), checkIsScrollRoot: () => true, }); const rootProjectionNode = { current: undefined, }; const HTMLProjectionNode = createProjectionNode({ measureScroll: (instance) => ({ x: instance.scrollLeft, y: instance.scrollTop, }), defaultParent: () => { if (!rootProjectionNode.current) { const documentNode = new DocumentProjectionNode({}); documentNode.mount(window); documentNode.setOptions({ layoutScroll: true }); rootProjectionNode.current = documentNode; } return rootProjectionNode.current; }, resetTransform: (instance, value) => { instance.style.transform = value !== undefined ? value : "none"; }, checkIsScrollRoot: (instance) => Boolean(window.getComputedStyle(instance).position === "fixed"), }); const notify = (node) => !node.isLayoutDirty && node.willUpdate(false); function nodeGroup() { const nodes = new Set(); const subscriptions = new WeakMap(); const dirtyAll = () => nodes.forEach(notify); return { add: (node) => { nodes.add(node); subscriptions.set(node, node.addEventListener("willUpdate", dirtyAll)); }, remove: (node) => { nodes.delete(node); const unsubscribe = subscriptions.get(node); if (unsubscribe) { unsubscribe(); subscriptions.delete(node); } dirtyAll(); }, dirty: dirtyAll, }; } function pixelsToPercent(pixels, axis) { if (axis.max === axis.min) return 0; return (pixels / (axis.max - axis.min)) * 100; } /** * We always correct borderRadius as a percentage rather than pixels to reduce paints. * For example, if you are projecting a box that is 100px wide with a 10px borderRadius * into a box that is 200px wide with a 20px borderRadius, that is actually a 10% * borderRadius in both states. If we animate between the two in pixels that will trigger * a paint each time. If we animate between the two in percentage we'll avoid a paint. */ const correctBorderRadius = { correct: (latest, node) => { if (!node.target) return latest; /** * If latest is a string, if it's a percentage we can return immediately as it's * going to be stretched appropriately. Otherwise, if it's a pixel, convert it to a number. */ if (typeof latest === "string") { if (px.test(latest)) { latest = parseFloat(latest); } else { return latest; } } /** * If latest is a number, it's a pixel value. We use the current viewportBox to calculate that * pixel value as a percentage of each axis */ const x = pixelsToPercent(latest, node.target.x); const y = pixelsToPercent(latest, node.target.y); return `${x}% ${y}%`; }, }; const correctBoxShadow = { correct: (latest, { treeScale, projectionDelta }) => { const original = latest; const shadow = complex.parse(latest); // TODO: Doesn't support multiple shadows if (shadow.length > 5) return original; const template = complex.createTransformer(latest); const offset = typeof shadow[0] !== "number" ? 1 : 0; // Calculate the overall context scale const xScale = projectionDelta.x.scale * treeScale.x; const yScale = projectionDelta.y.scale * treeScale.y; shadow[0 + offset] /= xScale; shadow[1 + offset] /= yScale; /** * Ideally we'd correct x and y scales individually, but because blur and * spread apply to both we have to take a scale average and apply that instead. * We could potentially improve the outcome of this by incorporating the ratio between * the two scales. */ const averageScale = mix(xScale, yScale, 0.5); // Blur if (typeof shadow[2 + offset] === "number") shadow[2 + offset] /= averageScale; // Spread if (typeof shadow[3 + offset] === "number") shadow[3 + offset] /= averageScale; return template(shadow); }, }; exports.HTMLProjectionNode = HTMLProjectionNode; exports.HTMLVisualElement = HTMLVisualElement; exports.addScaleCorrector = addScaleCorrector; exports.animate = animateValue; exports.buildTransform = buildTransform; exports.calcBoxDelta = calcBoxDelta; exports.correctBorderRadius = correctBorderRadius; exports.correctBoxShadow = correctBoxShadow; exports.frame = frame; exports.frameData = frameData; exports.mix = mix; exports.nodeGroup = nodeGroup; Object.defineProperty(exports, '__esModule', { value: true }); }));