Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts
T
suzmii b41b26cab8 修复资源画本进栏目只有少数卡片有位移:没有 First 帧的卡片改从那一摞起飞
- apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts:begin() 顺带按 (栏目, 类型摞列号) 记下同一摞最深那张卡的 First 矩形当"堆锚点",play() 时没有 First 帧的卡片认领它当合成 First(仍是同一套 FLIP,不新增动画系统、不为动画多渲染节点);堆锚点只在本次转场有效(maybeFinish / settle / invalidate 清掉),转场中重基(已在跑动画)不认领锚点,起点仍是"此刻像素"。
- apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts:展开态(allOpen)的卡片补上真实类型摞列号,不再一律 0——否则第 2 摞之后的卡片会全从第 1 摞飞出来。
- apps/ai-game-creator-shell/src/view/project-development/index.tsx:卡片宿主补 data-resource-book-stack-column / data-resource-book-stack-index,把"哪一摞"的摞身份交给转场层。
- apps/ai-game-creator-shell/tests/resourceBookController.test.ts:新增 3 条用例——每一张卡都从自己那一摞位移出来(含第 0/1 摞分别认领各自锚点、总览已渲染的仍用自己 First)、转场中重基从此刻像素而不是被拉回摞上、转场收尾后挂载的卡片不从过期锚点飞出。
- apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts:新增"展开态每张卡带自己那一摞的列号"用例(同栏目两摞 ⇒ [0,0,1])。
- apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:在既有总览/进栏目用例里钉住宿主上的摞身份属性(总览 3 张 = 下标 0,1,2;进栏目后 5 张 = 下标 0..4、同类型列号 0)。
- docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md:把资源画本转场一节更新为当前状态(堆锚点来源、锚点生命周期、展开态列号)。
- docs/project-memory/shared-memory/pitfalls.md:记录本次排障——现象、已用代码核实的原因(总览每摞只铺 3 张 ⇒ 其余卡片没有节点、没有 First 帧)、"钉住的标题栏移出 world"那笔改动不是原因的证据、处理、变异验证与真机判据。
2026-09-12 18:56:43 +08:00

729 lines
25 KiB
TypeScript

/**
* 资源画本转场:对真实元素做 FLIP,不再克隆快照。
*
* 总览与子画布之间,同一张卡一直是同一个 DOM 节点(同一个 React key、同一个父节点,
* 只是布局函数从 resourceBookOverviewCardLayout 换成 resourceBookChildCardLayout)。
* 因此转场只需要:状态提交前记录 First 矩形,提交后测 Last 矩形,给同一个节点加反向
* transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,也就没有交接瞬间
* 的跳变。
*
* 预览图尺寸是异步到达的,卡片尺寸/位置会在动画之后(甚至动画结束之后)再变一次。
* 所有布局变化都走同一台机器:sync() 比较每个节点的布局签名,变化时从"当前视觉矩形"
* 重新加反向 transform(可中断重基),所以二次变化是动画而不是瞬移。
*
* 总览里每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张卡(其余数量写在栏目卡标题栏的
* "N 项"里),所以进栏目时**大部分卡片在总览侧根本没有节点**、拿不到 First 帧。这类卡片
* 不能原地淡入(看起来就是"直接出现"),而是从"它自己那一摞"起飞:begin() 顺带记下同一
* 栏目、同一摞**最深那张卡**的矩形,play() 时给没有 First 帧的卡片当合成 First(堆锚点)。
* 仍然是同一套 FLIP,也没有为动画多渲染任何节点。
*/
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;
};
/**
* 一次转场的起点:逐节点的 First 帧,外加每摞的"起飞点"。
*
* `stackAnchors` 只收**总览里真实渲染出来的**卡片:同一栏目、同一摞最深那张卡的矩形,
* 就是"那一摞"在屏幕上的落点。没有 First 帧的卡片按 (栏目, 摞列号) 认领它。
*/
type MotionSnapshot = {
entries: Map<string, MotionSnapshotEntry>;
stackAnchors: Map<string, ResourceBookMotionRect>;
};
/**
* 节点在总览里的"那一摞"身份:栏目 + 类型摞的列号(与
* `resourceBookOverviewCardLayout` 的 `stackColumn` 同一口径,见
* `buildResourceBookScenePlan`)。渲染层把这两个数写在卡片宿主上,转场层不认布局函数
* 也能把卡片认回它自己那一摞。
*/
type MotionStackIdentity = {
category: string;
column: number;
index: number;
};
function readStackIdentity(host: HTMLElement): MotionStackIdentity | null {
const category = host.dataset.resourceBookCategory;
const column = Number.parseInt(
host.dataset.resourceBookStackColumn ?? '',
10,
);
if (!category || !Number.isFinite(column)) return null;
const index = Number.parseInt(host.dataset.resourceBookStackIndex ?? '', 10);
return { category, column, index: Number.isFinite(index) ? index : 0 };
}
function stackAnchorKey(category: string, column: number) {
return `${category}\n${column}`;
}
/** 没有 First 帧的卡片:用它自己那一摞的起飞点当合成 First(没有锚点就返回 null)。 */
function readStackAnchor(
host: HTMLElement,
stackAnchors: ReadonlyMap<string, ResourceBookMotionRect> | null,
): ResourceBookMotionRect | null {
if (!stackAnchors?.size) return null;
const stack = readStackIdentity(host);
if (!stack) return null;
const rect = stackAnchors.get(stackAnchorKey(stack.category, stack.column));
return resourceBookValidMotionRect(rect) ? rect : null;
}
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 局部空间(屏幕位移除以 world 缩放),钉住的栏目标题栏按屏幕空间(比例 1)。
*/
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,原点就是它的矩形左上角;标题栏是
* 有尺寸的节点,内联 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;
};
type SceneWorldTransform = WorldTransform & { element: HTMLElement | null };
/**
* 不在 world 里的节点(钉在视口上的栏目标题栏)所在坐标系:它本身就在屏幕坐标系里,
* 屏幕位移不需要除以 world 缩放,几何换算走恒等。
*/
const SCREEN_SPACE_TRANSFORM: WorldTransform = {
scale: 1,
origin: { left: 0, top: 0 },
};
/**
* world 变换(平移 + 缩放,transform-origin 0 0)。宿主节点的几何基准统一存成
* world 局部坐标,平移和缩放就不会把"上一次的干净位置"变成过期的屏幕坐标。
*/
function readWorld(root: HTMLElement): SceneWorldTransform {
const world = root.querySelector<HTMLElement>(WORLD_SELECTOR);
if (!world) return { element: null, 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 { element: world, scale, origin: { left: rect.left, top: rect.top } };
}
/**
* 节点自己的坐标系。钉住的栏目标题栏是 world 的**兄弟**节点(见 `ResourceBookScene`),
* 它不经 world 缩放,必须按屏幕坐标系换算,否则 FLIP 的反向平移会被 world 缩放除错,
* 动画终点会偏移。
*/
function nodeWorldTransform(
host: HTMLElement,
world: SceneWorldTransform,
): WorldTransform {
return world.element?.contains(host) ? world : SCREEN_SPACE_TRANSFORM;
}
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: MotionSnapshot | null = null;
/**
* 本次转场的堆锚点,只在这次转场期间有效。
*
* 转场一结束(`maybeFinish`)就清掉:否则转场之后才挂载的卡片会从一段过期的"摞"
* 位置飞出来(那时它和新位置毫无关系)。
*/
let stackAnchors: ReadonlyMap<string, ResourceBookMotionRect> | 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;
stackAnchors = null;
done?.();
};
const settle = () => {
records.forEach(cancelRecord);
const done = completion;
completion = null;
phasePending = false;
phaseStartedAt = 0;
stackAnchors = null;
done?.();
};
const invalidate = () => {
token += 1;
records.forEach(cancelRecord);
snapshot = null;
stackAnchors = 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;
// 每个节点用自己的坐标系:world 里的卡片按 world 缩放换算,钉住的栏目标题栏按屏幕换算。
const nodeWorld = nodeWorldTransform(node.host, world);
if (options.frozenKeys?.has(node.key)) {
// 拖拽中的卡片只跟指针走:不参与布局 FLIP,但保持基准几何最新,
// 落位提交后不会把卡片拉回拖拽前的位置。
cancelRecord(record);
record.signature = signature;
record.targetOpacity = targetOpacity;
record.cleanRect = toWorldRect(readRect(node.box), nodeWorld);
continue;
}
const snapshotEntry = options.snapshot?.get(node.key) ?? null;
const previousSignature = record.signature;
const previousTargetOpacity = record.targetOpacity;
const hadAnimation = record.animation !== null;
/**
* 合成 First:没有 First 帧、当前又没有动画在跑的卡片(总览每摞只铺 3 张,进栏目
* 时其余卡片在总览侧没有节点),从它自己那一摞的起飞点开始。
*
* 已经在跑动画的节点(转场中重基)不认领锚点:它的起点必须是"此刻像素",否则会被
* 拉回那一摞。
*/
const stackAnchor =
snapshotEntry === null && !hadAnimation && targetOpacity > 0
? readStackAnchor(node.host, stackAnchors)
: 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) ||
Boolean(stackAnchor) ||
signatureChanged ||
opacityChanged ||
(mountFade && targetOpacity > 0 && !hadAnimation);
if (!wantMotion) {
record.signature = signature;
record.targetOpacity = targetOpacity;
if (!hadAnimation) {
record.cleanRect = toWorldRect(readRect(node.box), nodeWorld);
}
continue;
}
const visual = readRect(node.box);
const previousCleanRect = record.cleanRect;
const startOpacity = hadAnimation
? readVisualOpacity(node.host, previousTargetOpacity)
: stackAnchor
? targetOpacity
: mountFade
? 0
: previousTargetOpacity;
cancelRecord(record);
record.signature = signature;
record.targetOpacity = targetOpacity;
record.cleanRect = toWorldRect(readRect(node.box), nodeWorld);
if (suppressed) continue;
// 转场用 begin 记录的 First(没有 First 的卡片用那一摞的堆锚点);动画中途重基用
// "此刻像素";空闲布局变化用上一次的干净矩形。
const first =
snapshotEntry?.rect ??
stackAnchor ??
(hadAnimation
? visual
: previousCleanRect
? fromWorldRect(previousCleanRect, nodeWorld)
: visual);
startMotion(record, {
first,
hold: targetOpacity < 1,
startOpacity:
snapshotEntry && !hadAnimation ? snapshotEntry.opacity : startOpacity,
endOpacity: targetOpacity,
duration,
worldScale: nodeWorld.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 的节点只走淡入,位置保持不动。
*
* 顺带记录每摞的起飞点:同一栏目、同一摞里**最深那张卡**(`stackIndex` 最大)的矩形。
* 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进栏目时的其余卡片在总览侧
* 没有节点、拿不到 First,它们就从这个矩形起飞(见 `stackAnchors`)。
*/
begin(manager: HTMLElement | null) {
const entries = new Map<string, MotionSnapshotEntry>();
const anchors = new Map<string, ResourceBookMotionRect>();
const anchorDepths = new Map<string, number>();
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;
entries.set(node.key, { rect, opacity });
const stack = readStackIdentity(node.host);
if (!stack) continue;
const anchorKey = stackAnchorKey(stack.category, stack.column);
if ((anchorDepths.get(anchorKey) ?? -1) >= stack.index) continue;
anchorDepths.set(anchorKey, stack.index);
anchors.set(anchorKey, rect);
}
}
invalidate();
snapshot = { entries, stackAnchors: anchors };
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 captured = snapshot;
snapshot = null;
completion = done;
phasePending = true;
phaseStartedAt = performance.now();
if (
!manager ||
!captured?.entries.size ||
prefersReducedMotion() ||
!supportsMotion(manager)
) {
settle();
return;
}
// 只有真正在铺卡片的转场才带堆锚点:没有任何 First 帧时整段转场本来就是跳过的。
stackAnchors = captured.stackAnchors;
syncNodes(manager, signatures, {
snapshot: captured.entries,
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();
},
};
}