Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts
T
suzmii 3bf499ddfd
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Native shell tests (pull_request) Successful in 19m29s
修复「所有资源」展开态文档卡与图片卡初始自动重叠
- resourceBookLayout:新增分带几何缓存键 resourceBookAllLayoutKey 与取几何的 resolveResourceBookAllLayout,键纳入布局是否就绪 / 卡片真实尺寸 / 排序模式,拖动只改坐标时键保持不变
- resourceBookLayout:抽出 ResourceBookAllLayoutInput,生产组件与回归用例共用同一份「键 + 缓存」口径
- index.tsx:展开态分带几何改走 resolveResourceBookAllLayout,并传入当前布局 hook 的 ready;此前只按项目 / 排序 / 可见资源 id 冻结,依赖模式关系图就绪前算出的单行带(144 高)被永久固化,图片带仍挤在 144 + 48 处,整排压住文档卡
- tests/resourceBookLayout.test.ts:新增混合类型(12 文档 + 12 图片)第一版坐标到位后分带重算、逐对矩形不相交的主用例
- tests/resourceBookLayout.test.ts:新增同一栏混排不同尺寸卡(类型 / 依赖两种模式)不相交、sidecar 手放坐标保留并让开下一带的对照用例
- tests/resourceBookLayout.test.ts:新增缓存键的拖动稳定性、就绪翻转、卡片尺寸与排序模式变化用例
- tests/projectResourceLiveIntegration.test.tsx:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查
2026-09-13 17:06:09 +08:00

685 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type {
ResourceBookCardPresentation,
ResourceBookCategory,
ResourceBookState,
ResourceBookTarget,
} from './resourceBookModel';
import {
isResourceBookAllTarget,
resourceBookCategoryCardPresentation,
resourceBookShowsAllCategories,
resourceBookShowsExitingCards,
} from './resourceBookModel';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
type ResourceCanvasCardSize,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import type { ProjectResource } from './resourceProjectionModel';
import { projectResourceTypeLabel } from './resourceProjectionModel';
export type ResourceBookOverviewRect = {
left: number;
top: number;
width: number;
height: number;
};
export type ResourceBookStack = [type: string, items: ProjectResource[]];
export type ResourceBookCardLayout = {
x: number;
y: number;
width: number;
height: number;
dragX: number;
dragY: number;
rotation: number;
};
// 总览每个资源类型最多铺这么多张卡片;其余数量由标题栏的“N 项”表达。
// 叠放角度同样只取这几个值,避免素材变多后旋转角随下标无限增大。
const overviewStackRotations = [0.5, -1.5, 2.5] as const;
export const RESOURCE_BOOK_OVERVIEW_STACK_LIMIT = overviewStackRotations.length;
/**
* 「所有资源」卡那一摞的类型标签占位值。
*
* 这一摞跨类型,没有真实类型名可用;它只在总览摞的分组里当 key,
* 不参与任何持久化或跨端口径。
*/
const RESOURCE_BOOK_ALL_STACK_LABEL = 'all';
export function groupResourceBookStacks(
resources: readonly ProjectResource[],
): ResourceBookStack[] {
const groups = new Map<string, ProjectResource[]>();
for (const resource of resources) {
const items = groups.get(projectResourceTypeLabel(resource)) ?? [];
items.push(resource);
groups.set(projectResourceTypeLabel(resource), items);
}
return Array.from(
groups,
([type, items]) => [type, items] as ResourceBookStack,
);
}
/**
* 「所有资源」展开态里一个栏目的分带。
*
* `originX / originY` 是**卡片空间原点**:栏目内局部坐标加它才得到世界坐标
* (`resourceBookChildCardLayout` 的产物仍是局部坐标)。展开态只铺卡片、不出栏目标题栏
* (见 `ResourceBookSceneCategoryPlan.titlebar`),所以这里没有任何标题栏几何。
*/
export type ResourceBookAllBand = {
category: ResourceBookCategory;
originX: number;
originY: number;
width: number;
height: number;
};
export type ResourceBookAllLayout = {
bands: readonly ResourceBookAllBand[];
bandByCategory: ReadonlyMap<ResourceBookCategory, ResourceBookAllBand>;
/** 全部分带(含标题栏横带)在世界坐标里的并集,供"适应画布"拟合。 */
bounds: { x: number; y: number; width: number; height: number };
};
/** 相邻两条分带之间的竖直留白(上一带最后一张卡到下一条带第一张卡)。 */
export const RESOURCE_BOOK_ALL_BAND_GAP = 48;
/**
* 「所有资源」展开态的分带几何:沿用总览的栏目顺序,每个**有可见资源**的栏目纵向占一带。
*
* 卡片坐标是栏目内局部坐标(见 `resourceCanvasSectionExtent`),铺进同一张画布前必须各给
* 一个带原点,否则各栏目全叠在原点。带宽高取可见资源的 extent:被筛掉的栏目不占屏,
* 与总览"只保留有资源的栏目"同口径。
*
* 带内只有卡片,没有标题栏横带:展开态是"一张平铺画布",唯一的标题栏是钉在视口上的
* `all` 那一条(见 `buildResourceBookScenePlan`)。因此带高就是卡片 extent,带间距就是
* `RESOURCE_BOOK_ALL_BAND_GAP`。没有可见资源的栏目不产生带 —— 这一点是硬约束:
* 曾经"每条带都配一条标题栏"时,空栏目拿不到带矩形,标题栏会全部回落到世界原点叠在一起。
*
* 这里的产物只描述几何,不参与任何持久化:落盘仍只有栏目内局部坐标。
*/
export type ResourceBookAllLayoutInput = {
categoryOrder: readonly ResourceBookCategory[];
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
};
export function buildResourceBookAllLayout({
categoryOrder,
resourcesByCategory,
positions,
cardSizes,
}: ResourceBookAllLayoutInput): ResourceBookAllLayout {
const bands: ResourceBookAllBand[] = [];
const bandByCategory = new Map<ResourceBookCategory, ResourceBookAllBand>();
let cursorTop = 0;
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 0;
for (const category of categoryOrder) {
const resources = resourcesByCategory.get(category) ?? [];
if (resources.length === 0) {
continue;
}
const extent = resourceCanvasSectionExtent(
resources.map((resource) => {
const position = positions.get(resource.id);
return {
resourceId: resource.id,
section: category,
x: position?.x ?? 0,
y: position?.y ?? 0,
manuallyPlaced: false,
};
}),
cardSizes,
);
const originY = cursorTop;
const band: ResourceBookAllBand = {
category,
originX: 0,
originY,
width: extent.width,
height: extent.height,
};
bands.push(band);
bandByCategory.set(category, band);
minX = Math.min(minX, extent.x);
maxX = Math.max(maxX, extent.x + extent.width);
minY = Math.min(minY, originY);
maxY = Math.max(maxY, originY + extent.height);
cursorTop = originY + extent.height + RESOURCE_BOOK_ALL_BAND_GAP;
}
return {
bands,
bandByCategory,
bounds: {
x: minX,
y: minY,
width: Math.max(1, Math.ceil(maxX - minX)),
height: Math.max(1, Math.ceil(maxY - minY)),
},
};
}
/** 分带几何的作用域:项目 / 排序模式 / 当前布局是否已经就绪。 */
export type ResourceBookAllLayoutScope = {
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
/**
* 当前排序模式的布局 hook 是否已经就绪(sidecar 已读回、或确认不需要读)。
*
* 就绪前 hook 会回退到"没有坐标"的布局,此时算出来的带几何是"所有卡都堆在原点"的
* 那份;若不把它当键的一部分,键在坐标到位时不变,缓存就会把那份错误的带几何一直用到
* 会话结束。
*/
layoutReady: boolean;
};
export type ResourceBookAllLayoutCache = {
key: string;
layout: ResourceBookAllLayout;
};
/**
* 分带几何的缓存键。
*
* **带几何必须跟卡片几何同源**:带高取该栏目可见资源的 extent,带原点由前一条带的带高
* 累加;卡片画的是"栏目内局部坐标 + 带原点"。所以键里少一个会变的输入,就会留下一条
* 用旧几何画的带:
*
* - **坐标是否到位**(`layoutReady` + 每张可见资源都能查到坐标):依赖模式下关系图就绪前
* 布局是空的,此时算出的带高只有"单行"(`128 + 16 = 144`);坐标到位后键若不变,
* 文档那一带仍是 144 高,而卡片已经铺到第 2、3 行(`336 + 128 = 464`),下一条带
* (图片)被摆在 `144 + 48` 处,直接压在文档卡上 —— 这正是「一开始自动重叠」。
* - **卡片尺寸**:图片卡的实际尺寸由预览探测出来的像素尺寸决定(最宽 220、最高 180),
* 探测前一律按 180×128 算。尺寸变而键不变,卡片就会长出带框、压到下一条带上。
* - **排序模式**:两个模式各有一份坐标,模式变了坐标就变,键必须跟着变。
*
* 键里**故意不含坐标的具体取值**:拖动只改坐标、键不变,带几何因此不会跟着抖
* (见 `resolveResourceBookAllLayout` 的说明)。
*/
export function resourceBookAllLayoutKey({
projectId,
mode,
layoutReady,
categoryOrder,
resourcesByCategory,
positions,
cardSizes,
}: ResourceBookAllLayoutScope & ResourceBookAllLayoutInput): string {
const resourceIdentities = categoryOrder
.flatMap((category) => resourcesByCategory.get(category) ?? [])
.map((resource) => {
const size =
cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE;
return `${resource.id}:${size.width}x${size.height}`;
})
.sort();
const settled =
layoutReady &&
categoryOrder.every((category) =>
(resourcesByCategory.get(category) ?? []).every((resource) =>
positions.has(resource.id),
),
);
return JSON.stringify([
projectId,
mode,
settled ? 'settled' : 'pending',
resourceIdentities,
]);
}
/**
* 取分带几何:键变了才重算,键没变就复用缓存里的那份。
*
* 这条"键 + 缓存"是展开态唯一的几何口径(生产组件与回归用例共用它),它同时钉住两件
* 互相拉扯的事:
*
* 1. **必须重算**:坐标 / 卡片尺寸 / 排序模式 / 项目 / 可见资源集、以及"坐标还没到位"这一
* 状态发生变化时,上一条带的位置已经不再匹配卡片,必须重算,否则卡片会画到带框之外、
* 压到下一条带上(跨栏目重叠)。
* 2. **不跟着拖动抖**:拖动只改坐标,键不变。带高若是每次拖动都跟着 extent 变,
* 一整列后面的带会整体位移,用户拖一张卡会把整页顶走。
*/
export function resolveResourceBookAllLayout(
cache: ResourceBookAllLayoutCache | null,
input: ResourceBookAllLayoutScope & ResourceBookAllLayoutInput,
): ResourceBookAllLayoutCache {
const key = resourceBookAllLayoutKey(input);
if (cache && cache.key === key) {
return cache;
}
return { key, layout: buildResourceBookAllLayout(input) };
}
/**
* 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。
*
* 展开态按分带铺卡片,卡片画的是"局部坐标 + 带原点";而布局 sidecar 只认栏目内局部坐标
* (`resource-layouts/{dependency,type}.json` 的 `x / y`,`section` 是资源当前分类)。
* 因此拖动提交前减掉带原点:写回的是**这张卡在它自己栏目里的同一个槽位**,栏目页立刻看到
* 同一位置,不存在第二份空间。带不存在(栏目页、或该栏目此刻没有分带)时减 0,
* 与栏目页本来就有的口径完全一致。
*/
export function resourceBookAllBandLocalPoint({
layout,
category,
x,
y,
}: {
layout: ResourceBookAllLayout | null;
category: ResourceBookCategory;
x: number;
y: number;
}) {
const band = layout?.bandByCategory.get(category) ?? null;
return {
x: x - (band?.originX ?? 0),
y: y - (band?.originY ?? 0),
};
}
export function groupResourceBookResourcesByCategory(
resources: readonly ProjectResource[],
) {
const result = new Map<ResourceBookCategory, ProjectResource[]>();
for (const resource of resources) {
const items = result.get(resource.category) ?? [];
items.push(resource);
result.set(resource.category, items);
}
return result;
}
/**
* 总览摞只铺有限张卡。挑哪几张决定了进入子画布时的观感:若按数组顺序取,
* 用户看到的卡和入场视口里最近的卡不是同一批,FLIP 找不到对应终点,动画会
* 退化成淡入淡出、返回时还会跳成另一张图。这里改为取离当前视图中心最近的卡。
*/
export function selectResourceBookOverviewCards({
resources,
visibleResourceIds,
positions,
cardSizes,
viewCenter,
limit = RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
}: {
resources: readonly ProjectResource[];
visibleResourceIds: ReadonlySet<string>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
viewCenter: { x: number; y: number } | undefined;
limit?: number;
}): ProjectResource[] {
const visible = resources.filter((resource) =>
visibleResourceIds.has(resource.id),
);
if (!viewCenter || visible.length <= limit) {
return visible.slice(0, limit);
}
const distance = (resource: ProjectResource) => {
const position = positions.get(resource.id);
if (!position) return Number.POSITIVE_INFINITY;
const size = cardSizes.get(resource.id) ?? {
width: RESOURCE_CANVAS_CARD_WIDTH,
height: RESOURCE_CANVAS_CARD_HEIGHT,
};
const centerX = position.x + size.width / 2;
const centerY = position.y + size.height / 2;
return (centerX - viewCenter.x) ** 2 + (centerY - viewCenter.y) ** 2;
};
return visible
.map((resource, index) => ({
resource,
index,
distance: distance(resource),
}))
.sort(
(left, right) =>
left.distance - right.distance || left.index - right.index,
)
.slice(0, limit)
.map((entry) => entry.resource);
}
export function resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
}: {
rect: ResourceBookOverviewRect | undefined;
stackIndex: number;
stackColumn: number;
}): ResourceBookCardLayout {
const rotationIndex = Math.min(
Math.max(stackIndex, 0),
overviewStackRotations.length - 1,
);
return {
x: (rect?.left ?? 0) + 18 + (stackColumn % 3) * 102,
y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5,
width: 92,
height: 64,
dragX: 0,
dragY: 0,
rotation: overviewStackRotations[rotationIndex] ?? 0,
};
}
export function resourceBookChildCardLayout({
position,
size,
}: {
position: { x: number; y: number } | undefined;
size: ResourceCanvasCardSize;
}): ResourceBookCardLayout {
const x = position?.x ?? 0;
const y = position?.y ?? 0;
// The child scene applies the viewport transform on its card-world wrapper.
// Keep card geometry logical here so persistence, drag callbacks, and the
// public ResourceCard style contract are not polluted by screen space.
return {
x,
y,
width: size.width,
height: size.height,
dragX: x,
dragY: y,
rotation: 0,
};
}
const RESOURCE_BOOK_CARD_FALLBACK_SIZE: ResourceCanvasCardSize = {
width: 180,
height: 128,
};
export function resourceBookSceneCardKey(resourceId: string) {
return `card:${resourceId}`;
}
export function resourceBookSceneTitleKey(category: ResourceBookTarget) {
return `title:${category}`;
}
export type ResourceBookSceneCardPlan = {
key: string;
category: ResourceBookTarget;
resource: ProjectResource;
presentation: ResourceBookCardPresentation;
stackIndex: number;
stackColumn: number;
/** 同一类型摞里的卡片总数,用于总览态 z-index。 */
stackCount: number;
layout: ResourceBookCardLayout;
};
export type ResourceBookSceneCategoryPlan = {
category: ResourceBookTarget;
resourceCount: number;
/**
* 这一组要不要渲染栏目标题栏节点。
*
* 展开态只有 `all` 那一条(钉在视口上、带「资源总览」收起入口):展开态是**一张平铺
* 画布**,真实栏目只铺卡片。总览态与栏目态每一组都要(总览态靠它承载计数徽标,
* 栏目态靠它承载当前栏目与返回入口)。
*/
titlebar: boolean;
titlebarActive: boolean;
titlebarOpacity: number;
cards: ResourceBookSceneCardPlan[];
};
/**
* 画本场景渲染计划。渲染、转场签名和淡出集合共用这一份计划,避免"渲染用一套、
* 测量用另一套"的错位:
* - 总览/返回态全部用总览摞布局;
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
* - 「所有资源」组在总览态只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载;
* 在**展开态**则反过来:各真实栏目把自己分带里的全部卡片平铺在画布上(**不出标题栏**),
* `all` 只留钉住的标题栏与计数。
*/
export function buildResourceBookScenePlan({
state,
visibleCategoryOrder,
resourcesByCategory,
overviewRects,
positions,
cardSizes,
visibleResourceIds,
categoryViewCenters,
cardsReady,
exitingCategory = null,
exitingCards = [],
allLayout = null,
}: {
state: ResourceBookState;
visibleCategoryOrder: readonly ResourceBookTarget[];
resourcesByCategory: ReadonlyMap<ResourceBookTarget, ProjectResource[]>;
overviewRects: ReadonlyMap<ResourceBookTarget, ResourceBookOverviewRect>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
visibleResourceIds: ReadonlySet<string>;
categoryViewCenters: ReadonlyMap<
ResourceBookTarget,
{ x: number; y: number }
>;
cardsReady: boolean;
exitingCategory?: ResourceBookTarget | null;
exitingCards?: readonly ResourceBookSceneCardPlan[];
/**
* 展开态的分带几何(见 `buildResourceBookAllLayout`)。总览态与栏目态不需要它;
* 只有展开态本身、以及"从展开态返回总览"的淡出会用到带偏移。
*/
allLayout?: ResourceBookAllLayout | null;
}): ResourceBookSceneCategoryPlan[] {
/**
* 展开态(子画布里的 `all`)与"从展开态返回总览"共用一套带偏移规则:
* - `allOpen`:每个真实栏目铺满自己分带里的全部卡片,`all` 只出标题栏与计数;
* - `exitsAllCategories`:刚离开展开态,没回到总览摞的卡片必须**按带位置**淡出,
* 否则它们会贴到未偏移的局部坐标上,返回动画会跳位置。
*/
const allOpen = resourceBookShowsAllCategories(state);
const exitsAllCategories = isResourceBookAllTarget(exitingCategory ?? null);
const bandsActive = allOpen || exitsAllCategories;
const bandFor = (category: ResourceBookTarget) =>
bandsActive && !isResourceBookAllTarget(category)
? (allLayout?.bandByCategory.get(category) ?? null)
: null;
/**
* 子画布布局 + 可选带偏移。偏移同时写进 `x / y` 与 `dragX / dragY`:后者是拖动起点,
* 必须与画出来的世界坐标同系;提交时再减回带原点(`resourceBookAllBandLocalPoint`)。
*/
const childLayoutFor = (
category: ResourceBookTarget,
resource: ProjectResource,
): ResourceBookCardLayout => {
const layout = resourceBookChildCardLayout({
position: positions.get(resource.id),
size: cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE,
});
const band = bandFor(category);
if (!band) {
return layout;
}
const x = layout.x + band.originX;
const y = layout.y + band.originY;
return { ...layout, x, y, dragX: x, dragY: y };
};
return visibleCategoryOrder.map((category) => {
const presentation = resourceBookCategoryCardPresentation(state, category);
const rect = overviewRects.get(category);
const cards: ResourceBookSceneCardPlan[] = [];
const categoryResources = resourcesByCategory.get(category) ?? [];
/**
* 展开态:`all` 只出标题栏与全量计数(`titlebarActive` 让它钉在视口上,并带上
* 「资源总览」收起入口);真实栏目把全部卡片平铺在自己那条分带里,**不渲染标题栏**。
*
* 真实栏目这一支仍然按 `presentation === 'child'` 取卡,而不是"展开态就一定出卡":
* "能出卡的判定"只有 `resourceBookCategoryCardPresentation` 一处,避免两边口径分叉。
*/
if (allOpen) {
if (isResourceBookAllTarget(category)) {
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: true,
titlebarOpacity: 1,
cards,
};
}
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),
category,
resource,
presentation: 'child',
stackIndex: 0,
stackColumn: stackColumnByResourceId.get(resource.id) ?? 0,
stackCount: 1,
layout: childLayoutFor(category, resource),
});
});
}
return {
category,
resourceCount: categoryResources.length,
titlebar: false,
titlebarActive: false,
titlebarOpacity: 0,
cards,
};
}
/**
* 「所有资源」卡的总览摞只铺一列。
*
* 它拿的是全量投影(与计数徽标同一份),按类型分列会一口气铺出十几列、在总览里
* 立刻失控;这里把它当成一摞,交给与栏目卡同一个选取函数:没有视图中心时该函数
* 直接按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,和栏目卡每摞的上限同口径。
*/
const stacks = isResourceBookAllTarget(category)
? [
[
RESOURCE_BOOK_ALL_STACK_LABEL,
categoryResources,
] as ResourceBookStack,
]
: groupResourceBookStacks(categoryResources);
const childLayout = presentation === 'child';
if (cardsReady && presentation) {
stacks.forEach(([, items], stackColumn) => {
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
const stackItems = childLayout
? items
: selectResourceBookOverviewCards({
resources: items,
visibleResourceIds,
positions,
cardSizes,
viewCenter: categoryViewCenters.get(category),
});
stackItems.forEach((resource, stackIndex) => {
cards.push({
key: resourceBookSceneCardKey(resource.id),
category,
resource,
presentation: childLayout ? 'child' : 'overview',
stackIndex,
stackColumn,
stackCount: stackItems.length,
layout: childLayout
? childLayoutFor(category, resource)
: resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
}),
});
});
});
}
if (
// 「所有资源」组永远不承载 child / exiting 卡:展开态它只出标题栏,总览态只出预览摞
// (预览摞是刻意不进转场层的第二份宿主)。真实卡片一律由各真实栏目组承载。
!isResourceBookAllTarget(category) &&
resourceBookShowsExitingCards(state) &&
(exitsAllCategories || exitingCategory === category) &&
cardsReady
) {
const onStack = new Set(
cards
.filter((card) => card.presentation === 'overview')
.map((card) => card.key),
);
categoryResources.forEach((resource) => {
const key = resourceBookSceneCardKey(resource.id);
if (onStack.has(key)) return;
if (!visibleResourceIds.has(resource.id)) return;
cards.push({
key,
category,
resource,
presentation: 'exiting',
stackIndex: 0,
stackColumn: 0,
stackCount: 1,
layout: childLayoutFor(category, resource),
});
});
}
exitingCards.forEach((card) => {
if (card.category !== category) return;
if (cards.some((entry) => entry.key === card.key)) return;
cards.push({ ...card, presentation: 'exiting' });
});
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: presentation === 'child',
titlebarOpacity: childLayout || state.view === 'main' ? 1 : 0,
cards,
};
});
}
export function resourceBookSceneCardLayoutSignature(
card: ResourceBookSceneCardPlan,
) {
return `${card.presentation}:${card.layout.x},${card.layout.y}:${card.layout.width},${card.layout.height}:${card.layout.rotation}`;
}