motion-dom
Version:
1,197 lines (1,194 loc) • 44.4 kB
JavaScript
import { isEasingArray, isBezierDefinition, easingDefinitionToFunction, millisecondsToSeconds, clamp, progress } from 'motion-utils';
import { frame } from '../../../frameloop/frame.mjs';
import { frameData } from '../../../frameloop/frame-data.mjs';
import { time } from '../../../frameloop/sync-time.mjs';
import { isDefaultTransform, transformFunction } from '../../../render/html/utils/build-transform.mjs';
import { transformPropOrder } from '../../../render/utils/keys-transform.mjs';
import { mixNumber } from '../../../utils/mix/number.mjs';
import { frameloopDriver } from '../../drivers/frame.mjs';
import { keyframes } from '../../generators/keyframes.mjs';
import { defaultOffset } from '../../keyframes/offsets/default.mjs';
import { JSAnimation, round } from '../../JSAnimation.mjs';
import { sampleDelta } from '../../NativeAnimationExtended.mjs';
import { cubicBezierAsString } from '../easing/cubic-bezier.mjs';
import { supportedWaapiEasing } from '../easing/supported.mjs';
import { supportsWaapi } from '../supports/waapi.mjs';
import { needsMixer } from '../utils/needs-mixer.mjs';
import { supportsLinearEasing } from '../../../utils/supports/linear-easing.mjs';
import { transformGroups } from './groups.mjs';
/**
* Sample at the interruption sampling rate, up to a cap for long
* animations.
*/
const maxSamples = 200;
/**
* How far values may stray from the WAAPI animation: as a fraction of
* each value's change when they share an easing, otherwise in px or deg,
* or for scale.
*/
const easingTolerance = 0.001;
const sampleTolerance = 0.1;
const scaleTolerance = 0.001;
/**
* If an outside write, or a seek the WAAPI animation can't follow,
* happens again within this window, the group is being driven every
* frame (scrubbing, drag), which is cheaper on the main thread than
* rebuilding the WAAPI animation every frame.
*/
const churnWindow = 100;
/**
* A WAAPI animation that starts moving later than this, as most segments
* of a long sequence do, is built this long before it starts, rather than
* all at once at the start.
*/
const buildAhead = 100;
/**
* Named easings that are the same cubic-bezier in JS and CSS.
*/
const nativeEasings = new Set(["linear", "easeIn", "easeOut", "easeInOut"]);
const isNativeEasing = (ease) => nativeEasings.has(ease);
/**
* The last trace of each easing WAAPI can't run into linear(), with its
* number of samples, so the elements of a sequence that share an easing
* and duration trace it once.
*/
const traced = new WeakMap();
const trace = (ease, samples) => {
let last = traced.get(ease);
if (last?.[0] !== samples) {
last = [
samples,
"linear(" +
defaultOffset(Array(samples + 1)).map((p) => round(ease(p)) + " " + round(p * 100) + "%") +
")",
];
traced.set(ease, last);
}
return last[1];
};
/**
* How many samples trace an easing between two timestamps.
*/
const countSamples = (from, to) => Math.min(maxSamples, Math.max(2, Math.ceil((to - from) / sampleDelta)));
/**
* Whether two eased progresses match. False if either is NaN.
*/
const isNear = (a, b) => Math.abs(a - b) <= easingTolerance;
const byTime = (a, b) => a - b;
/**
* n + 1 evenly spaced timestamps, from one timestamp to another.
*/
const spread = (from, to, n) => defaultOffset(Array(n + 1)).map((p) => mixNumber(from, to, p));
const getOwner = (options) => options.motionValue.owner;
const getGroup = (element) => transformGroups.get(element);
/**
* Whether an animation has ended, so can leave its group.
*/
const hasEnded = ({ state }) => state === "idle" || state === "finished";
const read = (owner, key) => owner.latestValues ? owner.latestValues[key] : owner.getValue(key)?.get();
/**
* Whether this animation should join its element's transform group.
* Animations that can't be accelerated still join, so the group knows
* to stay on the main thread while they run. Browsers without linear()
* easing stay on the main thread.
*/
const canGroupTransform = ({ name, motionValue, }) => name &&
transformPropOrder.includes(name) &&
motionValue?.owner?.current instanceof HTMLElement &&
supportsWaapi() &&
supportsLinearEasing();
function canAccelerateTransform(options) {
const owner = getOwner(options);
const { onUpdate, transformTemplate } = owner.getProps();
return (
/**
* A handoff from an optimised appear animation is already running
* on its own WAAPI animation. onUpdate needs every value, every
* frame.
*/
!options.isHandoff &&
!onUpdate &&
!options.hasOnUpdate &&
/**
* transformTemplate could output anything.
*/
!transformTemplate &&
options.type !== "inertia" &&
/**
* A literal transform, or pathRotation, replaces or adds to the
* transform we'd build. styleEffect builds its own as "none".
*/
(read(owner, "transform") || "none") === "none" &&
!read(owner, "pathRotation") &&
/**
* Layout animations write transform every frame.
*/
!owner.projection?.isProjecting() &&
/**
* A mixer that blends these values makes them every frame. It's
* asked last, as it's user code.
*/
!needsMixer(options));
}
/**
* Starting or stopping tells the group to update its WAAPI animation.
* The frame loop driver is only made once the group is on the main
* thread, as accelerated animations never tick.
*/
class TransformDriver {
constructor(element, update, makeDriver, animation) {
this.element = element;
this.update = update;
this.makeDriver = makeDriver;
this.animation = animation;
}
get js() {
return (this.driver || (this.driver = this.makeDriver(this.update)));
}
now() {
return this.driver || this.makeDriver !== frameloopDriver
? this.js.now()
: frameData.isProcessing
? frameData.timestamp
: time.now();
}
/**
* keepAlive is false when seeking. Seeking the WAAPI animation is
* free, but if a seek needs a rebuild and keeps happening, the group
* moves to the main thread.
*/
start(keepAlive = true) {
const group = getGroup(this.element);
!group || group.isJS
? this.js.start(keepAlive)
: group.schedule(false, !keepAlive);
}
stopJS() {
this.driver?.stop();
}
stop() {
this.stopJS();
const group = getGroup(this.element);
group && !group.isFinishing && group.release(this.animation);
}
}
/**
* JSAnimation's time setter, looked up on first use rather than at module
* load, so the module has no top-level side effects and stays
* tree-shakeable in flattened bundles.
*/
let setTime;
class TransformAnimation extends JSAnimation {
constructor(options) {
const element = getOwner(options).current;
const { driver = frameloopDriver } = options;
let animation;
/**
* Before JSAnimation replaces the type with its generator.
*/
const canAccelerate = canAccelerateTransform(options);
/**
* The driver ticks on the main thread only once the group has
* moved there, so its frame loop driver is made then.
*/
options.driver = (update) => new TransformDriver(element, update, driver, animation);
super(options);
animation = this;
/**
* The first driver is made in super(), before this was assigned.
*/
const firstDriver = this.driver;
firstDriver && (firstDriver.animation = this);
this.canAccelerate = canAccelerate;
/**
* stop is bound in JSAnimation, so wrap it rather than override.
* Before stopping, set the motion value to where the WAAPI
* animation is now, with velocity, for the next animation to
* start from.
*/
const { stop } = this;
this.stop = () => {
this.isAccelerated() && this.sync();
stop();
};
}
/**
* Each keyframe's offset, made once for the keyframes rather than on
* every build.
*/
offsets() {
const { keyframes, times } = this.options;
if (times)
return times;
if (this.offsetsOf !== keyframes) {
this.offsetsOf = keyframes;
this.cachedOffsets = defaultOffset(keyframes);
}
return this.cachedOffsets;
}
get group() {
return getGroup(getOwner(this.options).current);
}
get js() {
return this.driver?.js;
}
stopJS() {
this.driver?.stopJS();
}
get name() {
return this.options.name;
}
/**
* When this stops moving, or, if it repeats forever, when it starts
* repeating, and how long each repeat takes. Held (paused, or at
* speed 0) animations never stop moving.
*/
timing() {
const { startTime, speed, totalDuration } = this;
const { delay = 0, repeatType } = this.options;
return this.holdTime !== null
? [Infinity, 0]
: totalDuration === Infinity
? [
startTime + delay / speed,
(this.resolvedDuration * (repeatType === "loop" ? 1 : 2)) /
speed || 1,
]
: [startTime + (delay + totalDuration) / speed, 0];
}
/**
* Whether this is running, or paused, on the compositor.
*/
isAccelerated() {
const { group, state } = this;
return (group?.tracks.has(this) &&
!group.isJS &&
this.canAccelerate &&
(state === "running" || state === "paused"));
}
/**
* The value now, while this runs on the compositor and so doesn't
* set its motion value every frame.
*/
liveValue() {
return this.isAccelerated() ? this.sampleAt(time.now()) : undefined;
}
/**
* If this moves between two keyframes only, holding still either side
* of them, as each value of a sequence segment does, when it starts
* and stops moving, and its easing: as a string if WAAPI runs it
* exactly, otherwise its function, to trace.
*/
tween() {
const { keyframes: keyframes$1, type, repeat, delay = 0 } = this.options;
let span = -1;
if (repeat || type !== keyframes || this.holdTime !== null) {
return;
}
for (let i = 1; i < keyframes$1.length; i++) {
if (keyframes$1[i] !== keyframes$1[i - 1]) {
if (span >= 0)
return;
span = i - 1;
}
}
let { ease } = this.options;
if (isEasingArray(ease))
ease = ease[span];
const offsets = this.offsets();
const at = (i) => this.startTime +
(delay + offsets[i] * this.totalDuration) / this.speed;
if (span >= 0) {
const easing = isBezierDefinition(ease)
? cubicBezierAsString(ease)
: isNativeEasing(ease)
? supportedWaapiEasing[ease]
: easingDefinitionToFunction(ease);
/**
* An easing that doesn't run from 0 to 1 doesn't move between
* the keyframes, so is traced from the values it gives instead.
*/
return [
at(span),
at(span + 1),
typeof easing === "string" ||
(isNear(easing(0), 0) && isNear(easing(1), 1))
? easing
: undefined,
];
}
}
/**
* The timestamps between two timestamps where this reaches a
* keyframe or starts a repeat, where it can turn or jump.
*/
boundaries(from, to) {
const { startTime, speed, resolvedDuration, options } = this;
const { delay = 0, repeat = 0, repeatDelay = 0, repeatType, type, } = options;
const offsets = (type || keyframes) === keyframes
? this.offsets()
: [0, 1];
const start = startTime + delay / speed;
const result = [];
if (this.holdTime !== null || !(resolvedDuration > 0))
return result;
for (let i = Math.max(0, Math.floor(((from - start) * speed) / resolvedDuration)); i <= repeat; i++) {
const iterationStart = start + (i * resolvedDuration) / speed;
if (iterationStart > to)
break;
for (const offset of offsets) {
const t = iterationStart +
((i % 2 && repeatType && repeatType !== "loop"
? 1 - offset
: offset) *
(resolvedDuration - repeatDelay)) /
speed;
t > from && t < to && result.push(t);
}
}
return result;
}
/**
* The value at a timestamp, as a number.
*/
numberAt(timestamp) {
return parseFloat(this.sampleAt(timestamp, true));
}
/**
* Write the current value, and velocity, to the motion value.
*/
sync() {
const now = time.now();
const value = this.sampleAt(now, true);
/**
* A held value doesn't move, so there's no need to sample it
* twice.
*/
this.options.motionValue.setWithVelocity(this.holdTime === null
? this.sampleAt(now - sampleDelta, true)
: value, value, sampleDelta);
}
play() {
/**
* A stopped animation (one that was interrupted) can't play again,
* so it mustn't join the group.
*/
if (this.isStopped)
return;
(this.group || createGroup(getOwner(this.options))).tracks.add(this);
super.play();
}
pause() {
super.pause();
this.isAccelerated() && this.group.schedule(false);
}
/**
* JSAnimation finishes on its next tick, which only happens once the
* group is on the main thread, so finish now. That includes an
* animation that can't be accelerated but reached its end before the
* group could move there.
*/
complete() {
super.complete();
const { group } = this;
group && !group.isJS && this.tick(time.now());
}
/**
* Without a tick to rebase it, a held time still includes the delay,
* so take it off here, as a JS animation does on its next tick.
*/
get time() {
if (this.isAccelerated()) {
return millisecondsToSeconds(clamp(0, this.totalDuration, this.at(time.now()) - (this.options.delay || 0)));
}
return super.time;
}
/**
* Calls JSAnimation's setter directly, as `super.time =` is a slow
* path in V8 and this runs for every animation of a seeked sequence.
*/
set time(newTime) {
(setTime ?? (setTime = Object.getOwnPropertyDescriptor(JSAnimation.prototype, "time").set)).call(this, newTime);
}
/**
* Scroll timelines set time every frame, so run these on the main
* thread.
*/
attachTimeline(timeline) {
this.canAccelerate = false;
this.group?.demote(true);
return super.attachTimeline(timeline);
}
}
/**
* Groups that build straight after the current task build in one
* microtask, rather than one each.
*/
let builds;
const buildSoon = (build) => {
builds ||
queueMicrotask(() => {
const queue = builds;
builds = undefined;
queue.forEach((build) => build());
});
(builds || (builds = [])).push(build);
};
function createGroup(owner) {
const element = owner.current;
const tracks = new Set();
const byKey = new Map();
/**
* Transforms that aren't animating, as built into the WAAPI animation.
*/
let statics = {};
/**
* The transforms in the WAAPI animation, in order.
*/
let keys = [];
let built = new Map();
let segments = [];
let needsBuild = false;
let isScheduled = false;
let isEnded = false;
let hasChurned = false;
let lastChurn = -Infinity;
let wake;
let wakeAt = Infinity;
/**
* While waiting to build a tween that starts later, how long after
* the last build to build it.
*/
let buildAt;
const has = (key) => {
for (const track of tracks)
if (track.name === key)
return true;
};
/**
* The transform values at a timestamp or, without one, from the
* motion values. Animations that don't repeat forever are sampled no
* earlier than settled, so a loop that starts from an earlier cycle
* boundary holds them at their end.
*/
const valuesAt = (timestamp, settled) => {
const values = {};
for (const key of timestamp === undefined ? transformPropOrder : keys) {
const track = byKey.get(key);
const value = timestamp === undefined
? read(owner, key)
: track
? track.sampleAt(settled > timestamp && !track.timing()[1]
? settled
: timestamp)
: statics[key];
if (value !== undefined)
values[key] = value;
}
return values;
};
/**
* Build a transform from values in transformPropOrder. For a keyframe,
* every key is written, even at its default, so each keyframe has the
* same list of functions and the browser interpolates them one by one.
* As an inline style, defaults are left out, as buildTransform does.
*/
const toTransform = (values, isStyle) => {
let transform = "";
for (const key in values) {
const value = values[key];
if (!isStyle || !isDefaultTransform(key, value)) {
transform += transformFunction(key, value) + " ";
}
}
return (isStyle ? transform.trim() : transform) || "none";
};
/**
* Build the transform at a timestamp.
*/
const compose = (timestamp, settled) => toTransform(valuesAt(timestamp, settled));
/**
* Write the transform as an inline style, as the renderer would.
*/
const commit = (values = valuesAt()) => {
element.style.transform = toTransform(values, true);
};
/**
* Cancel a WAAPI animation and remove its handlers, as WebKit never
* collects an animation once it has run, nor anything its handlers
* reference (#4034).
*/
const cancel = (animation) => {
animation.onfinish = animation.oncancel = null;
animation.cancel();
};
/**
* Cancel the WAAPI animations, by default first committing the
* transform. The renderer writes its own version on its next render.
*/
const stop = (shouldCommit = true) => {
clearTimeout(wake);
wakeAt = Infinity;
buildAt = undefined;
shouldCommit && commit();
segments.forEach(({ animation }) => cancel(animation));
segments = [];
};
/**
* A WAAPI animation that add() starts by setting its startTime. Playing
* it first, as element.animate() does, would leave WebKit holding that
* play's ready time once startTime ends the play, and it uses that for
* the next updatePlaybackRate(), so the animation would jump.
*/
const create = (keyframes, options) => new Animation(new KeyframeEffect(element, keyframes, options));
const add = (animation, offset, end = Infinity) => {
const segment = { animation, offset, end };
animation.startTime = time.now() + offset;
/**
* Animations finish when the WAAPI animation reaches their end.
* Once a loop has taken over, the finished animation isn't needed.
*/
if (end < Infinity) {
animation.onfinish = () => {
segment.isDone = true;
if (segments.some((s) => s.end === Infinity)) {
cancel(animation);
segments = segments.filter((s) => s !== segment);
}
group.schedule(false);
};
}
/**
* Something else cancelled it, so carry on on the main thread.
*/
animation.oncancel = () => segments.includes(segment) && group.demote();
segments.push(segment);
};
/**
* Where a value jumps between two timestamps, as when a repeat starts
* again: the timestamps either side of the jump, or none if it moves
* there continuously.
*/
const jumpAt = (value, a, b) => {
const change = (x, y) => Math.abs(value(y) - value(x));
const size = change(a, b);
while (b - a > 0.01) {
const mid = (a + b) / 2;
change(a, mid) > change(mid, b) ? (b = mid) : (a = mid);
}
return change(a, b) > size / 2 ? [a, b] : [];
};
/**
* If every moving animation follows the same eased progress between
* two timestamps, the easing that describes it.
*/
const getEasing = (moving, from, to, bounds) => {
const samples = countSamples(from, to);
/**
* Too many keyframes or repeats to follow with this many samples.
*/
if (bounds.length * 4 > samples)
return;
/**
* First check half way, where values with different easings
* usually differ, before sampling every value many times.
*/
let quick;
for (const track of moving) {
const first = track.numberAt(from);
const p = (track.numberAt((from + to) / 2) - first) /
(track.numberAt(to) - first);
/**
* NaN if it doesn't move.
*/
if (p !== p)
continue;
if (quick === undefined)
quick = p;
else if (!isNear(p, quick))
return;
}
const at = spread(from, to, samples).concat(bounds).sort(byTime);
const position = (t) => progress(from, to, t);
let shared;
let progressAt;
for (const track of moving) {
const first = track.numberAt(from);
const changes = at.map((t) => track.numberAt(t) - first);
const end = changes[changes.length - 1];
/**
* A value that ends only a tiny way from where it started, as
* a round trip does if it's built just after it starts, or
* from a float residue, ends where it started. Dividing by
* that tiny range would stretch the easing to values the
* compositor can't render.
*/
const range = changes.some((change) => Math.abs(change) * easingTolerance > Math.abs(end))
? 0
: end;
const progress = changes.map((change) => range ? change / range : change);
/**
* The first value that moves sets the easing, and every other
* one must follow it. A value that doesn't change, like one
* still in its delay, fits any easing. One that changes but
* ends where it started fits none.
*/
if (range && !shared) {
shared = progress;
progressAt = (t) => (track.numberAt(t) - first) / range;
}
else if (progress.some((p, i) => !isNear(p, range && shared[i]))) {
return;
}
}
if (!shared || shared.every((p, i) => isNear(p, position(at[i])))) {
return "linear";
}
/**
* A repeat that starts again jumps back, which takes two points at
* the same position.
*/
const points = [];
const point = (t, p = progressAt(t)) => points.push(round(p) + " " + round(position(t) * 100) + "%");
shared.forEach((p, i) => {
i &&
Math.abs(p - shared[i - 1]) > 0.2 &&
jumpAt(progressAt, at[i - 1], at[i]).forEach((t) => point(t));
point(at[i], p);
});
return "linear(" + points + ")";
};
/**
* Sample timestamps between two timestamps, closer together where the
* moving animations curve.
*/
const sample = (moving, from, to, bounds) => {
const times = [from];
/**
* A linear tween is straight between its keyframes, so only the
* other values can curve, and a tween only while it moves.
*/
const curved = [];
let curveFrom = to;
let curveTo = from;
for (const track of moving) {
const tween = track.tween();
if (tween?.[2] !== "linear") {
const [trackEnd, cycle] = track.timing();
const [a, b] = tween || [from, cycle ? to : trackEnd];
curveFrom = Math.min(curveFrom, a);
curveTo = Math.max(curveTo, b);
curved.push(track);
}
}
curveFrom = Math.max(from, curveFrom);
curveTo = Math.min(to, curveTo);
/**
* Values that aren't numbers, like calc(), are left to the
* browser to interpolate.
*/
const isStraight = (a, b) => curved.every((track) => !(Math.abs(track.numberAt((a + b) / 2) -
(track.numberAt(a) + track.numberAt(b)) / 2) >
(track.name.startsWith("scale")
? scaleTolerance
: sampleTolerance)));
const split = (a, b, depth) => {
if (b - a < 1 || isStraight(a, b)) {
times.push(b);
}
else if (depth < 4) {
split(a, (a + b) / 2, depth + 1);
split((a + b) / 2, b, depth + 1);
}
else {
/**
* Keyframes either side of a jump, like a repeat starting
* again, so it doesn't play as a quick sweep.
*/
for (const track of moving) {
times.push(...jumpAt((t) => track.numberAt(t), a, b));
}
times.push(b);
}
};
/**
* Split at least every 100ms where a value can curve, and at every
* keyframe and repeat, either side of where a value can jump.
*/
const ends = curveTo > curveFrom
? spread(curveFrom, curveTo, Math.ceil((curveTo - curveFrom) / 100))
: [];
ends.push(to);
for (const t of bounds)
ends.push(t, Math.min(t + 0.01, to));
let start = from;
for (const end of ends.sort(byTime)) {
end > start && split(start, end, 0);
start = end;
}
return times.sort(byTime);
};
/**
* If every moving animation is the same tween, started together, as
* happens for the values of one animate() call or transition, when
* they move and their native easing.
*/
const getTween = (moving) => {
let shared;
for (const track of moving) {
const tween = track.tween();
if (!tween ||
(shared &&
(Math.abs(tween[0] - shared[0]) > 1 ||
Math.abs(tween[1] - shared[1]) > 1))) {
return;
}
/**
* Easings that differ, like the springs a sequence makes for
* each value, can still be the same curve.
*/
shared = [
tween[0],
tween[1],
!shared || tween[2] === shared[2] ? tween[2] : undefined,
];
}
return shared;
};
/**
* A WAAPI animation of the composed transform between two timestamps.
*/
const run = (moving, from, to, nativeEasing, options = { fill: "both" }, settled) => {
/**
* With an easing given, there's nothing to sample, so no need
* for the boundaries.
*/
const bounds = nativeEasing
? []
: moving.flatMap((track) => track.boundaries(from, to));
const easing = nativeEasing || getEasing(moving, from, to, bounds);
const times = easing ? [from, to] : sample(moving, from, to, bounds);
const transform = times.map((t) => compose(t, settled));
let keyframes = { transform };
if (!easing) {
keyframes.offset = times.map((t) => progress(from, to, t));
}
/**
* If nothing moves, don't hold a transform, which, even at its
* default, would make the element a containing block. The empty
* animation still finishes the animations.
*/
if (transform.every((t) => t === transform[0])) {
commit(valuesAt(from, settled));
keyframes = null;
}
return create(keyframes, {
...options,
duration: to - from,
easing: easing || "linear",
});
};
/**
* Check whether it's time to build in this long. A check that's due
* sooner is kept, and checks again if it's early, so pausing and
* playing don't each replace a timer. One due later, after a seek or
* speed change while held, is replaced.
*/
const wakeIn = (delay) => {
const at = time.now() + delay;
if (at < wakeAt) {
clearTimeout(wake);
wakeAt = at;
wake = setTimeout(() => {
wakeAt = Infinity;
group.schedule(false);
}, delay);
}
};
/**
* Replace the WAAPI animations with ones built from now. Returns
* undefined if the animations can't be composed.
*/
const animate = (now) => {
/**
* The finite part runs until the last finite animation (or the
* last delay) ends. Infinitely repeating animations then continue
* as one looping WAAPI animation, which needs them to share a
* cycle.
*/
let end = now;
let cycle = 0;
let cycleStart = 0;
const moving = [];
const looping = [];
const ends = new Set();
byKey.clear();
built = new Map();
for (const track of tracks) {
byKey.set(track.name, track);
const [trackEnd, trackCycle] = track.timing();
const isHeld = trackEnd === Infinity;
built.set(track, [track.at(now), track.speed, isHeld]);
if (isHeld)
continue;
if (track.speed < 0)
return;
if (trackCycle) {
if (cycle) {
const phase = (trackEnd - cycleStart) / trackCycle;
if (Math.abs(trackCycle - cycle) > 1 ||
Math.abs(phase - Math.round(phase)) * cycle > 1) {
return;
}
}
else {
cycle = trackCycle;
cycleStart = trackEnd;
}
looping.push(track);
}
else {
ends.add(trackEnd);
}
moving.push(track);
end = Math.max(end, trackEnd);
}
statics = {};
/**
* Transforms that aren't animating are left out at their default.
*/
keys = transformPropOrder.filter((key) => {
if (byKey.has(key))
return true;
const value = (statics[key] = read(owner, key));
return value !== undefined && !isDefaultTransform(key, value);
});
stop(false);
/**
* Values that share one tween are one WAAPI animation of it, with
* two keyframes and its own easing, from when they started.
*/
const tween = !cycle && getTween(moving);
try {
if (end > now) {
let easing;
if (tween && tween[1] > now) {
const [from, to, ease] = tween;
/**
* Until it's built, the motion values hold where it
* starts, for the renderer to write.
*/
if (from - now > buildAhead) {
moving.forEach((track) => track.sync());
wakeIn((buildAt = from - now - buildAhead));
return true;
}
easing =
typeof ease === "string"
? ease
: ease
? trace(ease, countSamples(from, to))
: getEasing(moving, from, to, []);
/**
* It holds its last keyframe until the animations end.
*/
easing &&
add(run(moving, from, to, easing, {
fill: "both",
endDelay: end - to,
}), from - now, end - from);
}
easing || add(run(moving, now, end), 0, end - now);
/**
* Animations that end sooner finish on the events of
* empty animations that end with them.
*/
ends.forEach((trackEnd) => trackEnd < end - 1 &&
add(create(null, { duration: trackEnd - now }), 0, trackEnd - now));
}
if (cycle) {
/**
* Build one cycle from a cycle boundary, so it repeats
* seamlessly, and start it part way through.
*/
const from = cycleStart + Math.floor((end - cycleStart) / cycle) * cycle;
add(run(looping, from, from + cycle, undefined, {
iterations: Infinity,
iterationStart: (end - from) / cycle,
/**
* It must not fill backwards over the finite part
* that runs before it.
*/
fill: "forwards",
}, end), end - now);
}
}
catch {
return;
}
/**
* Everything is held, so hold it with an inline style instead.
*/
segments.length || stop();
return true;
};
/**
* If every animation has been paused, played, seeked or sped up
* together since the WAAPI animation was built, do the same to it.
* Returns false if it needs rebuilding instead. Seeking to before a
* tween starts rebuilds, so a running group seeked every frame still
* moves to the main thread.
*/
const retime = (now, isSeek) => {
let elapsed;
let rate = 0;
for (const track of tracks) {
const state = built.get(track);
if (!state)
return false;
const [builtTime, builtSpeed, wasHeld] = state;
const trackTime = track.at(now);
const isHeld = track.timing()[0] === Infinity;
/**
* With nothing on the compositor, a held animation is set
* where it's held, so it can be seeked as often as needed.
*/
if (wasHeld) {
if (isHeld && (trackTime === builtTime || !segments.length)) {
continue;
}
return false;
}
const trackElapsed = (trackTime - builtTime) / builtSpeed;
const trackRate = isHeld ? 0 : track.speed / builtSpeed;
if (elapsed === undefined) {
elapsed = trackElapsed;
rate = trackRate;
}
else if (Math.abs(trackElapsed - elapsed) > 1 ||
trackRate !== rate) {
return false;
}
}
if (elapsed === undefined)
return true;
/**
* Waiting to build a tween that starts later. Until then the
* values hold where they start, so only when to build changes.
* Held, it has nothing on the compositor, so it can be seeked.
*/
if (!segments.length) {
if (buildAt === undefined || (isSeek && rate))
return false;
if (rate) {
const delay = (buildAt - elapsed) / rate;
if (delay < 1)
return false;
wakeIn(delay);
}
return true;
}
/**
* Only a tween is built to start later, and it holds its first
* keyframe until then, so it can be paused and played before it
* starts.
*/
for (const { offset, end, isDone } of segments) {
const local = elapsed - offset;
if (end < Infinity &&
(isDone
? local < end - 1
: (local < 0 && (isSeek || offset <= 0)) || local >= end)) {
return false;
}
}
for (const { animation, offset, isDone } of segments) {
if (isDone)
continue;
const local = elapsed - offset;
if (rate &&
animation.updatePlaybackRate &&
animation.playState === "running" &&
Math.abs(Number(animation.currentTime) - local) < 20) {
/**
* Only the speed changed. Setting playbackRate and
* startTime restarts a running compositor animation, which
* can render the underlying value for a frame.
*/
animation.playbackRate !== rate &&
animation.updatePlaybackRate(rate);
}
else if (rate) {
animation.playbackRate !== rate &&
(animation.playbackRate = rate);
animation.startTime = now - local / rate;
}
else {
/**
* Hold it without pause(), whose pending pause setting
* currentTime would end at once, leaving WebKit holding its
* ready time (see create()).
*/
animation.startTime = null;
animation.currentTime = local;
}
}
return true;
};
/**
* If no animations are left, write where they got to and cancel the
* WAAPI animations. Returns whether the group ended.
*/
const end = () => {
if (tracks.size)
return false;
isEnded = true;
/**
* The element may have a newer group if this one was detached
* (see handOffElementState).
*/
getGroup(element) === group && transformGroups.delete(element);
group.isJS || stop();
return true;
};
const build = () => {
const now = time.now();
/**
* Finish the animations that have reached their end, which the
* WAAPI animation has already rendered.
*/
if (!group.isJS) {
group.isFinishing = true;
tracks.forEach((track) => {
const [trackEnd, trackCycle] = track.timing();
if (track.state === "running" &&
track.speed > 0 &&
!trackCycle &&
trackEnd <= now + 1) {
track.complete();
/**
* From now it's checked for outside writes like any
* other static transform. If the WAAPI animation never
* rendered it (it was instant), rebuild with its value.
*/
statics[track.name] = read(owner, track.name);
built.has(track) || (needsBuild = true);
}
});
group.isFinishing = false;
}
isScheduled = false;
if (isEnded)
return;
tracks.forEach((track) => hasEnded(track) && tracks.delete(track));
if (end())
return;
for (const track of tracks) {
if (!track.canAccelerate)
return group.demote(true);
}
/**
* Everything that kept the group on the main thread has finished,
* so move the remaining animations back to the compositor.
*/
if (group.isJS) {
group.isJS = false;
needsBuild = true;
tracks.forEach((track) => track.stopJS());
}
const shouldBuild = needsBuild;
const churned = hasChurned;
needsBuild = hasChurned = false;
if (shouldBuild || !retime(now, churned)) {
if (churned) {
if (now - lastChurn < churnWindow)
return group.demote();
lastChurn = now;
}
if (!animate(now))
return group.demote();
}
else if (buildAt !== undefined && !churned) {
/**
* Still waiting to build, so the values still hold where
* they start.
*/
return;
}
/**
* Held animations don't move, so set their motion values to where
* they're held, as a JSAnimation would on its next tick. Pausing
* and seeking schedule this, so it's once however many there are.
*/
tracks.forEach((track) => track.timing()[0] === Infinity && track.sync());
};
const group = {
tracks,
isJS: false,
isFinishing: false,
schedule(build_, isChurn) {
if (isEnded)
return;
needsBuild || (needsBuild = build_);
hasChurned || (hasChurned = !!isChurn);
/**
* Build in this frame's render step, so it's in place before
* anything reads the DOM in postRender, or straight after the
* current task outside of a frame.
*/
if (!isScheduled) {
isScheduled = true;
frameData.isProcessing
? frame.render(build, false, true)
: buildSoon(build);
}
},
/**
* End the group now, rather than when it next builds, so the
* element's transform is the caller's again as soon as its last
* animation is stopped.
*/
release(track) {
/**
* Only drop the stopped animation. Others can be idle because
* they're part way through play(), and are about to run.
*/
hasEnded(track) && tracks.delete(track);
end() || group.schedule(true);
},
/**
* An outside write. Rebuild once, but if it keeps happening, move
* to the main thread.
*/
check() {
if (group.isJS)
return;
for (const key in statics) {
if (read(owner, key) !== statics[key] && !has(key)) {
return group.schedule(true, true);
}
}
},
demote(canReturn) {
if (group.isJS || isEnded)
return;
/**
* Unless only another animation is holding the group on the
* main thread, these animations stay there until they end.
*/
tracks.forEach((track) => {
track.isAccelerated() && track.sync();
canReturn || (track.canAccelerate = false);
});
group.isJS = true;
built = new Map();
stop();
tracks.forEach((track) => track.state === "running" && track.js?.start());
return true;
},
};
transformGroups.set(element, group);
return group;
}
export { TransformAnimation, canGroupTransform };
//# sourceMappingURL=TransformAnimation.mjs.map