Implement renderer-backed Web Animations

This commit is contained in:
SGavrl committed 2026-08-04 08:27:12 +00:00
1 parent 0a8c0feb06
commit 2b92236a2f
6 files changed
+848 -12

No files matched your search

+297 -11
View File
@@ -47,9 +47,10 @@
// Pre-declaring them non-enumerable here is enough -- per the note above,
// the later `globalThis.X = X` assignments only update the value.
'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType',
'Animation', 'KeyframeEffect', 'DocumentTimeline',
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
'CSSStyleDeclaration', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
'MessageChannel', 'MessagePort', 'CustomElementRegistry',
'MessageChannel', 'MessagePort', 'BroadcastChannel', 'CustomElementRegistry',
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
'HTMLAudioElement', 'WebGL2RenderingContext',
];
@@ -2093,6 +2094,281 @@ class NamedNodeMap {
}
globalThis.NamedNodeMap = NamedNodeMap;
let _waapiNextId = 1;
const _waapiAnimations = new Set();
function _normalizeWaapiKeyframes(input) {
let frames;
if (Array.isArray(input)) {
frames = input.map(frame => ({ ...(frame || {}) }));
} else if (input && typeof input === 'object') {
const properties = Object.keys(input).filter(name => name !== 'offset' && name !== 'easing' && name !== 'composite');
const count = Math.max(1, ...properties.map(name => Array.isArray(input[name]) ? input[name].length : 1));
frames = Array.from({ length: count }, (_, index) => {
const frame = {};
for (const name of properties) {
const values = Array.isArray(input[name]) ? input[name] : [input[name]];
frame[name] = values[Math.min(index, values.length - 1)];
}
if (Array.isArray(input.offset)) frame.offset = input.offset[Math.min(index, input.offset.length - 1)];
return frame;
});
} else {
throw new TypeError('Keyframes must be an object or an array');
}
if (frames.length === 0) return [];
let previous = -Infinity;
for (let i = 0; i < frames.length; i++) {
if (frames[i].offset != null) {
const offset = Number(frames[i].offset);
if (!Number.isFinite(offset) || offset < 0 || offset > 1 || offset < previous) {
throw new TypeError('Invalid keyframe offset');
}
frames[i].offset = offset;
previous = offset;
}
}
if (frames[0].offset == null) frames[0].offset = 0;
if (frames[frames.length - 1].offset == null) frames[frames.length - 1].offset = 1;
let anchor = 0;
while (anchor < frames.length - 1) {
let next = anchor + 1;
while (next < frames.length && frames[next].offset == null) next++;
const from = frames[anchor].offset;
const to = frames[next].offset;
for (let i = anchor + 1; i < next; i++) {
frames[i].offset = from + (to - from) * ((i - anchor) / (next - anchor));
}
anchor = next;
}
return frames.map(frame => {
const normalized = { offset: frame.offset };
if (frame.opacity != null) {
const value = Number(frame.opacity);
if (Number.isFinite(value)) normalized.opacity = Math.max(0, Math.min(1, value));
}
if (frame.transform != null) normalized.transform = String(frame.transform);
return normalized;
}).filter(frame => frame.opacity != null || frame.transform != null);
}
function _normalizeWaapiTiming(options) {
if (typeof options === 'number') options = { duration: options };
options = options || {};
const duration = options.duration === 'auto' || options.duration == null ? 0 : Number(options.duration);
const delay = options.delay == null ? 0 : Number(options.delay);
const iterations = options.iterations == null ? 1 : Number(options.iterations);
if (!Number.isFinite(duration) || duration < 0 || !Number.isFinite(delay)
|| !Number.isFinite(iterations) || iterations < 0) {
throw new TypeError('Invalid animation timing');
}
const easing = options.easing == null ? 'linear' : String(options.easing).trim();
const namedBezier = {
'ease': [0.25, 0.1, 0.25, 1],
'ease-in': [0.42, 0, 1, 1],
'ease-out': [0, 0, 0.58, 1],
'ease-in-out': [0.42, 0, 0.58, 1],
};
let easingBezier = easing === 'linear' ? null : namedBezier[easing];
let linearEasing = null;
if (easing.startsWith('linear(') && easing.endsWith(')')) {
const values = easing.slice(7, -1).split(',').map(value => Number(value.trim()));
if (values.length >= 2 && values.every(Number.isFinite)) linearEasing = values;
}
if (easingBezier === undefined) {
const match = /^cubic-bezier\(\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)\s*\)$/.exec(easing);
if (match) {
easingBezier = match.slice(1).map(Number);
if (!easingBezier.every(Number.isFinite) || easingBezier[0] < 0 || easingBezier[0] > 1
|| easingBezier[2] < 0 || easingBezier[2] > 1) easingBezier = undefined;
}
}
if (linearEasing) easingBezier = null;
// steps() and linear() with explicit stop positions remain explicit
// unsupported surfaces rather than being silently approximated.
if (easingBezier === undefined) throw new TypeError('Unsupported animation easing: ' + easing);
const fill = ['none', 'forwards', 'backwards', 'both'].includes(options.fill) ? options.fill : 'none';
const direction = ['normal', 'reverse', 'alternate', 'alternate-reverse'].includes(options.direction)
? options.direction : 'normal';
return { duration, delay, iterations, fill, direction, easing, easingBezier, linearEasing };
}
class KeyframeEffect {
constructor(target, keyframes, options) {
if (!(target instanceof Element)) throw new TypeError('KeyframeEffect target must be an Element');
this.target = target;
this._keyframes = _normalizeWaapiKeyframes(keyframes);
this._timing = _normalizeWaapiTiming(options);
}
getKeyframes() { return this._keyframes.map(frame => ({ ...frame, computedOffset: frame.offset, easing: 'linear', composite: 'auto' })); }
getTiming() {
const timing = this._timing;
return {
delay: timing.delay, endDelay: 0, fill: timing.fill,
iterationStart: 0, iterations: timing.iterations,
duration: timing.duration, direction: timing.direction, easing: timing.easing,
};
}
getComputedTiming() {
const animation = this._animation;
const local = animation ? animation.currentTime : 0;
const activeDuration = this._timing.duration * this._timing.iterations;
const endTime = this._timing.delay + activeDuration;
const progress = activeDuration > 0 ? Math.max(0, Math.min(1, (local - this._timing.delay) / activeDuration)) : null;
return {
...this.getTiming(), activeDuration, endTime, localTime: local,
progress, currentIteration: progress == null ? null : Math.min(this._timing.iterations, 1),
};
}
}
class Animation {
constructor(effect = null, timeline = globalThis.document?.timeline || null) {
this.id = '';
this.effect = effect;
this.timeline = timeline;
this.onfinish = null;
this.oncancel = null;
this._nativeId = _waapiNextId++;
this._registered = false;
this._playState = 'idle';
this._holdTime = 0;
this._startTime = null;
this._finishTimer = null;
this.ready = Promise.resolve(this);
this._resetFinishedPromise();
if (effect) effect._animation = this;
}
_resetFinishedPromise() {
this.finished = new Promise((resolve, reject) => {
this._resolveFinished = resolve;
this._rejectFinished = reject;
});
// Browser code commonly ignores the rejected cancel promise.
this.finished.catch(() => {});
}
_native(action, value = 0) {
try {
const changed = !!Deno.core.ops.op_waapi_control?.(this._nativeId, action, Number(value) || 0);
if (changed) _domMutationEpoch++;
return changed;
}
catch (_) { return false; }
}
_register() {
if (this._registered || !this.effect) return this._registered;
const input = {
id: this._nativeId,
node: this.effect.target._nid,
keyframes: this.effect._keyframes,
...this.effect._timing,
};
try { this._registered = !!Deno.core.ops.op_waapi_create?.(JSON.stringify(input)); }
catch (_) { this._registered = false; }
if (this._registered) {
_waapiAnimations.add(this);
_domMutationEpoch++;
}
return this._registered;
}
_scheduleFinish() {
if (this._finishTimer != null) clearTimeout(this._finishTimer);
if (this._playState !== 'running' || !this.effect) return;
const timing = this.effect._timing;
const end = Math.max(0, timing.delay + timing.duration * timing.iterations);
const remaining = Math.max(0, end - this.currentTime);
this._finishTimer = setTimeout(() => this.finish(), remaining);
}
get playState() { return this._playState; }
get currentTime() {
if (this._playState === 'running' && this._startTime != null) return Math.max(0, performance.now() - this._startTime);
return this._holdTime;
}
set currentTime(value) {
const time = Math.max(0, Number(value) || 0);
this._holdTime = time;
if (this._playState === 'running') this._startTime = performance.now() - time;
this._native('currentTime', time);
this._scheduleFinish();
}
get startTime() { return this._startTime; }
set startTime(value) {
if (value == null) { this._startTime = null; return; }
const start = Number(value);
if (!Number.isFinite(start)) throw new TypeError('Invalid startTime');
this._startTime = start;
this._holdTime = Math.max(0, performance.now() - start);
this._native('currentTime', this._holdTime);
this._scheduleFinish();
}
play() {
if (!this.effect) return;
if (this._playState === 'finished' || this._playState === 'idle') {
this._holdTime = 0;
if (this._playState === 'finished') this._resetFinishedPromise();
}
this._register();
this._startTime = performance.now() - this._holdTime;
this._playState = 'running';
this._native('play');
this.ready = Promise.resolve(this);
this._scheduleFinish();
}
pause() {
if (this._playState === 'idle') this._register();
this._holdTime = this.currentTime;
this._playState = 'paused';
this._native('currentTime', this._holdTime);
this._native('pause');
if (this._finishTimer != null) clearTimeout(this._finishTimer);
}
finish() {
if (!this.effect) return;
this._register();
const timing = this.effect._timing;
this._holdTime = Math.max(0, timing.delay + timing.duration * timing.iterations);
this._playState = 'finished';
this._native('finish');
if (this._finishTimer != null) clearTimeout(this._finishTimer);
this._resolveFinished(this);
const event = new Event('finish');
this.dispatchEvent(event);
if (typeof this.onfinish === 'function') { try { this.onfinish.call(this, event); } catch (e) { console.error(e); } }
}
cancel() {
if (this._finishTimer != null) clearTimeout(this._finishTimer);
this._native('cancel');
this._registered = false;
this._playState = 'idle';
this._holdTime = 0;
this._startTime = null;
_waapiAnimations.delete(this);
this._rejectFinished(new DOMException('The animation was canceled', 'AbortError'));
const event = new Event('cancel');
this.dispatchEvent(event);
if (typeof this.oncancel === 'function') { try { this.oncancel.call(this, event); } catch (e) { console.error(e); } }
this._resetFinishedPromise();
}
reverse() { throw new DOMException('reverse() is not implemented for this animation', 'NotSupportedError'); }
addEventListener(type, callback, options) { _eventTargetAdd(this, type, callback, options); }
removeEventListener(type, callback, options) { _eventTargetRemove(this, type, callback, options); }
dispatchEvent(event) { return _eventTargetDispatch(this, event); }
}
class DocumentTimeline {
constructor(options = {}) {
this.originTime = Number(options.originTime) || 0;
}
get currentTime() { return performance.now() - this.originTime; }
}
function _animationsForTarget(target) {
return Array.from(_waapiAnimations).filter(animation => {
if (animation.effect?.target !== target || animation.playState === 'idle') return false;
return animation.playState !== 'finished' || animation.effect._timing.fill === 'forwards' || animation.effect._timing.fill === 'both';
});
}
class Element extends Node {
constructor(nid) {
const entry = _customElementConstructionStack[_customElementConstructionStack.length - 1];
@@ -3563,16 +3839,11 @@ class Element extends Node {
}, 0);
}
animate(keyframes, options) {
const duration = typeof options === 'number' ? options : (options?.duration || 0);
return {
finished: Promise.resolve(), currentTime: 0, playState: 'finished',
effect: { getComputedTiming() { return { duration }; } },
cancel(){}, finish(){}, play(){}, pause(){}, reverse(){},
addEventListener(){}, removeEventListener(){},
onfinish: null, oncancel: null,
};
const animation = new Animation(new KeyframeEffect(this, keyframes, options), document.timeline);
animation.play();
return animation;
}
getAnimations() { return []; }
getAnimations() { return _animationsForTarget(this); }
get isConnected() {
const root = this.getRootNode({ composed: true });
return !!root && root.nodeType === 9;
@@ -3816,6 +4087,16 @@ function _throwDocumentDomainSecurityError() {
}
class Document extends Node {
get timeline() {
if (!this._timeline) {
this._timeline = new DocumentTimeline();
}
return this._timeline;
}
getAnimations() {
return Array.from(_waapiAnimations).filter(animation => animation.playState !== 'idle'
&& (animation.playState !== 'finished' || animation.effect?._timing.fill === 'forwards' || animation.effect?._timing.fill === 'both'));
}
get documentElement() { return _wrapEl(+_dom("document_element")); }
get children() {
const root = this.documentElement;
@@ -6655,7 +6936,9 @@ globalThis.getComputedStyle = (el) => {
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
}
const refreshRendered = () => {
if (snapshot.epoch === _domMutationEpoch) return;
const hasRunningAnimation = typeof _animationsForTarget === 'function'
&& _animationsForTarget(el).some(animation => animation.playState === 'running');
if (snapshot.epoch === _domMutationEpoch && !hasRunningAnimation) return;
snapshot.epoch = _domMutationEpoch;
snapshot.rendered = null;
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
@@ -9212,6 +9495,9 @@ globalThis.Document = Document;
// undefined (so `el.style instanceof CSSStyleDeclaration` threw). Assigning here
// only fills the value; the property stays enumerable:false, matching Chrome.
globalThis.CSSStyleDeclaration = CSSStyleDeclaration;
globalThis.Animation = Animation;
globalThis.KeyframeEffect = KeyframeEffect;
globalThis.DocumentTimeline = DocumentTimeline;
globalThis.XPathResult = globalThis.XPathResult || class XPathResult {};
Object.assign(globalThis.XPathResult, {
ANY_TYPE: 0,