Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts
T
suzmii 4a5b2ad524 修复返回总览时非活动栏目标题栏从屏幕外飞入
- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First
- 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点
- 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置
- 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出
- 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出
- 同步技术方案转场段与 pitfalls 经验条目
2026-09-09 21:39:20 +08:00

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();
},
};
}