Implement renderer-backed Web Animations
This commit is contained in:
1 parent
0a8c0feb06
commit
2b92236a2f
6 files changed
+848
-12
No files matched your search
Vendored
+297
-11
@@ -47,9 +47,10 @@
|
|||||||
// Pre-declaring them non-enumerable here is enough -- per the note above,
|
// Pre-declaring them non-enumerable here is enough -- per the note above,
|
||||||
// the later `globalThis.X = X` assignments only update the value.
|
// the later `globalThis.X = X` assignments only update the value.
|
||||||
'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType',
|
'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType',
|
||||||
|
'Animation', 'KeyframeEffect', 'DocumentTimeline',
|
||||||
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
|
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
|
||||||
'CSSStyleDeclaration', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
|
'CSSStyleDeclaration', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
|
||||||
'MessageChannel', 'MessagePort', 'CustomElementRegistry',
|
'MessageChannel', 'MessagePort', 'BroadcastChannel', 'CustomElementRegistry',
|
||||||
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
|
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
|
||||||
'HTMLAudioElement', 'WebGL2RenderingContext',
|
'HTMLAudioElement', 'WebGL2RenderingContext',
|
||||||
];
|
];
|
||||||
@@ -2093,6 +2094,281 @@ class NamedNodeMap {
|
|||||||
}
|
}
|
||||||
globalThis.NamedNodeMap = 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 {
|
class Element extends Node {
|
||||||
constructor(nid) {
|
constructor(nid) {
|
||||||
const entry = _customElementConstructionStack[_customElementConstructionStack.length - 1];
|
const entry = _customElementConstructionStack[_customElementConstructionStack.length - 1];
|
||||||
@@ -3563,16 +3839,11 @@ class Element extends Node {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
animate(keyframes, options) {
|
animate(keyframes, options) {
|
||||||
const duration = typeof options === 'number' ? options : (options?.duration || 0);
|
const animation = new Animation(new KeyframeEffect(this, keyframes, options), document.timeline);
|
||||||
return {
|
animation.play();
|
||||||
finished: Promise.resolve(), currentTime: 0, playState: 'finished',
|
return animation;
|
||||||
effect: { getComputedTiming() { return { duration }; } },
|
|
||||||
cancel(){}, finish(){}, play(){}, pause(){}, reverse(){},
|
|
||||||
addEventListener(){}, removeEventListener(){},
|
|
||||||
onfinish: null, oncancel: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
getAnimations() { return []; }
|
getAnimations() { return _animationsForTarget(this); }
|
||||||
get isConnected() {
|
get isConnected() {
|
||||||
const root = this.getRootNode({ composed: true });
|
const root = this.getRootNode({ composed: true });
|
||||||
return !!root && root.nodeType === 9;
|
return !!root && root.nodeType === 9;
|
||||||
@@ -3816,6 +4087,16 @@ function _throwDocumentDomainSecurityError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Document extends Node {
|
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 documentElement() { return _wrapEl(+_dom("document_element")); }
|
||||||
get children() {
|
get children() {
|
||||||
const root = this.documentElement;
|
const root = this.documentElement;
|
||||||
@@ -6655,7 +6936,9 @@ globalThis.getComputedStyle = (el) => {
|
|||||||
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
|
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
|
||||||
}
|
}
|
||||||
const refreshRendered = () => {
|
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.epoch = _domMutationEpoch;
|
||||||
snapshot.rendered = null;
|
snapshot.rendered = null;
|
||||||
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != 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
|
// undefined (so `el.style instanceof CSSStyleDeclaration` threw). Assigning here
|
||||||
// only fills the value; the property stays enumerable:false, matching Chrome.
|
// only fills the value; the property stays enumerable:false, matching Chrome.
|
||||||
globalThis.CSSStyleDeclaration = CSSStyleDeclaration;
|
globalThis.CSSStyleDeclaration = CSSStyleDeclaration;
|
||||||
|
globalThis.Animation = Animation;
|
||||||
|
globalThis.KeyframeEffect = KeyframeEffect;
|
||||||
|
globalThis.DocumentTimeline = DocumentTimeline;
|
||||||
globalThis.XPathResult = globalThis.XPathResult || class XPathResult {};
|
globalThis.XPathResult = globalThis.XPathResult || class XPathResult {};
|
||||||
Object.assign(globalThis.XPathResult, {
|
Object.assign(globalThis.XPathResult, {
|
||||||
ANY_TYPE: 0,
|
ANY_TYPE: 0,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ use obscura_net::{
|
|||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::import_map::ImportMap;
|
use crate::import_map::ImportMap;
|
||||||
|
|
||||||
pub type InterceptCallback = Arc<
|
pub type InterceptCallback = Arc<
|
||||||
@@ -3252,6 +3255,8 @@ pub fn build_extension() -> Extension {
|
|||||||
ops.push(op_element_scroll_to());
|
ops.push(op_element_scroll_to());
|
||||||
ops.push(op_scroll_offset());
|
ops.push(op_scroll_offset());
|
||||||
ops.push(op_scroll_to());
|
ops.push(op_scroll_to());
|
||||||
|
ops.push(op_waapi_create());
|
||||||
|
ops.push(op_waapi_control());
|
||||||
}
|
}
|
||||||
Extension {
|
Extension {
|
||||||
name: "obscura_dom",
|
name: "obscura_dom",
|
||||||
@@ -3260,6 +3265,143 @@ pub fn build_extension() -> Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WaapiCreateInput {
|
||||||
|
id: u64,
|
||||||
|
node: u32,
|
||||||
|
keyframes: Vec<WaapiKeyframeInput>,
|
||||||
|
duration: f32,
|
||||||
|
delay: f32,
|
||||||
|
iterations: f32,
|
||||||
|
fill: String,
|
||||||
|
direction: String,
|
||||||
|
easing_bezier: Option<[f32; 4]>,
|
||||||
|
linear_easing: Option<Vec<f32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WaapiKeyframeInput {
|
||||||
|
offset: f32,
|
||||||
|
opacity: Option<f32>,
|
||||||
|
transform: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn waapi_document_time_ms(state: &ObscuraState) -> f32 {
|
||||||
|
state.animation_timeline_origin.elapsed().as_secs_f32() * 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn invalidate_waapi_render(state: &mut ObscuraState) {
|
||||||
|
state.prepared_render = None;
|
||||||
|
state.resolved_scroll = None;
|
||||||
|
state.activity_generation = state.activity_generation.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[op2(fast)]
|
||||||
|
fn op_waapi_create(state: &OpState, #[string] input: &str) -> bool {
|
||||||
|
let Ok(input) = serde_json::from_str::<WaapiCreateInput>(input) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !input.duration.is_finite()
|
||||||
|
|| input.duration < 0.0
|
||||||
|
|| !input.delay.is_finite()
|
||||||
|
|| !input.iterations.is_finite()
|
||||||
|
|| input.iterations < 0.0
|
||||||
|
|| input.keyframes.is_empty()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let shared = state.borrow::<SharedState>().clone();
|
||||||
|
let mut state = shared.borrow_mut();
|
||||||
|
let node = NodeId::new(input.node);
|
||||||
|
if state.dom.as_ref().and_then(|dom| dom.get_node(node)).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start_time_ms = waapi_document_time_ms(&state);
|
||||||
|
let fill_mode = match input.fill.as_str() {
|
||||||
|
"forwards" => obscura_render::AnimationFillMode::Forwards,
|
||||||
|
"backwards" => obscura_render::AnimationFillMode::Backwards,
|
||||||
|
"both" => obscura_render::AnimationFillMode::Both,
|
||||||
|
_ => obscura_render::AnimationFillMode::None,
|
||||||
|
};
|
||||||
|
let direction = match input.direction.as_str() {
|
||||||
|
"reverse" => obscura_render::AnimationDirection::Reverse,
|
||||||
|
"alternate" => obscura_render::AnimationDirection::Alternate,
|
||||||
|
"alternate-reverse" => obscura_render::AnimationDirection::AlternateReverse,
|
||||||
|
_ => obscura_render::AnimationDirection::Normal,
|
||||||
|
};
|
||||||
|
state.animation_timeline.register_waapi(obscura_render::WaapiAnimation {
|
||||||
|
id: input.id,
|
||||||
|
node,
|
||||||
|
keyframes: input.keyframes.into_iter().map(|frame| obscura_render::WaapiKeyframe {
|
||||||
|
offset: frame.offset.clamp(0.0, 1.0),
|
||||||
|
opacity: frame.opacity.map(|value| value.clamp(0.0, 1.0)),
|
||||||
|
transform: frame.transform,
|
||||||
|
}).collect(),
|
||||||
|
timing: obscura_render::AnimationTiming {
|
||||||
|
duration_ms: input.duration,
|
||||||
|
delay_ms: input.delay,
|
||||||
|
iteration_count: input.iterations,
|
||||||
|
direction,
|
||||||
|
fill_mode,
|
||||||
|
play_state: obscura_render::AnimationPlayState::Running,
|
||||||
|
},
|
||||||
|
easing: input.easing_bezier,
|
||||||
|
linear_easing: input.linear_easing,
|
||||||
|
start_time_ms,
|
||||||
|
hold_time_ms: None,
|
||||||
|
play_state: obscura_render::WaapiPlayState::Running,
|
||||||
|
});
|
||||||
|
invalidate_waapi_render(&mut state);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[op2(fast)]
|
||||||
|
fn op_waapi_control(
|
||||||
|
state: &OpState,
|
||||||
|
id: f64,
|
||||||
|
#[string] action: &str,
|
||||||
|
value: f64,
|
||||||
|
) -> bool {
|
||||||
|
if !id.is_finite() || id < 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let shared = state.borrow::<SharedState>().clone();
|
||||||
|
let mut state = shared.borrow_mut();
|
||||||
|
let id = id as u64;
|
||||||
|
let document_time = waapi_document_time_ms(&state);
|
||||||
|
let changed = match action {
|
||||||
|
"cancel" => state.animation_timeline.cancel_waapi(id),
|
||||||
|
"finish" => state.animation_timeline.finish_waapi(id),
|
||||||
|
"pause" => state.animation_timeline.set_waapi_play_state(
|
||||||
|
id,
|
||||||
|
obscura_render::WaapiPlayState::Paused,
|
||||||
|
document_time,
|
||||||
|
),
|
||||||
|
"play" => state.animation_timeline.set_waapi_play_state(
|
||||||
|
id,
|
||||||
|
obscura_render::WaapiPlayState::Running,
|
||||||
|
document_time,
|
||||||
|
),
|
||||||
|
"currentTime" if value.is_finite() => state.animation_timeline.set_waapi_current_time(
|
||||||
|
id,
|
||||||
|
document_time,
|
||||||
|
value as f32,
|
||||||
|
),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
invalidate_waapi_render(&mut state);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub(crate) fn document_base_url(state: &ObscuraState) -> Option<String> {
|
pub(crate) fn document_base_url(state: &ObscuraState) -> Option<String> {
|
||||||
let document_url = url::Url::parse(&state.url).ok()?;
|
let document_url = url::Url::parse(&state.url).ok()?;
|
||||||
|
|||||||
@@ -6723,6 +6723,68 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[test]
|
||||||
|
fn waapi_pause_seek_and_cancel_preserve_authored_inline_style() {
|
||||||
|
let mut rt = setup_runtime(
|
||||||
|
r#"<html><body><div id="box" style="opacity:.2;width:20px;height:20px"></div></body></html>"#,
|
||||||
|
);
|
||||||
|
rt.execute_script(
|
||||||
|
"waapi",
|
||||||
|
r#"
|
||||||
|
globalThis.box = document.getElementById('box');
|
||||||
|
globalThis.__animation = box.animate(
|
||||||
|
[{opacity:.2, transform:'translateX(0px)'}, {opacity:1, transform:'translateX(100px)'}],
|
||||||
|
{duration:100, fill:'both', easing:'linear'}
|
||||||
|
);
|
||||||
|
__animation.pause();
|
||||||
|
__animation.currentTime = 50;
|
||||||
|
"#,
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(rt.evaluate("box.style.opacity").unwrap(), serde_json::json!(".2"));
|
||||||
|
assert_eq!(rt.evaluate("box.getAnimations()[0] === __animation").unwrap(), serde_json::json!(true));
|
||||||
|
assert_eq!(rt.evaluate("document.getAnimations()[0] === __animation").unwrap(), serde_json::json!(true));
|
||||||
|
assert_eq!(rt.evaluate("__animation.playState").unwrap(), serde_json::json!("paused"));
|
||||||
|
assert_eq!(
|
||||||
|
rt.evaluate("!('easingBezier' in __animation.effect.getTiming()) && !('linearEasing' in __animation.effect.getComputedTiming())").unwrap(),
|
||||||
|
serde_json::json!(true),
|
||||||
|
);
|
||||||
|
let opacity = rt.evaluate("getComputedStyle(box).opacity").unwrap();
|
||||||
|
let opacity = opacity.as_str().unwrap().parse::<f32>().unwrap();
|
||||||
|
assert!((opacity - 0.6).abs() < 0.001, "midpoint opacity was {opacity}");
|
||||||
|
|
||||||
|
rt.execute_script("cancel", "__animation.cancel()").unwrap();
|
||||||
|
assert_eq!(rt.evaluate("box.style.opacity").unwrap(), serde_json::json!(".2"));
|
||||||
|
assert_eq!(rt.evaluate("getComputedStyle(box).opacity").unwrap(), serde_json::json!("0.2"));
|
||||||
|
assert_eq!(rt.evaluate("box.getAnimations().length").unwrap(), serde_json::json!(0.0));
|
||||||
|
assert_eq!(rt.evaluate("document.getAnimations().length").unwrap(), serde_json::json!(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn waapi_zero_duration_finishes_asynchronously_and_fires_lifecycle() {
|
||||||
|
let mut rt = setup_runtime(r#"<div id="box" style="opacity:.1"></div>"#);
|
||||||
|
rt.execute_script(
|
||||||
|
"waapi-lifecycle",
|
||||||
|
r#"
|
||||||
|
globalThis.box = document.getElementById('box');
|
||||||
|
globalThis.__ready = false;
|
||||||
|
globalThis.__finished = false;
|
||||||
|
globalThis.__finishEvent = false;
|
||||||
|
globalThis.__animation = box.animate([{opacity:.1}, {opacity:1}], {duration:0, fill:'both'});
|
||||||
|
__animation.onfinish = () => { __finishEvent = true; };
|
||||||
|
__animation.ready.then(() => { __ready = true; });
|
||||||
|
__animation.finished.then(() => { __finished = true; });
|
||||||
|
"#,
|
||||||
|
).unwrap();
|
||||||
|
rt.run_event_loop_bounded(20).await.unwrap();
|
||||||
|
assert_eq!(rt.evaluate("__ready").unwrap(), serde_json::json!(true));
|
||||||
|
assert_eq!(rt.evaluate("__finished").unwrap(), serde_json::json!(true));
|
||||||
|
assert_eq!(rt.evaluate("__finishEvent").unwrap(), serde_json::json!(true));
|
||||||
|
assert_eq!(rt.evaluate("__animation.playState").unwrap(), serde_json::json!("finished"));
|
||||||
|
assert_eq!(rt.evaluate("getComputedStyle(box).opacity").unwrap(), serde_json::json!("1"));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
#[test]
|
#[test]
|
||||||
fn forward_animation_samples_retain_static_prepared_render() {
|
fn forward_animation_samples_retain_static_prepared_render() {
|
||||||
|
|||||||
@@ -3664,6 +3664,13 @@ impl Stylesheet {
|
|||||||
animation_timeline.clear_animation(nid, animation_sample);
|
animation_timeline.clear_animation(nid, animation_sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web Animations contribute at the animation cascade origin: above
|
||||||
|
// every normal author declaration (including inline style), but below
|
||||||
|
// author !important. Keep the renderer-side effect separate from the
|
||||||
|
// authored declaration block so cancel() reveals the exact underlying
|
||||||
|
// value and CSSOM never observes a synthetic inline rewrite.
|
||||||
|
sample_waapi_properties(animation_timeline, nid, style, animation_sample);
|
||||||
|
|
||||||
for &(_, _, i) in &important_matched {
|
for &(_, _, i) in &important_matched {
|
||||||
let rule = &self.rules[i];
|
let rule = &self.rules[i];
|
||||||
let expanded =
|
let expanded =
|
||||||
@@ -3677,6 +3684,151 @@ impl Stylesheet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_waapi_properties(
|
||||||
|
timeline: &crate::AnimationTimelineState,
|
||||||
|
node: NodeId,
|
||||||
|
style: &mut LayoutStyle,
|
||||||
|
sample: crate::AnimationSample,
|
||||||
|
) {
|
||||||
|
for (animation, local_time) in timeline.waapi_for_node(node, sample.time) {
|
||||||
|
let Some(mut progress) = animation_directed_progress(&animation.timing, local_time) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(samples) = animation.linear_easing.as_deref() {
|
||||||
|
progress = sample_linear_easing(samples, progress);
|
||||||
|
} else if let Some(points) = animation.easing {
|
||||||
|
progress = sample_cubic_bezier(points, progress);
|
||||||
|
}
|
||||||
|
let underlying = style.clone();
|
||||||
|
|
||||||
|
let opacity_track = animation
|
||||||
|
.keyframes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|frame| frame.opacity.map(|value| (frame.offset, value)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if let Some(value) = sample_numeric_waapi_track(
|
||||||
|
&opacity_track,
|
||||||
|
underlying.opacity.unwrap_or(1.0),
|
||||||
|
progress,
|
||||||
|
) {
|
||||||
|
style.opacity = Some(value.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let transform_track = animation
|
||||||
|
.keyframes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|frame| {
|
||||||
|
let value = frame.transform.as_ref()?;
|
||||||
|
if !crate::style::supports_declaration("transform", value) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut endpoint = underlying.clone();
|
||||||
|
crate::style::apply_animation_property_value(&mut endpoint, "transform", value);
|
||||||
|
Some((frame.offset, endpoint.transform_ops))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if let Some(value) = sample_transform_waapi_track(
|
||||||
|
&transform_track,
|
||||||
|
underlying.transform_ops.clone(),
|
||||||
|
progress,
|
||||||
|
) {
|
||||||
|
apply_animation_value(
|
||||||
|
style,
|
||||||
|
AnimatedProperty::Transform,
|
||||||
|
AnimationValue::Transform(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_linear_easing(samples: &[f32], progress: f32) -> f32 {
|
||||||
|
if samples.len() < 2 {
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
let scaled = progress.clamp(0.0, 1.0) * (samples.len() - 1) as f32;
|
||||||
|
let index = (scaled.floor() as usize).min(samples.len() - 2);
|
||||||
|
let local = scaled - index as f32;
|
||||||
|
samples[index] + (samples[index + 1] - samples[index]) * local
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_cubic_bezier(points: [f32; 4], progress: f32) -> f32 {
|
||||||
|
let [x1, y1, x2, y2] = points;
|
||||||
|
let target = progress.clamp(0.0, 1.0);
|
||||||
|
let component = |t: f32, first: f32, second: f32| {
|
||||||
|
let inverse = 1.0 - t;
|
||||||
|
3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
|
||||||
|
};
|
||||||
|
// x control points are constrained to [0,1], so bisection is stable even
|
||||||
|
// for flat derivatives at the ends.
|
||||||
|
let (mut low, mut high) = (0.0, 1.0);
|
||||||
|
for _ in 0..14 {
|
||||||
|
let middle = (low + high) * 0.5;
|
||||||
|
if component(middle, x1, x2) < target {
|
||||||
|
low = middle;
|
||||||
|
} else {
|
||||||
|
high = middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
component((low + high) * 0.5, y1, y2).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_numeric_waapi_track(
|
||||||
|
track: &[(f32, f32)],
|
||||||
|
underlying: f32,
|
||||||
|
progress: f32,
|
||||||
|
) -> Option<f32> {
|
||||||
|
sample_waapi_track(track, underlying, progress, |from, to, position| {
|
||||||
|
from + (to - from) * position
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_transform_waapi_track(
|
||||||
|
track: &[(f32, Vec<crate::TransformOp>)],
|
||||||
|
underlying: Vec<crate::TransformOp>,
|
||||||
|
progress: f32,
|
||||||
|
) -> Option<Vec<crate::TransformOp>> {
|
||||||
|
sample_waapi_track(track, underlying, progress, |from, to, position| {
|
||||||
|
interpolate_transform_list(from, to, position)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_waapi_track<T: Clone>(
|
||||||
|
track: &[(f32, T)],
|
||||||
|
underlying: T,
|
||||||
|
progress: f32,
|
||||||
|
interpolate: impl Fn(&T, &T, f32) -> T,
|
||||||
|
) -> Option<T> {
|
||||||
|
if track.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut resolved = track.to_vec();
|
||||||
|
resolved.sort_by(|left, right| left.0.total_cmp(&right.0));
|
||||||
|
if resolved[0].0 > 0.0 {
|
||||||
|
resolved.insert(0, (0.0, underlying.clone()));
|
||||||
|
}
|
||||||
|
if resolved.last().is_some_and(|stop| stop.0 < 1.0) {
|
||||||
|
resolved.push((1.0, underlying));
|
||||||
|
}
|
||||||
|
if progress <= resolved[0].0 {
|
||||||
|
return Some(resolved[0].1.clone());
|
||||||
|
}
|
||||||
|
for pair in resolved.windows(2) {
|
||||||
|
let (from_offset, from) = (&pair[0].0, &pair[0].1);
|
||||||
|
let (to_offset, to) = (&pair[1].0, &pair[1].1);
|
||||||
|
if progress <= *to_offset {
|
||||||
|
if progress == *to_offset || from_offset == to_offset {
|
||||||
|
return Some(to.clone());
|
||||||
|
}
|
||||||
|
return Some(interpolate(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
(progress - *from_offset) / (*to_offset - *from_offset),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved.last().map(|stop| stop.1.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Compile a keyframes body into sparse per-property tracks. Values remain in
|
/// Compile a keyframes body into sparse per-property tracks. Values remain in
|
||||||
/// specified form because `var()` and color-scheme resolution are element
|
/// specified form because `var()` and color-scheme resolution are element
|
||||||
/// dependent, but declaration splitting, shorthand-to-longhand membership,
|
/// dependent, but declaration splitting, shorthand-to-longhand membership,
|
||||||
@@ -7798,6 +7950,79 @@ mod tests {
|
|||||||
assert_eq!(style.background_color, Some([0, 0, 255, 255]));
|
assert_eq!(style.background_color, Some([0, 0, 255, 255]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waapi_samples_opacity_and_transform_without_rewriting_inline_cascade() {
|
||||||
|
let tree = obscura_dom::parse_html(r#"<div id="target" style="opacity:.2"></div>"#);
|
||||||
|
let target = tree.get_element_by_id("target").unwrap();
|
||||||
|
let sheet = Stylesheet::parse_for_viewport(&tree, &[], (800.0, 600.0));
|
||||||
|
let mut timeline = crate::AnimationTimelineState::default();
|
||||||
|
timeline.register_waapi(crate::WaapiAnimation {
|
||||||
|
id: 1,
|
||||||
|
node: target,
|
||||||
|
keyframes: vec![
|
||||||
|
crate::WaapiKeyframe { offset: 0.0, opacity: Some(0.2), transform: Some("translateX(0px)".into()) },
|
||||||
|
crate::WaapiKeyframe { offset: 1.0, opacity: Some(1.0), transform: Some("translateX(100px)".into()) },
|
||||||
|
],
|
||||||
|
timing: crate::AnimationTiming {
|
||||||
|
duration_ms: 100.0,
|
||||||
|
fill_mode: crate::AnimationFillMode::Both,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
easing: None,
|
||||||
|
linear_easing: None,
|
||||||
|
start_time_ms: 0.0,
|
||||||
|
hold_time_ms: Some(50.0),
|
||||||
|
play_state: crate::WaapiPlayState::Paused,
|
||||||
|
});
|
||||||
|
let node = tree.get_node(target).unwrap();
|
||||||
|
let element = node.as_element().unwrap();
|
||||||
|
let mut matcher = tree.matcher();
|
||||||
|
let mut style = LayoutStyle::default();
|
||||||
|
sheet.apply_at_animation_time(
|
||||||
|
&tree,
|
||||||
|
&mut matcher,
|
||||||
|
target,
|
||||||
|
node.get_attribute("id"),
|
||||||
|
&[],
|
||||||
|
element.local.as_ref(),
|
||||||
|
&mut style,
|
||||||
|
&HashMap::new(),
|
||||||
|
node.get_attribute("style"),
|
||||||
|
crate::AnimationSample::document(50.0),
|
||||||
|
&mut timeline,
|
||||||
|
);
|
||||||
|
assert_opacity(style.opacity.unwrap(), 0.6);
|
||||||
|
assert!(matches!(style.transform_ops.as_slice(),
|
||||||
|
[crate::TransformOp::Translate(x, _)] if x.value == crate::Dimension::Px(50.0)));
|
||||||
|
|
||||||
|
let important_tree = obscura_dom::parse_html(
|
||||||
|
r#"<div id="target" style="opacity:.2 !important"></div>"#,
|
||||||
|
);
|
||||||
|
let target = important_tree.get_element_by_id("target").unwrap();
|
||||||
|
let sheet = Stylesheet::parse_for_viewport(&important_tree, &[], (800.0, 600.0));
|
||||||
|
let mut timeline = crate::AnimationTimelineState::default();
|
||||||
|
timeline.register_waapi(crate::WaapiAnimation {
|
||||||
|
id: 2,
|
||||||
|
node: target,
|
||||||
|
keyframes: vec![crate::WaapiKeyframe { offset: 1.0, opacity: Some(1.0), transform: None }],
|
||||||
|
timing: crate::AnimationTiming { duration_ms: 100.0, fill_mode: crate::AnimationFillMode::Both, ..Default::default() },
|
||||||
|
easing: None,
|
||||||
|
linear_easing: None,
|
||||||
|
start_time_ms: 0.0,
|
||||||
|
hold_time_ms: Some(100.0),
|
||||||
|
play_state: crate::WaapiPlayState::Finished,
|
||||||
|
});
|
||||||
|
let node = important_tree.get_node(target).unwrap();
|
||||||
|
let mut matcher = important_tree.matcher();
|
||||||
|
let mut style = LayoutStyle::default();
|
||||||
|
sheet.apply_at_animation_time(
|
||||||
|
&important_tree, &mut matcher, target, node.get_attribute("id"), &[], "div",
|
||||||
|
&mut style, &HashMap::new(), node.get_attribute("style"),
|
||||||
|
crate::AnimationSample::document(100.0), &mut timeline,
|
||||||
|
);
|
||||||
|
assert_opacity(style.opacity.unwrap(), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn conditional_keyframes_and_empty_later_definitions_follow_browser_selection() {
|
fn conditional_keyframes_and_empty_later_definitions_follow_browser_selection() {
|
||||||
let css = r#"
|
let css = r#"
|
||||||
|
|||||||
@@ -1711,6 +1711,41 @@ struct AnimationInstance {
|
|||||||
was_paused: bool,
|
was_paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A normalized property keyframe supplied through the Web Animations API.
|
||||||
|
/// Values stay in specified form until cascade time because transforms may
|
||||||
|
/// contain relative units whose meaning depends on the animated element.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WaapiKeyframe {
|
||||||
|
pub offset: f32,
|
||||||
|
pub opacity: Option<f32>,
|
||||||
|
pub transform: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WaapiPlayState {
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderer-owned WAAPI effect. JavaScript keeps the wrapper object identity,
|
||||||
|
/// while this document-scoped record is the source of truth for cascade and
|
||||||
|
/// paint. It deliberately does not rewrite the element's inline style.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WaapiAnimation {
|
||||||
|
pub id: u64,
|
||||||
|
pub node: obscura_dom::tree::NodeId,
|
||||||
|
pub keyframes: Vec<WaapiKeyframe>,
|
||||||
|
pub timing: AnimationTiming,
|
||||||
|
/// Cubic Bezier timing function control points. `None` is linear.
|
||||||
|
pub easing: Option<[f32; 4]>,
|
||||||
|
/// CSS `linear()` output samples at evenly distributed input positions.
|
||||||
|
pub linear_easing: Option<Vec<f32>>,
|
||||||
|
pub start_time_ms: f32,
|
||||||
|
pub hold_time_ms: Option<f32>,
|
||||||
|
pub play_state: WaapiPlayState,
|
||||||
|
}
|
||||||
|
|
||||||
/// Page-owned CSS animation instance history retained across layout rebuilds.
|
/// Page-owned CSS animation instance history retained across layout rebuilds.
|
||||||
/// Node ids are document-scoped, so navigation must replace this value.
|
/// Node ids are document-scoped, so navigation must replace this value.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -1718,6 +1753,7 @@ pub struct AnimationTimelineState {
|
|||||||
instances: std::collections::HashMap<obscura_dom::tree::NodeId, AnimationInstance>,
|
instances: std::collections::HashMap<obscura_dom::tree::NodeId, AnimationInstance>,
|
||||||
start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
||||||
subtree_start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
subtree_start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
||||||
|
waapi: std::collections::BTreeMap<u64, WaapiAnimation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnimationTimelineState {
|
impl AnimationTimelineState {
|
||||||
@@ -1765,9 +1801,91 @@ impl AnimationTimelineState {
|
|||||||
self.instances.remove(node);
|
self.instances.remove(node);
|
||||||
self.start_candidates.remove(node);
|
self.start_candidates.remove(node);
|
||||||
self.subtree_start_candidates.remove(node);
|
self.subtree_start_candidates.remove(node);
|
||||||
|
self.waapi.retain(|_, animation| animation.node != *node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn register_waapi(&mut self, animation: WaapiAnimation) {
|
||||||
|
self.waapi.insert(animation.id, animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_waapi(&mut self, id: u64) -> bool {
|
||||||
|
self.waapi.remove(&id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_waapi_current_time(&mut self, id: u64, document_time_ms: f32, local_time_ms: f32) -> bool {
|
||||||
|
let Some(animation) = self.waapi.get_mut(&id) else { return false; };
|
||||||
|
let local = local_time_ms.max(0.0);
|
||||||
|
animation.hold_time_ms = Some(local);
|
||||||
|
animation.start_time_ms = document_time_ms - local;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_waapi_play_state(
|
||||||
|
&mut self,
|
||||||
|
id: u64,
|
||||||
|
state: WaapiPlayState,
|
||||||
|
document_time_ms: f32,
|
||||||
|
) -> bool {
|
||||||
|
let Some(animation) = self.waapi.get_mut(&id) else { return false; };
|
||||||
|
let current = animation
|
||||||
|
.hold_time_ms
|
||||||
|
.unwrap_or_else(|| (document_time_ms - animation.start_time_ms).max(0.0));
|
||||||
|
match state {
|
||||||
|
WaapiPlayState::Running => {
|
||||||
|
animation.start_time_ms = document_time_ms - current;
|
||||||
|
animation.hold_time_ms = None;
|
||||||
|
}
|
||||||
|
WaapiPlayState::Paused | WaapiPlayState::Finished => {
|
||||||
|
animation.hold_time_ms = Some(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animation.play_state = state;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_waapi(&mut self, id: u64) -> bool {
|
||||||
|
let Some(animation) = self.waapi.get_mut(&id) else { return false; };
|
||||||
|
let end = animation.timing.delay_ms
|
||||||
|
+ animation.timing.duration_ms * animation.timing.iteration_count.max(0.0);
|
||||||
|
animation.hold_time_ms = Some(end.max(0.0));
|
||||||
|
animation.play_state = WaapiPlayState::Finished;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn waapi_for_node(
|
||||||
|
&self,
|
||||||
|
node: obscura_dom::tree::NodeId,
|
||||||
|
document_time: AnimationSampleTime,
|
||||||
|
) -> impl Iterator<Item = (&WaapiAnimation, AnimationSampleTime)> {
|
||||||
|
self.waapi.values().filter_map(move |animation| {
|
||||||
|
if animation.node != node {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let local = animation.hold_time_ms.unwrap_or_else(|| {
|
||||||
|
(document_time.milliseconds - animation.start_time_ms).max(0.0)
|
||||||
|
});
|
||||||
|
Some((animation, AnimationSampleTime { milliseconds: local }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_active_waapi(&self, document_time: AnimationSampleTime) -> bool {
|
||||||
|
self.waapi.values().any(|animation| {
|
||||||
|
if animation.play_state != WaapiPlayState::Running
|
||||||
|
|| animation.timing.duration_ms <= 0.0
|
||||||
|
|| animation.timing.iteration_count <= 0.0
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let local = animation.hold_time_ms.unwrap_or_else(|| {
|
||||||
|
(document_time.milliseconds - animation.start_time_ms).max(0.0)
|
||||||
|
});
|
||||||
|
let end = animation.timing.delay_ms
|
||||||
|
+ animation.timing.duration_ms * animation.timing.iteration_count;
|
||||||
|
local < end.max(0.0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn sample_for(
|
pub(crate) fn sample_for(
|
||||||
&mut self,
|
&mut self,
|
||||||
node: obscura_dom::tree::NodeId,
|
node: obscura_dom::tree::NodeId,
|
||||||
@@ -1832,6 +1950,7 @@ impl AnimationTimelineState {
|
|||||||
self.instances.retain(|node, _| keep(*node));
|
self.instances.retain(|node, _| keep(*node));
|
||||||
self.start_candidates.retain(|node, _| keep(*node));
|
self.start_candidates.retain(|node, _| keep(*node));
|
||||||
self.subtree_start_candidates.retain(|node, _| keep(*node));
|
self.subtree_start_candidates.retain(|node, _| keep(*node));
|
||||||
|
self.waapi.retain(|_, animation| keep(animation.node));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_start_candidates(&mut self) {
|
pub fn clear_start_candidates(&mut self) {
|
||||||
|
|||||||
@@ -756,6 +756,7 @@ impl ResolvedScrollState {
|
|||||||
pub struct PreparedRender {
|
pub struct PreparedRender {
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
animation_sample: crate::AnimationSample,
|
animation_sample: crate::AnimationSample,
|
||||||
|
has_active_waapi_animations: bool,
|
||||||
root_font_size: f32,
|
root_font_size: f32,
|
||||||
base_url: Option<String>,
|
base_url: Option<String>,
|
||||||
content_size: (f32, f32),
|
content_size: (f32, f32),
|
||||||
@@ -784,7 +785,7 @@ impl PreparedRender {
|
|||||||
/// animation. Finite animations stop producing compositor damage after
|
/// animation. Finite animations stop producing compositor damage after
|
||||||
/// their active interval; paused and zero-duration animations never do.
|
/// their active interval; paused and zero-duration animations never do.
|
||||||
pub fn has_active_css_animations(&self) -> bool {
|
pub fn has_active_css_animations(&self) -> bool {
|
||||||
self.layout.styles.values().any(|style| {
|
self.has_active_waapi_animations || self.layout.styles.values().any(|style| {
|
||||||
if style.animation_name.is_none()
|
if style.animation_name.is_none()
|
||||||
|| !style.animation_has_render_effect
|
|| !style.animation_has_render_effect
|
||||||
|| style.animation_timing.play_state == crate::AnimationPlayState::Paused
|
|| style.animation_timing.play_state == crate::AnimationPlayState::Paused
|
||||||
@@ -2190,6 +2191,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
|||||||
Some(PreparedRender {
|
Some(PreparedRender {
|
||||||
viewport,
|
viewport,
|
||||||
animation_sample,
|
animation_sample,
|
||||||
|
has_active_waapi_animations: animation_timeline.has_active_waapi(animation_sample.time),
|
||||||
root_font_size,
|
root_font_size,
|
||||||
base_url: base_url.map(str::to_string),
|
base_url: base_url.map(str::to_string),
|
||||||
content_size,
|
content_size,
|
||||||
|
|||||||
Reference in new issue
Block a user