Frontend Enhancements: - Complete React TypeScript frontend with modern UI components - Distributed workflows management interface with real-time updates - Socket.IO integration for live agent status monitoring - Agent management dashboard with cluster visualization - Project management interface with metrics and task tracking - Responsive design with proper error handling and loading states Backend Infrastructure: - Distributed coordinator for multi-agent workflow orchestration - Cluster management API with comprehensive agent operations - Enhanced database models for agents and projects - Project service for filesystem-based project discovery - Performance monitoring and metrics collection - Comprehensive API documentation and error handling Documentation: - Complete distributed development guide (README_DISTRIBUTED.md) - Comprehensive development report with architecture insights - System configuration templates and deployment guides The platform now provides a complete web interface for managing the distributed AI cluster with real-time monitoring, workflow orchestration, and agent coordination capabilities. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
5718 lines
194 KiB
JavaScript
5718 lines
194 KiB
JavaScript
'use strict';
|
|
|
|
const isBrowser = typeof document !== "undefined";
|
|
|
|
/**
|
|
* Convert camelCase to dash-case properties.
|
|
*/
|
|
const camelToDash = (str) => str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
|
|
|
const optimizedAppearDataId = "framerAppearId";
|
|
const optimizedAppearDataAttribute = "data-" + camelToDash(optimizedAppearDataId);
|
|
|
|
function isRefObject(ref) {
|
|
return (ref &&
|
|
typeof ref === "object" &&
|
|
Object.prototype.hasOwnProperty.call(ref, "current"));
|
|
}
|
|
|
|
/**
|
|
* Decides if the supplied variable is variant label
|
|
*/
|
|
function isVariantLabel(v) {
|
|
return typeof v === "string" || Array.isArray(v);
|
|
}
|
|
|
|
function isAnimationControls(v) {
|
|
return (v !== null &&
|
|
typeof v === "object" &&
|
|
typeof v.start === "function");
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 scaleCorrectors = {};
|
|
function addScaleCorrector(correctors) {
|
|
Object.assign(scaleCorrectors, correctors);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
function isForcedMotionValue(key, { layout, layoutId }) {
|
|
return (transformProps.has(key) ||
|
|
key.startsWith("origin") ||
|
|
((layout || layoutId !== undefined) &&
|
|
(!!scaleCorrectors[key] || key === "opacity")));
|
|
}
|
|
|
|
const isMotionValue = (value) => Boolean(value && value.getVelocity);
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
|
|
/**
|
|
* 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;
|
|
};
|
|
|
|
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,
|
|
};
|
|
|
|
/**
|
|
* 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 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,
|
|
};
|
|
|
|
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 calcOrigin(origin, offset, size) {
|
|
return typeof origin === "string"
|
|
? origin
|
|
: px.transform(offset + size * origin);
|
|
}
|
|
/**
|
|
* The SVG transform origin defaults are different to CSS and is less intuitive,
|
|
* so we use the measured dimensions of the SVG to reconcile these.
|
|
*/
|
|
function calcSVGTransformOrigin(dimensions, originX, originY) {
|
|
const pxOriginX = calcOrigin(originX, dimensions.x, dimensions.width);
|
|
const pxOriginY = calcOrigin(originY, dimensions.y, dimensions.height);
|
|
return `${pxOriginX} ${pxOriginY}`;
|
|
}
|
|
|
|
const dashKeys = {
|
|
offset: "stroke-dashoffset",
|
|
array: "stroke-dasharray",
|
|
};
|
|
const camelKeys = {
|
|
offset: "strokeDashoffset",
|
|
array: "strokeDasharray",
|
|
};
|
|
/**
|
|
* Build SVG path properties. Uses the path's measured length to convert
|
|
* our custom pathLength, pathSpacing and pathOffset into stroke-dashoffset
|
|
* and stroke-dasharray attributes.
|
|
*
|
|
* This function is mutative to reduce per-frame GC.
|
|
*/
|
|
function buildSVGPath(attrs, length, spacing = 1, offset = 0, useDashCase = true) {
|
|
// Normalise path length by setting SVG attribute pathLength to 1
|
|
attrs.pathLength = 1;
|
|
// We use dash case when setting attributes directly to the DOM node and camel case
|
|
// when defining props on a React component.
|
|
const keys = useDashCase ? dashKeys : camelKeys;
|
|
// Build the dash offset
|
|
attrs[keys.offset] = px.transform(-offset);
|
|
// Build the dash array
|
|
const pathLength = px.transform(length);
|
|
const pathSpacing = px.transform(spacing);
|
|
attrs[keys.array] = `${pathLength} ${pathSpacing}`;
|
|
}
|
|
|
|
/**
|
|
* Build SVG visual attrbutes, like cx and style.transform
|
|
*/
|
|
function buildSVGAttrs(state, { attrX, attrY, attrScale, originX, originY, pathLength, pathSpacing = 1, pathOffset = 0,
|
|
// This is object creation, which we try to avoid per-frame.
|
|
...latest }, options, isSVGTag, transformTemplate) {
|
|
buildHTMLStyles(state, latest, options, transformTemplate);
|
|
/**
|
|
* For svg tags we just want to make sure viewBox is animatable and treat all the styles
|
|
* as normal HTML tags.
|
|
*/
|
|
if (isSVGTag) {
|
|
if (state.style.viewBox) {
|
|
state.attrs.viewBox = state.style.viewBox;
|
|
}
|
|
return;
|
|
}
|
|
state.attrs = state.style;
|
|
state.style = {};
|
|
const { attrs, style, dimensions } = state;
|
|
/**
|
|
* However, we apply transforms as CSS transforms. So if we detect a transform we take it from attrs
|
|
* and copy it into style.
|
|
*/
|
|
if (attrs.transform) {
|
|
if (dimensions)
|
|
style.transform = attrs.transform;
|
|
delete attrs.transform;
|
|
}
|
|
// Parse transformOrigin
|
|
if (dimensions &&
|
|
(originX !== undefined || originY !== undefined || style.transform)) {
|
|
style.transformOrigin = calcSVGTransformOrigin(dimensions, originX !== undefined ? originX : 0.5, originY !== undefined ? originY : 0.5);
|
|
}
|
|
// Render attrX/attrY/attrScale as attributes
|
|
if (attrX !== undefined)
|
|
attrs.x = attrX;
|
|
if (attrY !== undefined)
|
|
attrs.y = attrY;
|
|
if (attrScale !== undefined)
|
|
attrs.scale = attrScale;
|
|
// Build SVG path if one has been defined
|
|
if (pathLength !== undefined) {
|
|
buildSVGPath(attrs, pathLength, pathSpacing, pathOffset, false);
|
|
}
|
|
}
|
|
|
|
const isSVGTag = (tag) => typeof tag === "string" && tag.toLowerCase() === "svg";
|
|
|
|
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]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A set of attribute names that are always read/written as camel case.
|
|
*/
|
|
const camelCaseAttributes = new Set([
|
|
"baseFrequency",
|
|
"diffuseConstant",
|
|
"kernelMatrix",
|
|
"kernelUnitLength",
|
|
"keySplines",
|
|
"keyTimes",
|
|
"limitingConeAngle",
|
|
"markerHeight",
|
|
"markerWidth",
|
|
"numOctaves",
|
|
"targetX",
|
|
"targetY",
|
|
"surfaceScale",
|
|
"specularConstant",
|
|
"specularExponent",
|
|
"stdDeviation",
|
|
"tableValues",
|
|
"viewBox",
|
|
"gradientTransform",
|
|
"pathLength",
|
|
"startOffset",
|
|
"textLength",
|
|
"lengthAdjust",
|
|
]);
|
|
|
|
function renderSVG(element, renderState, _styleProp, projection) {
|
|
renderHTML(element, renderState, undefined, projection);
|
|
for (const key in renderState.attrs) {
|
|
element.setAttribute(!camelCaseAttributes.has(key) ? camelToDash(key) : key, renderState.attrs[key]);
|
|
}
|
|
}
|
|
|
|
function scrapeMotionValuesFromProps$1(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;
|
|
}
|
|
|
|
function scrapeMotionValuesFromProps(props, prevProps) {
|
|
const newValues = scrapeMotionValuesFromProps$1(props, prevProps);
|
|
for (const key in props) {
|
|
if (isMotionValue(props[key]) || isMotionValue(prevProps[key])) {
|
|
const targetKey = transformPropOrder.indexOf(key) !== -1
|
|
? "attr" + key.charAt(0).toUpperCase() + key.substring(1)
|
|
: key;
|
|
newValues[targetKey] = props[key];
|
|
}
|
|
}
|
|
return newValues;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const isKeyframesTarget = (v) => {
|
|
return Array.isArray(v);
|
|
};
|
|
|
|
const isCustomValue = (v) => {
|
|
return Boolean(v && typeof v === "object" && v.mix && v.toValue);
|
|
};
|
|
const resolveFinalValueInKeyframes = (v) => {
|
|
// TODO maybe throw if v.length - 1 is placeholder token?
|
|
return isKeyframesTarget(v) ? v[v.length - 1] || 0 : v;
|
|
};
|
|
|
|
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$1 = 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), 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);
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/**
|
|
* Creates an object containing the latest state of every MotionValue on a VisualElement
|
|
*/
|
|
function getCurrent(visualElement) {
|
|
const current = {};
|
|
visualElement.values.forEach((value, key) => (current[key] = value.get()));
|
|
return current;
|
|
}
|
|
/**
|
|
* Creates an object containing the latest velocity of every MotionValue on a VisualElement
|
|
*/
|
|
function getVelocity(visualElement) {
|
|
const velocity = {};
|
|
visualElement.values.forEach((value, key) => (velocity[key] = value.getVelocity()));
|
|
return velocity;
|
|
}
|
|
function resolveVariant(visualElement, definition, custom) {
|
|
const props = visualElement.getProps();
|
|
return resolveVariantFromProps(props, definition, custom !== undefined ? custom : props.custom, getCurrent(visualElement), getVelocity(visualElement));
|
|
}
|
|
|
|
exports.warning = noop;
|
|
exports.invariant = noop;
|
|
if (process.env.NODE_ENV !== "production") {
|
|
exports.warning = (check, message) => {
|
|
if (!check && typeof console !== "undefined") {
|
|
console.warn(message);
|
|
}
|
|
};
|
|
exports.invariant = (check, message) => {
|
|
if (!check) {
|
|
throw new Error(message);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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";
|
|
};
|
|
|
|
// 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);
|
|
|
|
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
|
|
exports.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
|
|
exports.invariant(easingLookup[definition] !== undefined, `Invalid easing type '${definition}'`);
|
|
return easingLookup[definition];
|
|
}
|
|
return definition;
|
|
};
|
|
|
|
/**
|
|
* 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);
|
|
},
|
|
};
|
|
|
|
/*
|
|
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;
|
|
|
|
// 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);
|
|
exports.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);
|
|
};
|
|
};
|
|
|
|
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$1(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$1(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$1(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 {
|
|
exports.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);
|
|
}
|
|
};
|
|
|
|
/*
|
|
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;
|
|
};
|
|
|
|
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;
|
|
exports.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$1(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$1(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;
|
|
},
|
|
};
|
|
}
|
|
|
|
/*
|
|
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 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;
|
|
exports.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") {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
exports.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;
|
|
}
|
|
|
|
function memo(callback) {
|
|
let result;
|
|
return () => {
|
|
if (result === undefined)
|
|
result = callback();
|
|
return result;
|
|
};
|
|
}
|
|
|
|
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;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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$1(transition, key) {
|
|
return transition[key] || transition["default"] || transition;
|
|
}
|
|
|
|
const MotionGlobalConfig = {
|
|
skipAnimations: false,
|
|
};
|
|
|
|
const animateMotionValue = (valueName, value, target, transition = {}) => {
|
|
return (onComplete) => {
|
|
const valueTransition = getValueTransition$1(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);
|
|
exports.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);
|
|
|
|
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);
|
|
}
|
|
// Adapted from array-move
|
|
function moveItem([...arr], fromIndex, toIndex) {
|
|
const startIndex = fromIndex < 0 ? arr.length + fromIndex : fromIndex;
|
|
if (startIndex >= 0 && startIndex < arr.length) {
|
|
const endIndex = toIndex < 0 ? arr.length + toIndex : toIndex;
|
|
const [item] = arr.splice(fromIndex, 1);
|
|
arr.splice(endIndex, 0, item);
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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));
|
|
};
|
|
const collectMotionValues = {
|
|
current: undefined,
|
|
};
|
|
/**
|
|
* `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 <motion.div style={{ x }} />
|
|
* }
|
|
* ```
|
|
*
|
|
* @param subscriber - A function that receives the latest value.
|
|
* @returns A function that, when called, will cancel this subscription.
|
|
*
|
|
* @deprecated
|
|
*/
|
|
onChange(subscription) {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
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() {
|
|
if (collectMotionValues.current) {
|
|
collectMotionValues.current.push(this);
|
|
}
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
|
|
/**
|
|
* Set VisualElement's MotionValue, creating a new MotionValue for it if
|
|
* it doesn't exist.
|
|
*/
|
|
function setMotionValue(visualElement, key, value) {
|
|
if (visualElement.hasValue(key)) {
|
|
visualElement.getValue(key).set(value);
|
|
}
|
|
else {
|
|
visualElement.addValue(key, motionValue(value));
|
|
}
|
|
}
|
|
function setTarget(visualElement, definition) {
|
|
const resolved = resolveVariant(visualElement, definition);
|
|
let { transitionEnd = {}, transition = {}, ...target } = resolved ? visualElement.makeTargetAnimatable(resolved, false) : {};
|
|
target = { ...target, ...transitionEnd };
|
|
for (const key in target) {
|
|
const value = resolveFinalValueInKeyframes(target[key]);
|
|
setMotionValue(visualElement, key, value);
|
|
}
|
|
}
|
|
function setVariants(visualElement, variantLabels) {
|
|
const reversedLabels = [...variantLabels].reverse();
|
|
reversedLabels.forEach((key) => {
|
|
const variant = visualElement.getVariant(key);
|
|
variant && setTarget(visualElement, variant);
|
|
if (visualElement.variantChildren) {
|
|
visualElement.variantChildren.forEach((child) => {
|
|
setVariants(child, variantLabels);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
function setValues(visualElement, definition) {
|
|
if (Array.isArray(definition)) {
|
|
return setVariants(visualElement, definition);
|
|
}
|
|
else if (typeof definition === "string") {
|
|
return setVariants(visualElement, [definition]);
|
|
}
|
|
else {
|
|
setTarget(visualElement, 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;
|
|
}
|
|
|
|
/**
|
|
* Decide whether we should block this animation. Previously, we achieved this
|
|
* just by checking whether the key was listed in protectedKeys, but this
|
|
* posed problems if an animation was triggered by afterChildren and protectedKeys
|
|
* had been set to true in the meantime.
|
|
*/
|
|
function shouldBlockAnimation({ protectedKeys, needsAnimating }, key) {
|
|
const shouldBlock = protectedKeys.hasOwnProperty(key) && needsAnimating[key] !== true;
|
|
needsAnimating[key] = false;
|
|
return shouldBlock;
|
|
}
|
|
function hasKeyframesChanged(value, target) {
|
|
const current = value.get();
|
|
if (Array.isArray(target)) {
|
|
for (let i = 0; i < target.length; i++) {
|
|
if (target[i] !== current)
|
|
return true;
|
|
}
|
|
}
|
|
else {
|
|
return current !== target;
|
|
}
|
|
}
|
|
function animateTarget(visualElement, definition, { delay = 0, transitionOverride, type } = {}) {
|
|
let { transition = visualElement.getDefaultTransition(), transitionEnd, ...target } = visualElement.makeTargetAnimatable(definition);
|
|
const willChange = visualElement.getValue("willChange");
|
|
if (transitionOverride)
|
|
transition = transitionOverride;
|
|
const animations = [];
|
|
const animationTypeState = type &&
|
|
visualElement.animationState &&
|
|
visualElement.animationState.getState()[type];
|
|
for (const key in target) {
|
|
const value = visualElement.getValue(key);
|
|
const valueTarget = target[key];
|
|
if (!value ||
|
|
valueTarget === undefined ||
|
|
(animationTypeState &&
|
|
shouldBlockAnimation(animationTypeState, key))) {
|
|
continue;
|
|
}
|
|
const valueTransition = {
|
|
delay,
|
|
elapsed: 0,
|
|
...getValueTransition$1(transition || {}, key),
|
|
};
|
|
/**
|
|
* If this is the first time a value is being animated, check
|
|
* to see if we're handling off from an existing animation.
|
|
*/
|
|
if (window.HandoffAppearAnimations) {
|
|
const appearId = visualElement.getProps()[optimizedAppearDataAttribute];
|
|
if (appearId) {
|
|
const elapsed = window.HandoffAppearAnimations(appearId, key, value, frame);
|
|
if (elapsed !== null) {
|
|
valueTransition.elapsed = elapsed;
|
|
valueTransition.isHandoff = true;
|
|
}
|
|
}
|
|
}
|
|
let canSkip = !valueTransition.isHandoff &&
|
|
!hasKeyframesChanged(value, valueTarget);
|
|
if (valueTransition.type === "spring" &&
|
|
(value.getVelocity() || valueTransition.velocity)) {
|
|
canSkip = false;
|
|
}
|
|
/**
|
|
* Temporarily disable skipping animations if there's an animation in
|
|
* progress. Better would be to track the current target of a value
|
|
* and compare that against valueTarget.
|
|
*/
|
|
if (value.animation) {
|
|
canSkip = false;
|
|
}
|
|
if (canSkip)
|
|
continue;
|
|
value.start(animateMotionValue(key, value, valueTarget, visualElement.shouldReduceMotion && transformProps.has(key)
|
|
? { type: false }
|
|
: valueTransition));
|
|
const animation = value.animation;
|
|
if (isWillChangeMotionValue(willChange)) {
|
|
willChange.add(key);
|
|
animation.then(() => willChange.remove(key));
|
|
}
|
|
animations.push(animation);
|
|
}
|
|
if (transitionEnd) {
|
|
Promise.all(animations).then(() => {
|
|
transitionEnd && setTarget(visualElement, transitionEnd);
|
|
});
|
|
}
|
|
return animations;
|
|
}
|
|
|
|
const distance = (a, b) => Math.abs(a - b);
|
|
function distance2D(a, b) {
|
|
// Multi-dimensional
|
|
const xDelta = distance(a.x, b.x);
|
|
const yDelta = distance(a.y, b.y);
|
|
return Math.sqrt(xDelta ** 2 + yDelta ** 2);
|
|
}
|
|
|
|
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(),
|
|
});
|
|
|
|
/**
|
|
* 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 },
|
|
};
|
|
}
|
|
function convertBoxToBoundingBox({ x, y }) {
|
|
return { top: y.min, right: x.max, bottom: y.max, left: x.min };
|
|
}
|
|
/**
|
|
* 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 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 = ["x", "scaleX", "originX"];
|
|
const yKeys = ["y", "scaleY", "originY"];
|
|
/**
|
|
* Apply a transform to a box from the latest resolved motion values.
|
|
*/
|
|
function transformBox(box, transform) {
|
|
transformAxis(box.x, transform, xKeys);
|
|
transformAxis(box.y, transform, yKeys);
|
|
}
|
|
|
|
function measureViewportBox(instance, transformPoint) {
|
|
return convertBoundingBoxToBox(transformBoxPoints(instance.getBoundingClientRect(), transformPoint));
|
|
}
|
|
function measurePageBox(element, rootProjectionNode, transformPagePoint) {
|
|
const viewportBox = measureViewportBox(element, transformPagePoint);
|
|
const { scroll } = rootProjectionNode;
|
|
if (scroll) {
|
|
translateAxis(viewportBox.x, scroll.offset.x);
|
|
translateAxis(viewportBox.y, scroll.offset.y);
|
|
}
|
|
return viewportBox;
|
|
}
|
|
|
|
/**
|
|
* 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 resolveElements(elements, scope, selectorCache) {
|
|
var _a;
|
|
if (typeof elements === "string") {
|
|
let root = document;
|
|
if (scope) {
|
|
exports.invariant(Boolean(scope.current), "Scope provided, but no element detected.");
|
|
root = scope.current;
|
|
}
|
|
if (selectorCache) {
|
|
(_a = selectorCache[elements]) !== null && _a !== void 0 ? _a : (selectorCache[elements] = root.querySelectorAll(elements));
|
|
elements = selectorCache[elements];
|
|
}
|
|
else {
|
|
elements = root.querySelectorAll(elements);
|
|
}
|
|
}
|
|
else if (elements instanceof Element) {
|
|
elements = [elements];
|
|
}
|
|
/**
|
|
* Return an empty array
|
|
*/
|
|
return Array.from(elements || []);
|
|
}
|
|
|
|
const visualElementStore = new WeakMap();
|
|
|
|
function observeTimeline(update, timeline) {
|
|
let prevProgress;
|
|
const onFrame = () => {
|
|
const { currentTime } = timeline;
|
|
const percentage = currentTime === null ? 0 : currentTime.value;
|
|
const progress = percentage / 100;
|
|
if (prevProgress !== progress) {
|
|
update(progress);
|
|
}
|
|
prevProgress = progress;
|
|
};
|
|
frame.update(onFrame, true);
|
|
return () => cancelFrame(onFrame);
|
|
}
|
|
|
|
const supportsScrollTimeline = memo(() => window.ScrollTimeline !== undefined);
|
|
|
|
class GroupPlaybackControls {
|
|
constructor(animations) {
|
|
this.animations = animations.filter(Boolean);
|
|
}
|
|
then(onResolve, onReject) {
|
|
return Promise.all(this.animations).then(onResolve).catch(onReject);
|
|
}
|
|
/**
|
|
* TODO: Filter out cancelled or stopped animations before returning
|
|
*/
|
|
getAll(propName) {
|
|
return this.animations[0][propName];
|
|
}
|
|
setAll(propName, newValue) {
|
|
for (let i = 0; i < this.animations.length; i++) {
|
|
this.animations[i][propName] = newValue;
|
|
}
|
|
}
|
|
attachTimeline(timeline) {
|
|
const cancelAll = this.animations.map((animation) => {
|
|
if (supportsScrollTimeline() && animation.attachTimeline) {
|
|
animation.attachTimeline(timeline);
|
|
}
|
|
else {
|
|
animation.pause();
|
|
return observeTimeline((progress) => {
|
|
animation.time = animation.duration * progress;
|
|
}, timeline);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelAll.forEach((cancelTimeline, i) => {
|
|
if (cancelTimeline)
|
|
cancelTimeline();
|
|
this.animations[i].stop();
|
|
});
|
|
};
|
|
}
|
|
get time() {
|
|
return this.getAll("time");
|
|
}
|
|
set time(time) {
|
|
this.setAll("time", time);
|
|
}
|
|
get speed() {
|
|
return this.getAll("speed");
|
|
}
|
|
set speed(speed) {
|
|
this.setAll("speed", speed);
|
|
}
|
|
get duration() {
|
|
let max = 0;
|
|
for (let i = 0; i < this.animations.length; i++) {
|
|
max = Math.max(max, this.animations[i].duration);
|
|
}
|
|
return max;
|
|
}
|
|
runAll(methodName) {
|
|
this.animations.forEach((controls) => controls[methodName]());
|
|
}
|
|
play() {
|
|
this.runAll("play");
|
|
}
|
|
pause() {
|
|
this.runAll("pause");
|
|
}
|
|
stop() {
|
|
this.runAll("stop");
|
|
}
|
|
cancel() {
|
|
this.runAll("cancel");
|
|
}
|
|
complete() {
|
|
this.runAll("complete");
|
|
}
|
|
}
|
|
|
|
function isDOMKeyframes(keyframes) {
|
|
return typeof keyframes === "object" && !Array.isArray(keyframes);
|
|
}
|
|
|
|
function isSVGElement(element) {
|
|
return element instanceof SVGElement && element.tagName !== "svg";
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
exports.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 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]);
|
|
exports.invariant(toType === fromType ||
|
|
(isNumOrPxType(fromType) && isNumOrPxType(toType)), "Keyframes must be of the same dimension as the current value");
|
|
}
|
|
else {
|
|
exports.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);
|
|
};
|
|
|
|
// 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 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.
|
|
*/
|
|
if (process.env.NODE_ENV === "development") {
|
|
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 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;
|
|
if (process.env.NODE_ENV !== "production") {
|
|
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 (process.env.NODE_ENV !== "production" &&
|
|
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
|
|
? exports.warning(false, strictMessage)
|
|
: exports.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,
|
|
};
|
|
}
|
|
}
|
|
|
|
class SVGVisualElement extends DOMVisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.type = "svg";
|
|
this.isSVGTag = false;
|
|
}
|
|
getBaseTargetFromProps(props, key) {
|
|
return props[key];
|
|
}
|
|
readValueFromInstance(instance, key) {
|
|
if (transformProps.has(key)) {
|
|
const defaultType = getDefaultValueType(key);
|
|
return defaultType ? defaultType.default || 0 : 0;
|
|
}
|
|
key = !camelCaseAttributes.has(key) ? camelToDash(key) : key;
|
|
return instance.getAttribute(key);
|
|
}
|
|
measureInstanceViewportBox() {
|
|
return createBox();
|
|
}
|
|
scrapeMotionValuesFromProps(props, prevProps) {
|
|
return scrapeMotionValuesFromProps(props, prevProps);
|
|
}
|
|
build(renderState, latestValues, options, props) {
|
|
buildSVGAttrs(renderState, latestValues, options, this.isSVGTag, props.transformTemplate);
|
|
}
|
|
renderInstance(instance, renderState, styleProp, projection) {
|
|
renderSVG(instance, renderState, styleProp, projection);
|
|
}
|
|
mount(instance) {
|
|
this.isSVGTag = isSVGTag(instance.tagName);
|
|
super.mount(instance);
|
|
}
|
|
}
|
|
|
|
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$1(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 createVisualElement(element) {
|
|
const options = {
|
|
presenceContext: null,
|
|
props: {},
|
|
visualState: {
|
|
renderState: {
|
|
transform: {},
|
|
transformOrigin: {},
|
|
style: {},
|
|
vars: {},
|
|
attrs: {},
|
|
},
|
|
latestValues: {},
|
|
},
|
|
};
|
|
const node = isSVGElement(element)
|
|
? new SVGVisualElement(options, {
|
|
enableHardwareAcceleration: false,
|
|
})
|
|
: new HTMLVisualElement(options, {
|
|
enableHardwareAcceleration: true,
|
|
});
|
|
node.mount(element);
|
|
visualElementStore.set(element, node);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Create a progress => progress easing function from a generator.
|
|
*/
|
|
function createGeneratorEasing(options, scale = 100) {
|
|
const generator = spring({ keyframes: [0, scale], ...options });
|
|
const duration = Math.min(calcGeneratorDuration(generator), maxGeneratorDuration);
|
|
return {
|
|
type: "keyframes",
|
|
ease: (progress) => generator.next(duration * progress).value / scale,
|
|
duration: millisecondsToSeconds(duration),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Given a absolute or relative time definition and current/prev time state of the sequence,
|
|
* calculate an absolute time for the next keyframes.
|
|
*/
|
|
function calcNextTime(current, next, prev, labels) {
|
|
var _a;
|
|
if (typeof next === "number") {
|
|
return next;
|
|
}
|
|
else if (next.startsWith("-") || next.startsWith("+")) {
|
|
return Math.max(0, current + parseFloat(next));
|
|
}
|
|
else if (next === "<") {
|
|
return prev;
|
|
}
|
|
else {
|
|
return (_a = labels.get(next)) !== null && _a !== void 0 ? _a : current;
|
|
}
|
|
}
|
|
|
|
const wrap = (min, max, v) => {
|
|
const rangeSize = max - min;
|
|
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
|
|
};
|
|
|
|
function getEasingForSegment(easing, i) {
|
|
return isEasingArray(easing) ? easing[wrap(0, easing.length, i)] : easing;
|
|
}
|
|
|
|
function eraseKeyframes(sequence, startTime, endTime) {
|
|
for (let i = 0; i < sequence.length; i++) {
|
|
const keyframe = sequence[i];
|
|
if (keyframe.at > startTime && keyframe.at < endTime) {
|
|
removeItem(sequence, keyframe);
|
|
// If we remove this item we have to push the pointer back one
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) {
|
|
/**
|
|
* Erase every existing value between currentTime and targetTime,
|
|
* this will essentially splice this timeline into any currently
|
|
* defined ones.
|
|
*/
|
|
eraseKeyframes(sequence, startTime, endTime);
|
|
for (let i = 0; i < keyframes.length; i++) {
|
|
sequence.push({
|
|
value: keyframes[i],
|
|
at: mix(startTime, endTime, offset[i]),
|
|
easing: getEasingForSegment(easing, i),
|
|
});
|
|
}
|
|
}
|
|
|
|
function compareByTime(a, b) {
|
|
if (a.at === b.at) {
|
|
if (a.value === null)
|
|
return 1;
|
|
if (b.value === null)
|
|
return -1;
|
|
return 0;
|
|
}
|
|
else {
|
|
return a.at - b.at;
|
|
}
|
|
}
|
|
|
|
const defaultSegmentEasing = "easeInOut";
|
|
function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope) {
|
|
const defaultDuration = defaultTransition.duration || 0.3;
|
|
const animationDefinitions = new Map();
|
|
const sequences = new Map();
|
|
const elementCache = {};
|
|
const timeLabels = new Map();
|
|
let prevTime = 0;
|
|
let currentTime = 0;
|
|
let totalDuration = 0;
|
|
/**
|
|
* Build the timeline by mapping over the sequence array and converting
|
|
* the definitions into keyframes and offsets with absolute time values.
|
|
* These will later get converted into relative offsets in a second pass.
|
|
*/
|
|
for (let i = 0; i < sequence.length; i++) {
|
|
const segment = sequence[i];
|
|
/**
|
|
* If this is a timeline label, mark it and skip the rest of this iteration.
|
|
*/
|
|
if (typeof segment === "string") {
|
|
timeLabels.set(segment, currentTime);
|
|
continue;
|
|
}
|
|
else if (!Array.isArray(segment)) {
|
|
timeLabels.set(segment.name, calcNextTime(currentTime, segment.at, prevTime, timeLabels));
|
|
continue;
|
|
}
|
|
let [subject, keyframes, transition = {}] = segment;
|
|
/**
|
|
* If a relative or absolute time value has been specified we need to resolve
|
|
* it in relation to the currentTime.
|
|
*/
|
|
if (transition.at !== undefined) {
|
|
currentTime = calcNextTime(currentTime, transition.at, prevTime, timeLabels);
|
|
}
|
|
/**
|
|
* Keep track of the maximum duration in this definition. This will be
|
|
* applied to currentTime once the definition has been parsed.
|
|
*/
|
|
let maxDuration = 0;
|
|
const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numElements = 0) => {
|
|
const valueKeyframesAsList = keyframesAsList(valueKeyframes);
|
|
const { delay = 0, times = defaultOffset$1(valueKeyframesAsList), type = "keyframes", ...remainingTransition } = valueTransition;
|
|
let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition;
|
|
/**
|
|
* Resolve stagger() if defined.
|
|
*/
|
|
const calculatedDelay = typeof delay === "function"
|
|
? delay(elementIndex, numElements)
|
|
: delay;
|
|
/**
|
|
* If this animation should and can use a spring, generate a spring easing function.
|
|
*/
|
|
const numKeyframes = valueKeyframesAsList.length;
|
|
if (numKeyframes <= 2 && type === "spring") {
|
|
/**
|
|
* As we're creating an easing function from a spring,
|
|
* ideally we want to generate it using the real distance
|
|
* between the two keyframes. However this isn't always
|
|
* possible - in these situations we use 0-100.
|
|
*/
|
|
let absoluteDelta = 100;
|
|
if (numKeyframes === 2 &&
|
|
isNumberKeyframesArray(valueKeyframesAsList)) {
|
|
const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0];
|
|
absoluteDelta = Math.abs(delta);
|
|
}
|
|
const springTransition = { ...remainingTransition };
|
|
if (duration !== undefined) {
|
|
springTransition.duration = secondsToMilliseconds(duration);
|
|
}
|
|
const springEasing = createGeneratorEasing(springTransition, absoluteDelta);
|
|
ease = springEasing.ease;
|
|
duration = springEasing.duration;
|
|
}
|
|
duration !== null && duration !== void 0 ? duration : (duration = defaultDuration);
|
|
const startTime = currentTime + calculatedDelay;
|
|
const targetTime = startTime + duration;
|
|
/**
|
|
* If there's only one time offset of 0, fill in a second with length 1
|
|
*/
|
|
if (times.length === 1 && times[0] === 0) {
|
|
times[1] = 1;
|
|
}
|
|
/**
|
|
* Fill out if offset if fewer offsets than keyframes
|
|
*/
|
|
const remainder = times.length - valueKeyframesAsList.length;
|
|
remainder > 0 && fillOffset(times, remainder);
|
|
/**
|
|
* If only one value has been set, ie [1], push a null to the start of
|
|
* the keyframe array. This will let us mark a keyframe at this point
|
|
* that will later be hydrated with the previous value.
|
|
*/
|
|
valueKeyframesAsList.length === 1 &&
|
|
valueKeyframesAsList.unshift(null);
|
|
/**
|
|
* Add keyframes, mapping offsets to absolute time.
|
|
*/
|
|
addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime);
|
|
maxDuration = Math.max(calculatedDelay + duration, maxDuration);
|
|
totalDuration = Math.max(targetTime, totalDuration);
|
|
};
|
|
if (isMotionValue(subject)) {
|
|
const subjectSequence = getSubjectSequence(subject, sequences);
|
|
resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence));
|
|
}
|
|
else {
|
|
/**
|
|
* Find all the elements specified in the definition and parse value
|
|
* keyframes from their timeline definitions.
|
|
*/
|
|
const elements = resolveElements(subject, scope, elementCache);
|
|
const numElements = elements.length;
|
|
/**
|
|
* For every element in this segment, process the defined values.
|
|
*/
|
|
for (let elementIndex = 0; elementIndex < numElements; elementIndex++) {
|
|
/**
|
|
* Cast necessary, but we know these are of this type
|
|
*/
|
|
keyframes = keyframes;
|
|
transition = transition;
|
|
const element = elements[elementIndex];
|
|
const subjectSequence = getSubjectSequence(element, sequences);
|
|
for (const key in keyframes) {
|
|
resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), elementIndex, numElements);
|
|
}
|
|
}
|
|
}
|
|
prevTime = currentTime;
|
|
currentTime += maxDuration;
|
|
}
|
|
/**
|
|
* For every element and value combination create a new animation.
|
|
*/
|
|
sequences.forEach((valueSequences, element) => {
|
|
for (const key in valueSequences) {
|
|
const valueSequence = valueSequences[key];
|
|
/**
|
|
* Arrange all the keyframes in ascending time order.
|
|
*/
|
|
valueSequence.sort(compareByTime);
|
|
const keyframes = [];
|
|
const valueOffset = [];
|
|
const valueEasing = [];
|
|
/**
|
|
* For each keyframe, translate absolute times into
|
|
* relative offsets based on the total duration of the timeline.
|
|
*/
|
|
for (let i = 0; i < valueSequence.length; i++) {
|
|
const { at, value, easing } = valueSequence[i];
|
|
keyframes.push(value);
|
|
valueOffset.push(progress(0, totalDuration, at));
|
|
valueEasing.push(easing || "easeOut");
|
|
}
|
|
/**
|
|
* If the first keyframe doesn't land on offset: 0
|
|
* provide one by duplicating the initial keyframe. This ensures
|
|
* it snaps to the first keyframe when the animation starts.
|
|
*/
|
|
if (valueOffset[0] !== 0) {
|
|
valueOffset.unshift(0);
|
|
keyframes.unshift(keyframes[0]);
|
|
valueEasing.unshift(defaultSegmentEasing);
|
|
}
|
|
/**
|
|
* If the last keyframe doesn't land on offset: 1
|
|
* provide one with a null wildcard value. This will ensure it
|
|
* stays static until the end of the animation.
|
|
*/
|
|
if (valueOffset[valueOffset.length - 1] !== 1) {
|
|
valueOffset.push(1);
|
|
keyframes.push(null);
|
|
}
|
|
if (!animationDefinitions.has(element)) {
|
|
animationDefinitions.set(element, {
|
|
keyframes: {},
|
|
transition: {},
|
|
});
|
|
}
|
|
const definition = animationDefinitions.get(element);
|
|
definition.keyframes[key] = keyframes;
|
|
definition.transition[key] = {
|
|
...defaultTransition,
|
|
duration: totalDuration,
|
|
ease: valueEasing,
|
|
times: valueOffset,
|
|
...sequenceTransition,
|
|
};
|
|
}
|
|
});
|
|
return animationDefinitions;
|
|
}
|
|
function getSubjectSequence(subject, sequences) {
|
|
!sequences.has(subject) && sequences.set(subject, {});
|
|
return sequences.get(subject);
|
|
}
|
|
function getValueSequence(name, sequences) {
|
|
if (!sequences[name])
|
|
sequences[name] = [];
|
|
return sequences[name];
|
|
}
|
|
function keyframesAsList(keyframes) {
|
|
return Array.isArray(keyframes) ? keyframes : [keyframes];
|
|
}
|
|
function getValueTransition(transition, key) {
|
|
return transition[key]
|
|
? { ...transition, ...transition[key] }
|
|
: { ...transition };
|
|
}
|
|
const isNumber = (keyframe) => typeof keyframe === "number";
|
|
const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber);
|
|
|
|
function animateElements(elementOrSelector, keyframes, options, scope) {
|
|
const elements = resolveElements(elementOrSelector, scope);
|
|
const numElements = elements.length;
|
|
exports.invariant(Boolean(numElements), "No valid element provided.");
|
|
const animations = [];
|
|
for (let i = 0; i < numElements; i++) {
|
|
const element = elements[i];
|
|
/**
|
|
* Check each element for an associated VisualElement. If none exists,
|
|
* we need to create one.
|
|
*/
|
|
if (!visualElementStore.has(element)) {
|
|
/**
|
|
* TODO: We only need render-specific parts of the VisualElement.
|
|
* With some additional work the size of the animate() function
|
|
* could be reduced significantly.
|
|
*/
|
|
createVisualElement(element);
|
|
}
|
|
const visualElement = visualElementStore.get(element);
|
|
const transition = { ...options };
|
|
/**
|
|
* Resolve stagger function if provided.
|
|
*/
|
|
if (typeof transition.delay === "function") {
|
|
transition.delay = transition.delay(i, numElements);
|
|
}
|
|
animations.push(...animateTarget(visualElement, { ...keyframes, transition }, {}));
|
|
}
|
|
return new GroupPlaybackControls(animations);
|
|
}
|
|
const isSequence = (value) => Array.isArray(value) && Array.isArray(value[0]);
|
|
function animateSequence(sequence, options, scope) {
|
|
const animations = [];
|
|
const animationDefinitions = createAnimationsFromSequence(sequence, options, scope);
|
|
animationDefinitions.forEach(({ keyframes, transition }, subject) => {
|
|
let animation;
|
|
if (isMotionValue(subject)) {
|
|
animation = animateSingleValue(subject, keyframes.default, transition.default);
|
|
}
|
|
else {
|
|
animation = animateElements(subject, keyframes, transition);
|
|
}
|
|
animations.push(animation);
|
|
});
|
|
return new GroupPlaybackControls(animations);
|
|
}
|
|
const createScopedAnimate = (scope) => {
|
|
/**
|
|
* Implementation
|
|
*/
|
|
function scopedAnimate(valueOrElementOrSequence, keyframes, options) {
|
|
let animation;
|
|
if (isSequence(valueOrElementOrSequence)) {
|
|
animation = animateSequence(valueOrElementOrSequence, keyframes, scope);
|
|
}
|
|
else if (isDOMKeyframes(keyframes)) {
|
|
animation = animateElements(valueOrElementOrSequence, keyframes, options, scope);
|
|
}
|
|
else {
|
|
animation = animateSingleValue(valueOrElementOrSequence, keyframes, options);
|
|
}
|
|
if (scope) {
|
|
scope.animations.push(animation);
|
|
}
|
|
return animation;
|
|
}
|
|
return scopedAnimate;
|
|
};
|
|
const animate = createScopedAnimate();
|
|
|
|
const resizeHandlers = new WeakMap();
|
|
let observer;
|
|
function getElementSize(target, borderBoxSize) {
|
|
if (borderBoxSize) {
|
|
const { inlineSize, blockSize } = borderBoxSize[0];
|
|
return { width: inlineSize, height: blockSize };
|
|
}
|
|
else if (target instanceof SVGElement && "getBBox" in target) {
|
|
return target.getBBox();
|
|
}
|
|
else {
|
|
return {
|
|
width: target.offsetWidth,
|
|
height: target.offsetHeight,
|
|
};
|
|
}
|
|
}
|
|
function notifyTarget({ target, contentRect, borderBoxSize, }) {
|
|
var _a;
|
|
(_a = resizeHandlers.get(target)) === null || _a === void 0 ? void 0 : _a.forEach((handler) => {
|
|
handler({
|
|
target,
|
|
contentSize: contentRect,
|
|
get size() {
|
|
return getElementSize(target, borderBoxSize);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
function notifyAll(entries) {
|
|
entries.forEach(notifyTarget);
|
|
}
|
|
function createResizeObserver() {
|
|
if (typeof ResizeObserver === "undefined")
|
|
return;
|
|
observer = new ResizeObserver(notifyAll);
|
|
}
|
|
function resizeElement(target, handler) {
|
|
if (!observer)
|
|
createResizeObserver();
|
|
const elements = resolveElements(target);
|
|
elements.forEach((element) => {
|
|
let elementHandlers = resizeHandlers.get(element);
|
|
if (!elementHandlers) {
|
|
elementHandlers = new Set();
|
|
resizeHandlers.set(element, elementHandlers);
|
|
}
|
|
elementHandlers.add(handler);
|
|
observer === null || observer === void 0 ? void 0 : observer.observe(element);
|
|
});
|
|
return () => {
|
|
elements.forEach((element) => {
|
|
const elementHandlers = resizeHandlers.get(element);
|
|
elementHandlers === null || elementHandlers === void 0 ? void 0 : elementHandlers.delete(handler);
|
|
if (!(elementHandlers === null || elementHandlers === void 0 ? void 0 : elementHandlers.size)) {
|
|
observer === null || observer === void 0 ? void 0 : observer.unobserve(element);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
const windowCallbacks = new Set();
|
|
let windowResizeHandler;
|
|
function createWindowResizeHandler() {
|
|
windowResizeHandler = () => {
|
|
const size = {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
};
|
|
const info = {
|
|
target: window,
|
|
size,
|
|
contentSize: size,
|
|
};
|
|
windowCallbacks.forEach((callback) => callback(info));
|
|
};
|
|
window.addEventListener("resize", windowResizeHandler);
|
|
}
|
|
function resizeWindow(callback) {
|
|
windowCallbacks.add(callback);
|
|
if (!windowResizeHandler)
|
|
createWindowResizeHandler();
|
|
return () => {
|
|
windowCallbacks.delete(callback);
|
|
if (!windowCallbacks.size && windowResizeHandler) {
|
|
windowResizeHandler = undefined;
|
|
}
|
|
};
|
|
}
|
|
|
|
function resize(a, b) {
|
|
return typeof a === "function" ? resizeWindow(a) : resizeElement(a, b);
|
|
}
|
|
|
|
/**
|
|
* A time in milliseconds, beyond which we consider the scroll velocity to be 0.
|
|
*/
|
|
const maxElapsed = 50;
|
|
const createAxisInfo = () => ({
|
|
current: 0,
|
|
offset: [],
|
|
progress: 0,
|
|
scrollLength: 0,
|
|
targetOffset: 0,
|
|
targetLength: 0,
|
|
containerLength: 0,
|
|
velocity: 0,
|
|
});
|
|
const createScrollInfo = () => ({
|
|
time: 0,
|
|
x: createAxisInfo(),
|
|
y: createAxisInfo(),
|
|
});
|
|
const keys = {
|
|
x: {
|
|
length: "Width",
|
|
position: "Left",
|
|
},
|
|
y: {
|
|
length: "Height",
|
|
position: "Top",
|
|
},
|
|
};
|
|
function updateAxisInfo(element, axisName, info, time) {
|
|
const axis = info[axisName];
|
|
const { length, position } = keys[axisName];
|
|
const prev = axis.current;
|
|
const prevTime = info.time;
|
|
axis.current = element["scroll" + position];
|
|
axis.scrollLength = element["scroll" + length] - element["client" + length];
|
|
axis.offset.length = 0;
|
|
axis.offset[0] = 0;
|
|
axis.offset[1] = axis.scrollLength;
|
|
axis.progress = progress(0, axis.scrollLength, axis.current);
|
|
const elapsed = time - prevTime;
|
|
axis.velocity =
|
|
elapsed > maxElapsed
|
|
? 0
|
|
: velocityPerSecond(axis.current - prev, elapsed);
|
|
}
|
|
function updateScrollInfo(element, info, time) {
|
|
updateAxisInfo(element, "x", info, time);
|
|
updateAxisInfo(element, "y", info, time);
|
|
info.time = time;
|
|
}
|
|
|
|
function calcInset(element, container) {
|
|
const inset = { x: 0, y: 0 };
|
|
let current = element;
|
|
while (current && current !== container) {
|
|
if (current instanceof HTMLElement) {
|
|
inset.x += current.offsetLeft;
|
|
inset.y += current.offsetTop;
|
|
current = current.offsetParent;
|
|
}
|
|
else if (current.tagName === "svg") {
|
|
/**
|
|
* This isn't an ideal approach to measuring the offset of <svg /> tags.
|
|
* It would be preferable, given they behave like HTMLElements in most ways
|
|
* to use offsetLeft/Top. But these don't exist on <svg />. Likewise we
|
|
* can't use .getBBox() like most SVG elements as these provide the offset
|
|
* relative to the SVG itself, which for <svg /> is usually 0x0.
|
|
*/
|
|
const svgBoundingBox = current.getBoundingClientRect();
|
|
current = current.parentElement;
|
|
const parentBoundingBox = current.getBoundingClientRect();
|
|
inset.x += svgBoundingBox.left - parentBoundingBox.left;
|
|
inset.y += svgBoundingBox.top - parentBoundingBox.top;
|
|
}
|
|
else if (current instanceof SVGGraphicsElement) {
|
|
const { x, y } = current.getBBox();
|
|
inset.x += x;
|
|
inset.y += y;
|
|
let svg = null;
|
|
let parent = current.parentNode;
|
|
while (!svg) {
|
|
if (parent.tagName === "svg") {
|
|
svg = parent;
|
|
}
|
|
parent = current.parentNode;
|
|
}
|
|
current = svg;
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
return inset;
|
|
}
|
|
|
|
const ScrollOffset = {
|
|
Enter: [
|
|
[0, 1],
|
|
[1, 1],
|
|
],
|
|
Exit: [
|
|
[0, 0],
|
|
[1, 0],
|
|
],
|
|
Any: [
|
|
[1, 0],
|
|
[0, 1],
|
|
],
|
|
All: [
|
|
[0, 0],
|
|
[1, 1],
|
|
],
|
|
};
|
|
|
|
const namedEdges = {
|
|
start: 0,
|
|
center: 0.5,
|
|
end: 1,
|
|
};
|
|
function resolveEdge(edge, length, inset = 0) {
|
|
let delta = 0;
|
|
/**
|
|
* If we have this edge defined as a preset, replace the definition
|
|
* with the numerical value.
|
|
*/
|
|
if (namedEdges[edge] !== undefined) {
|
|
edge = namedEdges[edge];
|
|
}
|
|
/**
|
|
* Handle unit values
|
|
*/
|
|
if (typeof edge === "string") {
|
|
const asNumber = parseFloat(edge);
|
|
if (edge.endsWith("px")) {
|
|
delta = asNumber;
|
|
}
|
|
else if (edge.endsWith("%")) {
|
|
edge = asNumber / 100;
|
|
}
|
|
else if (edge.endsWith("vw")) {
|
|
delta = (asNumber / 100) * document.documentElement.clientWidth;
|
|
}
|
|
else if (edge.endsWith("vh")) {
|
|
delta = (asNumber / 100) * document.documentElement.clientHeight;
|
|
}
|
|
else {
|
|
edge = asNumber;
|
|
}
|
|
}
|
|
/**
|
|
* If the edge is defined as a number, handle as a progress value.
|
|
*/
|
|
if (typeof edge === "number") {
|
|
delta = length * edge;
|
|
}
|
|
return inset + delta;
|
|
}
|
|
|
|
const defaultOffset = [0, 0];
|
|
function resolveOffset(offset, containerLength, targetLength, targetInset) {
|
|
let offsetDefinition = Array.isArray(offset) ? offset : defaultOffset;
|
|
let targetPoint = 0;
|
|
let containerPoint = 0;
|
|
if (typeof offset === "number") {
|
|
/**
|
|
* If we're provided offset: [0, 0.5, 1] then each number x should become
|
|
* [x, x], so we default to the behaviour of mapping 0 => 0 of both target
|
|
* and container etc.
|
|
*/
|
|
offsetDefinition = [offset, offset];
|
|
}
|
|
else if (typeof offset === "string") {
|
|
offset = offset.trim();
|
|
if (offset.includes(" ")) {
|
|
offsetDefinition = offset.split(" ");
|
|
}
|
|
else {
|
|
/**
|
|
* If we're provided a definition like "100px" then we want to apply
|
|
* that only to the top of the target point, leaving the container at 0.
|
|
* Whereas a named offset like "end" should be applied to both.
|
|
*/
|
|
offsetDefinition = [offset, namedEdges[offset] ? offset : `0`];
|
|
}
|
|
}
|
|
targetPoint = resolveEdge(offsetDefinition[0], targetLength, targetInset);
|
|
containerPoint = resolveEdge(offsetDefinition[1], containerLength);
|
|
return targetPoint - containerPoint;
|
|
}
|
|
|
|
const point = { x: 0, y: 0 };
|
|
function getTargetSize(target) {
|
|
return "getBBox" in target && target.tagName !== "svg"
|
|
? target.getBBox()
|
|
: { width: target.clientWidth, height: target.clientHeight };
|
|
}
|
|
function resolveOffsets(container, info, options) {
|
|
let { offset: offsetDefinition = ScrollOffset.All } = options;
|
|
const { target = container, axis = "y" } = options;
|
|
const lengthLabel = axis === "y" ? "height" : "width";
|
|
const inset = target !== container ? calcInset(target, container) : point;
|
|
/**
|
|
* Measure the target and container. If they're the same thing then we
|
|
* use the container's scrollWidth/Height as the target, from there
|
|
* all other calculations can remain the same.
|
|
*/
|
|
const targetSize = target === container
|
|
? { width: container.scrollWidth, height: container.scrollHeight }
|
|
: getTargetSize(target);
|
|
const containerSize = {
|
|
width: container.clientWidth,
|
|
height: container.clientHeight,
|
|
};
|
|
/**
|
|
* Reset the length of the resolved offset array rather than creating a new one.
|
|
* TODO: More reusable data structures for targetSize/containerSize would also be good.
|
|
*/
|
|
info[axis].offset.length = 0;
|
|
/**
|
|
* Populate the offset array by resolving the user's offset definition into
|
|
* a list of pixel scroll offets.
|
|
*/
|
|
let hasChanged = !info[axis].interpolate;
|
|
const numOffsets = offsetDefinition.length;
|
|
for (let i = 0; i < numOffsets; i++) {
|
|
const offset = resolveOffset(offsetDefinition[i], containerSize[lengthLabel], targetSize[lengthLabel], inset[axis]);
|
|
if (!hasChanged && offset !== info[axis].interpolatorOffsets[i]) {
|
|
hasChanged = true;
|
|
}
|
|
info[axis].offset[i] = offset;
|
|
}
|
|
/**
|
|
* If the pixel scroll offsets have changed, create a new interpolator function
|
|
* to map scroll value into a progress.
|
|
*/
|
|
if (hasChanged) {
|
|
info[axis].interpolate = interpolate(info[axis].offset, defaultOffset$1(offsetDefinition));
|
|
info[axis].interpolatorOffsets = [...info[axis].offset];
|
|
}
|
|
info[axis].progress = info[axis].interpolate(info[axis].current);
|
|
}
|
|
|
|
function measure(container, target = container, info) {
|
|
/**
|
|
* Find inset of target within scrollable container
|
|
*/
|
|
info.x.targetOffset = 0;
|
|
info.y.targetOffset = 0;
|
|
if (target !== container) {
|
|
let node = target;
|
|
while (node && node !== container) {
|
|
info.x.targetOffset += node.offsetLeft;
|
|
info.y.targetOffset += node.offsetTop;
|
|
node = node.offsetParent;
|
|
}
|
|
}
|
|
info.x.targetLength =
|
|
target === container ? target.scrollWidth : target.clientWidth;
|
|
info.y.targetLength =
|
|
target === container ? target.scrollHeight : target.clientHeight;
|
|
info.x.containerLength = container.clientWidth;
|
|
info.y.containerLength = container.clientHeight;
|
|
/**
|
|
* In development mode ensure scroll containers aren't position: static as this makes
|
|
* it difficult to measure their relative positions.
|
|
*/
|
|
if (process.env.NODE_ENV !== "production") {
|
|
if (container && target && target !== container) {
|
|
warnOnce(getComputedStyle(container).position !== "static", "Please ensure that the container has a non-static position, like 'relative', 'fixed', or 'absolute' to ensure scroll offset is calculated correctly.");
|
|
}
|
|
}
|
|
}
|
|
function createOnScrollHandler(element, onScroll, info, options = {}) {
|
|
return {
|
|
measure: () => measure(element, options.target, info),
|
|
update: (time) => {
|
|
updateScrollInfo(element, info, time);
|
|
if (options.offset || options.target) {
|
|
resolveOffsets(element, info, options);
|
|
}
|
|
},
|
|
notify: () => onScroll(info),
|
|
};
|
|
}
|
|
|
|
const scrollListeners = new WeakMap();
|
|
const resizeListeners = new WeakMap();
|
|
const onScrollHandlers = new WeakMap();
|
|
const getEventTarget = (element) => element === document.documentElement ? window : element;
|
|
function scrollInfo(onScroll, { container = document.documentElement, ...options } = {}) {
|
|
let containerHandlers = onScrollHandlers.get(container);
|
|
/**
|
|
* Get the onScroll handlers for this container.
|
|
* If one isn't found, create a new one.
|
|
*/
|
|
if (!containerHandlers) {
|
|
containerHandlers = new Set();
|
|
onScrollHandlers.set(container, containerHandlers);
|
|
}
|
|
/**
|
|
* Create a new onScroll handler for the provided callback.
|
|
*/
|
|
const info = createScrollInfo();
|
|
const containerHandler = createOnScrollHandler(container, onScroll, info, options);
|
|
containerHandlers.add(containerHandler);
|
|
/**
|
|
* Check if there's a scroll event listener for this container.
|
|
* If not, create one.
|
|
*/
|
|
if (!scrollListeners.has(container)) {
|
|
const measureAll = () => {
|
|
for (const handler of containerHandlers)
|
|
handler.measure();
|
|
};
|
|
const updateAll = () => {
|
|
for (const handler of containerHandlers) {
|
|
handler.update(frameData.timestamp);
|
|
}
|
|
};
|
|
const notifyAll = () => {
|
|
for (const handler of containerHandlers)
|
|
handler.notify();
|
|
};
|
|
const listener = () => {
|
|
frame.read(measureAll, false, true);
|
|
frame.read(updateAll, false, true);
|
|
frame.update(notifyAll, false, true);
|
|
};
|
|
scrollListeners.set(container, listener);
|
|
const target = getEventTarget(container);
|
|
window.addEventListener("resize", listener, { passive: true });
|
|
if (container !== document.documentElement) {
|
|
resizeListeners.set(container, resize(container, listener));
|
|
}
|
|
target.addEventListener("scroll", listener, { passive: true });
|
|
}
|
|
const listener = scrollListeners.get(container);
|
|
frame.read(listener, false, true);
|
|
return () => {
|
|
var _a;
|
|
cancelFrame(listener);
|
|
/**
|
|
* Check if we even have any handlers for this container.
|
|
*/
|
|
const currentHandlers = onScrollHandlers.get(container);
|
|
if (!currentHandlers)
|
|
return;
|
|
currentHandlers.delete(containerHandler);
|
|
if (currentHandlers.size)
|
|
return;
|
|
/**
|
|
* If no more handlers, remove the scroll listener too.
|
|
*/
|
|
const scrollListener = scrollListeners.get(container);
|
|
scrollListeners.delete(container);
|
|
if (scrollListener) {
|
|
getEventTarget(container).removeEventListener("scroll", scrollListener);
|
|
(_a = resizeListeners.get(container)) === null || _a === void 0 ? void 0 : _a();
|
|
window.removeEventListener("resize", scrollListener);
|
|
}
|
|
};
|
|
}
|
|
|
|
function scrollTimelineFallback({ source, axis = "y" }) {
|
|
// ScrollTimeline records progress as a percentage CSSUnitValue
|
|
const currentTime = { value: 0 };
|
|
const cancel = scrollInfo((info) => {
|
|
currentTime.value = info[axis].progress * 100;
|
|
}, { container: source, axis });
|
|
return { currentTime, cancel };
|
|
}
|
|
const timelineCache = new Map();
|
|
function getTimeline({ source = document.documentElement, axis = "y", } = {}) {
|
|
if (!timelineCache.has(source)) {
|
|
timelineCache.set(source, {});
|
|
}
|
|
const elementCache = timelineCache.get(source);
|
|
if (!elementCache[axis]) {
|
|
elementCache[axis] = supportsScrollTimeline()
|
|
? new ScrollTimeline({ source, axis })
|
|
: scrollTimelineFallback({ source, axis });
|
|
}
|
|
return elementCache[axis];
|
|
}
|
|
function scroll(onScroll, options) {
|
|
const timeline = getTimeline(options);
|
|
if (typeof onScroll === "function") {
|
|
return observeTimeline(onScroll, timeline);
|
|
}
|
|
else {
|
|
return onScroll.attachTimeline(timeline);
|
|
}
|
|
}
|
|
|
|
const thresholds = {
|
|
some: 0,
|
|
all: 1,
|
|
};
|
|
function inView(elementOrSelector, onStart, { root, margin: rootMargin, amount = "some" } = {}) {
|
|
const elements = resolveElements(elementOrSelector);
|
|
const activeIntersections = new WeakMap();
|
|
const onIntersectionChange = (entries) => {
|
|
entries.forEach((entry) => {
|
|
const onEnd = activeIntersections.get(entry.target);
|
|
/**
|
|
* If there's no change to the intersection, we don't need to
|
|
* do anything here.
|
|
*/
|
|
if (entry.isIntersecting === Boolean(onEnd))
|
|
return;
|
|
if (entry.isIntersecting) {
|
|
const newOnEnd = onStart(entry);
|
|
if (typeof newOnEnd === "function") {
|
|
activeIntersections.set(entry.target, newOnEnd);
|
|
}
|
|
else {
|
|
observer.unobserve(entry.target);
|
|
}
|
|
}
|
|
else if (onEnd) {
|
|
onEnd(entry);
|
|
activeIntersections.delete(entry.target);
|
|
}
|
|
});
|
|
};
|
|
const observer = new IntersectionObserver(onIntersectionChange, {
|
|
root,
|
|
rootMargin,
|
|
threshold: typeof amount === "number" ? amount : thresholds[amount],
|
|
});
|
|
elements.forEach((element) => observer.observe(element));
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
function getOriginIndex(from, total) {
|
|
if (from === "first") {
|
|
return 0;
|
|
}
|
|
else {
|
|
const lastIndex = total - 1;
|
|
return from === "last" ? lastIndex : lastIndex / 2;
|
|
}
|
|
}
|
|
function stagger(duration = 0.1, { startDelay = 0, from = 0, ease } = {}) {
|
|
return (i, total) => {
|
|
const fromIndex = typeof from === "number" ? from : getOriginIndex(from, total);
|
|
const distance = Math.abs(fromIndex - i);
|
|
let delay = duration * distance;
|
|
if (ease) {
|
|
const maxDelay = total * duration;
|
|
const easingFunction = easingDefinitionToFunction(ease);
|
|
delay = easingFunction(delay / maxDelay) * maxDelay;
|
|
}
|
|
return startDelay + delay;
|
|
};
|
|
}
|
|
|
|
const isCustomValueType = (v) => {
|
|
return v && typeof v === "object" && v.mix;
|
|
};
|
|
const getMixer = (v) => (isCustomValueType(v) ? v.mix : undefined);
|
|
function transform(...args) {
|
|
const useImmediate = !Array.isArray(args[0]);
|
|
const argOffset = useImmediate ? 0 : -1;
|
|
const inputValue = args[0 + argOffset];
|
|
const inputRange = args[1 + argOffset];
|
|
const outputRange = args[2 + argOffset];
|
|
const options = args[3 + argOffset];
|
|
const interpolator = interpolate(inputRange, outputRange, {
|
|
mixer: getMixer(outputRange[0]),
|
|
...options,
|
|
});
|
|
return useImmediate ? interpolator(inputValue) : interpolator;
|
|
}
|
|
|
|
/**
|
|
* @deprecated
|
|
*
|
|
* Import as `frame` instead.
|
|
*/
|
|
const sync = frame;
|
|
/**
|
|
* @deprecated
|
|
*
|
|
* Use cancelFrame(callback) instead.
|
|
*/
|
|
const cancelSync = stepsOrder.reduce((acc, key) => {
|
|
acc[key] = (process) => cancelFrame(process);
|
|
return acc;
|
|
}, {});
|
|
|
|
exports.HTMLVisualElement = HTMLVisualElement;
|
|
exports.MotionGlobalConfig = MotionGlobalConfig;
|
|
exports.MotionValue = MotionValue;
|
|
exports.SVGVisualElement = SVGVisualElement;
|
|
exports.SubscriptionManager = SubscriptionManager;
|
|
exports.VisualElement = VisualElement;
|
|
exports.addScaleCorrector = addScaleCorrector;
|
|
exports.addUniqueItem = addUniqueItem;
|
|
exports.animate = animate;
|
|
exports.animateMotionValue = animateMotionValue;
|
|
exports.animateSingleValue = animateSingleValue;
|
|
exports.animateStyle = animateStyle;
|
|
exports.animateTarget = animateTarget;
|
|
exports.animateValue = animateValue;
|
|
exports.anticipate = anticipate;
|
|
exports.applyBoxDelta = applyBoxDelta;
|
|
exports.applyTreeDeltas = applyTreeDeltas;
|
|
exports.backIn = backIn;
|
|
exports.backInOut = backInOut;
|
|
exports.backOut = backOut;
|
|
exports.buildHTMLStyles = buildHTMLStyles;
|
|
exports.buildSVGAttrs = buildSVGAttrs;
|
|
exports.buildTransform = buildTransform;
|
|
exports.camelToDash = camelToDash;
|
|
exports.cancelFrame = cancelFrame;
|
|
exports.cancelSync = cancelSync;
|
|
exports.checkTargetForNewValues = checkTargetForNewValues;
|
|
exports.circIn = circIn;
|
|
exports.circInOut = circInOut;
|
|
exports.circOut = circOut;
|
|
exports.clamp = clamp;
|
|
exports.collectMotionValues = collectMotionValues;
|
|
exports.color = color;
|
|
exports.complex = complex;
|
|
exports.convertBoundingBoxToBox = convertBoundingBoxToBox;
|
|
exports.convertBoxToBoundingBox = convertBoxToBoundingBox;
|
|
exports.createBox = createBox;
|
|
exports.createDelta = createDelta;
|
|
exports.createScopedAnimate = createScopedAnimate;
|
|
exports.cubicBezier = cubicBezier;
|
|
exports.delay = delay;
|
|
exports.distance = distance;
|
|
exports.distance2D = distance2D;
|
|
exports.easeIn = easeIn;
|
|
exports.easeInOut = easeInOut;
|
|
exports.easeOut = easeOut;
|
|
exports.featureDefinitions = featureDefinitions;
|
|
exports.frame = frame;
|
|
exports.frameData = frameData;
|
|
exports.getOrigin = getOrigin;
|
|
exports.getValueTransition = getValueTransition$1;
|
|
exports.has2DTranslate = has2DTranslate;
|
|
exports.hasReducedMotionListener = hasReducedMotionListener;
|
|
exports.hasScale = hasScale;
|
|
exports.hasTransform = hasTransform;
|
|
exports.inView = inView;
|
|
exports.initPrefersReducedMotion = initPrefersReducedMotion;
|
|
exports.instantAnimationState = instantAnimationState;
|
|
exports.interpolate = interpolate;
|
|
exports.isAnimationControls = isAnimationControls;
|
|
exports.isBrowser = isBrowser;
|
|
exports.isCSSVariableName = isCSSVariableName;
|
|
exports.isControllingVariants = isControllingVariants;
|
|
exports.isCustomValue = isCustomValue;
|
|
exports.isForcedMotionValue = isForcedMotionValue;
|
|
exports.isKeyframesTarget = isKeyframesTarget;
|
|
exports.isMotionValue = isMotionValue;
|
|
exports.isRefObject = isRefObject;
|
|
exports.isSVGElement = isSVGElement;
|
|
exports.isSVGTag = isSVGTag;
|
|
exports.isVariantLabel = isVariantLabel;
|
|
exports.isVariantNode = isVariantNode;
|
|
exports.measurePageBox = measurePageBox;
|
|
exports.millisecondsToSeconds = millisecondsToSeconds;
|
|
exports.mirrorEasing = mirrorEasing;
|
|
exports.mix = mix;
|
|
exports.motionValue = motionValue;
|
|
exports.moveItem = moveItem;
|
|
exports.noop = noop;
|
|
exports.optimizedAppearDataAttribute = optimizedAppearDataAttribute;
|
|
exports.optimizedAppearDataId = optimizedAppearDataId;
|
|
exports.percent = percent;
|
|
exports.pipe = pipe;
|
|
exports.prefersReducedMotion = prefersReducedMotion;
|
|
exports.progress = progress;
|
|
exports.px = px;
|
|
exports.removeItem = removeItem;
|
|
exports.renderSVG = renderSVG;
|
|
exports.resolveVariant = resolveVariant;
|
|
exports.resolveVariantFromProps = resolveVariantFromProps;
|
|
exports.reverseEasing = reverseEasing;
|
|
exports.scaleCorrectors = scaleCorrectors;
|
|
exports.scalePoint = scalePoint;
|
|
exports.scrapeMotionValuesFromProps = scrapeMotionValuesFromProps;
|
|
exports.scrapeMotionValuesFromProps$1 = scrapeMotionValuesFromProps$1;
|
|
exports.scroll = scroll;
|
|
exports.scrollInfo = scrollInfo;
|
|
exports.secondsToMilliseconds = secondsToMilliseconds;
|
|
exports.setValues = setValues;
|
|
exports.spring = spring;
|
|
exports.stagger = stagger;
|
|
exports.steps = steps;
|
|
exports.sync = sync;
|
|
exports.transform = transform;
|
|
exports.transformBox = transformBox;
|
|
exports.transformProps = transformProps;
|
|
exports.translateAxis = translateAxis;
|
|
exports.variantPriorityOrder = variantPriorityOrder;
|
|
exports.visualElementStore = visualElementStore;
|
|
exports.warnOnce = warnOnce;
|
|
exports.wrap = wrap;
|