Files
2026-10-14 04:20:24 +02:00

150 lines
5.1 KiB
JavaScript

// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
// One element's engine values: lazily allocated per key, retargeted from wherever they are, and
// released together. JavaScript runs only when an animation starts or settles, never per frame.
import {engineConfig, repeatOf} from './transitions.js';
/**
* @typedef {object} Engine
* @property {(initial: number) => object} create
* @property {(value: object, to: number, config: object, done: (finished: boolean) => void) => () => void} animate
* Returns stop(); done(false) when interrupted.
* @property {(value: object, to: number) => void} set
* @property {(value: object) => number} get
* @property {(value: object) => void} destroy
* @typedef {{value: number, transition?: object, delay?: number}} KeyTarget logical value; delay in seconds
*/
function once(fn) {
let called = false;
return finished => {
if (called) return;
called = true;
fn(finished);
};
}
export class MotionValues {
/** @param {Engine} engine @param {number} scale physical px per logical px */
constructor(engine, scale) {
this.engine = engine;
this.scale = scale;
/** @type {Map<string, {value: object, target: number, stop: (() => void) | null, token: object | null,
* waiters: Array<(finished: boolean) => void>}>} */
this.entries = new Map();
this.batch = 0;
}
physical(key, value) {
return key === 'x' || key === 'y' ? value * this.scale : value;
}
/** Allocates the key's value at `initial` (logical) on first use. */
ensure(key, initial) {
if (this.entries.has(key)) return;
this.entries.set(key, {value: this.engine.create(this.physical(key, initial)), target: initial,
stop: null, token: null, waiters: []});
}
/** @returns {Map<string, object>} */
bound() {
return new Map([...this.entries].map(([key, entry]) => [key, entry.value]));
}
/**
* Animates every key whose target changed, then calls `onDone(finished)` once every key in
* `targets` has settled (right away when none is moving). Each call supersedes the previous one:
* its onDone no longer fires, and keys it retargets are interrupted.
* @param {{[key: string]: KeyTarget}} targets
* @param {(finished: boolean) => void} onDone
*/
apply(targets, onDone) {
const batch = ++this.batch;
for (const entry of this.entries.values()) entry.waiters = [];
for (const [key, {value, transition = {}, delay = 0}] of Object.entries(targets)) {
const entry = this.entries.get(key);
if (entry.target !== value) this.start(entry, key, value, transition, delay);
}
const moving = Object.keys(targets).map(key => this.entries.get(key)).filter(entry => entry.stop);
if (!moving.length) {
onDone(true);
return;
}
let pending = moving.length;
let finishedAll = true;
for (const entry of moving) {
entry.waiters.push(finished => {
if (batch !== this.batch) return;
finishedAll &&= finished;
if (--pending === 0) onDone(finishedAll);
});
}
}
start(entry, key, to, transition, delay) {
const previous = entry.stop;
const token = {};
entry.token = token;
entry.stop = null;
entry.target = to;
previous?.();
const stop = this.run(entry, key, to, transition, delay, once(finished => {
if (entry.token !== token) return;
entry.token = null;
entry.stop = null;
const waiters = entry.waiters;
entry.waiters = [];
for (const waiter of waiters) waiter(finished);
}));
if (entry.token === token) entry.stop = stop;
}
run(entry, key, to, transition, delay, done) {
const {count, reverse} = repeatOf(transition);
const config = engineConfig(transition, key, delay, this.scale);
const target = this.physical(key, to);
if (count === 1) {
const stop = this.engine.animate(entry.value, target, config, done);
return () => {
stop();
done(false);
};
}
const from = this.engine.get(entry.value);
let iteration = 0;
let stop = () => {};
let timer;
const step = () => {
const back = reverse && iteration % 2 === 1;
if (!reverse && iteration > 0) this.engine.set(entry.value, from);
const iterationConfig = iteration === 0 ? config : {...config, delay: 0};
iteration++;
stop = this.engine.animate(entry.value, back ? from : target, iterationConfig, finished => {
if (!finished) done(false);
else if (iteration >= count) done(true);
// A short iteration can settle synchronously; deferring keeps the chain off the stack.
else timer = setTimeout(step, 0);
});
};
step();
return () => {
clearTimeout(timer);
stop();
done(false);
};
}
/** Stops every animation and releases the engine values. */
dispose() {
for (const entry of this.entries.values()) {
const stop = entry.stop;
entry.token = null;
entry.stop = null;
stop?.();
this.engine.destroy(entry.value);
}
this.entries.clear();
}
}