3bf499ddfd
- 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:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查
685 lines
25 KiB
TypeScript
685 lines
25 KiB
TypeScript
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}`;
|
||
}
|