「所有资源」展开态改回平铺画布:不再为每个栏目渲染标题栏
Project CI / Repository checks (pull_request) Successful in 2m50s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Backend tests (pull_request) Successful in 7m23s
Project CI / Native shell tests (pull_request) Successful in 19m4s

展开态真实栏目不再渲染栏目标题栏,整个场景只留 all 那一条钉在视口上、带「资源总览」收起入口的标题栏

原因是分带只为有可见资源的栏目生成,空栏目拿不到标题栏矩形后全部回落到世界原点叠成一摞(空项目必现,现场是 7 条)

ResourceBookSceneCategoryPlan 新增 titlebar 字段,渲染层按它决定是否产出标题栏节点

分带去掉标题栏横带的高度预留与矩形换算,删除 RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT 与 resourceBookAllTitlebarRects

拖动写回口径不变(resourceBookAllBandLocalPoint 一行未改),零 CSS 变更

resourceBookLayout.test 与 appSurface 两条「所有资源」用例加反向守卫;变异验证改回旧行为时两条同时红(got 7)

decision-log 与 pitfalls 记这次倒因为「缺一路坐标输入时不要静默回落到原点」
This commit is contained in:
2026-09-12 09:38:03 +08:00
parent 5b62d1e4ef
commit 3f7e41e466
7 changed files with 114 additions and 115 deletions
@@ -165,7 +165,6 @@ import {
buildResourceBookScenePlan,
resourceBookAllBandLocalPoint,
type ResourceBookAllLayout,
resourceBookAllTitlebarRects,
type ResourceBookOverviewRect,
resourceBookSceneCardKey,
resourceBookSceneCardLayoutSignature,
@@ -1176,26 +1175,32 @@ function ResourceBookScene({
: undefined;
return (
<Fragment key={group.category}>
<div
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
data-resource-book-category={group.category}
style={
{
left: `${titleLayout.left}px`,
top: `${titleLayout.top}px`,
width: titleLayout.width,
opacity: titleLayout.opacity,
transform: titlebarTransform,
transformOrigin: active ? '0 0' : undefined,
} as CSSProperties
}
>
<ResourceBookTitleBar
category={group.category}
countLabel={`${group.resourceCount} 项`}
onCollapse={active ? onCollapse : undefined}
/>
</div>
{/*
展开态的真实栏目不出标题栏:那一页是**一张平铺画布**,只有 `all` 那一条钉在
视口上的标题栏(见 `ResourceBookSceneCategoryPlan.titlebar`)。
*/}
{group.titlebar ? (
<div
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
data-resource-book-category={group.category}
style={
{
left: `${titleLayout.left}px`,
top: `${titleLayout.top}px`,
width: titleLayout.width,
opacity: titleLayout.opacity,
transform: titlebarTransform,
transformOrigin: active ? '0 0' : undefined,
} as CSSProperties
}
>
<ResourceBookTitleBar
category={group.category}
countLabel={`${group.resourceCount} 项`}
onCollapse={active ? onCollapse : undefined}
/>
</div>
) : null}
{group.cards.map((card) => {
const expanded = card.presentation === 'child';
const cardVisible =
@@ -2307,20 +2312,6 @@ export default function ProjectDevelopmentView({
resourcePositionById,
visibleResourcesByCategory,
]);
/**
* 展开态的栏目标题栏落点。复用总览态"按矩形钉标题栏"的同一条渲染路径,
* 只是矩形来自分带而不是总览缩略卡的测量。
*/
const resourceBookAllTitlebarRectMap = useMemo(
() => resourceBookAllTitlebarRects(resourceBookAllLayout),
[resourceBookAllLayout],
);
/**
* 画本场景当前该用哪份标题栏矩形:展开态用分带,其余状态用总览缩略卡的测量结果。
*/
const resourceBookSceneRects = resourceBookOpensAllResources
? resourceBookAllTitlebarRectMap
: resourceBookOverviewRects;
/**
* 展开态的视口拟合(入口与「适应画布」共用一处,避免两份几何口径)。
*/
@@ -2990,7 +2981,7 @@ export default function ProjectDevelopmentView({
state: resourceBookState,
visibleCategoryOrder: resourceBookSceneCategoryOrder,
resourcesByCategory: resourceBookSceneResourcesByCategory,
overviewRects: resourceBookSceneRects,
overviewRects: resourceBookOverviewRects,
positions: resourcePositionById,
cardSizes: resourceCardSizeByResourceId,
visibleResourceIds,
@@ -3010,8 +3001,8 @@ export default function ProjectDevelopmentView({
resourceBookAllLayout,
resourceBookCategoryViewCenters,
resourceBookExitingCards,
resourceBookOverviewRects,
resourceBookSceneCategoryOrder,
resourceBookSceneRects,
resourceBookSceneResourcesByCategory,
resourceBookState,
resourceCardSizeByResourceId,
@@ -3022,8 +3013,8 @@ export default function ProjectDevelopmentView({
],
);
/**
* 展开态的标题栏几何由分带同步算出,不需要等总览缩略卡测量完(那套是总览态的 `is-measuring`
* 首帧门)。其它状态维持原判据不变。
* 展开态不需要等总览缩略卡测量完就能画(它的几何来自分带,不来自测量);其它状态维持
* 原判据不变(总览态的 `is-measuring` 首帧门)。
*/
const resourceBookOverviewReady = useMemo(
() =>
@@ -3037,7 +3028,7 @@ export default function ProjectDevelopmentView({
const resourceBookMotionSignatures = useMemo(() => {
const signatures = new Map<string, string>();
for (const group of resourceBookScenePlan) {
const rect = resourceBookSceneRects.get(group.category);
const rect = resourceBookOverviewRects.get(group.category);
signatures.set(
resourceBookSceneTitleKey(group.category),
group.titlebarActive
@@ -3049,7 +3040,7 @@ export default function ProjectDevelopmentView({
}
}
return signatures;
}, [resourceBookScenePlan, resourceBookSceneRects]);
}, [resourceBookScenePlan, resourceBookOverviewRects]);
// 拖拽中的卡片只跟指针走,不参与布局 FLIP。
const resourceBookFrozenMotionKeys = useMemo(() => {
const keys = new Set<string>();
@@ -6248,7 +6239,7 @@ export default function ProjectDevelopmentView({
<ResourceBookScene
plan={resourceBookScenePlan}
resourceBookState={resourceBookState}
overviewRects={resourceBookSceneRects}
overviewRects={resourceBookOverviewRects}
overviewReady={resourceBookOverviewReady}
visibleResourceIds={visibleResourceIds}
viewport={activeResourceCanvasViewport}
@@ -70,13 +70,12 @@ export function groupResourceBookStacks(
/**
* 「所有资源」展开态里一个栏目的分带。
*
* `titlebarTop` 是**标题栏**在世界坐标里的顶边,`originX / originY` 是**卡片空间原点**:
* 栏目内局部坐标加它才得到世界坐标(`resourceBookChildCardLayout` 的产物仍是局部坐标)。
* 两者分开是因为标题栏要占一条不被卡片压住的横带。
* `originX / originY` 是**卡片空间原点**:栏目内局部坐标加它才得到世界坐标
* (`resourceBookChildCardLayout` 的产物仍是局部坐标)。展开态只铺卡片、不出栏目标题栏
* (见 `ResourceBookSceneCategoryPlan.titlebar`),所以这里没有任何标题栏几何。
*/
export type ResourceBookAllBand = {
category: ResourceBookCategory;
titlebarTop: number;
originX: number;
originY: number;
width: number;
@@ -90,21 +89,20 @@ export type ResourceBookAllLayout = {
bounds: { x: number; y: number; width: number; height: number };
};
/** 相邻两条分带之间的竖直留白(带上一条的卡片底边到下一条的标题栏顶边)。 */
/** 相邻两条分带之间的竖直留白(上一带最后一张卡到下一条带第一张卡)。 */
export const RESOURCE_BOOK_ALL_BAND_GAP = 48;
/**
* 分带标题栏的高度。取 `.game-resource-book-scene-titlebar .game-resource-book-titlebar`
* 的 `min-height`,让展开态的标题栏与总览/栏目态那一条同高,不为它另立一套尺寸。
*/
export const RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT = 42;
/**
* 「所有资源」展开态的分带几何:沿用总览的栏目顺序,每个**有可见资源**的栏目纵向占一带。
*
* 卡片坐标是栏目内局部坐标(见 `resourceCanvasSectionExtent`),铺进同一张画布前必须各给
* 一个带原点,否则各栏目全叠在原点。带宽高取可见资源的 extent:被筛掉的栏目不占屏,
* 与总览/旧网格页"只保留有资源的栏目"同口径。
* 与总览"只保留有资源的栏目"同口径。
*
* 带内只有卡片,没有标题栏横带:展开态是"一张平铺画布",唯一的标题栏是钉在视口上的
* `all` 那一条(见 `buildResourceBookScenePlan`)。因此带高就是卡片 extent,带间距就是
* `RESOURCE_BOOK_ALL_BAND_GAP`。没有可见资源的栏目不产生带 —— 这一点是硬约束:
* 曾经"每条带都配一条标题栏"时,空栏目拿不到带矩形,标题栏会全部回落到世界原点叠在一起。
*
* 这里的产物只描述几何,不参与任何持久化:落盘仍只有栏目内局部坐标。
*/
@@ -144,14 +142,9 @@ export function buildResourceBookAllLayout({
}),
cardSizes,
);
const titlebarTop = cursorTop;
const originY =
titlebarTop +
RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT +
RESOURCE_BOOK_ALL_BAND_GAP / 2;
const originY = cursorTop;
const band: ResourceBookAllBand = {
category,
titlebarTop,
originX: 0,
originY,
width: extent.width,
@@ -161,9 +154,9 @@ export function buildResourceBookAllLayout({
bandByCategory.set(category, band);
minX = Math.min(minX, extent.x);
maxX = Math.max(maxX, extent.x + extent.width);
minY = Math.min(minY, titlebarTop);
minY = Math.min(minY, originY);
maxY = Math.max(maxY, originY + extent.height);
cursorTop = originY + extent.height + RESOURCE_BOOK_ALL_BAND_GAP / 2;
cursorTop = originY + extent.height + RESOURCE_BOOK_ALL_BAND_GAP;
}
return {
bands,
@@ -177,28 +170,6 @@ export function buildResourceBookAllLayout({
};
}
/**
* 分带 → 场景标题栏矩形。
*
* 展开态复用总览态"按矩形把栏目标题栏钉在世界坐标上"的同一条渲染路径,只是矩形来自分带,
* 而不是总览缩略卡的 DOM 测量;`ResourceBookScene` 不需要为这一态知道任何新概念。
*/
export function resourceBookAllTitlebarRects(
layout: ResourceBookAllLayout,
): Map<ResourceBookTarget, ResourceBookOverviewRect> {
return new Map(
layout.bands.map((band) => [
band.category as ResourceBookTarget,
{
left: band.originX,
top: band.titlebarTop,
width: band.width,
height: RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT,
},
]),
);
}
/**
* 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。
*
@@ -364,6 +335,14 @@ export type ResourceBookSceneCardPlan = {
export type ResourceBookSceneCategoryPlan = {
category: ResourceBookTarget;
resourceCount: number;
/**
* 这一组要不要渲染栏目标题栏节点。
*
* 展开态只有 `all` 那一条(钉在视口上、带「资源总览」收起入口):展开态是**一张平铺
* 画布**,真实栏目只铺卡片。总览态与栏目态每一组都要(总览态靠它承载计数徽标,
* 栏目态靠它承载当前栏目与返回入口)。
*/
titlebar: boolean;
titlebarActive: boolean;
titlebarOpacity: number;
cards: ResourceBookSceneCardPlan[];
@@ -377,7 +356,8 @@ export type ResourceBookSceneCategoryPlan = {
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
* - 「所有资源」组在总览态只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载;
* 在**展开态**则反过来:各真实栏目铺满自己分带里的全部卡片,`all` 只留标题栏与计数。
* 在**展开态**则反过来:各真实栏目把自己分带里的全部卡片平铺在画布上(**不出标题栏**),
* `all` 只留钉住的标题栏与计数。
*/
export function buildResourceBookScenePlan({
state,
@@ -453,7 +433,7 @@ export function buildResourceBookScenePlan({
const categoryResources = resourcesByCategory.get(category) ?? [];
/**
* 展开态:`all` 只出标题栏与全量计数(`titlebarActive` 让它钉在视口上,并带上
* 「资源总览」收起入口);真实栏目把全部卡片铺在自己那条分带里。
* 「资源总览」收起入口);真实栏目把全部卡片平铺在自己那条分带里,**不渲染标题栏**。
*
* 真实栏目这一支仍然按 `presentation === 'child'` 取卡,而不是"展开态就一定出卡":
* "能出卡的判定"只有 `resourceBookCategoryCardPresentation` 一处,避免两边口径分叉。
@@ -463,6 +443,7 @@ export function buildResourceBookScenePlan({
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: true,
titlebarOpacity: 1,
cards,
@@ -485,8 +466,9 @@ export function buildResourceBookScenePlan({
return {
category,
resourceCount: categoryResources.length,
titlebar: false,
titlebarActive: false,
titlebarOpacity: 1,
titlebarOpacity: 0,
cards,
};
}
@@ -575,6 +557,7 @@ export function buildResourceBookScenePlan({
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: presentation === 'child',
titlebarOpacity: childLayout || state.view === 'main' ? 1 : 0,
cards,
@@ -9609,9 +9609,9 @@ export function registerProjectAgentStatusTests() {
expect(previewHostRule).toMatch(/position:\s*absolute/);
expect(previewHostRule).toMatch(/transform-origin:\s*0 0/);
// 3) 点它进入的是**同一套画本场景**,不是另一条渲染分支:每个真实栏目铺在自己的分带上,
// 卡片宿主是栏目页那一个 `.game-resource-book-scene-card`、`data-resource-book-category`
// 仍是真实栏目;`all` 自己只留钉住的标题栏与全量计数。
// 3) 点它进入的是**同一套画本场景**,不是另一条渲染分支:真实栏目的卡片平铺在同一张
// 画布上,卡片宿主是栏目页那一个 `.game-resource-book-scene-card`、
// `data-resource-book-category` 仍是真实栏目;`all` 自己只留钉住的标题栏与全量计数。
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
await waitFor(() =>
expect(
@@ -9651,6 +9651,14 @@ export function registerProjectAgentStatusTests() {
);
expect(hostCategories.length).toBeGreaterThan(1);
expect(hostCategories).not.toContain('all');
// 展开态是一张**平铺画布**:真实栏目不出标题栏,整个场景只留 `all` 那一条钉住的。
// 曾经"每条分带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会全部回落到
// 世界原点叠成一摞(用户报的破版就是这一摞);下面这条把它钉死。
expect(
document.querySelectorAll(
'.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])',
),
).toHaveLength(0);
// 每张资源恰好一个宿主(展开态没有"第二份宿主"):条数与全量计数同源,
// 因此不会出现「标题说 N 项、画布少几张」。
expect(
@@ -9785,6 +9793,13 @@ export function registerProjectAgentStatusTests() {
expect(
document.querySelectorAll('.game-resource-book-scene-card'),
).toHaveLength(0);
// 空项目恰恰是那一摞的现场:没有任何带 ⇒ 曾经每个真实栏目的标题栏都回落到世界原点
// 叠在一起(最后画的「项目版本」浮在画布中央)。展开态只允许 `all` 那一条标题栏。
expect(
document.querySelectorAll(
'.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])',
),
).toHaveLength(0);
expect(
document.querySelector('.game-resource-page-empty')?.textContent,
).toBe('没有匹配资源');
@@ -4,12 +4,11 @@ import {
buildResourceBookAllLayout,
buildResourceBookScenePlan,
groupResourceBookStacks,
RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT,
RESOURCE_BOOK_ALL_BAND_GAP,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
type ResourceBookAllBand,
resourceBookAllBandLocalPoint,
type ResourceBookAllLayout,
resourceBookAllTitlebarRects,
resourceBookOverviewCardLayout,
type ResourceBookOverviewRect,
resourceBookSceneCardKey,
@@ -421,7 +420,7 @@ describe('buildResourceBookScenePlan', () => {
['unclassified', resources],
['scene', [sceneResource]],
]),
overviewRects: resourceBookAllTitlebarRects(allLayout),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
positions: allPositions,
cardSizes: allCardSizes,
visibleResourceIds: new Set(allResources.map((item) => item.id)),
@@ -439,6 +438,7 @@ describe('buildResourceBookScenePlan', () => {
)!;
// `all` 一张卡都不挂:卡片由各真实栏目承载,否则同一张卡会挂两份、FLIP 键互相顶掉。
expect(allGroup.cards).toEqual([]);
expect(allGroup.titlebar).toBe(true);
expect(allGroup.titlebarActive).toBe(true);
expect(allGroup.titlebarOpacity).toBe(1);
expect(allGroup.resourceCount).toBe(allResources.length);
@@ -449,9 +449,11 @@ describe('buildResourceBookScenePlan', () => {
expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe(
true,
);
// 展开态的栏目标题栏不钉在视口上,但要可见(它随分带一起被视口变换)。
// 展开态的真实栏目**不出标题栏**:那一页是一张平铺画布,标题栏只有 `all` 那一条。
// 曾经"每条带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会集体回落到
// 世界原点叠成一摞(用户报的正是这个)。
expect(artGroup.titlebar).toBe(false);
expect(artGroup.titlebarActive).toBe(false);
expect(artGroup.titlebarOpacity).toBe(1);
// 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。
const first = artGroup.cards[0]!;
expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX);
@@ -491,7 +493,7 @@ describe('buildResourceBookScenePlan', () => {
[RESOURCE_BOOK_ALL_TARGET, resources],
['unclassified', resources],
]),
overviewRects: resourceBookAllTitlebarRects(allLayout),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
state: {
view: 'main',
category: null,
@@ -550,13 +552,17 @@ describe('buildResourceBookAllLayout', () => {
it('stacks the bands downward so no two categories share one origin', () => {
const [first, second] = layout.bands;
// 标题栏横带压在卡片区之上,两个带的首尾不重叠:否则拖上面一带的卡会压到下面一带的标题。
expect(first!.titlebarTop).toBeLessThan(first!.originY);
expect(second!.titlebarTop).toBeGreaterThan(first!.originY + first!.height);
// 带里只有卡片,所以带宽从原点直接长出 extent 高;两带之间隔一条固定的留白:
// 否则拖上面一带的卡会压到下面一带的卡上。
expect(first!.originY).toBe(0);
expect(first!.height).toBeGreaterThan(0);
expect(second!.originY).toBe(
first!.originY + first!.height + RESOURCE_BOOK_ALL_BAND_GAP,
);
expect(second!.originY).toBeGreaterThan(first!.originY);
});
it('covers every titlebar and card box in its bounds', () => {
it('covers every card box in its bounds', () => {
const last = layout.bands.at(-1)!;
expect(layout.bounds.x).toBeLessThanOrEqual(0);
expect(layout.bounds.y).toBeLessThanOrEqual(0);
@@ -565,25 +571,11 @@ describe('buildResourceBookAllLayout', () => {
last.originY + last.height,
);
});
it('hands every band a titlebar rect so the scene reuses the overview titlebar path', () => {
const rects = resourceBookAllTitlebarRects(layout);
const audio = layout.bandByCategory.get('audio')!;
expect(rects.get('audio')).toEqual({
left: audio.originX,
top: audio.titlebarTop,
width: audio.width,
height: RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT,
});
expect(rects.has('character')).toBe(false);
});
});
describe('resourceBookAllBandLocalPoint', () => {
const band: ResourceBookAllBand = {
category: 'document',
titlebarTop: 0,
originX: 40,
originY: 100,
width: 620,