合并 #283 资源画本画布实现

合入 feat/game-agent-canvas-resource-workbench-v2 的完整画布实现:资源画本控制器/模型/视口/布局、转场层、资源分页缓存与自动聚焦修复、资源总览卡片预览懒加载
保留本分支已有的聊天素材引用改动(引用输入区、@ 引用按钮、运行画面点选)
解决 index.tsx 状态声明与 pitfalls.md 文档冲突;styles.css 去重后仅保留一份画本视觉层
This commit is contained in:
2026-09-09 15:43:32 +08:00
21 changed files with 3280 additions and 1149 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
export function ResourceBookTransitionLayer() {
return (
<div
className="game-resource-book-transition-layer"
aria-hidden="true"
inert
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
export const RESOURCE_BOOK_MOTION_DURATION = 420;
const EASING = 'cubic-bezier(0.2, 0.78, 0.2, 1)';
type Snapshot = {
key: string;
rect: { left: number; top: number; width: number; height: number };
opacity: number;
content: HTMLElement;
};
export function resourceBookFlipTransform(
from: Snapshot['rect'],
to: Snapshot['rect'],
) {
return `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / to.width}, ${from.height / to.height})`;
}
function validRect(rect: Snapshot['rect']) {
return (
Object.values(rect).every(Number.isFinite) &&
rect.width > 0 &&
rect.height > 0
);
}
// Freeze presentation only. Clones cannot expose duplicate controls, replay
// media, or depend on CSS selectors belonging to the old ancestor tree.
function clonePresentation(
source: HTMLElement,
rect: Snapshot['rect'],
): HTMLElement {
const sourceStyle = getComputedStyle(source);
const width =
source.offsetWidth || Number.parseFloat(sourceStyle.width) || rect.width;
const height =
source.offsetHeight || Number.parseFloat(sourceStyle.height) || rect.height;
let transform = `scale(${rect.width / width}, ${rect.height / height})`;
if (typeof DOMMatrix !== 'undefined') {
let matrix = new DOMMatrix();
for (
let element: HTMLElement | null = source;
element;
element = element.parentElement
) {
const value = getComputedStyle(element).transform;
if (value && value !== 'none')
matrix = new DOMMatrix(value).multiply(matrix);
}
const { a, b, c, d } = matrix;
const left = Math.min(0, a * width, c * height, a * width + c * height);
const top = Math.min(0, b * width, d * height, b * width + d * height);
transform = `matrix(${a}, ${b}, ${c}, ${d}, ${-left}, ${-top})`;
}
const clone = source.cloneNode(true) as HTMLElement;
const originals = [source, ...source.querySelectorAll<HTMLElement>('*')];
const copies = [clone, ...clone.querySelectorAll<HTMLElement>('*')];
originals.forEach((original, index) => {
const copy = copies[index]!;
const style = getComputedStyle(original);
for (const property of Array.from(style)) {
copy.style.setProperty(property, style.getPropertyValue(property));
}
for (const attribute of Array.from(copy.attributes)) {
if (
attribute.name === 'id' ||
attribute.name.startsWith('data-') ||
attribute.name.startsWith('aria-') ||
attribute.name === 'autoplay'
) {
copy.removeAttribute(attribute.name);
}
}
copy.style.transition = 'none';
copy.style.animation = 'none';
copy.style.pointerEvents = 'none';
copy.setAttribute('tabindex', '-1');
if (copy instanceof HTMLMediaElement) {
copy.removeAttribute('src');
copy.querySelectorAll('source').forEach((item) => item.remove());
copy.autoplay = false;
}
if (original instanceof HTMLVideoElement && original.readyState >= 2) {
try {
const bitmap = document.createElement('canvas');
bitmap.width = original.videoWidth;
bitmap.height = original.videoHeight;
bitmap.getContext('2d')?.drawImage(original, 0, 0);
const image = document.createElement('img');
image.src = bitmap.toDataURL();
image.style.cssText = copy.style.cssText;
copy.replaceWith(image);
} catch {
// Cross-origin media may prohibit readback; retain the existing poster.
}
}
});
Object.assign(clone.style, {
position: 'absolute',
left: '0',
top: '0',
margin: '0',
transform,
transformOrigin: '0 0',
translate: 'none',
rotate: 'none',
scale: 'none',
width: `${width}px`,
height: `${height}px`,
opacity: '1',
visibility: 'visible',
});
return clone;
}
function capture(root: HTMLElement): Map<string, Snapshot> {
const result = new Map<string, Snapshot>();
const bounds = root.getBoundingClientRect();
const layer = root.querySelector<HTMLElement>(
'.game-resource-book-transition-layer',
);
const moving = layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
const elements = moving?.length
? Array.from(moving)
: Array.from(
root.querySelectorAll<HTMLElement>(
'.game-resource-book-scene .game-resource-card, .game-resource-book-scene-titlebar',
),
);
for (const element of elements) {
const box = element.getBoundingClientRect();
const rect = {
left: box.left - bounds.left,
top: box.top - bounds.top,
width: box.width,
height: box.height,
};
if (
!validRect(rect) ||
box.right < bounds.left ||
box.left > bounds.right ||
box.bottom < bounds.top ||
box.top > bounds.bottom
)
continue;
let opacity = 1;
let visible = true;
for (
let parent: HTMLElement | null = element;
parent && parent !== root;
parent = parent.parentElement
) {
const style = getComputedStyle(parent);
opacity *= Number(style.opacity || 1);
if (style.visibility === 'hidden' || style.display === 'none')
visible = false;
}
if (!visible || opacity <= 0) continue;
const key =
element.dataset.motionSnapshot ??
(element.dataset.resourceCardId
? `card:${element.dataset.resourceCardId}`
: `title:${element.dataset.resourceBookCategory}`);
result.set(key, {
key,
rect,
opacity,
content: clonePresentation(
element.dataset.motionSnapshot
? (element.firstElementChild as HTMLElement)
: element,
rect,
),
});
}
return result;
}
export type ResourceBookTransitionController = ReturnType<
typeof createResourceBookTransitionController
>;
export function createResourceBookTransitionController() {
let token = 0;
let source = new Map<string, Snapshot>();
let animations: Animation[] = [];
let layer: HTMLElement | null = null;
let root: HTMLElement | null = null;
let completion: (() => void) | null = null;
let observer: ResizeObserver | null = null;
let motionPreference: MediaQueryList | null = null;
const clear = () => {
animations.forEach((animation) => animation.cancel());
animations = [];
layer?.replaceChildren();
root?.removeAttribute('data-book-motion');
observer?.disconnect();
observer = null;
window.removeEventListener('resize', settle);
motionPreference?.removeEventListener?.('change', settle);
motionPreference = null;
completion = null;
};
const settle = () => {
const done = completion;
clear();
source.clear();
done?.();
};
const invalidate = () => {
token += 1;
clear();
source.clear();
return token;
};
return {
begin(manager: HTMLElement | null) {
const current = manager ? capture(manager) : new Map<string, Snapshot>();
invalidate();
root = manager;
source = current;
return token;
},
invalidate,
settle,
isCurrent: (expected: number) => token === expected,
play(manager: HTMLElement, expected: number, done: () => void) {
if (token !== expected) return;
root = manager;
completion = done;
layer = manager.querySelector('.game-resource-book-transition-layer');
motionPreference =
window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
if (
!layer ||
typeof layer.animate !== 'function' ||
motionPreference?.matches ||
source.size === 0
) {
settle();
return;
}
const target = capture(manager);
const keys = new Set([...source.keys(), ...target.keys()]);
if (!target.size) {
settle();
return;
}
try {
for (const key of keys) {
const from = source.get(key);
const to = target.get(key);
const item = to ?? from!;
const wrapper = document.createElement('div');
wrapper.dataset.motionSnapshot = key;
Object.assign(wrapper.style, {
position: 'absolute',
left: `${item.rect.left}px`,
top: `${item.rect.top}px`,
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transformOrigin: '0 0',
pointerEvents: 'none',
});
wrapper.append(item.content);
layer.append(wrapper);
const animation = wrapper.animate(
[
{
transform: resourceBookFlipTransform(
from?.rect ?? item.rect,
item.rect,
),
opacity: from?.opacity ?? 0,
},
{
transform: 'translate(0px, 0px) scale(1, 1)',
opacity: to?.opacity ?? 0,
},
],
{
duration: RESOURCE_BOOK_MOTION_DURATION,
easing: EASING,
fill: 'both',
},
);
animations.push(animation);
// Attach rejection handling immediately in case a later item fails
// before the aggregate completion handler has been installed.
void animation.finished.catch(() => undefined);
}
} catch {
settle();
return;
}
manager.setAttribute('data-book-motion', 'running');
// Canceled promises belong to their original generation.
void Promise.all(animations.map((animation) => animation.finished)).then(
() => {
if (token === expected) settle();
},
() => {
if (token === expected) settle();
},
);
window.addEventListener('resize', settle);
motionPreference?.addEventListener?.('change', settle);
if (typeof ResizeObserver !== 'undefined') {
const box = manager.getBoundingClientRect();
observer = new ResizeObserver(() => {
const next = manager.getBoundingClientRect();
if (next.width !== box.width || next.height !== box.height) settle();
});
observer.observe(manager);
}
},
dispose: invalidate,
};
}
@@ -0,0 +1,103 @@
import type { ResourceBookCategory } from './resourceBookModel';
import type { ResourceCanvasCardSize } 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;
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,
);
}
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;
}
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,
};
}
@@ -0,0 +1,80 @@
import type { ProjectResourceCategory } from './resourceProjectionModel';
export type ResourceBookCategory = ProjectResourceCategory;
export type ResourceBookPhase = 'idle' | 'entering' | 'returning-main';
export type ResourceBookState = {
view: 'main' | 'child';
category: ResourceBookCategory | null;
phase: ResourceBookPhase;
token: number;
};
export type ResourceBookAction =
| { type: 'open-category'; category: ResourceBookCategory; token: number }
| { type: 'finish-transition'; token: number }
| { type: 'return-to-main'; token: number }
| { type: 'reset'; token: number };
export const initialResourceBookState: ResourceBookState = {
view: 'main',
category: null,
phase: 'idle',
token: 0,
};
export function resourceBookReducer(
state: ResourceBookState,
action: ResourceBookAction,
): ResourceBookState {
switch (action.type) {
case 'open-category': {
if (
state.view === 'child' &&
state.category === action.category &&
state.phase === 'idle'
) {
return state;
}
return {
...state,
view: 'child',
category: action.category,
phase: 'entering',
token: action.token,
};
}
case 'finish-transition':
return state.token === action.token ? { ...state, phase: 'idle' } : state;
case 'return-to-main':
if (state.view !== 'child') {
return state;
}
return {
...state,
view: 'main',
category: null,
phase: 'returning-main',
token: action.token,
};
case 'reset':
return {
...initialResourceBookState,
token: action.token,
};
default:
return state;
}
}
export function resourceBookSceneCategory(state: ResourceBookState) {
return state.category;
}
export function resourceBookShowsOverviewCards(state: ResourceBookState) {
return state.view === 'main';
}
export function resourceBookShowsChildCards(state: ResourceBookState) {
return state.view === 'child';
}
@@ -0,0 +1,119 @@
import {
type CanvasViewport,
MAX_SCALE,
MIN_SCALE,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
export const DEFAULT_RESOURCE_BOOK_VIEWPORT: CanvasViewport = {
x: 0,
y: 0,
scale: 1,
};
/**
* Child resource books are infinite canvases. Do not shrink their initial
* card world below its logical 1:1 size just to fit every card at once; that
* makes the canvas look tiny and gives the user no useful pan space.
*/
export function keepResourceBookViewportAtReadableScale({
viewport,
bounds,
canvasSize,
padding = 16,
}: {
viewport: CanvasViewport;
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
}): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
if (current.scale >= 1) {
return current;
}
const inset = Math.max(0, padding);
const availableWidth = Math.max(1, canvasSize.width - inset * 2);
const availableHeight = Math.max(1, canvasSize.height - inset * 2);
return {
scale: 1,
x: inset + availableWidth / 2 - (bounds.x + bounds.width / 2),
y: inset + availableHeight / 2 - (bounds.y + bounds.height / 2),
};
}
export function normalizeResourceBookViewport(
viewport: CanvasViewport | undefined,
): CanvasViewport {
return {
x: Number.isFinite(viewport?.x) ? viewport!.x : 0,
y: Number.isFinite(viewport?.y) ? viewport!.y : 0,
scale: Math.min(
MAX_SCALE,
Math.max(
MIN_SCALE,
Number.isFinite(viewport?.scale) ? viewport!.scale : 1,
),
),
};
}
export function zoomResourceBookViewport(
viewport: CanvasViewport,
factor: number,
anchor: { x: number; y: number },
): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
const nextScale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, current.scale * factor),
);
if (nextScale === current.scale || !Number.isFinite(nextScale)) {
return current;
}
const ratio = nextScale / current.scale;
return {
scale: nextScale,
x: anchor.x - (anchor.x - current.x) * ratio,
y: anchor.y - (anchor.y - current.y) * ratio,
};
}
export function panResourceBookViewport(
viewport: CanvasViewport,
delta: { x: number; y: number },
): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
return {
...current,
x: current.x + (Number.isFinite(delta.x) ? delta.x : 0),
y: current.y + (Number.isFinite(delta.y) ? delta.y : 0),
};
}
export function resolveResourceBookWheelViewport({
viewport,
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
}: {
viewport: CanvasViewport;
deltaX: number;
deltaY: number;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
screenPoint: { x: number; y: number };
}) {
return resolveViewportFromWheel({
viewport: normalizeResourceBookViewport(viewport),
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
});
}
@@ -0,0 +1,8 @@
export function createResourceCanvasPageCategorySignature<
TCategory extends string,
>(resources: readonly { category: TCategory }[]) {
return resources
.map((resource) => resource.category)
.sort()
.join('|');
}
@@ -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,
@@ -61,6 +61,32 @@ function ApprovedGddStartHarness() {
);
}
async function openResourceBookCategory(label: string) {
const categoryByLabel: Record<string, string> = {
: 'document',
: 'art',
: 'audio',
: 'version',
: 'code',
};
const category = categoryByLabel[label];
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
if (category) {
expect(
manager?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${category}"]`,
),
).not.toBeNull();
}
});
}
export function registerClientHomeTests() {
it('adds a model selector to the home composer and shows the default model', async () => {
const invoke = vi.fn(async (command: string, args?: unknown) => {
@@ -265,15 +291,14 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' }));
await openResourceBookCategory('美术资源');
expect(
await screen.findByRole('button', { name: /live-hero\.png/ }),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /^项目版本/,
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -436,6 +461,7 @@ export function registerClientHomeTests() {
runtimeHarness.emitManifestInvalidated('art-asset-plan');
});
await openResourceBookCategory('美术资源');
expect(
await screen.findByRole(
'button',
@@ -443,12 +469,10 @@ export function registerClientHomeTests() {
{ timeout: 5_000 },
),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /^项目版本/,
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
@@ -615,6 +639,7 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
pickProjectFromLauncher(secondProjectPath);
await openResourceBookCategory('美术资源');
expect(
await screen.findByRole('button', { name: /second\.png/ }),
).not.toBeNull();
File diff suppressed because it is too large Load Diff
@@ -731,7 +731,7 @@ describe('project resource live canvas integration', () => {
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByRole('button', { name: '按类型' })).not.toBeNull();
expect(screen.getByRole('button', { name: '复位资源画布' })).not.toBeNull();
expect(screen.getByRole('button', { name: '复位资源视图' })).not.toBeNull();
expect(screen.getByLabelText('资源依赖视图').hasAttribute('inert')).toBe(
false,
);
@@ -0,0 +1,193 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createResourceBookTransitionController,
resourceBookFlipTransform,
} from '../src/view/project-development/resourceBookController';
function rect(left = 0, top = 0, width = 100, height = 80): DOMRect {
return {
left,
top,
width,
height,
x: left,
y: top,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
};
}
function fixture() {
const root = document.createElement('div');
root.innerHTML = `<div class="game-resource-book-scene"><div class="game-resource-book-scene-world">
<div class="game-resource-card" data-resource-card-id="one"><button id="original">Open</button></div>
</div></div><div class="game-resource-book-transition-layer" inert aria-hidden="true"></div>`;
document.body.append(root);
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600));
const card = root.querySelector<HTMLElement>('.game-resource-card')!;
const measure = vi
.spyOn(card, 'getBoundingClientRect')
.mockReturnValue(rect());
const flights: {
finish(): void;
reject(): void;
cancel: ReturnType<typeof vi.fn>;
frames: Keyframe[];
}[] = [];
vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => {
let finish!: () => void;
let reject!: () => void;
const finished = new Promise<Animation>((resolve, fail) => {
finish = () => resolve({} as Animation);
reject = () => fail(new Error('canceled'));
});
const cancel = vi.fn(reject);
flights.push({ finish, reject, cancel, frames: frames as Keyframe[] });
return { finished, cancel } as unknown as Animation;
});
return { root, card, measure, flights };
}
const originalAnimate = Object.getOwnPropertyDescriptor(
Element.prototype,
'animate',
);
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (originalAnimate)
Object.defineProperty(Element.prototype, 'animate', originalAnimate);
else delete (Element.prototype as Partial<Element>).animate;
document.body.replaceChildren();
});
function setup() {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
writable: true,
value: () => ({}),
});
return fixture();
}
describe('resource book FLIP controller', () => {
it('uses screen geometry independently of either viewport', () => {
expect(
resourceBookFlipTransform(rect(30, 40, 50, 20), rect(10, 10, 100, 80)),
).toBe('translate(20px, 30px) scale(0.5, 0.25)');
});
it('finishes from animation completion, removes clones and preserves real controls', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
measure.mockReturnValue(rect(150, 100, 200, 160));
const done = vi.fn();
controller.play(root, token, done);
expect(root.dataset.bookMotion).toBe('running');
expect(root.querySelectorAll('#original')).toHaveLength(1);
expect(flights[0].frames[0].transform).toBe(
'translate(-150px, -100px) scale(0.5, 0.5)',
);
expect(done).not.toHaveBeenCalled();
flights[0].finish();
await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1));
expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(root.hasAttribute('data-book-motion')).toBe(false);
controller.dispose();
});
it('retargets from the current overlay geometry and ignores old completion', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const firstDone = vi.fn();
let token = controller.begin(root);
measure.mockReturnValue(rect(200, 100, 200, 160));
controller.play(root, token, firstDone);
const moving = root.querySelector<HTMLElement>('[data-motion-snapshot]')!;
vi.spyOn(moving, 'getBoundingClientRect').mockReturnValue(
rect(90, 40, 150, 120),
);
token = controller.begin(root);
measure.mockReturnValue(rect(300, 200, 100, 80));
const secondDone = vi.fn();
controller.play(root, token, secondDone);
expect(flights[1].frames[0].transform).toBe(
'translate(-210px, -160px) scale(1.5, 1.5)',
);
await Promise.resolve();
expect(firstDone).not.toHaveBeenCalled();
expect(secondDone).not.toHaveBeenCalled();
flights[1].finish();
await vi.waitFor(() => expect(secondDone).toHaveBeenCalledTimes(1));
controller.dispose();
});
it.each(['reduced', 'zero', 'unsupported'])(
'settles without a timer for %s motion',
(reason) => {
const { root, measure, flights } = setup();
if (reason === 'zero') measure.mockReturnValue(rect(0, 0, 0, 0));
if (reason === 'unsupported')
delete (Element.prototype as Partial<Element>).animate;
if (reason === 'reduced')
vi.stubGlobal('matchMedia', () => ({ matches: true }));
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
const done = vi.fn();
controller.play(root, token, done);
expect(done).toHaveBeenCalledTimes(1);
expect(flights).toHaveLength(0);
controller.dispose();
},
);
it('settles on resize and suppresses completion after disposal', async () => {
const { root, flights } = setup();
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
window.dispatchEvent(new Event('resize'));
expect(done).toHaveBeenCalledTimes(1);
controller.play(root, controller.begin(root), done);
controller.dispose();
flights[1].finish();
await Promise.resolve();
expect(done).toHaveBeenCalledTimes(1);
expect(root.hasAttribute('data-book-motion')).toBe(false);
});
it('cleans partially started animations when animation creation fails', () => {
const { root } = setup();
vi.mocked(Element.prototype.animate).mockImplementation(() => {
throw new Error('animation unavailable');
});
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
expect(done).toHaveBeenCalledTimes(1);
expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(root.hasAttribute('data-book-motion')).toBe(false);
controller.dispose();
});
it('cancellation never waits for a CSS timer and leaves the viewport untouched', async () => {
const { root, flights } = setup();
const world = root.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
)!;
world.style.transform = 'translate(20px, 40px) scale(1.5)';
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
controller.settle();
expect(flights[0].cancel).toHaveBeenCalledTimes(1);
expect(done).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(world.style.transform).toBe('translate(20px, 40px) scale(1.5)');
controller.dispose();
});
});
@@ -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);
});
});
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
initialResourceBookState,
resourceBookReducer,
resourceBookSceneCategory,
resourceBookShowsOverviewCards,
} from '../src/view/project-development/resourceBookModel';
describe('resource book state machine', () => {
it('keeps overview titles and preview cards visible on the steady main canvas', () => {
expect(resourceBookShowsOverviewCards(initialResourceBookState)).toBe(true);
});
it('commits the child target immediately and only keeps the motion phase transient', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
expect(entering).toMatchObject({
view: 'child',
category: 'document',
phase: 'entering',
token: 1,
});
expect(resourceBookSceneCategory(entering)).toBe('document');
});
it('retargets directly from one child canvas to another', () => {
const current = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switching = resourceBookReducer(current, {
type: 'open-category',
category: 'art',
token: 2,
});
expect(switching).toMatchObject({
view: 'child',
category: 'art',
phase: 'entering',
});
expect(resourceBookSceneCategory(switching)).toBe('art');
});
it('marks a return to the main canvas separately from a child enter transition', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const returning = resourceBookReducer(entering, {
type: 'return-to-main',
token: 2,
});
expect(returning).toMatchObject({
view: 'main',
category: null,
phase: 'returning-main',
});
});
it('lets an explicit collapse cancel an in-flight child switch', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switching = resourceBookReducer(entering, {
type: 'open-category',
category: 'art',
token: 2,
});
const main = resourceBookReducer(switching, {
type: 'return-to-main',
token: 3,
});
expect(main).toMatchObject({
view: 'main',
category: null,
phase: 'returning-main',
token: 3,
});
});
it('ignores stale transition callbacks', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switched = resourceBookReducer(entering, {
type: 'open-category',
category: 'art',
token: 2,
});
expect(
resourceBookReducer(switched, { type: 'finish-transition', token: 1 }),
).toEqual(switched);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
keepResourceBookViewportAtReadableScale,
panResourceBookViewport,
} from '../src/view/project-development/resourceBookViewport';
describe('resource book viewport', () => {
it('keeps an initially fitted child canvas readable instead of shrinking it below 1:1', () => {
expect(
keepResourceBookViewportAtReadableScale({
viewport: { x: 120, y: 80, scale: 0.42 },
bounds: { x: 0, y: 0, width: 1_600, height: 1_000 },
canvasSize: { width: 900, height: 640 },
padding: 16,
}),
).toEqual({
x: -350,
y: -180,
scale: 1,
});
});
it('allows panning beyond the content bounds in both directions', () => {
expect(
panResourceBookViewport(
{ x: 0, y: 0, scale: 1 },
{ x: -4_000, y: 3_000 },
),
).toEqual({ x: -4_000, y: 3_000, scale: 1 });
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { createResourceCanvasPageCategorySignature } from '../src/view/project-development/resourceCanvasController';
describe('resource canvas page wheel controller', () => {
it('invalidates page categories for same-count category changes without reacting to resource order', () => {
const original = [{ category: 'document' }, { category: 'art' }];
const reordered = [...original].reverse();
const changed = [{ category: 'document' }, { category: 'audio' }];
expect(createResourceCanvasPageCategorySignature(reordered)).toBe(
createResourceCanvasPageCategorySignature(original),
);
expect(createResourceCanvasPageCategorySignature(changed)).not.toBe(
createResourceCanvasPageCategorySignature(original),
);
});
});
@@ -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');
}
}
});
});
@@ -322,8 +322,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。
- 任一可见资源出现后,普通用户资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 项目版本` 四栏目分页画布;游戏代码仍保留在内部资源、布局和依赖事实中,但不进入普通资源画布的导航、分页、卡片、搜索或详情入口。每个可见栏目按 `projectId + mode + category` 保留独立 viewport,普通 wheel 切换栏目`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
- 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击、滚轮或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。
- 任一可见资源出现后,普通用户资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 项目版本` 四栏目分页画布;游戏代码仍保留在内部资源、布局和依赖事实中,但不进入普通资源画布的导航、分页、卡片、搜索或详情入口。每个可见栏目按 `projectId + mode + category` 保留独立 viewport,普通 wheel 平移当前视图`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
- 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready``failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。
@@ -331,7 +331,7 @@ type UpdateProjectResourceCanvasLayoutResult =
#### 5.2.5 手动拖动合同
- Pointer Events 使用 `5px` 移动阈值,拖动期间指针捕获到卡片,画布普通滚轮仍切栏目`Ctrl/Cmd + wheel` 仍以真实画布指针位置缩放。
- Pointer Events 使用 `5px` 移动阈值,拖动期间指针捕获到卡片,画布普通滚轮只平移当前视图`Ctrl/Cmd + wheel` 仍以真实画布指针位置缩放。
- 拖动提交沿用现有单写者 FIFO 与 CAS 冲突重试;冲突提示沿用“布局已在其他窗口更新”,用户可重新拖动。历史 `manuallyPlaced=true` 坐标仍优先保留。
- 资源卡拖动统一调用现役 Hook 手动意图、命令式 SVG preview、sidecar 字段和 Rust CAS:拖动期间只更新当前会话预览,成功释放后提交一次手动布局 CAS,取消或未超过阈值不写入。
@@ -350,7 +350,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- 同类型精确引用按稳定边 ID 和对端次序为同一卡片同侧的多条边分配独立端口;横向层级可用时优先左右连接,同列或横向间隙不足时才上下连接。同轴端点直接用直线,需转向时使用正交线段与最大 `10px` 的小圆角,不使用大范围贝塞尔控制柄。端口顺序不使用显示名称、随机数或浏览器枚举顺序,相同输入必须产生相同路径。资源点击只进入中央聚焦并保留当前选中卡片,不改变依赖卡片或连线的颜色、线宽与透明度;关系线始终直接展示,不提供点击后的上下游高亮或无关线弱化。
- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、项目切换或 section origin 变化而更新。
- 当前 dependency 栏目平面最多构造一个 `ResizeObserver`observer 只维护当前栏目 viewport,不测量或重建卡片屏幕端点。栏目 extent 必须为最右侧自环和箭头保留视觉 gutter,但不得修改卡片坐标或布局 sidecar。
- 非空资源页的普通滚轮固定切换栏目;拖拽平移、Ctrl/Meta + 滚轮缩放和窗口 resize 共用当前栏目 viewport 变换。原生 `{ passive: false }` wheel 监听必须阻止 WebView 默认滚动或缩放,并对连续滚轮事件做节流。完全空项目的分区展览保留原生分区滚动。项目 / mode 切换或卸载时必须清理 observer、wheel 与 window resize 监听。
- 非空资源页的普通滚轮只平移当前视图;拖拽平移、Ctrl/Meta + 滚轮缩放和窗口 resize 共用当前栏目 viewport 变换。原生 `{ passive: false }` wheel 监听必须阻止 WebView 默认滚动或缩放。完全空项目的分区展览保留原生分区滚动。项目 / mode 切换或卸载时必须清理 observer、wheel 与 window resize 监听。
- 阶段五保留已有 `manuallyPlaced=true` 坐标;资源卡拖动成功释放会将该卡片写为 `manuallyPlaced=true`,后续资源引用新增或变化只重新派生 `manuallyPlaced=false` 的自动坐标。任务流继续按任务对与资源分区聚合,禁止为了布局分组生成资源笛卡尔积,且不进入 SVG。
### 5.3 资源类型与替换兼容性(P1)
@@ -6,6 +6,15 @@
主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。
## 2026-09-05 资源画本转场与视口分离
- 无限画布的指针交互必须绑定未变换、铺满可视区域的视口,平移和缩放只作用于内部 world;若连交互容器一起移动或缩小,内容移走后的空白处会失去拖动入口。内容层不裁剪,裁剪仅由固定视口承担;主画布滚轮使用非 passive 监听,避免浏览器默认滚动或缩放。
- `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点。再次导航从当前快照位置开始,旧完成回调由 token 失效。
- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白。
- 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。
- jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。
## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe
- **现象**Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。
@@ -1,8 +1,34 @@
# Game Agent 资源自由画板与快速编辑
## 2026-09-05 资源画本独立转场
- `resourceBookModel` 立即提交目标 `main / child + category``entering / returning-main` 只表示尚未收尾的视觉过程,不承载待提交的栏目。缩略入口、大纲、下一页与资源定位共用分类导航入口;栏目互切直接到达目标,不经过总览。滚轮不切换栏目。
- 真实 `ResourceCard` 仍由唯一的 `ResourceBookScene` 挂载,保持预览请求、播放器和交互的单一所有者。转场前 `resourceBookController.begin` 捕获当前可见标题与资源卡的屏幕矩形和表现;目标 DOM 提交后的 layout effect 调用 `play`
- `ResourceBookTransitionLayer` 是 manager 内未变换的独立层。快照仅复制表现,不挂载 React 业务组件,移除重复 ID、业务 data 属性及可访问控件身份,整层 `inert / aria-hidden / pointer-events:none`。播放中的视频优先捕获当前帧;跨源禁止读取时保留已有 poster,不启动第二个播放器。
- FLIP 只动画快照的 transform/opacity,统一时长 420ms,所有 `Animation.finished` 完成后删除快照并恢复目标真实节点;动画创建失败、零尺寸或不支持 Web Animations 时直接完成。`prefers-reduced-motion` 不创建动画,不等待定时器。
- 再次导航先读取当前动画快照的实际位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。窗口尺寸变化、排序或异步布局更新时直接收尾到当前目标,避免使用过期几何。
- 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮只平移当前视图,Ctrl/Meta 滚轮以指针为锚点缩放,均不切换栏目。
- viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。
验收使用 `resourceBookController.test.ts``resourceBookModel.test.ts``resourceBookViewport.test.ts``appSurface.test.ts`;页面 `.suite.ts``appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖总览/栏目往返、连续改选、滚轮平移、缩放、尺寸变化、减少动画偏好,以及快照图片和字体的正确比例。jsdom 不模拟真实插值,不能代替浏览器验收。
### 2026-09-05 子画布无限平移与初始可读比例
子画布改为固定视口加可无限平移的 world 层:视口本身保持 `overflow: hidden`,平移和缩放只作用于 `.game-resource-book-scene-world`,不再让内容容器的边界限制拖动范围。首次 fit(包括隐藏分页画布提前完成的 fit)统一保证比例不低于 `1:1`,避免子画布展开后卡片缩成不可读的小块;用户后续主动缩放或平移的视口仍按每个分类独立保留。新增 viewport 回归测试覆盖初始可读比例和超出内容边界双向平移。
返回主画布时立即提交主画布布局,由独立快照层完成缩回;world/main viewport 不播放附加动画。
总览和栏目视图同样使用固定铺满 manager 的交互视口,只有内部 world 层承担平移和缩放,内容层不裁剪缩略入口。空白拖动允许双向越过全部内容边界;移走内容后仍可在原视口继续拖动。普通滚轮/触控板平移,Shift 滚轮横向平移,Ctrl/Meta 滚轮以指针为锚点缩放;wheel 监听阻止浏览器滚动或页面缩放,但不切换栏目。总览/栏目往返保留总览原有位置和比例,只有主动适应内容才复位。
## 2026-09-04 资源画本 UI 阶段补充
资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。
本阶段先实现前端 UI、导航状态和转场表现,资源真实跨画布移动、主画布正式资源登记、上传/生成任务、版本绑定以及 UI 编辑器导入与 UI 包工作流继续沿用现有权威链路,待 UI 编辑器完善后再接入。临时画本视图状态不得替代 manifest、草稿或其它后端业务真相。
## 目标
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:固定五个栏目各自拥有一个铺满资源管理区域的独立画布,空栏目也保留可打开的空画布;栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮切换栏目,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:固定五个栏目各自拥有一个铺满资源管理区域的独立画布,空栏目也保留可打开的空画布;栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮平移当前视图,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
图片资源从“编辑资源”进入持续精修草稿后,点击任意图片直接在图片下方显示快速编辑卡。每次快速编辑创建新的候选图,不覆盖来源图层;生成任务立即在画布中创建占位并进入任务侧栏。用户显式选择候选图“设为最终图”后,保持原资产 ID 不变,以事务方式切换 manifest 指向的正式 PNG;精修草稿和其它候选图继续保留。
@@ -87,12 +113,12 @@
## 操作边界
- 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;逻辑目标栏目在事件处理中同步推进,可见栏目和 viewport 通过 React transition 异步提交,渲染尚未完成时到达的新滚轮仍基于最新逻辑目标继续计算,渲染过程不得把目标栏目回写成旧页面。同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲、底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;Ctrl/Meta 缩放、触控板缩放手势、排序切换和开始拖动画布或资源卡也必须取消待处理切页,缩放后的下一次普通滚轮应立即建立新的翻页意图。切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。
- 滚轮不切换栏目。点击大纲、底部“下一页”标题、总览入口或自动定位资源属于显式切页切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。
- 每种排序模式下的每个栏目画布都保留独立 viewport;首次进入该“排序模式 + 栏目”组合时按当前内容适配视口,离开后再返回则恢复该组合上次的平移和缩放。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前组合,不能牵动其它排序模式或栏目。
- 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。
- 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、底部下一页标题和滚轮切换。
- 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、总览入口和底部下一页标题切换。
- 当前栏目画布背景是无限的:用户可以将 viewport 沿 x/y 任意方向平移,画布不以资源 extent 作为导航边界,也不显示可见画布边缘。资源卡片的持久化布局坐标允许落在 `-1_000_000..=1_000_000`,用于支撑元素位于世界原点左上方;超出该范围仍拒绝写入,避免持久化非法布局。这与 viewport 能否继续平移是两层独立语义。搜索、详情卡和临时隐藏不得改变 viewport。
- 普通滚轮切换栏目,指针拖动空白平移画布,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。只有“排序模式 + 栏目”组合首次获得可测量容器尺寸或用户显式复位时才重新适配内容;返回已访问组合、图片尺寸测量、布局拖动或资源 extent 变化只归一化并保留该组合的当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制。初次 fit 与显式复位只使用资源卡真实包围盒,不把导航最小尺寸、原点空区或额外布局 gap 算入,并以 `16px` 紧凑留白在共享缩放上限内尽量铺满视口。
- 指针拖动空白平移画布,普通滚轮平移当前视图,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。只有“排序模式 + 栏目”组合首次获得可测量容器尺寸或用户显式复位时才重新适配内容;返回已访问组合、图片尺寸测量、布局拖动或资源 extent 变化只归一化并保留该组合的当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制。初次 fit 与显式复位只使用资源卡真实包围盒,不把导航最小尺寸、原点空区或额外布局 gap 算入,并以 `16px` 紧凑留白在共享缩放上限内尽量铺满视口。
- 资源卡拖动使用 `5px` 阈值区分点击与移动;移动期间按当前 scale 乐观换算世界坐标、显示拖动态并同步依赖线,释放时提交一次 `manuallyPlaced=true` 布局 CAS,取消则回滚预览且不提交。拖动后的释放点击不打开详情。
- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。依赖线的 viewport 测量按动画帧合并,平移和缩放只更新已挂载观察器消费的最新 viewport,不得在每次输入时重建 ResizeObserver、scroll 或 resize 监听。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
- 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。角色资源同时显示“生成动画”时,两个业务操作按钮必须使用一致样式,不能依赖 DOM 中的首按钮位置。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。
@@ -117,7 +143,7 @@
## 验收
1. 完全空项目在按依赖与按类型下都显示相同的分区展览;任一栏目出现资源后,两种模式都切换为栏目分页画布,大纲以左侧垂直居中的悬浮 Dock 覆盖在全宽画布上,包含全部栏目的图标和文字、不显示数量,空栏目仍可打开空画布。
2. 栏目顺序为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`,游戏代码不再固定在首位;普通滚轮可循环切换栏目,点击大纲和底部下一页标题也可切页
2. 栏目顺序为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`,游戏代码不再固定在首位;点击大纲、总览入口和底部下一页标题切页,滚轮不切换栏目
3. 在当前栏目内拖拽空白可以无限平移画布,拖拽资源卡可以移动卡片并同步依赖线,Ctrl/Meta + 滚轮以指针位置为锚点缩放,复位按钮可以适配当前栏目内容;普通平移不会因资源 extent、图片测量或窗口 resize 被拉回,连续缩放仍停在共享画布的最小/最大比例范围内。
4. 按依赖模式显示当前栏目内同类型两端资源的合法精确引用,并具有与当前可见连线一致的无障碍关系说明;按类型模式不显示引导线;搜索隐藏任一端点后连线和说明同时消失。
5. 点击任意资源后“按依赖 / 按类型 / 复位”等画布级动作仍保持挂载和原状态;顶部不出现手动新建入口。详情为非模态独立卡片,背景画板 viewport、搜索、排序、卡片和连线不卸载、不重置。