修复资源总览摞堆叠旋转与卡片预览懒加载
- 总览每个资源类型最多铺 3 张卡片,其余数量由栏目标题栏的“N 项”表达 - 总览摞旋转角改为固定小角度,素材变多后不再随堆叠下标无限增大 - 总览摞下标小的卡片压在上层,不再被模糊卡遮住最上面那张清晰卡 - 截断前先按搜索可见性过滤,避免搜索结果落在深处时总览显示空摞 - 资源卡片可见性观察改用真正包含卡片的容器,恢复滚动时的预览懒加载 - 补充资源画本布局单测、预览观察根回归用例和总览摞上限用例
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user