4a5b2ad524
- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First - 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点 - 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置 - 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出 - 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出 - 同步技术方案转场段与 pitfalls 经验条目
608 lines
19 KiB
TypeScript
608 lines
19 KiB
TypeScript
/**
|
|
* 资源画本转场:对真实元素做 FLIP,不再克隆快照。
|
|
*
|
|
* 总览与子画布之间,同一张卡一直是同一个 DOM 节点(同一个 React key、同一个父节点,
|
|
* 只是布局函数从 resourceBookOverviewCardLayout 换成 resourceBookChildCardLayout)。
|
|
* 因此转场只需要:状态提交前记录 First 矩形,提交后测 Last 矩形,给同一个节点加反向
|
|
* transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,也就没有交接瞬间
|
|
* 的跳变。
|
|
*
|
|
* 预览图尺寸是异步到达的,卡片尺寸/位置会在动画之后(甚至动画结束之后)再变一次。
|
|
* 所有布局变化都走同一台机器:sync() 比较每个节点的布局签名,变化时从"当前视觉矩形"
|
|
* 重新加反向 transform(可中断重基),所以二次变化是动画而不是瞬移。
|
|
*/
|
|
export const RESOURCE_BOOK_MOTION_DURATION = 420;
|
|
// 重基时给剩余时长设下限,避免 0ms 跳变。
|
|
export const RESOURCE_BOOK_MOTION_MIN_DURATION = 80;
|
|
const EASING = 'cubic-bezier(0.2, 0.78, 0.2, 1)';
|
|
const RECT_EPSILON = 0.5;
|
|
const SCALE_EPSILON = 0.001;
|
|
const NONE_TRANSFORM = 'none';
|
|
|
|
const CARD_HOST_SELECTOR = '.game-resource-book-scene-card';
|
|
const CARD_BOX_SELECTOR = '.game-resource-card';
|
|
const TITLE_HOST_SELECTOR = '.game-resource-book-scene-titlebar';
|
|
const WORLD_SELECTOR = '.game-resource-book-scene-world';
|
|
|
|
export type ResourceBookMotionRect = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type ResourceBookMotionOrigin = {
|
|
left: number;
|
|
top: number;
|
|
};
|
|
|
|
export type ResourceBookMotionSignatures = ReadonlyMap<string, string>;
|
|
|
|
type MotionSnapshotEntry = {
|
|
rect: ResourceBookMotionRect;
|
|
opacity: number;
|
|
};
|
|
|
|
type MotionNode = {
|
|
key: string;
|
|
host: HTMLElement;
|
|
box: HTMLElement;
|
|
};
|
|
|
|
type MotionRecord = {
|
|
key: string;
|
|
host: HTMLElement;
|
|
box: HTMLElement;
|
|
signature: string;
|
|
targetOpacity: number;
|
|
cleanRect: ResourceBookMotionRect | null;
|
|
animation: Animation | null;
|
|
epoch: number;
|
|
finishAt: number;
|
|
};
|
|
|
|
type FlipValues = {
|
|
translateX: number;
|
|
translateY: number;
|
|
scaleX: number;
|
|
scaleY: number;
|
|
};
|
|
|
|
const EMPTY_SIGNATURES: ResourceBookMotionSignatures = new Map();
|
|
|
|
function formatNumber(value: number) {
|
|
const rounded = Math.round(value * 1000) / 1000;
|
|
return Object.is(rounded, -0) ? 0 : rounded;
|
|
}
|
|
|
|
export function resourceBookMotionRect(rect: {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
}): ResourceBookMotionRect {
|
|
return {
|
|
left: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
}
|
|
|
|
export function resourceBookValidMotionRect(
|
|
rect: ResourceBookMotionRect | null | undefined,
|
|
): rect is ResourceBookMotionRect {
|
|
return Boolean(
|
|
rect &&
|
|
Number.isFinite(rect.left) &&
|
|
Number.isFinite(rect.top) &&
|
|
Number.isFinite(rect.width) &&
|
|
Number.isFinite(rect.height) &&
|
|
rect.width > 0 &&
|
|
rect.height > 0,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* FLIP 反向 transform。transform-origin 固定 0 0,缩放围绕宿主节点自身的原点进行,
|
|
* 所以位置需要按原点偏移换算;translate 的数值单位是宿主节点所在坐标系(world 局部
|
|
* 空间),屏幕位移要先除以 world 的缩放。
|
|
*/
|
|
export function resourceBookFlipValues(
|
|
first: ResourceBookMotionRect,
|
|
last: ResourceBookMotionRect,
|
|
origin: ResourceBookMotionOrigin = { left: last.left, top: last.top },
|
|
worldScale = 1,
|
|
): FlipValues {
|
|
const scaleX = first.width / last.width;
|
|
const scaleY = first.height / last.height;
|
|
const scale = worldScale > 0 ? worldScale : 1;
|
|
return {
|
|
translateX:
|
|
(first.left - origin.left - scaleX * (last.left - origin.left)) / scale,
|
|
translateY:
|
|
(first.top - origin.top - scaleY * (last.top - origin.top)) / scale,
|
|
scaleX,
|
|
scaleY,
|
|
};
|
|
}
|
|
|
|
export function resourceBookFlipTransform(
|
|
first: ResourceBookMotionRect,
|
|
last: ResourceBookMotionRect,
|
|
origin: ResourceBookMotionOrigin = { left: last.left, top: last.top },
|
|
worldScale = 1,
|
|
): string {
|
|
const values = resourceBookFlipValues(first, last, origin, worldScale);
|
|
return `translate(${formatNumber(values.translateX)}px, ${formatNumber(
|
|
values.translateY,
|
|
)}px) scale(${formatNumber(values.scaleX)}, ${formatNumber(values.scaleY)})`;
|
|
}
|
|
|
|
function resourceBookFlipIsIdentity(values: FlipValues) {
|
|
return (
|
|
Math.abs(values.translateX) < RECT_EPSILON &&
|
|
Math.abs(values.translateY) < RECT_EPSILON &&
|
|
Math.abs(values.scaleX - 1) < SCALE_EPSILON &&
|
|
Math.abs(values.scaleY - 1) < SCALE_EPSILON
|
|
);
|
|
}
|
|
|
|
function readRect(element: Element): ResourceBookMotionRect {
|
|
return resourceBookMotionRect(element.getBoundingClientRect());
|
|
}
|
|
|
|
function readTargetOpacity(host: HTMLElement) {
|
|
const raw = host.style.opacity;
|
|
if (!raw) return 1;
|
|
const value = Number.parseFloat(raw);
|
|
return Number.isFinite(value) ? value : 1;
|
|
}
|
|
|
|
function readVisualOpacity(host: HTMLElement, fallback: number) {
|
|
const value = Number.parseFloat(getComputedStyle(host).opacity);
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
/**
|
|
* 宿主节点自身原点的屏幕位置。卡片包装节点是 0x0,原点就是它的矩形左上角;标题栏是
|
|
* 有尺寸的节点,激活时还带一层抵消 world 的内联 transform,需要临时摘掉再量。
|
|
*/
|
|
function readOrigin(host: HTMLElement): ResourceBookMotionOrigin {
|
|
const base = host.style.transform;
|
|
if (!base) {
|
|
const rect = host.getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top };
|
|
}
|
|
host.style.transform = '';
|
|
const rect = host.getBoundingClientRect();
|
|
host.style.transform = base;
|
|
return { left: rect.left, top: rect.top };
|
|
}
|
|
|
|
type WorldTransform = {
|
|
scale: number;
|
|
origin: ResourceBookMotionOrigin;
|
|
};
|
|
|
|
/**
|
|
* world 变换(平移 + 缩放,transform-origin 0 0)。宿主节点的几何基准统一存成
|
|
* world 局部坐标,平移和缩放就不会把"上一次的干净位置"变成过期的屏幕坐标。
|
|
*/
|
|
function readWorld(root: HTMLElement): WorldTransform {
|
|
const world = root.querySelector<HTMLElement>(WORLD_SELECTOR);
|
|
if (!world) return { scale: 1, origin: { left: 0, top: 0 } };
|
|
const rect = world.getBoundingClientRect();
|
|
const transform = getComputedStyle(world).transform;
|
|
const match = /^matrix(?:3d)?\(([^)]+)\)$/.exec(transform);
|
|
const parsed = match
|
|
? Number.parseFloat(match[1]!.split(',')[0] ?? '')
|
|
: Number.NaN;
|
|
const scale = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
|
return { scale, origin: { left: rect.left, top: rect.top } };
|
|
}
|
|
|
|
function toWorldRect(
|
|
rect: ResourceBookMotionRect,
|
|
world: WorldTransform,
|
|
): ResourceBookMotionRect {
|
|
return {
|
|
left: (rect.left - world.origin.left) / world.scale,
|
|
top: (rect.top - world.origin.top) / world.scale,
|
|
width: rect.width / world.scale,
|
|
height: rect.height / world.scale,
|
|
};
|
|
}
|
|
|
|
function fromWorldRect(
|
|
rect: ResourceBookMotionRect,
|
|
world: WorldTransform,
|
|
): ResourceBookMotionRect {
|
|
return {
|
|
left: world.origin.left + rect.left * world.scale,
|
|
top: world.origin.top + rect.top * world.scale,
|
|
width: rect.width * world.scale,
|
|
height: rect.height * world.scale,
|
|
};
|
|
}
|
|
|
|
function collectNodes(root: HTMLElement): MotionNode[] {
|
|
const nodes: MotionNode[] = [];
|
|
for (const host of root.querySelectorAll<HTMLElement>(CARD_HOST_SELECTOR)) {
|
|
const box = host.querySelector<HTMLElement>(CARD_BOX_SELECTOR);
|
|
const resourceId = box?.dataset.resourceCardId;
|
|
if (box && resourceId) {
|
|
nodes.push({ key: `card:${resourceId}`, host, box });
|
|
}
|
|
}
|
|
for (const host of root.querySelectorAll<HTMLElement>(TITLE_HOST_SELECTOR)) {
|
|
const category = host.dataset.resourceBookCategory;
|
|
if (category) {
|
|
nodes.push({ key: `title:${category}`, host, box: host });
|
|
}
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
function supportsMotion(host: Element) {
|
|
return typeof (host as HTMLElement).animate === 'function';
|
|
}
|
|
|
|
function prefersReducedMotion() {
|
|
return (
|
|
window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
);
|
|
}
|
|
|
|
export type ResourceBookTransitionController = ReturnType<
|
|
typeof createResourceBookTransitionController
|
|
>;
|
|
|
|
export function createResourceBookTransitionController() {
|
|
let token = 0;
|
|
const records = new Map<string, MotionRecord>();
|
|
let snapshot: Map<string, MotionSnapshotEntry> | null = null;
|
|
let completion: (() => void) | null = null;
|
|
let phasePending = false;
|
|
let phaseStartedAt = 0;
|
|
let lastManagerWidth: number | null = null;
|
|
let lastManagerHeight: number | null = null;
|
|
|
|
const cancelRecord = (record: MotionRecord) => {
|
|
const animation = record.animation;
|
|
record.animation = null;
|
|
record.finishAt = 0;
|
|
if (animation) {
|
|
// 取消会让 finished reject;完成判定只认当前 epoch 的动画。
|
|
record.epoch += 1;
|
|
animation.cancel();
|
|
}
|
|
};
|
|
|
|
const maybeFinish = () => {
|
|
if (!phasePending) return;
|
|
for (const record of records.values()) {
|
|
if (record.animation) return;
|
|
}
|
|
const done = completion;
|
|
completion = null;
|
|
phasePending = false;
|
|
phaseStartedAt = 0;
|
|
done?.();
|
|
};
|
|
|
|
const settle = () => {
|
|
records.forEach(cancelRecord);
|
|
const done = completion;
|
|
completion = null;
|
|
phasePending = false;
|
|
phaseStartedAt = 0;
|
|
done?.();
|
|
};
|
|
|
|
const invalidate = () => {
|
|
token += 1;
|
|
records.forEach(cancelRecord);
|
|
snapshot = null;
|
|
completion = null;
|
|
phasePending = false;
|
|
phaseStartedAt = 0;
|
|
lastManagerWidth = null;
|
|
lastManagerHeight = null;
|
|
return token;
|
|
};
|
|
|
|
const startMotion = (
|
|
record: MotionRecord,
|
|
spec: {
|
|
first: ResourceBookMotionRect | null;
|
|
hold: boolean;
|
|
startOpacity: number;
|
|
endOpacity: number;
|
|
duration: number;
|
|
worldScale: number;
|
|
},
|
|
) => {
|
|
if (!supportsMotion(record.host)) return false;
|
|
const origin = readOrigin(record.host);
|
|
const last = readRect(record.box);
|
|
const base = record.host.style.transform;
|
|
const baseTransform = base === '' ? NONE_TRANSFORM : base;
|
|
const first = resourceBookValidMotionRect(spec.first) ? spec.first : null;
|
|
const values =
|
|
first && resourceBookValidMotionRect(last)
|
|
? resourceBookFlipValues(first, last, origin, spec.worldScale)
|
|
: null;
|
|
const flip =
|
|
values && !resourceBookFlipIsIdentity(values)
|
|
? resourceBookFlipTransform(first!, last, origin, spec.worldScale)
|
|
: null;
|
|
if (!flip && Math.abs(spec.startOpacity - spec.endOpacity) < 0.001) {
|
|
return false;
|
|
}
|
|
const startTransform = flip
|
|
? base === ''
|
|
? flip
|
|
: `${flip} ${base}`
|
|
: baseTransform;
|
|
// 淡出把节点钉在 First 位置,不要一边淡出一边飞到新位置。
|
|
const endTransform = spec.hold || !flip ? startTransform : baseTransform;
|
|
const frames: Keyframe[] = [
|
|
{ transform: startTransform, opacity: spec.startOpacity },
|
|
{ transform: endTransform, opacity: spec.endOpacity },
|
|
];
|
|
let animation: Animation;
|
|
try {
|
|
animation = record.host.animate(frames, {
|
|
duration: spec.duration,
|
|
easing: EASING,
|
|
fill: 'both',
|
|
});
|
|
} catch {
|
|
return false;
|
|
}
|
|
record.epoch += 1;
|
|
const epoch = record.epoch;
|
|
record.animation = animation;
|
|
record.finishAt = performance.now() + spec.duration;
|
|
const finish = () => {
|
|
if (record.epoch !== epoch) return;
|
|
const current = record.animation;
|
|
record.animation = null;
|
|
record.finishAt = 0;
|
|
current?.cancel();
|
|
maybeFinish();
|
|
};
|
|
// 立即挂上拒绝处理,避免后续节点创建失败时留下未处理的 Promise。
|
|
void animation.finished.then(finish, () => undefined);
|
|
return true;
|
|
};
|
|
|
|
const syncNodes = (
|
|
manager: HTMLElement,
|
|
signatures: ResourceBookMotionSignatures,
|
|
options: {
|
|
snapshot?: Map<string, MotionSnapshotEntry> | null;
|
|
frozenKeys?: ReadonlySet<string>;
|
|
forceMountFadeIn?: boolean;
|
|
duration?: number;
|
|
} = {},
|
|
) => {
|
|
const world = readWorld(manager);
|
|
const managerRect = manager.getBoundingClientRect();
|
|
// 容器尺寸变化(窗口 resize、面板折叠)不做动画,只刷新记录。
|
|
const sizeChanged =
|
|
lastManagerWidth !== null &&
|
|
lastManagerHeight !== null &&
|
|
(managerRect.width !== lastManagerWidth ||
|
|
managerRect.height !== lastManagerHeight);
|
|
lastManagerWidth = managerRect.width;
|
|
lastManagerHeight = managerRect.height;
|
|
const suppressed = sizeChanged || prefersReducedMotion();
|
|
const duration =
|
|
options.duration ??
|
|
(phasePending
|
|
? Math.max(
|
|
RESOURCE_BOOK_MOTION_MIN_DURATION,
|
|
phaseStartedAt + RESOURCE_BOOK_MOTION_DURATION - performance.now(),
|
|
)
|
|
: RESOURCE_BOOK_MOTION_DURATION);
|
|
const liveKeys = new Set<string>();
|
|
for (const node of collectNodes(manager)) {
|
|
liveKeys.add(node.key);
|
|
const signature = signatures.get(node.key) ?? '';
|
|
const targetOpacity = readTargetOpacity(node.host);
|
|
const existing = records.get(node.key) ?? null;
|
|
const remounted = existing !== null && existing.host !== node.host;
|
|
const record =
|
|
existing ??
|
|
({
|
|
key: node.key,
|
|
host: node.host,
|
|
box: node.box,
|
|
signature: '',
|
|
targetOpacity: 1,
|
|
cleanRect: null,
|
|
animation: null,
|
|
epoch: 0,
|
|
finishAt: 0,
|
|
} satisfies MotionRecord);
|
|
if (!existing) records.set(node.key, record);
|
|
record.host = node.host;
|
|
record.box = node.box;
|
|
if (options.frozenKeys?.has(node.key)) {
|
|
// 拖拽中的卡片只跟指针走:不参与布局 FLIP,但保持基准几何最新,
|
|
// 落位提交后不会把卡片拉回拖拽前的位置。
|
|
cancelRecord(record);
|
|
record.signature = signature;
|
|
record.targetOpacity = targetOpacity;
|
|
record.cleanRect = toWorldRect(readRect(node.box), world);
|
|
continue;
|
|
}
|
|
|
|
const snapshotEntry = options.snapshot?.get(node.key) ?? null;
|
|
const previousSignature = record.signature;
|
|
const previousTargetOpacity = record.targetOpacity;
|
|
const hadAnimation = record.animation !== null;
|
|
const signatureChanged =
|
|
previousSignature !== '' && previousSignature !== signature;
|
|
const opacityChanged = previousTargetOpacity !== targetOpacity;
|
|
// 已经渲染过、但节点被重新挂载:补一次淡入,避免"凭空出现"。
|
|
const mountFade =
|
|
(options.forceMountFadeIn === true && snapshotEntry === null) ||
|
|
(remounted && record.cleanRect !== null);
|
|
const wantMotion =
|
|
Boolean(snapshotEntry) ||
|
|
signatureChanged ||
|
|
opacityChanged ||
|
|
(mountFade && targetOpacity > 0 && !hadAnimation);
|
|
|
|
if (!wantMotion) {
|
|
record.signature = signature;
|
|
record.targetOpacity = targetOpacity;
|
|
if (!hadAnimation) {
|
|
record.cleanRect = toWorldRect(readRect(node.box), world);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const visual = readRect(node.box);
|
|
const previousCleanRect = record.cleanRect;
|
|
const startOpacity = hadAnimation
|
|
? readVisualOpacity(node.host, previousTargetOpacity)
|
|
: mountFade
|
|
? 0
|
|
: previousTargetOpacity;
|
|
cancelRecord(record);
|
|
record.signature = signature;
|
|
record.targetOpacity = targetOpacity;
|
|
record.cleanRect = toWorldRect(readRect(node.box), world);
|
|
if (suppressed) continue;
|
|
// 转场用 begin 记录的 First;动画中途重基用"此刻像素";空闲布局变化用上一次的干净矩形。
|
|
const first =
|
|
snapshotEntry?.rect ??
|
|
(hadAnimation
|
|
? visual
|
|
: previousCleanRect
|
|
? fromWorldRect(previousCleanRect, world)
|
|
: visual);
|
|
startMotion(record, {
|
|
first,
|
|
hold: targetOpacity < 1,
|
|
startOpacity:
|
|
snapshotEntry && !hadAnimation ? snapshotEntry.opacity : startOpacity,
|
|
endOpacity: targetOpacity,
|
|
duration,
|
|
worldScale: world.scale,
|
|
});
|
|
}
|
|
|
|
for (const record of records.values()) {
|
|
if (!liveKeys.has(record.key) && !record.host.isConnected) {
|
|
cancelRecord(record);
|
|
}
|
|
}
|
|
// 保留已卸载节点的记录用于重新挂载时的淡入;数量过多时回收。
|
|
if (records.size > 512) {
|
|
for (const [key, record] of records) {
|
|
if (!record.host.isConnected) records.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
/**
|
|
* 状态提交前记录 First:真实元素的屏幕矩形和当前不透明度。
|
|
*
|
|
* 只记录当前可见的节点。不可见节点(内联 opacity 0)的屏幕矩形是"看不见的位置":
|
|
* 非活动栏目的标题栏在子画布态就属于这种情况——它的布局坐标还是总览矩形,但被
|
|
* 子画布 world 变换推到了屏幕外。若把它当 First,返回总览时标题栏会从屏幕外飞
|
|
* 进来。没有 First 的节点只走淡入,位置保持不动。
|
|
*/
|
|
begin(manager: HTMLElement | null) {
|
|
const captured = new Map<string, MotionSnapshotEntry>();
|
|
if (manager) {
|
|
for (const node of collectNodes(manager)) {
|
|
const opacity = readVisualOpacity(
|
|
node.host,
|
|
readTargetOpacity(node.host),
|
|
);
|
|
if (opacity <= 0.001) continue;
|
|
const rect = readRect(node.box);
|
|
if (!resourceBookValidMotionRect(rect)) continue;
|
|
captured.set(node.key, { rect, opacity });
|
|
}
|
|
}
|
|
invalidate();
|
|
snapshot = captured;
|
|
return token;
|
|
},
|
|
invalidate,
|
|
settle,
|
|
isCurrent: (expected: number) => token === expected,
|
|
/** 目标 DOM 提交后播放转场:用 begin 记录的 First 反算反向 transform。 */
|
|
play(
|
|
manager: HTMLElement | null,
|
|
expected: number,
|
|
done: () => void,
|
|
signatures: ResourceBookMotionSignatures = EMPTY_SIGNATURES,
|
|
frozenKeys?: ReadonlySet<string>,
|
|
) {
|
|
if (token !== expected) return;
|
|
const first = snapshot;
|
|
snapshot = null;
|
|
completion = done;
|
|
phasePending = true;
|
|
phaseStartedAt = performance.now();
|
|
if (
|
|
!manager ||
|
|
!first?.size ||
|
|
prefersReducedMotion() ||
|
|
!supportsMotion(manager)
|
|
) {
|
|
settle();
|
|
return;
|
|
}
|
|
syncNodes(manager, signatures, {
|
|
snapshot: first,
|
|
frozenKeys,
|
|
forceMountFadeIn: true,
|
|
duration: RESOURCE_BOOK_MOTION_DURATION,
|
|
});
|
|
maybeFinish();
|
|
},
|
|
/**
|
|
* 每次布局提交后调用。比较布局签名,变化时从当前视觉矩形重基:
|
|
* 取消旧动画 → 重新测量 → 按"此刻像素 → 新 Last"加反向 transform 再过渡回 identity。
|
|
*/
|
|
sync(
|
|
manager: HTMLElement | null,
|
|
signatures: ResourceBookMotionSignatures = EMPTY_SIGNATURES,
|
|
frozenKeys?: ReadonlySet<string>,
|
|
) {
|
|
if (!manager) return;
|
|
syncNodes(manager, signatures, { frozenKeys });
|
|
maybeFinish();
|
|
},
|
|
isAnimating() {
|
|
for (const record of records.values()) {
|
|
if (record.animation) return true;
|
|
}
|
|
return false;
|
|
},
|
|
/** 运行中动画的最长剩余时长;用于决定淡出节点何时从 DOM 撤下。 */
|
|
motionRemainingMs() {
|
|
const now = performance.now();
|
|
let remaining = 0;
|
|
for (const record of records.values()) {
|
|
if (!record.animation) continue;
|
|
remaining = Math.max(remaining, Math.max(0, record.finishAt - now));
|
|
}
|
|
return remaining;
|
|
},
|
|
dispose() {
|
|
return invalidate();
|
|
},
|
|
};
|
|
}
|