motion-dom
Version:
155 lines (152 loc) • 5.37 kB
JavaScript
import { time } from '../frameloop/sync-time.mjs';
import { calcGeneratorVelocity } from './generators/utils/velocity.mjs';
import { resolveStartTime } from './utils/resolve-start-time.mjs';
import { WithPromise } from './utils/WithPromise.mjs';
import { frame, cancelFrame } from '../frameloop/frame.mjs';
/**
* Every running follower ticks from this one frame callback.
*/
const active = new Set();
const tickAll = ({ timestamp }) => {
active.forEach((animation) => animation.tick(timestamp));
};
/**
* A lean animation for values that follow a target (`followValue`,
* `springValue`): a single numeric generator, retargeted in place as
* the target moves and ticked from a frame callback shared by every
* follower. Only implements the controls `MotionValue.animation`
* exposes. Repeat options are ignored: a follower only ever heads for
* its latest target.
*/
class FollowAnimation extends WithPromise {
constructor(options) {
super();
this.state = "idle";
this.startTime = 0;
this.currentTime = 0;
/**
* Whether the first tick has fired `onPlay`.
*/
this.started = false;
/**
* A target set since the last tick, applied at the next one so any
* number of sets per frame cost one retarget.
*/
this.hasNextTarget = false;
this.nextTarget = 0;
/**
* Bound to the instance so `animation.stop` can be passed around.
*/
this.stop = () => {
if (this.state === "idle")
return;
this.teardown();
this.options.onStop?.();
};
this.options = options;
this.factory = options.type;
this.generator = this.factory(options);
const { driver } = options;
if (driver)
this.driver = driver((timestamp) => this.tick(timestamp));
this.startTime = this.now();
this.state = "running";
if (this.driver) {
this.driver.start();
}
else {
active.size || frame.update(tickAll, true);
active.add(this);
}
}
/**
* Aim at a new target from wherever the animation has reached by the
* next tick. `velocity` fixes the velocity to steer with, otherwise
* the generator's is used.
*/
setTarget(target, velocity) {
this.nextTarget = target;
this.nextVelocity = velocity;
this.hasNextTarget = true;
}
/**
* Steer towards new keyframes from the current position and velocity.
* Springs are updated in place; other generators are recreated.
*/
retarget(keyframes, velocity) {
const { options, generator } = this;
options.keyframes = keyframes;
options.velocity = velocity;
this.startTime = this.now();
this.currentTime = 0;
if (generator.retarget) {
generator.retarget(keyframes, velocity);
}
else {
this.generator = this.factory(options);
}
}
tick(timestamp) {
const { options, hasNextTarget: retargeted } = this;
const { delay = 0, onUpdate, onPlay } = options;
// Retargets happen on a tick, so only the first start can lag
if (!this.started) {
this.startTime = resolveStartTime(this.startTime, timestamp);
}
const elapsed = Math.round(timestamp - this.startTime) - delay;
const t = (this.currentTime = Math.max(0, elapsed));
const state = this.generator.next(t);
// Hold the origin through the delay, even if the generator is already at rest
const value = elapsed < 0 ? options.keyframes[0] : state.value;
if (retargeted) {
/**
* Steer from where the current trajectory has reached this
* frame, then render that position as the new origin.
*/
this.hasNextTarget = false;
const { keyframes } = options;
keyframes[0] = value;
keyframes[1] = this.nextTarget;
this.retarget(keyframes, this.nextVelocity ?? this.getGeneratorVelocity());
}
if (retargeted || !this.started) {
this.started = true;
onPlay?.();
}
/**
* Callbacks may stop this animation or set a new target, so check
* the live state rather than what was read at the top of the tick.
*/
if (this.state !== "running")
return;
onUpdate?.(value);
if (state.done &&
elapsed >= 0 &&
!retargeted &&
!this.hasNextTarget &&
this.state === "running") {
this.notifyFinished();
this.teardown();
this.state = "finished";
options.onComplete?.();
}
}
getGeneratorVelocity() {
return calcGeneratorVelocity(this.generator, this.currentTime, this.options.velocity);
}
now() {
return this.driver ? this.driver.now() : time.now();
}
teardown() {
this.state = "idle";
if (this.driver) {
this.driver.stop();
}
else {
active.delete(this);
active.size || cancelFrame(tickAll);
}
}
}
export { FollowAnimation };
//# sourceMappingURL=FollowAnimation.mjs.map