From 36747b3ece10020089150464d13140872e705642 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 8 Sep 2026 20:23:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E6=80=BB?= =?UTF-8?q?=E8=A7=88=E6=91=9E=E5=A0=86=E5=8F=A0=E6=97=8B=E8=BD=AC=E4=B8=8E?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=A2=84=E8=A7=88=E6=87=92=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 总览每个资源类型最多铺 3 张卡片,其余数量由栏目标题栏的“N 项”表达 - 总览摞旋转角改为固定小角度,素材变多后不再随堆叠下标无限增大 - 总览摞下标小的卡片压在上层,不再被模糊卡遮住最上面那张清晰卡 - 截断前先按搜索可见性过滤,避免搜索结果落在深处时总览显示空摞 - 资源卡片可见性观察改用真正包含卡片的容器,恢复滚动时的预览懒加载 - 补充资源画本布局单测、预览观察根回归用例和总览摞上限用例 --- .../src/view/project-development/index.tsx | 59 +++++----- .../project-development/resourceBookLayout.ts | 12 +- .../useProjectResourceCardPreviews.ts | 7 +- .../appSurface/project-development.suite.ts | 99 +++++++++++++++++ .../tests/resourceBookLayout.test.ts | 75 +++++++++++++ .../useProjectResourceCardPreviews.test.ts | 103 ++++++++++++++++++ 6 files changed, 319 insertions(+), 36 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e89f6e217..13c294fc4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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({ /> {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)} ); - }), - )} + }); + })} ); })} @@ -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(() => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts index 5fd660045..f187029b7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts @@ -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, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 935ad4c5b..58d0934a6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -198,6 +198,9 @@ export function useProjectResourceCardPreviews(input: { mode: ProjectResourceCanvasLayoutMode; resources: ProjectResource[]; canvasRef: React.RefObject; + // 可见性判定容器。卡片渲染在资源画本场景里时,传真正包含卡片的容器; + // 缺省回退到 canvasRef。 + intersectionRootRef?: React.RefObject; eagerPreviewLimit?: number; previewVersionByResourceId?: ReadonlyMap; }) { @@ -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, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 8537eef78..922b87457 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -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) => { + 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( + '.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', diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts new file mode 100644 index 000000000..efdcbbf02 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -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); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 95a2afe68..a18d1e583 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -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) => + 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(); + 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'); + } + } + }); });