b41b26cab8
- 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"那笔改动不是原因的证据、处理、变异验证与真机判据。
729 lines
25 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|