2493 lines
89 KiB
JavaScript
2493 lines
89 KiB
JavaScript
'use strict';
|
|
|
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
|
var motionDom = require('motion-dom');
|
|
var motionUtils = require('motion-utils');
|
|
|
|
function isDOMKeyframes(keyframes) {
|
|
return typeof keyframes === "object" && !Array.isArray(keyframes);
|
|
}
|
|
|
|
function resolveSubjects(subject, keyframes, scope, selectorCache) {
|
|
if (typeof subject === "string" && isDOMKeyframes(keyframes)) {
|
|
return motionDom.resolveElements(subject, scope, selectorCache);
|
|
}
|
|
else if (subject instanceof NodeList) {
|
|
return Array.from(subject);
|
|
}
|
|
else if (Array.isArray(subject)) {
|
|
return subject;
|
|
}
|
|
else {
|
|
return [subject];
|
|
}
|
|
}
|
|
|
|
function calculateRepeatDuration(duration, repeat, _repeatDelay) {
|
|
return duration * (repeat + 1);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
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 if (next.startsWith("<")) {
|
|
return Math.max(0, prev + parseFloat(next.slice(1)));
|
|
}
|
|
else {
|
|
return labels.get(next) ?? current;
|
|
}
|
|
}
|
|
|
|
function eraseKeyframes(sequence, startTime, endTime) {
|
|
for (let i = 0; i < sequence.length; i++) {
|
|
const keyframe = sequence[i];
|
|
if (keyframe.at > startTime && keyframe.at < endTime) {
|
|
motionUtils.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: motionDom.mixNumber(startTime, endTime, offset[i]),
|
|
easing: motionUtils.getEasingForSegment(easing, i),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take an array of times that represent repeated keyframes. For instance
|
|
* if we have original times of [0, 0.5, 1] then our repeated times will
|
|
* be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back
|
|
* down to a 0-1 scale.
|
|
*/
|
|
function normalizeTimes(times, repeat) {
|
|
for (let i = 0; i < times.length; i++) {
|
|
times[i] = times[i] / (repeat + 1);
|
|
}
|
|
}
|
|
|
|
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";
|
|
const MAX_REPEAT = 20;
|
|
function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators) {
|
|
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, numSubjects = 0) => {
|
|
const valueKeyframesAsList = keyframesAsList(valueKeyframes);
|
|
const { delay = 0, times = motionDom.defaultOffset(valueKeyframesAsList), type = "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition;
|
|
let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition;
|
|
/**
|
|
* Resolve stagger() if defined.
|
|
*/
|
|
const calculatedDelay = typeof delay === "function"
|
|
? delay(elementIndex, numSubjects)
|
|
: delay;
|
|
/**
|
|
* If this animation should and can use a spring, generate a spring easing function.
|
|
*/
|
|
const numKeyframes = valueKeyframesAsList.length;
|
|
const createGenerator = motionDom.isGenerator(type)
|
|
? type
|
|
: generators?.[type || "keyframes"];
|
|
if (numKeyframes <= 2 && createGenerator) {
|
|
/**
|
|
* 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 = motionUtils.secondsToMilliseconds(duration);
|
|
}
|
|
const springEasing = motionDom.createGeneratorEasing(springTransition, absoluteDelta, createGenerator);
|
|
ease = springEasing.ease;
|
|
duration = springEasing.duration;
|
|
}
|
|
duration ?? (duration = defaultDuration);
|
|
const startTime = currentTime + calculatedDelay;
|
|
/**
|
|
* 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 && motionDom.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);
|
|
/**
|
|
* Handle repeat options
|
|
*/
|
|
if (repeat) {
|
|
motionUtils.invariant(repeat < MAX_REPEAT, "Repeat count too high, must be less than 20", "repeat-count-high");
|
|
duration = calculateRepeatDuration(duration, repeat);
|
|
const originalKeyframes = [...valueKeyframesAsList];
|
|
const originalTimes = [...times];
|
|
ease = Array.isArray(ease) ? [...ease] : [ease];
|
|
const originalEase = [...ease];
|
|
for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) {
|
|
valueKeyframesAsList.push(...originalKeyframes);
|
|
for (let keyframeIndex = 0; keyframeIndex < originalKeyframes.length; keyframeIndex++) {
|
|
times.push(originalTimes[keyframeIndex] + (repeatIndex + 1));
|
|
ease.push(keyframeIndex === 0
|
|
? "linear"
|
|
: motionUtils.getEasingForSegment(originalEase, keyframeIndex - 1));
|
|
}
|
|
}
|
|
normalizeTimes(times, repeat);
|
|
}
|
|
const targetTime = startTime + duration;
|
|
/**
|
|
* 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 (motionDom.isMotionValue(subject)) {
|
|
const subjectSequence = getSubjectSequence(subject, sequences);
|
|
resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence));
|
|
}
|
|
else {
|
|
const subjects = resolveSubjects(subject, keyframes, scope, elementCache);
|
|
const numSubjects = subjects.length;
|
|
/**
|
|
* For every element in this segment, process the defined values.
|
|
*/
|
|
for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) {
|
|
/**
|
|
* Cast necessary, but we know these are of this type
|
|
*/
|
|
keyframes = keyframes;
|
|
transition = transition;
|
|
const thisSubject = subjects[subjectIndex];
|
|
const subjectSequence = getSubjectSequence(thisSubject, sequences);
|
|
for (const key in keyframes) {
|
|
resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects);
|
|
}
|
|
}
|
|
}
|
|
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(motionUtils.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 && transition[key]
|
|
? {
|
|
...transition,
|
|
...transition[key],
|
|
}
|
|
: { ...transition };
|
|
}
|
|
const isNumber = (keyframe) => typeof keyframe === "number";
|
|
const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber);
|
|
|
|
const visualElementStore = new WeakMap();
|
|
|
|
const isKeyframesTarget = (v) => {
|
|
return Array.isArray(v);
|
|
};
|
|
|
|
function getValueState(visualElement) {
|
|
const state = [{}, {}];
|
|
visualElement?.values.forEach((value, key) => {
|
|
state[0][key] = value.get();
|
|
state[1][key] = value.getVelocity();
|
|
});
|
|
return state;
|
|
}
|
|
function resolveVariantFromProps(props, definition, custom, visualElement) {
|
|
/**
|
|
* If the variant definition is a function, resolve.
|
|
*/
|
|
if (typeof definition === "function") {
|
|
const [current, velocity] = getValueState(visualElement);
|
|
definition = definition(custom !== undefined ? custom : props.custom, current, velocity);
|
|
}
|
|
/**
|
|
* 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") {
|
|
const [current, velocity] = getValueState(visualElement);
|
|
definition = definition(custom !== undefined ? custom : props.custom, current, velocity);
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
function resolveVariant(visualElement, definition, custom) {
|
|
const props = visualElement.getProps();
|
|
return resolveVariantFromProps(props, definition, props.custom, visualElement);
|
|
}
|
|
|
|
/**
|
|
* 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, motionDom.motionValue(value));
|
|
}
|
|
}
|
|
function resolveFinalValueInKeyframes(v) {
|
|
// TODO maybe throw if v.length - 1 is placeholder token?
|
|
return isKeyframesTarget(v) ? v[v.length - 1] || 0 : v;
|
|
}
|
|
function setTarget(visualElement, definition) {
|
|
const resolved = resolveVariant(visualElement, definition);
|
|
let { transitionEnd = {}, transition = {}, ...target } = resolved || {};
|
|
target = { ...target, ...transitionEnd };
|
|
for (const key in target) {
|
|
const value = resolveFinalValueInKeyframes(target[key]);
|
|
setMotionValue(visualElement, key, value);
|
|
}
|
|
}
|
|
|
|
function isWillChangeMotionValue(value) {
|
|
return Boolean(motionDom.isMotionValue(value) && value.add);
|
|
}
|
|
|
|
function addValueToWillChange(visualElement, key) {
|
|
const willChange = visualElement.getValue("willChange");
|
|
/**
|
|
* It could be that a user has set willChange to a regular MotionValue,
|
|
* in which case we can't add the value to it.
|
|
*/
|
|
if (isWillChangeMotionValue(willChange)) {
|
|
return willChange.add(key);
|
|
}
|
|
else if (!willChange && motionUtils.MotionGlobalConfig.WillChange) {
|
|
const newWillChange = new motionUtils.MotionGlobalConfig.WillChange("auto");
|
|
visualElement.addValue("willChange", newWillChange);
|
|
newWillChange.add(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert camelCase to dash-case properties.
|
|
*/
|
|
const camelToDash = (str) => str.replace(/([a-z])([A-Z])/gu, "$1-$2").toLowerCase();
|
|
|
|
const optimizedAppearDataId = "framerAppearId";
|
|
const optimizedAppearDataAttribute = "data-" + camelToDash(optimizedAppearDataId);
|
|
|
|
function getOptimisedAppearId(visualElement) {
|
|
return visualElement.props[optimizedAppearDataAttribute];
|
|
}
|
|
|
|
const isNotNull = (value) => value !== null;
|
|
function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }, finalKeyframe) {
|
|
const resolvedKeyframes = keyframes.filter(isNotNull);
|
|
const index = repeat && repeatType !== "loop" && repeat % 2 === 1
|
|
? 0
|
|
: resolvedKeyframes.length - 1;
|
|
return !index || finalKeyframe === undefined
|
|
? resolvedKeyframes[index]
|
|
: finalKeyframe;
|
|
}
|
|
|
|
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 (motionDom.transformProps.has(valueKey)) {
|
|
return valueKey.startsWith("scale")
|
|
? criticallyDampedSpring(keyframes[1])
|
|
: underDampedSpring;
|
|
}
|
|
return ease;
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
const animateMotionValue = (name, value, target, transition = {}, element, isHandoff) => (onComplete) => {
|
|
const valueTransition = motionDom.getValueTransition(transition, name) || {};
|
|
/**
|
|
* 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 - motionUtils.secondsToMilliseconds(delay);
|
|
const options = {
|
|
keyframes: Array.isArray(target) ? target : [null, target],
|
|
ease: "easeOut",
|
|
velocity: value.getVelocity(),
|
|
...valueTransition,
|
|
delay: -elapsed,
|
|
onUpdate: (v) => {
|
|
value.set(v);
|
|
valueTransition.onUpdate && valueTransition.onUpdate(v);
|
|
},
|
|
onComplete: () => {
|
|
onComplete();
|
|
valueTransition.onComplete && valueTransition.onComplete();
|
|
},
|
|
name,
|
|
motionValue: value,
|
|
element: isHandoff ? undefined : element,
|
|
};
|
|
/**
|
|
* If there's no transition defined for this value, we can generate
|
|
* unique transition settings for this value.
|
|
*/
|
|
if (!isTransitionDefined(valueTransition)) {
|
|
Object.assign(options, getDefaultTransition(name, options));
|
|
}
|
|
/**
|
|
* Both WAAPI and our internal animation functions use durations
|
|
* as defined by milliseconds, while our external API defines them
|
|
* as seconds.
|
|
*/
|
|
options.duration && (options.duration = motionUtils.secondsToMilliseconds(options.duration));
|
|
options.repeatDelay && (options.repeatDelay = motionUtils.secondsToMilliseconds(options.repeatDelay));
|
|
/**
|
|
* Support deprecated way to set initial value. Prefer keyframe syntax.
|
|
*/
|
|
if (options.from !== undefined) {
|
|
options.keyframes[0] = options.from;
|
|
}
|
|
let shouldSkip = false;
|
|
if (options.type === false ||
|
|
(options.duration === 0 && !options.repeatDelay)) {
|
|
motionDom.makeAnimationInstant(options);
|
|
if (options.delay === 0) {
|
|
shouldSkip = true;
|
|
}
|
|
}
|
|
if (motionUtils.MotionGlobalConfig.instantAnimations ||
|
|
motionUtils.MotionGlobalConfig.skipAnimations) {
|
|
shouldSkip = true;
|
|
motionDom.makeAnimationInstant(options);
|
|
options.delay = 0;
|
|
}
|
|
/**
|
|
* If the transition type or easing has been explicitly set by the user
|
|
* then we don't want to allow flattening the animation.
|
|
*/
|
|
options.allowFlatten = !valueTransition.type && !valueTransition.ease;
|
|
/**
|
|
* If we can or must skip creating the animation, and apply only
|
|
* the final keyframe, do so. We also check once keyframes are resolved but
|
|
* this early check prevents the need to create an animation at all.
|
|
*/
|
|
if (shouldSkip && !isHandoff && value.get() !== undefined) {
|
|
const finalKeyframe = getFinalKeyframe(options.keyframes, valueTransition);
|
|
if (finalKeyframe !== undefined) {
|
|
motionDom.frame.update(() => {
|
|
options.onUpdate(finalKeyframe);
|
|
options.onComplete();
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
return valueTransition.isSync
|
|
? new motionDom.JSAnimation(options)
|
|
: new motionDom.AsyncMotionValueAnimation(options);
|
|
};
|
|
|
|
/**
|
|
* 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 animateTarget(visualElement, targetAndTransition, { delay = 0, transitionOverride, type } = {}) {
|
|
let { transition = visualElement.getDefaultTransition(), transitionEnd, ...target } = targetAndTransition;
|
|
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, visualElement.latestValues[key] ?? null);
|
|
const valueTarget = target[key];
|
|
if (valueTarget === undefined ||
|
|
(animationTypeState &&
|
|
shouldBlockAnimation(animationTypeState, key))) {
|
|
continue;
|
|
}
|
|
const valueTransition = {
|
|
delay,
|
|
...motionDom.getValueTransition(transition || {}, key),
|
|
};
|
|
/**
|
|
* If the value is already at the defined target, skip the animation.
|
|
*/
|
|
const currentValue = value.get();
|
|
if (currentValue !== undefined &&
|
|
!value.isAnimating &&
|
|
!Array.isArray(valueTarget) &&
|
|
valueTarget === currentValue &&
|
|
!valueTransition.velocity) {
|
|
continue;
|
|
}
|
|
/**
|
|
* If this is the first time a value is being animated, check
|
|
* to see if we're handling off from an existing animation.
|
|
*/
|
|
let isHandoff = false;
|
|
if (window.MotionHandoffAnimation) {
|
|
const appearId = getOptimisedAppearId(visualElement);
|
|
if (appearId) {
|
|
const startTime = window.MotionHandoffAnimation(appearId, key, motionDom.frame);
|
|
if (startTime !== null) {
|
|
valueTransition.startTime = startTime;
|
|
isHandoff = true;
|
|
}
|
|
}
|
|
}
|
|
addValueToWillChange(visualElement, key);
|
|
value.start(animateMotionValue(key, value, valueTarget, visualElement.shouldReduceMotion && motionDom.positionalKeys.has(key)
|
|
? { type: false }
|
|
: valueTransition, visualElement, isHandoff));
|
|
const animation = value.animation;
|
|
if (animation) {
|
|
animations.push(animation);
|
|
}
|
|
}
|
|
if (transitionEnd) {
|
|
Promise.all(animations).then(() => {
|
|
motionDom.frame.update(() => {
|
|
transitionEnd && setTarget(visualElement, transitionEnd);
|
|
});
|
|
});
|
|
}
|
|
return animations;
|
|
}
|
|
|
|
/**
|
|
* Bounding boxes tend to be defined as top, left, right, bottom. For various operations
|
|
* it's easier to consider each axis individually. This function returns a bounding box
|
|
* as a map of single-axis min/max values.
|
|
*/
|
|
function convertBoundingBoxToBox({ top, left, right, bottom, }) {
|
|
return {
|
|
x: { min: left, max: right },
|
|
y: { min: top, max: bottom },
|
|
};
|
|
}
|
|
/**
|
|
* Applies a TransformPoint function to a bounding box. TransformPoint is usually a function
|
|
* provided by Framer to allow measured points to be corrected for device scaling. This is used
|
|
* when measuring DOM elements and DOM event points.
|
|
*/
|
|
function transformBoxPoints(point, transformPoint) {
|
|
if (!transformPoint)
|
|
return point;
|
|
const topLeft = transformPoint({ x: point.left, y: point.top });
|
|
const bottomRight = transformPoint({ x: point.right, y: point.bottom });
|
|
return {
|
|
top: topLeft.y,
|
|
left: topLeft.x,
|
|
bottom: bottomRight.y,
|
|
right: bottomRight.x,
|
|
};
|
|
}
|
|
|
|
function measureViewportBox(instance, transformPoint) {
|
|
return convertBoundingBoxToBox(transformBoxPoints(instance.getBoundingClientRect(), transformPoint));
|
|
}
|
|
|
|
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 createAxis = () => ({ min: 0, max: 0 });
|
|
const createBox = () => ({
|
|
x: createAxis(),
|
|
y: createAxis(),
|
|
});
|
|
|
|
const isBrowser = typeof window !== "undefined";
|
|
|
|
// 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.addEventListener("change", setReducedMotionPreferences);
|
|
setReducedMotionPreferences();
|
|
}
|
|
else {
|
|
prefersReducedMotion.current = false;
|
|
}
|
|
}
|
|
|
|
function isAnimationControls(v) {
|
|
return (v !== null &&
|
|
typeof v === "object" &&
|
|
typeof v.start === "function");
|
|
}
|
|
|
|
/**
|
|
* Decides if the supplied variable is variant label
|
|
*/
|
|
function isVariantLabel(v) {
|
|
return typeof v === "string" || Array.isArray(v);
|
|
}
|
|
|
|
const variantPriorityOrder = [
|
|
"animate",
|
|
"whileInView",
|
|
"whileFocus",
|
|
"whileHover",
|
|
"whileTap",
|
|
"whileDrag",
|
|
"exit",
|
|
];
|
|
const variantProps = ["initial", ...variantPriorityOrder];
|
|
|
|
function isControllingVariants(props) {
|
|
return (isAnimationControls(props.animate) ||
|
|
variantProps.some((name) => isVariantLabel(props[name])));
|
|
}
|
|
function isVariantNode(props) {
|
|
return Boolean(isControllingVariants(props) || props.variants);
|
|
}
|
|
|
|
function updateMotionValuesFromProps(element, next, prev) {
|
|
for (const key in next) {
|
|
const nextValue = next[key];
|
|
const prevValue = prev[key];
|
|
if (motionDom.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);
|
|
}
|
|
else if (motionDom.isMotionValue(prevValue)) {
|
|
/**
|
|
* If we're swapping from a motion value to a static value,
|
|
* create a new motion value from that
|
|
*/
|
|
element.addValue(key, motionDom.motionValue(nextValue, { owner: element }));
|
|
}
|
|
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);
|
|
if (existingValue.liveStyle === true) {
|
|
existingValue.jump(nextValue);
|
|
}
|
|
else if (!existingValue.hasAnimated) {
|
|
existingValue.set(nextValue);
|
|
}
|
|
}
|
|
else {
|
|
const latestValue = element.getStaticValue(key);
|
|
element.addValue(key, motionDom.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 propEventHandlers = [
|
|
"AnimationStart",
|
|
"AnimationComplete",
|
|
"Update",
|
|
"BeforeLayoutMeasure",
|
|
"LayoutMeasure",
|
|
"LayoutAnimationStart",
|
|
"LayoutAnimationComplete",
|
|
];
|
|
/**
|
|
* A VisualElement is an imperative abstraction around UI elements such as
|
|
* HTMLElement, SVGElement, Three.Object3D etc.
|
|
*/
|
|
class VisualElement {
|
|
/**
|
|
* 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, _visualElement) {
|
|
return {};
|
|
}
|
|
constructor({ parent, props, presenceContext, reducedMotionConfig, blockInitialAnimation, 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();
|
|
this.KeyframeResolver = motionDom.KeyframeResolver;
|
|
/**
|
|
* 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.renderScheduledAt = 0.0;
|
|
this.scheduleRender = () => {
|
|
const now = motionDom.time.now();
|
|
if (this.renderScheduledAt < now) {
|
|
this.renderScheduledAt = now;
|
|
motionDom.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.blockInitialAnimation = Boolean(blockInitialAnimation);
|
|
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 necessarily a breaking change,
|
|
* more a reflection of the test.
|
|
*/
|
|
const { willChange, ...initialMotionValues } = this.scrapeMotionValuesFromProps(props, {}, this);
|
|
for (const key in initialMotionValues) {
|
|
const value = initialMotionValues[key];
|
|
if (latestValues[key] !== undefined && motionDom.isMotionValue(value)) {
|
|
value.set(latestValues[key]);
|
|
}
|
|
}
|
|
}
|
|
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") {
|
|
motionUtils.warnOnce(this.shouldReduceMotion !== true, "You have Reduced Motion enabled on your device. Animations may not appear as expected.", "reduced-motion-disabled");
|
|
}
|
|
this.parent?.addChild(this);
|
|
this.update(this.props, this.presenceContext);
|
|
}
|
|
unmount() {
|
|
this.projection && this.projection.unmount();
|
|
motionDom.cancelFrame(this.notifyUpdate);
|
|
motionDom.cancelFrame(this.render);
|
|
this.valueSubscriptions.forEach((remove) => remove());
|
|
this.valueSubscriptions.clear();
|
|
this.removeFromVariantTree && this.removeFromVariantTree();
|
|
this.parent?.removeChild(this);
|
|
for (const key in this.events) {
|
|
this.events[key].clear();
|
|
}
|
|
for (const key in this.features) {
|
|
const feature = this.features[key];
|
|
if (feature) {
|
|
feature.unmount();
|
|
feature.isMounted = false;
|
|
}
|
|
}
|
|
this.current = null;
|
|
}
|
|
addChild(child) {
|
|
this.children.add(child);
|
|
this.enteringChildren ?? (this.enteringChildren = new Set());
|
|
this.enteringChildren.add(child);
|
|
}
|
|
removeChild(child) {
|
|
this.children.delete(child);
|
|
this.enteringChildren && this.enteringChildren.delete(child);
|
|
}
|
|
bindToMotionValue(key, value) {
|
|
if (this.valueSubscriptions.has(key)) {
|
|
this.valueSubscriptions.get(key)();
|
|
}
|
|
const valueIsTransform = motionDom.transformProps.has(key);
|
|
if (valueIsTransform && this.onBindTransform) {
|
|
this.onBindTransform();
|
|
}
|
|
const removeOnChange = value.on("change", (latestValue) => {
|
|
this.latestValues[key] = latestValue;
|
|
this.props.onUpdate && motionDom.frame.preRender(this.notifyUpdate);
|
|
if (valueIsTransform && this.projection) {
|
|
this.projection.isTransformDirty = true;
|
|
}
|
|
this.scheduleRender();
|
|
});
|
|
let removeSyncCheck;
|
|
if (window.MotionCheckAppearSync) {
|
|
removeSyncCheck = window.MotionCheckAppearSync(this, key, value);
|
|
}
|
|
this.valueSubscriptions.set(key, () => {
|
|
removeOnChange();
|
|
if (removeSyncCheck)
|
|
removeSyncCheck();
|
|
if (value.owner)
|
|
value.stop();
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
updateFeatures() {
|
|
let key = "animation";
|
|
for (key in featureDefinitions) {
|
|
const featureDefinition = featureDefinitions[key];
|
|
if (!featureDefinition)
|
|
continue;
|
|
const { isEnabled, Feature: FeatureConstructor } = featureDefinition;
|
|
/**
|
|
* If this feature is enabled but not active, make a new instance.
|
|
*/
|
|
if (!this.features[key] &&
|
|
FeatureConstructor &&
|
|
isEnabled(this.props)) {
|
|
this.features[key] = new FeatureConstructor(this);
|
|
}
|
|
/**
|
|
* If we have a feature, mount or update it.
|
|
*/
|
|
if (this.features[key]) {
|
|
const feature = this.features[key];
|
|
if (feature.isMounted) {
|
|
feature.update();
|
|
}
|
|
else {
|
|
feature.mount();
|
|
feature.isMounted = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
triggerBuild() {
|
|
this.build(this.renderState, this.latestValues, 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;
|
|
}
|
|
/**
|
|
* 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 listenerName = ("on" + key);
|
|
const listener = props[listenerName];
|
|
if (listener) {
|
|
this.propEventSubscriptions[key] = this.on(key, listener);
|
|
}
|
|
}
|
|
this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps, this), 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;
|
|
}
|
|
/**
|
|
* 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
|
|
const existingValue = this.values.get(key);
|
|
if (value !== existingValue) {
|
|
if (existingValue)
|
|
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 = motionDom.motionValue(defaultValue === null ? undefined : 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, target) {
|
|
let value = this.latestValues[key] !== undefined || !this.current
|
|
? this.latestValues[key]
|
|
: this.getBaseTargetFromProps(this.props, key) ??
|
|
this.readValueFromInstance(this.current, key, this.options);
|
|
if (value !== undefined && value !== null) {
|
|
if (typeof value === "string" &&
|
|
(motionUtils.isNumericalString(value) || motionUtils.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 (!motionDom.findValueType(value) && motionDom.complex.test(target)) {
|
|
value = motionDom.getAnimatableNone(key, target);
|
|
}
|
|
this.setBaseTarget(key, motionDom.isMotionValue(value) ? value.get() : value);
|
|
}
|
|
return motionDom.isMotionValue(value) ? value.get() : value;
|
|
}
|
|
/**
|
|
* 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) {
|
|
const { initial } = this.props;
|
|
let valueFromInitial;
|
|
if (typeof initial === "string" || typeof initial === "object") {
|
|
const variant = resolveVariantFromProps(this.props, initial, this.presenceContext?.custom);
|
|
if (variant) {
|
|
valueFromInitial = variant[key];
|
|
}
|
|
}
|
|
/**
|
|
* 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 && !motionDom.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 motionUtils.SubscriptionManager();
|
|
}
|
|
return this.events[eventName].add(callback);
|
|
}
|
|
notify(eventName, ...args) {
|
|
if (this.events[eventName]) {
|
|
this.events[eventName].notify(...args);
|
|
}
|
|
}
|
|
scheduleRenderMicrotask() {
|
|
motionDom.microtask.render(this.render);
|
|
}
|
|
}
|
|
|
|
class DOMVisualElement extends VisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.KeyframeResolver = motionDom.DOMKeyframesResolver;
|
|
}
|
|
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];
|
|
}
|
|
handleChildMotionValue() {
|
|
if (this.childSubscription) {
|
|
this.childSubscription();
|
|
delete this.childSubscription;
|
|
}
|
|
const { children } = this.props;
|
|
if (motionDom.isMotionValue(children)) {
|
|
this.childSubscription = children.on("change", (latest) => {
|
|
if (this.current) {
|
|
this.current.textContent = `${latest}`;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const translateAlias = {
|
|
x: "translateX",
|
|
y: "translateY",
|
|
z: "translateZ",
|
|
transformPerspective: "perspective",
|
|
};
|
|
const numTransforms = motionDom.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(latestValues, transform, transformTemplate) {
|
|
// The transform string we're going to build into.
|
|
let transformString = "";
|
|
let transformIsDefault = true;
|
|
/**
|
|
* 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 = motionDom.transformPropOrder[i];
|
|
const value = latestValues[key];
|
|
if (value === undefined)
|
|
continue;
|
|
let valueIsDefault = true;
|
|
if (typeof value === "number") {
|
|
valueIsDefault = value === (key.startsWith("scale") ? 1 : 0);
|
|
}
|
|
else {
|
|
valueIsDefault = parseFloat(value) === 0;
|
|
}
|
|
if (!valueIsDefault || transformTemplate) {
|
|
const valueAsType = motionDom.getValueAsType(value, motionDom.numberValueTypes[key]);
|
|
if (!valueIsDefault) {
|
|
transformIsDefault = false;
|
|
const transformName = translateAlias[key] || key;
|
|
transformString += `${transformName}(${valueAsType}) `;
|
|
}
|
|
if (transformTemplate) {
|
|
transform[key] = valueAsType;
|
|
}
|
|
}
|
|
}
|
|
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 (transformIsDefault) {
|
|
transformString = "none";
|
|
}
|
|
return transformString;
|
|
}
|
|
|
|
function buildHTMLStyles(state, latestValues, transformTemplate) {
|
|
const { style, vars, transformOrigin } = state;
|
|
// Track whether we encounter any transform or transformOrigin values.
|
|
let hasTransform = false;
|
|
let hasTransformOrigin = false;
|
|
/**
|
|
* 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 separately for further processing.
|
|
*/
|
|
for (const key in latestValues) {
|
|
const value = latestValues[key];
|
|
if (motionDom.transformProps.has(key)) {
|
|
// If this is a transform, flag to enable further transform processing
|
|
hasTransform = true;
|
|
continue;
|
|
}
|
|
else if (motionDom.isCSSVariableName(key)) {
|
|
vars[key] = value;
|
|
continue;
|
|
}
|
|
else {
|
|
// Convert the value to its default value type, ie 0 -> "0px"
|
|
const valueAsType = motionDom.getValueAsType(value, motionDom.numberValueTypes[key]);
|
|
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(latestValues, state.transform, transformTemplate);
|
|
}
|
|
else if (style.transform) {
|
|
/**
|
|
* If we have previously created a transform but currently don't have any,
|
|
* reset transform style to none.
|
|
*/
|
|
style.transform = "none";
|
|
}
|
|
}
|
|
/**
|
|
* Build a transformOrigin style. Uses the same defaults as the browser for
|
|
* undefined origins.
|
|
*/
|
|
if (hasTransformOrigin) {
|
|
const { originX = "50%", originY = "50%", originZ = 0, } = transformOrigin;
|
|
style.transformOrigin = `${originX} ${originY} ${originZ}`;
|
|
}
|
|
}
|
|
|
|
function renderHTML(element, { style, vars }, styleProp, projection) {
|
|
const elementStyle = element.style;
|
|
let key;
|
|
for (key in style) {
|
|
// CSSStyleDeclaration has [index: number]: string; in the types, so we use that as key type.
|
|
elementStyle[key] = style[key];
|
|
}
|
|
// Write projection styles directly to element style
|
|
projection?.applyProjectionStyles(elementStyle, styleProp);
|
|
for (key in vars) {
|
|
// Loop over any CSS variables and assign those.
|
|
// They can only be assigned using `setProperty`.
|
|
elementStyle.setProperty(key, vars[key]);
|
|
}
|
|
}
|
|
|
|
const scaleCorrectors = {};
|
|
|
|
function isForcedMotionValue(key, { layout, layoutId }) {
|
|
return (motionDom.transformProps.has(key) ||
|
|
key.startsWith("origin") ||
|
|
((layout || layoutId !== undefined) &&
|
|
(!!scaleCorrectors[key] || key === "opacity")));
|
|
}
|
|
|
|
function scrapeMotionValuesFromProps$1(props, prevProps, visualElement) {
|
|
const { style } = props;
|
|
const newValues = {};
|
|
for (const key in style) {
|
|
if (motionDom.isMotionValue(style[key]) ||
|
|
(prevProps.style &&
|
|
motionDom.isMotionValue(prevProps.style[key])) ||
|
|
isForcedMotionValue(key, props) ||
|
|
visualElement?.getValue(key)?.liveStyle !== undefined) {
|
|
newValues[key] = style[key];
|
|
}
|
|
}
|
|
return newValues;
|
|
}
|
|
|
|
function getComputedStyle$1(element) {
|
|
return window.getComputedStyle(element);
|
|
}
|
|
class HTMLVisualElement extends DOMVisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.type = "html";
|
|
this.renderInstance = renderHTML;
|
|
}
|
|
readValueFromInstance(instance, key) {
|
|
if (motionDom.transformProps.has(key)) {
|
|
return this.projection?.isProjecting
|
|
? motionDom.defaultTransformValue(key)
|
|
: motionDom.readTransformValue(instance, key);
|
|
}
|
|
else {
|
|
const computedStyle = getComputedStyle$1(instance);
|
|
const value = (motionDom.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, props) {
|
|
buildHTMLStyles(renderState, latestValues, props.transformTemplate);
|
|
}
|
|
scrapeMotionValuesFromProps(props, prevProps, visualElement) {
|
|
return scrapeMotionValuesFromProps$1(props, prevProps, visualElement);
|
|
}
|
|
}
|
|
|
|
function isObjectKey(key, object) {
|
|
return key in object;
|
|
}
|
|
class ObjectVisualElement extends VisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.type = "object";
|
|
}
|
|
readValueFromInstance(instance, key) {
|
|
if (isObjectKey(key, instance)) {
|
|
const value = instance[key];
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
getBaseTargetFromProps() {
|
|
return undefined;
|
|
}
|
|
removeValueFromRenderState(key, renderState) {
|
|
delete renderState.output[key];
|
|
}
|
|
measureInstanceViewportBox() {
|
|
return createBox();
|
|
}
|
|
build(renderState, latestValues) {
|
|
Object.assign(renderState.output, latestValues);
|
|
}
|
|
renderInstance(instance, { output }) {
|
|
Object.assign(instance, output);
|
|
}
|
|
sortInstanceNodePosition() {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
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] = motionDom.px.transform(-offset);
|
|
// Build the dash array
|
|
const pathLength = motionDom.px.transform(length);
|
|
const pathSpacing = motionDom.px.transform(spacing);
|
|
attrs[keys.array] = `${pathLength} ${pathSpacing}`;
|
|
}
|
|
|
|
/**
|
|
* Build SVG visual attributes, like cx and style.transform
|
|
*/
|
|
function buildSVGAttrs(state, { attrX, attrY, attrScale, pathLength, pathSpacing = 1, pathOffset = 0,
|
|
// This is object creation, which we try to avoid per-frame.
|
|
...latest }, isSVGTag, transformTemplate, styleProp) {
|
|
buildHTMLStyles(state, latest, 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 } = state;
|
|
/**
|
|
* However, we apply transforms as CSS transforms.
|
|
* So if we detect a transform, transformOrigin we take it from attrs and copy it into style.
|
|
*/
|
|
if (attrs.transform) {
|
|
style.transform = attrs.transform;
|
|
delete attrs.transform;
|
|
}
|
|
if (style.transform || attrs.transformOrigin) {
|
|
style.transformOrigin = attrs.transformOrigin ?? "50% 50%";
|
|
delete attrs.transformOrigin;
|
|
}
|
|
if (style.transform) {
|
|
/**
|
|
* SVG's element transform-origin uses its own median as a reference.
|
|
* Therefore, transformBox becomes a fill-box
|
|
*/
|
|
style.transformBox = styleProp?.transformBox ?? "fill-box";
|
|
delete attrs.transformBox;
|
|
}
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
]);
|
|
|
|
const isSVGTag = (tag) => typeof tag === "string" && tag.toLowerCase() === "svg";
|
|
|
|
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(props, prevProps, visualElement) {
|
|
const newValues = scrapeMotionValuesFromProps$1(props, prevProps, visualElement);
|
|
for (const key in props) {
|
|
if (motionDom.isMotionValue(props[key]) ||
|
|
motionDom.isMotionValue(prevProps[key])) {
|
|
const targetKey = motionDom.transformPropOrder.indexOf(key) !== -1
|
|
? "attr" + key.charAt(0).toUpperCase() + key.substring(1)
|
|
: key;
|
|
newValues[targetKey] = props[key];
|
|
}
|
|
}
|
|
return newValues;
|
|
}
|
|
|
|
class SVGVisualElement extends DOMVisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.type = "svg";
|
|
this.isSVGTag = false;
|
|
this.measureInstanceViewportBox = createBox;
|
|
}
|
|
getBaseTargetFromProps(props, key) {
|
|
return props[key];
|
|
}
|
|
readValueFromInstance(instance, key) {
|
|
if (motionDom.transformProps.has(key)) {
|
|
const defaultType = motionDom.getDefaultValueType(key);
|
|
return defaultType ? defaultType.default || 0 : 0;
|
|
}
|
|
key = !camelCaseAttributes.has(key) ? camelToDash(key) : key;
|
|
return instance.getAttribute(key);
|
|
}
|
|
scrapeMotionValuesFromProps(props, prevProps, visualElement) {
|
|
return scrapeMotionValuesFromProps(props, prevProps, visualElement);
|
|
}
|
|
build(renderState, latestValues, props) {
|
|
buildSVGAttrs(renderState, latestValues, this.isSVGTag, props.transformTemplate, props.style);
|
|
}
|
|
renderInstance(instance, renderState, styleProp, projection) {
|
|
renderSVG(instance, renderState, styleProp, projection);
|
|
}
|
|
mount(instance) {
|
|
this.isSVGTag = isSVGTag(instance.tagName);
|
|
super.mount(instance);
|
|
}
|
|
}
|
|
|
|
function createDOMVisualElement(element) {
|
|
const options = {
|
|
presenceContext: null,
|
|
props: {},
|
|
visualState: {
|
|
renderState: {
|
|
transform: {},
|
|
transformOrigin: {},
|
|
style: {},
|
|
vars: {},
|
|
attrs: {},
|
|
},
|
|
latestValues: {},
|
|
},
|
|
};
|
|
const node = motionDom.isSVGElement(element) && !motionDom.isSVGSVGElement(element)
|
|
? new SVGVisualElement(options)
|
|
: new HTMLVisualElement(options);
|
|
node.mount(element);
|
|
visualElementStore.set(element, node);
|
|
}
|
|
function createObjectVisualElement(subject) {
|
|
const options = {
|
|
presenceContext: null,
|
|
props: {},
|
|
visualState: {
|
|
renderState: {
|
|
output: {},
|
|
},
|
|
latestValues: {},
|
|
},
|
|
};
|
|
const node = new ObjectVisualElement(options);
|
|
node.mount(subject);
|
|
visualElementStore.set(subject, node);
|
|
}
|
|
|
|
function animateSingleValue(value, keyframes, options) {
|
|
const motionValue = motionDom.isMotionValue(value) ? value : motionDom.motionValue(value);
|
|
motionValue.start(animateMotionValue("", motionValue, keyframes, options));
|
|
return motionValue.animation;
|
|
}
|
|
|
|
function isSingleValue(subject, keyframes) {
|
|
return (motionDom.isMotionValue(subject) ||
|
|
typeof subject === "number" ||
|
|
(typeof subject === "string" && !isDOMKeyframes(keyframes)));
|
|
}
|
|
/**
|
|
* Implementation
|
|
*/
|
|
function animateSubject(subject, keyframes, options, scope) {
|
|
const animations = [];
|
|
if (isSingleValue(subject, keyframes)) {
|
|
animations.push(animateSingleValue(subject, isDOMKeyframes(keyframes)
|
|
? keyframes.default || keyframes
|
|
: keyframes, options ? options.default || options : options));
|
|
}
|
|
else {
|
|
const subjects = resolveSubjects(subject, keyframes, scope);
|
|
const numSubjects = subjects.length;
|
|
motionUtils.invariant(Boolean(numSubjects), "No valid elements provided.", "no-valid-elements");
|
|
for (let i = 0; i < numSubjects; i++) {
|
|
const thisSubject = subjects[i];
|
|
motionUtils.invariant(thisSubject !== null, "You're trying to perform an animation on null. Ensure that selectors are correctly finding elements and refs are correctly hydrated.", "animate-null");
|
|
const createVisualElement = thisSubject instanceof Element
|
|
? createDOMVisualElement
|
|
: createObjectVisualElement;
|
|
if (!visualElementStore.has(thisSubject)) {
|
|
createVisualElement(thisSubject);
|
|
}
|
|
const visualElement = visualElementStore.get(thisSubject);
|
|
const transition = { ...options };
|
|
/**
|
|
* Resolve stagger function if provided.
|
|
*/
|
|
if ("delay" in transition &&
|
|
typeof transition.delay === "function") {
|
|
transition.delay = transition.delay(i, numSubjects);
|
|
}
|
|
animations.push(...animateTarget(visualElement, { ...keyframes, transition }, {}));
|
|
}
|
|
}
|
|
return animations;
|
|
}
|
|
|
|
function animateSequence(sequence, options, scope) {
|
|
const animations = [];
|
|
const animationDefinitions = createAnimationsFromSequence(sequence, options, scope, { spring: motionDom.spring });
|
|
animationDefinitions.forEach(({ keyframes, transition }, subject) => {
|
|
animations.push(...animateSubject(subject, keyframes, transition));
|
|
});
|
|
return animations;
|
|
}
|
|
|
|
function isSequence(value) {
|
|
return Array.isArray(value) && value.some(Array.isArray);
|
|
}
|
|
/**
|
|
* Creates an animation function that is optionally scoped
|
|
* to a specific element.
|
|
*/
|
|
function createScopedAnimate(scope) {
|
|
/**
|
|
* Implementation
|
|
*/
|
|
function scopedAnimate(subjectOrSequence, optionsOrKeyframes, options) {
|
|
let animations = [];
|
|
let animationOnComplete;
|
|
if (isSequence(subjectOrSequence)) {
|
|
animations = animateSequence(subjectOrSequence, optionsOrKeyframes, scope);
|
|
}
|
|
else {
|
|
// Extract top-level onComplete so it doesn't get applied per-value
|
|
const { onComplete, ...rest } = options || {};
|
|
if (typeof onComplete === "function") {
|
|
animationOnComplete = onComplete;
|
|
}
|
|
animations = animateSubject(subjectOrSequence, optionsOrKeyframes, rest, scope);
|
|
}
|
|
const animation = new motionDom.GroupAnimationWithThen(animations);
|
|
if (animationOnComplete) {
|
|
animation.finished.then(animationOnComplete);
|
|
}
|
|
if (scope) {
|
|
scope.animations.push(animation);
|
|
animation.finished.then(() => {
|
|
motionUtils.removeItem(scope.animations, animation);
|
|
});
|
|
}
|
|
return animation;
|
|
}
|
|
return scopedAnimate;
|
|
}
|
|
const animate = createScopedAnimate();
|
|
|
|
function animateElements(elementOrSelector, keyframes, options, scope) {
|
|
const elements = motionDom.resolveElements(elementOrSelector, scope);
|
|
const numElements = elements.length;
|
|
motionUtils.invariant(Boolean(numElements), "No valid elements provided.", "no-valid-elements");
|
|
/**
|
|
* WAAPI doesn't support interrupting animations.
|
|
*
|
|
* Therefore, starting animations requires a three-step process:
|
|
* 1. Stop existing animations (write styles to DOM)
|
|
* 2. Resolve keyframes (read styles from DOM)
|
|
* 3. Create new animations (write styles to DOM)
|
|
*
|
|
* The hybrid `animate()` function uses AsyncAnimation to resolve
|
|
* keyframes before creating new animations, which removes style
|
|
* thrashing. Here, we have much stricter filesize constraints.
|
|
* Therefore we do this in a synchronous way that ensures that
|
|
* at least within `animate()` calls there is no style thrashing.
|
|
*
|
|
* In the motion-native-animate-mini-interrupt benchmark this
|
|
* was 80% faster than a single loop.
|
|
*/
|
|
const animationDefinitions = [];
|
|
/**
|
|
* Step 1: Build options and stop existing animations (write)
|
|
*/
|
|
for (let i = 0; i < numElements; i++) {
|
|
const element = elements[i];
|
|
const elementTransition = { ...options };
|
|
/**
|
|
* Resolve stagger function if provided.
|
|
*/
|
|
if (typeof elementTransition.delay === "function") {
|
|
elementTransition.delay = elementTransition.delay(i, numElements);
|
|
}
|
|
for (const valueName in keyframes) {
|
|
let valueKeyframes = keyframes[valueName];
|
|
if (!Array.isArray(valueKeyframes)) {
|
|
valueKeyframes = [valueKeyframes];
|
|
}
|
|
const valueOptions = {
|
|
...motionDom.getValueTransition(elementTransition, valueName),
|
|
};
|
|
valueOptions.duration && (valueOptions.duration = motionUtils.secondsToMilliseconds(valueOptions.duration));
|
|
valueOptions.delay && (valueOptions.delay = motionUtils.secondsToMilliseconds(valueOptions.delay));
|
|
/**
|
|
* If there's an existing animation playing on this element then stop it
|
|
* before creating a new one.
|
|
*/
|
|
const map = motionDom.getAnimationMap(element);
|
|
const key = motionDom.animationMapKey(valueName, valueOptions.pseudoElement || "");
|
|
const currentAnimation = map.get(key);
|
|
currentAnimation && currentAnimation.stop();
|
|
animationDefinitions.push({
|
|
map,
|
|
key,
|
|
unresolvedKeyframes: valueKeyframes,
|
|
options: {
|
|
...valueOptions,
|
|
element,
|
|
name: valueName,
|
|
allowFlatten: !elementTransition.type && !elementTransition.ease,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Step 2: Resolve keyframes (read)
|
|
*/
|
|
for (let i = 0; i < animationDefinitions.length; i++) {
|
|
const { unresolvedKeyframes, options: animationOptions } = animationDefinitions[i];
|
|
const { element, name, pseudoElement } = animationOptions;
|
|
if (!pseudoElement && unresolvedKeyframes[0] === null) {
|
|
unresolvedKeyframes[0] = motionDom.getComputedStyle(element, name);
|
|
}
|
|
motionDom.fillWildcards(unresolvedKeyframes);
|
|
motionDom.applyPxDefaults(unresolvedKeyframes, name);
|
|
/**
|
|
* If we only have one keyframe, explicitly read the initial keyframe
|
|
* from the computed style. This is to ensure consistency with WAAPI behaviour
|
|
* for restarting animations, for instance .play() after finish, when it
|
|
* has one vs two keyframes.
|
|
*/
|
|
if (!pseudoElement && unresolvedKeyframes.length < 2) {
|
|
unresolvedKeyframes.unshift(motionDom.getComputedStyle(element, name));
|
|
}
|
|
animationOptions.keyframes = unresolvedKeyframes;
|
|
}
|
|
/**
|
|
* Step 3: Create new animations (write)
|
|
*/
|
|
const animations = [];
|
|
for (let i = 0; i < animationDefinitions.length; i++) {
|
|
const { map, key, options: animationOptions } = animationDefinitions[i];
|
|
const animation = new motionDom.NativeAnimation(animationOptions);
|
|
map.set(key, animation);
|
|
animation.finished.finally(() => map.delete(key));
|
|
animations.push(animation);
|
|
}
|
|
return animations;
|
|
}
|
|
|
|
const createScopedWaapiAnimate = (scope) => {
|
|
function scopedAnimate(elementOrSelector, keyframes, options) {
|
|
return new motionDom.GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
|
|
}
|
|
return scopedAnimate;
|
|
};
|
|
const animateMini = /*@__PURE__*/ createScopedWaapiAnimate();
|
|
|
|
/**
|
|
* 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 = motionUtils.progress(0, axis.scrollLength, axis.current);
|
|
const elapsed = time - prevTime;
|
|
axis.velocity =
|
|
elapsed > maxElapsed
|
|
? 0
|
|
: motionUtils.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 (motionDom.isHTMLElement(current)) {
|
|
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 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 (edge in namedEdges) {
|
|
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 ScrollOffset = {
|
|
Enter: [
|
|
[0, 1],
|
|
[1, 1],
|
|
],
|
|
Exit: [
|
|
[0, 0],
|
|
[1, 0],
|
|
],
|
|
Any: [
|
|
[1, 0],
|
|
[0, 1],
|
|
],
|
|
All: [
|
|
[0, 0],
|
|
[1, 1],
|
|
],
|
|
};
|
|
|
|
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) {
|
|
const { 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 = motionDom.interpolate(info[axis].offset, motionDom.defaultOffset(offsetDefinition), { clamp: false });
|
|
info[axis].interpolatorOffsets = [...info[axis].offset];
|
|
}
|
|
info[axis].progress = motionUtils.clamp(0, 1, 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) {
|
|
motionUtils.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: (time) => {
|
|
measure(element, options.target, info);
|
|
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.scrollingElement ? window : element;
|
|
function scrollInfo(onScroll, { container = document.scrollingElement, ...options } = {}) {
|
|
if (!container)
|
|
return motionUtils.noop;
|
|
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(motionDom.frameData.timestamp);
|
|
}
|
|
motionDom.frame.preUpdate(notifyAll);
|
|
};
|
|
const notifyAll = () => {
|
|
for (const handler of containerHandlers) {
|
|
handler.notify();
|
|
}
|
|
};
|
|
const listener = () => motionDom.frame.read(measureAll);
|
|
scrollListeners.set(container, listener);
|
|
const target = getEventTarget(container);
|
|
window.addEventListener("resize", listener, { passive: true });
|
|
if (container !== document.documentElement) {
|
|
resizeListeners.set(container, motionDom.resize(container, listener));
|
|
}
|
|
target.addEventListener("scroll", listener, { passive: true });
|
|
listener();
|
|
}
|
|
const listener = scrollListeners.get(container);
|
|
motionDom.frame.read(listener, false, true);
|
|
return () => {
|
|
motionDom.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);
|
|
resizeListeners.get(container)?.();
|
|
window.removeEventListener("resize", scrollListener);
|
|
}
|
|
};
|
|
}
|
|
|
|
const timelineCache = new Map();
|
|
function scrollTimelineFallback(options) {
|
|
const currentTime = { value: 0 };
|
|
const cancel = scrollInfo((info) => {
|
|
currentTime.value = info[options.axis].progress * 100;
|
|
}, options);
|
|
return { currentTime, cancel };
|
|
}
|
|
function getTimeline({ source, container, ...options }) {
|
|
const { axis } = options;
|
|
if (source)
|
|
container = source;
|
|
const containerCache = timelineCache.get(container) ?? new Map();
|
|
timelineCache.set(container, containerCache);
|
|
const targetKey = options.target ?? "self";
|
|
const targetCache = containerCache.get(targetKey) ?? {};
|
|
const axisKey = axis + (options.offset ?? []).join(",");
|
|
if (!targetCache[axisKey]) {
|
|
targetCache[axisKey] =
|
|
!options.target && motionDom.supportsScrollTimeline()
|
|
? new ScrollTimeline({ source: container, axis })
|
|
: scrollTimelineFallback({ container, ...options });
|
|
}
|
|
return targetCache[axisKey];
|
|
}
|
|
|
|
function attachToAnimation(animation, options) {
|
|
const timeline = getTimeline(options);
|
|
return animation.attachTimeline({
|
|
timeline: options.target ? undefined : timeline,
|
|
observe: (valueAnimation) => {
|
|
valueAnimation.pause();
|
|
return motionDom.observeTimeline((progress) => {
|
|
valueAnimation.time =
|
|
valueAnimation.iterationDuration * progress;
|
|
}, timeline);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* If the onScroll function has two arguments, it's expecting
|
|
* more specific information about the scroll from scrollInfo.
|
|
*/
|
|
function isOnScrollWithInfo(onScroll) {
|
|
return onScroll.length === 2;
|
|
}
|
|
function attachToFunction(onScroll, options) {
|
|
if (isOnScrollWithInfo(onScroll)) {
|
|
return scrollInfo((info) => {
|
|
onScroll(info[options.axis].progress, info);
|
|
}, options);
|
|
}
|
|
else {
|
|
return motionDom.observeTimeline(onScroll, getTimeline(options));
|
|
}
|
|
}
|
|
|
|
function scroll(onScroll, { axis = "y", container = document.scrollingElement, ...options } = {}) {
|
|
if (!container)
|
|
return motionUtils.noop;
|
|
const optionsWithDefaults = { axis, container, ...options };
|
|
return typeof onScroll === "function"
|
|
? attachToFunction(onScroll, optionsWithDefaults)
|
|
: attachToAnimation(onScroll, optionsWithDefaults);
|
|
}
|
|
|
|
const thresholds = {
|
|
some: 0,
|
|
all: 1,
|
|
};
|
|
function inView(elementOrSelector, onStart, { root, margin: rootMargin, amount = "some" } = {}) {
|
|
const elements = motionDom.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.target, entry);
|
|
if (typeof newOnEnd === "function") {
|
|
activeIntersections.set(entry.target, newOnEnd);
|
|
}
|
|
else {
|
|
observer.unobserve(entry.target);
|
|
}
|
|
}
|
|
else if (typeof onEnd === "function") {
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Timeout defined in ms
|
|
*/
|
|
function delay(callback, timeout) {
|
|
const start = motionDom.time.now();
|
|
const checkElapsed = ({ timestamp }) => {
|
|
const elapsed = timestamp - start;
|
|
if (elapsed >= timeout) {
|
|
motionDom.cancelFrame(checkElapsed);
|
|
callback(elapsed - timeout);
|
|
}
|
|
};
|
|
motionDom.frame.setup(checkElapsed, true);
|
|
return () => motionDom.cancelFrame(checkElapsed);
|
|
}
|
|
function delayInSeconds(callback, timeout) {
|
|
return delay(callback, motionUtils.secondsToMilliseconds(timeout));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
exports.animate = animate;
|
|
exports.animateMini = animateMini;
|
|
exports.createScopedAnimate = createScopedAnimate;
|
|
exports.delay = delayInSeconds;
|
|
exports.distance = distance;
|
|
exports.distance2D = distance2D;
|
|
exports.inView = inView;
|
|
exports.scroll = scroll;
|
|
exports.scrollInfo = scrollInfo;
|
|
Object.keys(motionDom).forEach(function (k) {
|
|
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
enumerable: true,
|
|
get: function () { return motionDom[k]; }
|
|
});
|
|
});
|
|
Object.keys(motionUtils).forEach(function (k) {
|
|
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
enumerable: true,
|
|
get: function () { return motionUtils[k]; }
|
|
});
|
|
});
|