修复资源总览摞堆叠旋转与卡片预览懒加载

- 总览每个资源类型最多铺 3 张卡片,其余数量由栏目标题栏的“N 项”表达
- 总览摞旋转角改为固定小角度,素材变多后不再随堆叠下标无限增大
- 总览摞下标小的卡片压在上层,不再被模糊卡遮住最上面那张清晰卡
- 截断前先按搜索可见性过滤,避免搜索结果落在深处时总览显示空摞
- 资源卡片可见性观察改用真正包含卡片的容器,恢复滚动时的预览懒加载
- 补充资源画本布局单测、预览观察根回归用例和总览摞上限用例
This commit is contained in:
2026-09-08 20:23:33 +08:00
parent 8a47cdfc1b
commit 36747b3ece
6 changed files with 319 additions and 36 deletions
@@ -87,6 +87,7 @@ import {
import {
groupResourceBookResourcesByCategory,
groupResourceBookStacks,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
resourceBookChildCardLayout,
resourceBookOverviewCardLayout,
type ResourceBookOverviewRect,
@@ -108,9 +109,7 @@ import {
resolveResourceBookWheelViewport,
zoomResourceBookViewport,
} from './resourceBookViewport';
import {
createResourceCanvasPageCategorySignature,
} from './resourceCanvasController';
import { createResourceCanvasPageCategorySignature } from './resourceCanvasController';
import {
createResourceCanvasCardSizeByResourceId,
fitResourceCanvasViewportToContent,
@@ -1110,15 +1109,23 @@ function ResourceBookScene({
/>
</div>
{renderCards &&
stacks.flatMap(([_type, items], stackColumn) =>
items.map((resource, stackIndex) => {
stacks.flatMap(([_type, items], stackColumn) => {
const expanded = active;
// 总览只铺前几张,避免一个类型素材变多时把整摞堆到同一处;
// 数量由栏目标题栏的“N 项”表达,子画布仍然渲染全部资源。
const stackItems = expanded
? items
: items
.filter((resource) =>
visibleResourceIds.has(resource.id),
)
.slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
return stackItems.map((resource, stackIndex) => {
const position = positions.get(resource.id);
const size = cardSizes.get(resource.id) ?? {
width: 180,
height: 128,
};
const overflow = stackIndex >= 3;
const expanded = active;
const cardVisible =
visibleResourceIds.has(resource.id) &&
(renderTransitionCards || expanded);
@@ -1144,27 +1151,23 @@ function ResourceBookScene({
top: 0,
width: 0,
height: 0,
opacity: cardVisible
? overflow && !expanded
? 0.28
: 1
: 0,
opacity: cardVisible ? 1 : 0,
// 总览摞里下标小的卡片压在上层,保证最上面那张是清晰的。
zIndex: expanded
? undefined
: stackItems.length - stackIndex,
pointerEvents:
expanded && visibleResourceIds.has(resource.id)
? 'auto'
: 'none',
filter:
overflow && !expanded ? 'blur(1.6px)' : 'none',
transform:
overflow && !expanded ? 'scale(0.96)' : undefined,
} as CSSProperties
}
>
{renderCard(resource, layout)}
</div>
);
}),
)}
});
})}
</Fragment>
);
})}
@@ -1655,6 +1658,8 @@ export default function ProjectDevelopmentView({
mode: sortMode,
resources: canvasResources,
canvasRef: resourceCanvasRef,
// 资源卡片渲染在资源画本场景里,可见性判定要用真正包含它们的容器。
intersectionRootRef: resourceBookManagerRef,
eagerPreviewLimit: 12,
previewVersionByResourceId: resourcePreviewVersionByResourceId,
});
@@ -2533,11 +2538,7 @@ export default function ProjectDevelopmentView({
resourceBookManagerRef.current,
);
dispatchResourceBook({ type: 'return-to-main', token: transitionToken });
}, [
cancelResourceCanvasPan,
cancelResourceCardDrag,
resourceBookState,
]);
}, [cancelResourceCanvasPan, cancelResourceCardDrag, resourceBookState]);
const selectResourceCanvasPage = useCallback(
(category: ResourceCategory) => {
@@ -3383,11 +3384,7 @@ export default function ProjectDevelopmentView({
if (next.scale === viewport.scale) return;
setResourceCanvasViewport(category, next);
},
[
activePageCategory,
resourceBookView,
setResourceCanvasViewport,
],
[activePageCategory, resourceBookView, setResourceCanvasViewport],
);
const handleResourceBookWheel = useCallback(
@@ -3433,11 +3430,7 @@ export default function ProjectDevelopmentView({
});
setResourceCanvasViewport(category, result.viewport, true);
},
[
activePageCategory,
resourceBookView,
setResourceCanvasViewport,
],
[activePageCategory, resourceBookView, setResourceCanvasViewport],
);
useEffect(() => {
@@ -22,6 +22,12 @@ export type ResourceBookCardLayout = {
rotation: number;
};
// 总览每个资源类型最多铺这么多张卡片;其余数量由标题栏的“N 项”表达。
// 叠放角度同样只取这几个值,避免素材变多后旋转角随下标无限增大。
const overviewStackRotations = [0.5, -1.5, 2.5] as const;
export const RESOURCE_BOOK_OVERVIEW_STACK_LIMIT = overviewStackRotations.length;
export function groupResourceBookStacks(
resources: readonly ProjectResource[],
): ResourceBookStack[] {
@@ -58,6 +64,10 @@ export function resourceBookOverviewCardLayout({
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,
@@ -65,7 +75,7 @@ export function resourceBookOverviewCardLayout({
height: 64,
dragX: 0,
dragY: 0,
rotation: stackIndex % 2 ? -(stackIndex + 0.5) : stackIndex + 0.5,
rotation: overviewStackRotations[rotationIndex] ?? 0,
};
}
@@ -198,6 +198,9 @@ export function useProjectResourceCardPreviews(input: {
mode: ProjectResourceCanvasLayoutMode;
resources: ProjectResource[];
canvasRef: React.RefObject<HTMLDivElement | null>;
// 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器;
// 缺省回退到 canvasRef。
intersectionRootRef?: React.RefObject<HTMLDivElement | null>;
eagerPreviewLimit?: number;
previewVersionByResourceId?: ReadonlyMap<string, string>;
}) {
@@ -738,7 +741,7 @@ export function useProjectResourceCardPreviews(input: {
}
},
{
root: input.canvasRef.current,
root: input.intersectionRootRef?.current ?? input.canvasRef.current,
rootMargin: '160px',
},
);
@@ -752,7 +755,7 @@ export function useProjectResourceCardPreviews(input: {
observerRef.current = null;
}
};
}, [input.canvasRef, requestPreview, scopeKey]);
}, [input.canvasRef, input.intersectionRootRef, requestPreview, scopeKey]);
return {
identityByResourceId,
@@ -2,6 +2,7 @@ import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
@@ -997,6 +998,104 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull();
}, 10_000);
it('caps the overview stack for a crowded resource type without trimming the child canvas', async () => {
const manifest = createGameCreationAppManifest(
'workbench-overview-stack-cap',
'资源总览堆叠项目',
);
manifest.assets = Array.from({ length: 5 }, (_value, index) => ({
id: `overview-art-${index}`,
kind: 'character',
mediaType: 'image/png',
localPath: `assets/overview-art-${index}.png`,
source: { kind: 'generated', taskId: 'art-asset-plan' },
}));
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-overview-stack-cap',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await waitFor(() =>
expect(
document.querySelectorAll(
'.game-resource-book-scene-card[data-resource-book-category="art"]',
),
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT),
);
expect(screen.getByText('5 项')).not.toBeNull();
const overviewCardLayers = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category="art"]',
),
).map((card) => Number(card.style.zIndex));
expect(overviewCardLayers[0]).toBeGreaterThan(overviewCardLayers.at(-1)!);
await openResourceBookCategory('美术资源');
await waitFor(() =>
expect(
document.querySelectorAll(
'.game-resource-book-scene-card.is-expanded[data-resource-book-category="art"]',
),
).toHaveLength(5),
);
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
target: { value: 'overview-art-4' },
});
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelectorAll(
'.game-resource-book-scene-card[data-resource-book-category="art"]',
),
).toHaveLength(1),
);
});
it('marks newly added resources on inactive section tabs until the user opens them', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-unread',
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
groupResourceBookStacks,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
resourceBookOverviewCardLayout,
} from '../src/view/project-development/resourceBookLayout';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(id: string): ProjectResource {
return {
id,
category: 'art',
subtype: 'image',
label: id,
path: `assets/${id}.png`,
mediaType: 'image/png',
sourceLabel: '测试',
taskTitle: null,
manifestAssetId: id,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
};
}
const rect = { left: 100, top: 200, width: 280, height: 210 };
describe('resourceBookOverviewCardLayout', () => {
it('keeps the stack tilt bounded however many resources share one type', () => {
const rotations = [0, 1, 2, 3, 12, 59].map(
(stackIndex) =>
resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 })
.rotation,
);
for (const rotation of rotations) {
expect(Math.abs(rotation)).toBeLessThanOrEqual(3);
}
expect(rotations[0]).toBe(0.5);
expect(rotations.at(-1)).toBe(rotations[2]);
});
it('stops offsetting deeper cards once the visible stack depth is reached', () => {
const positions = [0, 1, 2, 3, 59].map(
(stackIndex) =>
resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 }).y,
);
expect(positions[0]).toBe(rect.top + 58);
expect(positions[1]).toBe(rect.top + 63);
expect(positions[2]).toBe(rect.top + 68);
expect(positions.at(-1)).toBe(positions[2]);
});
it('limits how many cards the overview renders per type stack', () => {
expect(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT).toBe(3);
});
});
describe('groupResourceBookStacks', () => {
it('keeps resources of one type in a single stack', () => {
const stacks = groupResourceBookStacks([
resource('overview-art-0'),
resource('overview-art-1'),
resource('overview-art-2'),
resource('overview-art-3'),
]);
expect(stacks).toHaveLength(1);
expect(stacks[0]?.[1]).toHaveLength(4);
});
});
@@ -804,4 +804,107 @@ describe('useProjectResourceCardPreviews', () => {
);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
});
it('observes cards against the provided intersection root and loads visible previews', async () => {
const art = resource('intersection-root-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const intersectionRoot = document.createElement('div');
const callbacks: IntersectionObserverCallback[] = [];
const roots: (Element | Document | null)[] = [];
const observed = new Set<Element>();
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
class TestIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
callbacks.push(callback);
roots.push(options?.root ?? null);
}
observe(element: Element) {
observed.add(element);
}
unobserve(element: Element) {
observed.delete(element);
}
disconnect() {
observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: TestIntersectionObserver,
});
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-intersection-root',
projectId: 'preview-intersection-root',
mode: 'type',
resources: [art],
canvasRef,
intersectionRootRef: { current: intersectionRoot },
eagerPreviewLimit: 0,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
const card = document.createElement('div');
act(() => {
result.current.observePreview(card, art, identity);
});
await waitFor(() => expect(callbacks.length).toBeGreaterThan(0));
expect(roots.at(-1)).toBe(intersectionRoot);
expect(observed.has(card)).toBe(true);
expect(previewReadCalls(invoke)).toHaveLength(0);
act(() => {
callbacks.at(-1)!(
[
{
target: card,
isIntersecting: true,
intersectionRatio: 1,
} as IntersectionObserverEntry,
],
{} as IntersectionObserver,
);
});
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(previewReadCalls(invoke)).toHaveLength(1);
} finally {
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
}
});
});