Files
hyperframes/packages/core/src/runtime/adapters/css.ts
T

176 lines
5.7 KiB
TypeScript

import type { RuntimeDeterministicAdapter } from "../types";
import { swallow } from "../diagnostics";
export function createCssAdapter(params?: {
resolveStartSeconds?: (element: Element) => number;
}): RuntimeDeterministicAdapter {
let entries: Array<{
el: HTMLElement;
baseDelay: string;
basePlayState: string;
animations: Animation[];
}> = [];
const getAnimationsForElement = (el: HTMLElement): Animation[] => {
if (typeof el.getAnimations !== "function") return [];
try {
return el.getAnimations();
} catch {
return [];
}
};
const resolveEntryStartSeconds = (el: HTMLElement): number =>
params?.resolveStartSeconds
? params.resolveStartSeconds(el)
: Number.parseFloat(el.getAttribute("data-start") ?? "0") || 0;
/**
* End time (seconds, relative to composition start) for one WAAPI
* animation handle. `endSeconds` is set only when the timing is readable
* AND finite; `unbounded` is true when a timing was read but its endTime
* is Infinity/NaN (an infinite iteration count the caller can't
* auto-infer a duration from) — distinct from "no timing available at
* all" (both fields absent), which callers should simply skip.
*/
const inferAnimationEndSeconds = (
animation: Animation,
startSeconds: number,
): { endSeconds?: number; unbounded?: true } => {
let timing: ComputedEffectTiming | null = null;
try {
timing = animation.effect?.getComputedTiming?.() ?? null;
} catch (err) {
swallow("runtime.adapters.css.site5", err);
}
if (!timing) return {};
const endTimeMs = Number(timing.endTime);
if (!Number.isFinite(endTimeMs)) return { unbounded: true };
return { endSeconds: startSeconds + endTimeMs / 1000 };
};
const seekAnimations = (animations: Animation[], timeMs: number) => {
for (const animation of animations) {
try {
animation.currentTime = timeMs;
} catch (err) {
// ignore animations that reject currentTime writes
swallow("runtime.adapters.css.site1", err);
}
try {
animation.pause();
} catch (err) {
// infinite unresolved animations can throw on pause before currentTime sticks
swallow("runtime.adapters.css.site2", err);
}
}
};
const playAnimations = (animations: Animation[]) => {
for (const animation of animations) {
try {
animation.play();
} catch (err) {
// ignore animation edge-cases
swallow("runtime.adapters.css.site3", err);
}
}
};
const pauseAnimations = (animations: Animation[]) => {
for (const animation of animations) {
try {
animation.pause();
} catch (err) {
// ignore animation edge-cases
swallow("runtime.adapters.css.site4", err);
}
}
};
const restoreInlineStyles = (entry: (typeof entries)[number]) => {
if (entry.baseDelay) {
entry.el.style.animationDelay = entry.baseDelay;
} else {
entry.el.style.removeProperty("animation-delay");
}
if (entry.basePlayState) {
entry.el.style.animationPlayState = entry.basePlayState;
} else {
entry.el.style.removeProperty("animation-play-state");
}
};
return {
name: "css",
discover: () => {
entries = [];
const all = document.querySelectorAll("*");
for (const rawEl of all) {
if (!(rawEl instanceof HTMLElement)) continue;
const style = window.getComputedStyle(rawEl);
if (!style.animationName || style.animationName === "none") continue;
entries.push({
el: rawEl,
baseDelay: rawEl.style.animationDelay || "",
basePlayState: rawEl.style.animationPlayState || "",
animations: getAnimationsForElement(rawEl),
});
}
},
getInferredDurationSeconds: () => {
let maxEndSeconds = 0;
for (const entry of entries) {
if (!entry.el.isConnected) continue;
const start = resolveEntryStartSeconds(entry.el);
for (const animation of getAnimationsForElement(entry.el)) {
const result = inferAnimationEndSeconds(animation, start);
// Unbounded (Infinity/NaN endTime) animations are skipped here —
// they never contribute to maxEndSeconds. A finite animation
// elsewhere on the composition still supplies a valid duration
// signal; only fall through to null when nothing finite was found.
if (result.endSeconds != null) maxEndSeconds = Math.max(maxEndSeconds, result.endSeconds);
}
}
return maxEndSeconds > 0 ? maxEndSeconds : null;
},
seek: (ctx) => {
const time = Number(ctx.time) || 0;
for (const entry of entries) {
if (!entry.el.isConnected) continue;
const start = resolveEntryStartSeconds(entry.el);
const localTimeMs = Math.max(0, time - start) * 1000;
const animations = entry.animations;
if (animations.length > 0) {
seekAnimations(animations, localTimeMs);
continue;
}
// Fallback for environments without WAAPI-backed CSS animation handles.
entry.el.style.animationPlayState = "paused";
entry.el.style.animationDelay = `-${(localTimeMs / 1000).toFixed(3)}s`;
}
},
pause: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
const animations = entry.animations;
if (animations.length > 0) {
pauseAnimations(animations);
}
restoreInlineStyles(entry);
}
},
play: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
restoreInlineStyles(entry);
playAnimations(entry.animations);
}
},
revert: () => {
entries = [];
},
};
}