修复资源画本进栏目只有少数卡片有位移:没有 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"那笔改动不是原因的证据、处理、变异验证与真机判据。
This commit is contained in:
@@ -1293,6 +1293,14 @@ function ResourceBookScene({
|
||||
<div
|
||||
className={`game-resource-book-scene-card${expanded ? ' is-expanded' : ''}`}
|
||||
data-resource-book-category={group.category}
|
||||
/*
|
||||
卡片在总览里属于哪一摞(栏目 + 类型摞列号 + 摞内下标):总览每摞只铺
|
||||
`RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进栏目时其余卡片在总览侧没有
|
||||
节点、拿不到 First 帧,转场层靠这两个数把它们认回自己那一摞的起飞点
|
||||
(见 `resourceBookController` 的堆锚点)。
|
||||
*/
|
||||
data-resource-book-stack-column={card.stackColumn}
|
||||
data-resource-book-stack-index={card.stackIndex}
|
||||
aria-hidden={!cardVisible}
|
||||
key={card.key}
|
||||
style={hostStyle}
|
||||
|
||||
+108
-12
@@ -10,6 +10,12 @@
|
||||
* 预览图尺寸是异步到达的,卡片尺寸/位置会在动画之后(甚至动画结束之后)再变一次。
|
||||
* 所有布局变化都走同一台机器:sync() 比较每个节点的布局签名,变化时从"当前视觉矩形"
|
||||
* 重新加反向 transform(可中断重基),所以二次变化是动画而不是瞬移。
|
||||
*
|
||||
* 总览里每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张卡(其余数量写在栏目卡标题栏的
|
||||
* "N 项"里),所以进栏目时**大部分卡片在总览侧根本没有节点**、拿不到 First 帧。这类卡片
|
||||
* 不能原地淡入(看起来就是"直接出现"),而是从"它自己那一摞"起飞:begin() 顺带记下同一
|
||||
* 栏目、同一摞**最深那张卡**的矩形,play() 时给没有 First 帧的卡片当合成 First(堆锚点)。
|
||||
* 仍然是同一套 FLIP,也没有为动画多渲染任何节点。
|
||||
*/
|
||||
export const RESOURCE_BOOK_MOTION_DURATION = 420;
|
||||
// 重基时给剩余时长设下限,避免 0ms 跳变。
|
||||
@@ -43,6 +49,56 @@ type MotionSnapshotEntry = {
|
||||
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;
|
||||
@@ -284,7 +340,14 @@ export type ResourceBookTransitionController = ReturnType<
|
||||
export function createResourceBookTransitionController() {
|
||||
let token = 0;
|
||||
const records = new Map<string, MotionRecord>();
|
||||
let snapshot: Map<string, MotionSnapshotEntry> | null = null;
|
||||
let snapshot: MotionSnapshot | null = null;
|
||||
/**
|
||||
* 本次转场的堆锚点,只在这次转场期间有效。
|
||||
*
|
||||
* 转场一结束(`maybeFinish`)就清掉:否则转场之后才挂载的卡片会从一段过期的"摞"
|
||||
* 位置飞出来(那时它和新位置毫无关系)。
|
||||
*/
|
||||
let stackAnchors: ReadonlyMap<string, ResourceBookMotionRect> | null = null;
|
||||
let completion: (() => void) | null = null;
|
||||
let phasePending = false;
|
||||
let phaseStartedAt = 0;
|
||||
@@ -311,6 +374,7 @@ export function createResourceBookTransitionController() {
|
||||
completion = null;
|
||||
phasePending = false;
|
||||
phaseStartedAt = 0;
|
||||
stackAnchors = null;
|
||||
done?.();
|
||||
};
|
||||
|
||||
@@ -320,6 +384,7 @@ export function createResourceBookTransitionController() {
|
||||
completion = null;
|
||||
phasePending = false;
|
||||
phaseStartedAt = 0;
|
||||
stackAnchors = null;
|
||||
done?.();
|
||||
};
|
||||
|
||||
@@ -327,6 +392,7 @@ export function createResourceBookTransitionController() {
|
||||
token += 1;
|
||||
records.forEach(cancelRecord);
|
||||
snapshot = null;
|
||||
stackAnchors = null;
|
||||
completion = null;
|
||||
phasePending = false;
|
||||
phaseStartedAt = 0;
|
||||
@@ -469,6 +535,17 @@ export function createResourceBookTransitionController() {
|
||||
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;
|
||||
@@ -478,6 +555,7 @@ export function createResourceBookTransitionController() {
|
||||
(remounted && record.cleanRect !== null);
|
||||
const wantMotion =
|
||||
Boolean(snapshotEntry) ||
|
||||
Boolean(stackAnchor) ||
|
||||
signatureChanged ||
|
||||
opacityChanged ||
|
||||
(mountFade && targetOpacity > 0 && !hadAnimation);
|
||||
@@ -495,17 +573,21 @@ export function createResourceBookTransitionController() {
|
||||
const previousCleanRect = record.cleanRect;
|
||||
const startOpacity = hadAnimation
|
||||
? readVisualOpacity(node.host, previousTargetOpacity)
|
||||
: mountFade
|
||||
? 0
|
||||
: 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;动画中途重基用"此刻像素";空闲布局变化用上一次的干净矩形。
|
||||
// 转场用 begin 记录的 First(没有 First 的卡片用那一摞的堆锚点);动画中途重基用
|
||||
// "此刻像素";空闲布局变化用上一次的干净矩形。
|
||||
const first =
|
||||
snapshotEntry?.rect ??
|
||||
stackAnchor ??
|
||||
(hadAnimation
|
||||
? visual
|
||||
: previousCleanRect
|
||||
@@ -543,9 +625,15 @@ export function createResourceBookTransitionController() {
|
||||
* 非活动栏目的标题栏在子画布态就属于这种情况——它的布局坐标还是总览矩形,但被
|
||||
* 子画布 world 变换推到了屏幕外。若把它当 First,返回总览时标题栏会从屏幕外飞
|
||||
* 进来。没有 First 的节点只走淡入,位置保持不动。
|
||||
*
|
||||
* 顺带记录每摞的起飞点:同一栏目、同一摞里**最深那张卡**(`stackIndex` 最大)的矩形。
|
||||
* 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进栏目时的其余卡片在总览侧
|
||||
* 没有节点、拿不到 First,它们就从这个矩形起飞(见 `stackAnchors`)。
|
||||
*/
|
||||
begin(manager: HTMLElement | null) {
|
||||
const captured = new Map<string, MotionSnapshotEntry>();
|
||||
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(
|
||||
@@ -555,17 +643,23 @@ export function createResourceBookTransitionController() {
|
||||
if (opacity <= 0.001) continue;
|
||||
const rect = readRect(node.box);
|
||||
if (!resourceBookValidMotionRect(rect)) continue;
|
||||
captured.set(node.key, { rect, opacity });
|
||||
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 = captured;
|
||||
snapshot = { entries, stackAnchors: anchors };
|
||||
return token;
|
||||
},
|
||||
invalidate,
|
||||
settle,
|
||||
isCurrent: (expected: number) => token === expected,
|
||||
/** 目标 DOM 提交后播放转场:用 begin 记录的 First 反算反向 transform。 */
|
||||
/** 目标 DOM 提交后播放转场:用 begin 记录的 First(或那一摞的堆锚点)反算反向 transform。 */
|
||||
play(
|
||||
manager: HTMLElement | null,
|
||||
expected: number,
|
||||
@@ -574,22 +668,24 @@ export function createResourceBookTransitionController() {
|
||||
frozenKeys?: ReadonlySet<string>,
|
||||
) {
|
||||
if (token !== expected) return;
|
||||
const first = snapshot;
|
||||
const captured = snapshot;
|
||||
snapshot = null;
|
||||
completion = done;
|
||||
phasePending = true;
|
||||
phaseStartedAt = performance.now();
|
||||
if (
|
||||
!manager ||
|
||||
!first?.size ||
|
||||
!captured?.entries.size ||
|
||||
prefersReducedMotion() ||
|
||||
!supportsMotion(manager)
|
||||
) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
// 只有真正在铺卡片的转场才带堆锚点:没有任何 First 帧时整段转场本来就是跳过的。
|
||||
stackAnchors = captured.stackAnchors;
|
||||
syncNodes(manager, signatures, {
|
||||
snapshot: first,
|
||||
snapshot: captured.entries,
|
||||
frozenKeys,
|
||||
forceMountFadeIn: true,
|
||||
duration: RESOURCE_BOOK_MOTION_DURATION,
|
||||
|
||||
@@ -450,6 +450,19 @@ export function buildResourceBookScenePlan({
|
||||
};
|
||||
}
|
||||
if (cardsReady && presentation === 'child') {
|
||||
/**
|
||||
* 展开态的每张卡仍带"它在总览里属于哪一摞"的列号:总览每摞只铺
|
||||
* `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进展开态时的其余卡片在总览侧没有节点、
|
||||
* 拿不到 First 帧,转场层要按 (栏目, 列号) 认领那一摞的起飞点(见
|
||||
* `resourceBookController` 的堆锚点)。
|
||||
*
|
||||
* 列号只服务这个身份,不影响几何:展开态用分带布局,`stackIndex / stackCount`
|
||||
* 只服务 z-index,而展开态不设 z-index(见 `ResourceBookScene`)。
|
||||
*/
|
||||
const stackColumnByResourceId = new Map<string, number>();
|
||||
groupResourceBookStacks(categoryResources).forEach(([, items], column) => {
|
||||
items.forEach((item) => stackColumnByResourceId.set(item.id, column));
|
||||
});
|
||||
categoryResources.forEach((resource) => {
|
||||
cards.push({
|
||||
key: resourceBookSceneCardKey(resource.id),
|
||||
@@ -457,7 +470,7 @@ export function buildResourceBookScenePlan({
|
||||
resource,
|
||||
presentation: 'child',
|
||||
stackIndex: 0,
|
||||
stackColumn: 0,
|
||||
stackColumn: stackColumnByResourceId.get(resource.id) ?? 0,
|
||||
stackCount: 1,
|
||||
layout: childLayoutFor(category, resource),
|
||||
});
|
||||
|
||||
@@ -1178,6 +1178,16 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
),
|
||||
).map((card) => Number(card.style.zIndex));
|
||||
expect(overviewCardLayers[0]).toBeGreaterThan(overviewCardLayers.at(-1)!);
|
||||
// 摞身份(栏目 × 列号 × 摞内下标)写在卡片宿主上:总览每摞只铺 N 张,进栏目时其余卡片在
|
||||
// 总览侧没有节点、拿不到 First 帧,转场层靠这两个数把它们认回自己那一摞的起飞点
|
||||
// (见 `resourceBookController` 的堆锚点)。
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-book-scene-card[data-resource-book-category="character"]',
|
||||
),
|
||||
).map((card) => card.dataset.resourceBookStackIndex),
|
||||
).toEqual(['0', '1', '2']);
|
||||
|
||||
await openResourceBookCategory('角色与对象');
|
||||
await waitFor(() =>
|
||||
@@ -1187,6 +1197,22 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
),
|
||||
).toHaveLength(5),
|
||||
);
|
||||
// 进栏目后铺满全部卡片,每张卡都还带着自己那一摞的身份:没有 First 帧的那几张正是靠它
|
||||
// 从摞里飞出来(只带下标、不带列号,或者干脆不带,都会退化成原地淡入)。
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]',
|
||||
),
|
||||
).map((card) => card.dataset.resourceBookStackIndex),
|
||||
).toEqual(['0', '1', '2', '3', '4']);
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]',
|
||||
),
|
||||
).map((card) => card.dataset.resourceBookStackColumn),
|
||||
).toEqual(['0', '0', '0', '0', '0']);
|
||||
|
||||
fireEvent.change(openResourceSearch(), {
|
||||
target: { value: 'overview-art-4' },
|
||||
|
||||
@@ -160,6 +160,297 @@ afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
type StackCardSpec = {
|
||||
id: string;
|
||||
/** 总览里的类型摞列号(与 `resourceBookOverviewCardLayout` 的 `stackColumn` 同口径)。 */
|
||||
column: number;
|
||||
/** 摞内下标:越大越深、越靠后。 */
|
||||
index: number;
|
||||
rect: MotionRect;
|
||||
};
|
||||
|
||||
/**
|
||||
* 多卡宿主:把场景世界换成"一根栏目摞"。
|
||||
*
|
||||
* 宿主是 0x0 的定位包装,它的原点就是卡片自己的落点(经典 FLIP 起点);真假两态都复用它,
|
||||
* 差别只在传入的卡片清单与矩形——总览一摞只铺 3 张,进栏目后这一摞铺满。
|
||||
*/
|
||||
function mountStackCards(world: HTMLElement, cards: StackCardSpec[]) {
|
||||
world.replaceChildren();
|
||||
const hosts = new Map<string, HTMLElement>();
|
||||
const boxes = new Map<string, HTMLElement>();
|
||||
for (const card of cards) {
|
||||
const host = document.createElement('div');
|
||||
host.className = 'game-resource-book-scene-card';
|
||||
host.dataset.resourceBookCategory = 'unclassified';
|
||||
host.dataset.resourceBookStackColumn = String(card.column);
|
||||
host.dataset.resourceBookStackIndex = String(card.index);
|
||||
const box = document.createElement('div');
|
||||
box.className = 'game-resource-card';
|
||||
box.dataset.resourceCardId = card.id;
|
||||
host.append(box);
|
||||
world.append(host);
|
||||
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(() =>
|
||||
toDomRect({
|
||||
left: card.rect.left,
|
||||
top: card.rect.top,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}),
|
||||
);
|
||||
vi.spyOn(box, 'getBoundingClientRect').mockImplementation(() =>
|
||||
toDomRect(card.rect),
|
||||
);
|
||||
hosts.set(card.id, host);
|
||||
boxes.set(card.id, box);
|
||||
}
|
||||
return { hosts, boxes };
|
||||
}
|
||||
|
||||
/** 卡片宿主上必须正好有一段动画;返回它,避免逐处 `flights.find(...)!`。 */
|
||||
function flightFor(flights: Flight[], host: Element | undefined) {
|
||||
const matches = flights.filter((flight) => flight.host === host);
|
||||
expect(matches).toHaveLength(1);
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
describe('resource book pile anchors', () => {
|
||||
/**
|
||||
* 用户口径:进栏目时**每一张卡**都要从那摞里位移出来,而不是只有总览里已经渲染的那几张。
|
||||
*
|
||||
* 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张(其余数量写在"N 项"里),所以
|
||||
* 大部分卡片在总览侧没有节点、拿不到 First 帧;它们必须从自己那一摞起飞(合成 First),
|
||||
* 而不是原地淡入。
|
||||
*/
|
||||
it('flies every card of the opened category out of its own overview pile', () => {
|
||||
const flights = installAnimateMock();
|
||||
const { manager, world, signatures } = fixture();
|
||||
// 总览:第 0 摞铺满 3 张(下标 2 最深,就是"那一摞"的落点),第 1 摞只有 1 张。
|
||||
mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 100, top: 100, width: 92, height: 64 },
|
||||
},
|
||||
{
|
||||
id: 'art-1',
|
||||
column: 0,
|
||||
index: 1,
|
||||
rect: { left: 100, top: 105, width: 92, height: 64 },
|
||||
},
|
||||
{
|
||||
id: 'art-2',
|
||||
column: 0,
|
||||
index: 2,
|
||||
rect: { left: 100, top: 110, width: 92, height: 64 },
|
||||
},
|
||||
{
|
||||
id: 'art-3',
|
||||
column: 1,
|
||||
index: 0,
|
||||
rect: { left: 202, top: 110, width: 92, height: 64 },
|
||||
},
|
||||
]);
|
||||
const controller = createResourceBookTransitionController();
|
||||
const token = controller.begin(manager);
|
||||
|
||||
// 提交后:这一摞铺满全部卡片,art-4 / art-5 / art-6 在总览侧从来没有节点。
|
||||
const { hosts } = mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 400, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-1',
|
||||
column: 0,
|
||||
index: 1,
|
||||
rect: { left: 400, top: 700, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-2',
|
||||
column: 0,
|
||||
index: 2,
|
||||
rect: { left: 400, top: 900, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-4',
|
||||
column: 0,
|
||||
index: 3,
|
||||
rect: { left: 700, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-5',
|
||||
column: 0,
|
||||
index: 4,
|
||||
rect: { left: 700, top: 700, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-3',
|
||||
column: 1,
|
||||
index: 0,
|
||||
rect: { left: 1_000, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-6',
|
||||
column: 1,
|
||||
index: 1,
|
||||
rect: { left: 1_000, top: 700, width: 180, height: 128 },
|
||||
},
|
||||
]);
|
||||
controller.play(manager, token, () => undefined, signatures);
|
||||
|
||||
// 名单:这一栏目进的每一张卡都要有位移(起点 ≠ 终点),不是只有总览那几张。
|
||||
for (const [id, host] of hosts) {
|
||||
const flight = flightFor(flights, host);
|
||||
expect(flight.frames[0]!.transform, id).not.toBe(
|
||||
flight.frames[1]!.transform,
|
||||
);
|
||||
expect(flight.frames[1]!.transform, id).toBe('none');
|
||||
}
|
||||
|
||||
// 起点是"自己那一摞"最深那张卡的矩形。
|
||||
expect(flightFor(flights, hosts.get('art-4')).frames[0]!.transform).toBe(
|
||||
'translate(-600px, -390px) scale(0.511, 0.5)',
|
||||
);
|
||||
// 第 1 摞的卡片从第 1 摞起飞,不会被贴到第 0 摞的落点上。
|
||||
expect(flightFor(flights, hosts.get('art-6')).frames[0]!.transform).toBe(
|
||||
'translate(-798px, -590px) scale(0.511, 0.5)',
|
||||
);
|
||||
// 总览里已经渲染的那几张仍从它自己的 First 帧起飞。
|
||||
expect(flightFor(flights, hosts.get('art-0')).frames[0]!.transform).toBe(
|
||||
'translate(-300px, -400px) scale(0.511, 0.5)',
|
||||
);
|
||||
// 从摞里出来是"看得见地飞",不叠一层原地淡入。
|
||||
expect(flightFor(flights, hosts.get('art-4')).frames[0]!.opacity).toBe(1);
|
||||
expect(flightFor(flights, hosts.get('art-4')).frames[1]!.opacity).toBe(1);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
/**
|
||||
* 卡片已经飞出那一摞、正在动的时候,预览图尺寸才到(布局再变一次)。
|
||||
*
|
||||
* 重基必须从"此刻像素"续上:堆锚点在整段转场里都还活着,一旦重基也去认领它,
|
||||
* 卡片会被拉回摞上再飞一次——用户看到的是"飞一半又跳回卡片堆"。
|
||||
*/
|
||||
it('rebases a flying card from the current pixels instead of the pile anchor', () => {
|
||||
const flights = installAnimateMock();
|
||||
const now = vi.spyOn(performance, 'now');
|
||||
now.mockReturnValue(1_000);
|
||||
const { manager, world, signatures } = fixture();
|
||||
mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 100, top: 100, width: 92, height: 64 },
|
||||
},
|
||||
]);
|
||||
const controller = createResourceBookTransitionController();
|
||||
const token = controller.begin(manager);
|
||||
const { hosts, boxes } = mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 400, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-4',
|
||||
column: 0,
|
||||
index: 1,
|
||||
rect: { left: 700, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
]);
|
||||
signatures.set('card:art-0', 'child:400,500:180,128');
|
||||
signatures.set('card:art-4', 'child:700,500:180,128');
|
||||
controller.play(manager, token, () => undefined, signatures);
|
||||
const flying = flightFor(flights, hosts.get('art-4'));
|
||||
expect(flying.frames[0]!.transform).not.toBe('none');
|
||||
|
||||
now.mockReturnValue(1_300);
|
||||
const visual: MotionRect = { left: 650, top: 470, width: 140, height: 100 };
|
||||
const clean: MotionRect = { left: 720, top: 520, width: 220, height: 160 };
|
||||
vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() =>
|
||||
toDomRect(visual),
|
||||
);
|
||||
flying.cancel.mockImplementation(() => {
|
||||
vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() =>
|
||||
toDomRect(clean),
|
||||
);
|
||||
flying.reject();
|
||||
});
|
||||
signatures.set('card:art-4', 'child:720,520:220,160');
|
||||
controller.sync(manager, signatures);
|
||||
|
||||
const rebased = flights.filter(
|
||||
(flight) => flight.host === hosts.get('art-4'),
|
||||
);
|
||||
expect(rebased).toHaveLength(2);
|
||||
expect(rebased[1]!.frames[0]!.transform).toBe(
|
||||
resourceBookFlipTransform(visual, clean, { left: 700, top: 500 }),
|
||||
);
|
||||
expect(rebased[1]!.frames[1]!.transform).toBe('none');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
it('does not fly a card mounted after the transition out of a stale pile', async () => {
|
||||
const flights = installAnimateMock();
|
||||
const { manager, world, signatures } = fixture();
|
||||
mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 100, top: 100, width: 92, height: 64 },
|
||||
},
|
||||
]);
|
||||
const controller = createResourceBookTransitionController();
|
||||
const token = controller.begin(manager);
|
||||
mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 400, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-4',
|
||||
column: 0,
|
||||
index: 1,
|
||||
rect: { left: 700, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
]);
|
||||
controller.play(manager, token, () => undefined, signatures);
|
||||
for (const flight of flights) flight.finish();
|
||||
await vi.waitFor(() => expect(controller.isAnimating()).toBe(false));
|
||||
|
||||
// 转场收尾后堆锚点必须失效:否则此后挂载的卡片会从一段过期位置飞出来。
|
||||
const { hosts: lateHosts } = mountStackCards(world, [
|
||||
{
|
||||
id: 'art-0',
|
||||
column: 0,
|
||||
index: 0,
|
||||
rect: { left: 400, top: 500, width: 180, height: 128 },
|
||||
},
|
||||
{
|
||||
id: 'art-9',
|
||||
column: 0,
|
||||
index: 1,
|
||||
rect: { left: 900, top: 900, width: 180, height: 128 },
|
||||
},
|
||||
]);
|
||||
controller.sync(manager, signatures);
|
||||
|
||||
expect(
|
||||
flights.filter((flight) => flight.host === lateHosts.get('art-9')),
|
||||
).toHaveLength(0);
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource book FLIP geometry', () => {
|
||||
it('uses the classic FLIP transform when the origin is the last box', () => {
|
||||
expect(
|
||||
|
||||
@@ -477,6 +477,64 @@ describe('buildResourceBookScenePlan', () => {
|
||||
expect(keys).toHaveLength(allResources.length);
|
||||
});
|
||||
|
||||
/**
|
||||
* 展开态(进「所有资源」)也要把每张卡认回"它在总览里属于哪一摞"。
|
||||
*
|
||||
* 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,展开态铺满全部卡片:没有 First
|
||||
* 帧的卡片由转场层按 (栏目, 列号) 认领那一摞的起飞点(见 `resourceBookController` 的堆锚点)。
|
||||
* 列号一律写 0 的话,第 2 摞之后的卡片会全都从第 1 摞飞出来。
|
||||
*/
|
||||
it('keeps every expanded card on its own overview pile column', () => {
|
||||
const svg = {
|
||||
...resource('svg-0'),
|
||||
path: 'assets/svg-0.svg',
|
||||
mediaType: 'image/svg+xml',
|
||||
};
|
||||
// 同一个栏目里两摞:'图片' 一摞(2 张)+ 'SVG' 一摞(1 张)。
|
||||
const mixed = [resources[0]!, resources[1]!, svg];
|
||||
const mixedPositions = new Map([
|
||||
[resources[0]!.id, { x: 0, y: 0 }],
|
||||
[resources[1]!.id, { x: 200, y: 0 }],
|
||||
[svg.id, { x: 0, y: 300 }],
|
||||
]);
|
||||
const mixedCardSizes = new Map(
|
||||
mixed.map((item) => [item.id, { width: 180, height: 128 }]),
|
||||
);
|
||||
const allLayout = buildResourceBookAllLayout({
|
||||
categoryOrder: ['unclassified'],
|
||||
resourcesByCategory: new Map<ResourceBookCategory, ProjectResource[]>([
|
||||
['unclassified', mixed],
|
||||
]),
|
||||
positions: mixedPositions,
|
||||
cardSizes: mixedCardSizes,
|
||||
});
|
||||
const plan = buildResourceBookScenePlan({
|
||||
...base,
|
||||
visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified'],
|
||||
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
|
||||
[RESOURCE_BOOK_ALL_TARGET, mixed],
|
||||
['unclassified', mixed],
|
||||
]),
|
||||
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
|
||||
positions: mixedPositions,
|
||||
cardSizes: mixedCardSizes,
|
||||
visibleResourceIds: new Set(mixed.map((item) => item.id)),
|
||||
state: {
|
||||
view: 'child',
|
||||
category: RESOURCE_BOOK_ALL_TARGET,
|
||||
phase: 'idle',
|
||||
token: 1,
|
||||
},
|
||||
allLayout,
|
||||
});
|
||||
|
||||
const cards = plan.find((group) => group.category === 'unclassified')!.cards;
|
||||
expect(cards.map((card) => card.resource.id)).toEqual(
|
||||
mixed.map((item) => item.id),
|
||||
);
|
||||
expect(cards.map((card) => card.stackColumn)).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
it('fades the leaving all-resources cards from their band positions', () => {
|
||||
const allLayout = buildResourceBookAllLayout({
|
||||
categoryOrder: ['unclassified'],
|
||||
|
||||
Reference in New Issue
Block a user