资源画布新增组织操作撤销重做(WP4)

新增 features/resource-canvas/resourceCanvasHistoryModel:纯函数有界快照栈(capture/push/undo/redo/clear + 只回写差异位置),快照只含资源卡布局坐标,不回滚素材内容

新增 tests/resourceCanvasHistoryModel.test.ts 覆盖排序稳定、相同快照不入栈、栈上限、push 清空重做分支、撤销重做互逆、空栈返回 null、差异回写与已删除资源不重建

资源卡拖动持久化成功前先落快照,撤销/重做复用现役 commitPosition 手动 CAS 写链

画布缩放集群新增撤销/重做按钮(不可用时禁用),并支持 Ctrl/Cmd+Z 与 Ctrl/Cmd+Shift+Z、Ctrl+Y(输入框内不拦截)

项目切换时清空布局历史,避免跨项目回滚
This commit is contained in:
2026-09-10 15:43:03 +08:00
parent f247a5106a
commit 9121e89798
3 changed files with 507 additions and 0 deletions
@@ -0,0 +1,208 @@
/**
* 资源画布的组织操作历史:只记录资源卡的布局坐标,**不记录、也不回滚素材内容**。
*
* 素材不可变是硬约束,所以撤销/重做只作用于「画布上卡片怎么排」,绝不反向修改
* manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPosition`),
* 本模块只做纯函数的快照栈。
*/
import type { ProjectResourceCanvasSection } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export const MAX_RESOURCE_CANVAS_HISTORY_STEPS = 40;
export type ResourceCanvasLayoutSnapshotEntry = {
resourceId: string;
section: ProjectResourceCanvasSection;
x: number;
y: number;
manuallyPlaced: boolean;
};
export type ResourceCanvasLayoutSnapshot = {
entries: ResourceCanvasLayoutSnapshotEntry[];
};
export type ResourceCanvasHistoryEntry = {
id: number;
label: string;
snapshot: ResourceCanvasLayoutSnapshot;
createdAt: number;
};
export type ResourceCanvasHistory = {
undoStack: ResourceCanvasHistoryEntry[];
redoStack: ResourceCanvasHistoryEntry[];
nextEntryId: number;
};
export function createResourceCanvasHistory(): ResourceCanvasHistory {
return { undoStack: [], redoStack: [], nextEntryId: 1 };
}
/**
* 按 resourceId 排序后落快照,保证同一组坐标无论 map 顺序如何都比较得出相等。
*/
export function captureResourceCanvasSnapshot(
positions: readonly ResourceCanvasLayoutSnapshotEntry[],
): ResourceCanvasLayoutSnapshot {
return {
entries: positions
.map((position) => ({
resourceId: position.resourceId,
section: position.section,
x: position.x,
y: position.y,
manuallyPlaced: position.manuallyPlaced,
}))
.sort((left, right) => left.resourceId.localeCompare(right.resourceId)),
};
}
export function resourceCanvasSnapshotsEqual(
left: ResourceCanvasLayoutSnapshot,
right: ResourceCanvasLayoutSnapshot,
) {
if (left.entries.length !== right.entries.length) {
return false;
}
return left.entries.every((entry, index) => {
const other = right.entries[index];
return (
other !== undefined &&
entry.resourceId === other.resourceId &&
entry.section === other.section &&
entry.x === other.x &&
entry.y === other.y &&
entry.manuallyPlaced === other.manuallyPlaced
);
});
}
export function pushResourceCanvasHistory(
history: ResourceCanvasHistory,
{
label,
snapshot,
createdAt = 0,
}: {
label: string;
snapshot: ResourceCanvasLayoutSnapshot;
createdAt?: number;
},
): ResourceCanvasHistory {
const previous = history.undoStack.at(-1);
if (previous && resourceCanvasSnapshotsEqual(previous.snapshot, snapshot)) {
return history;
}
const entry: ResourceCanvasHistoryEntry = {
id: history.nextEntryId,
label,
snapshot,
createdAt,
};
return {
undoStack: [
...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
entry,
],
// 新的操作会让原来的重做分支失效。
redoStack: [],
nextEntryId: history.nextEntryId + 1,
};
}
export type ResourceCanvasHistoryStep = {
history: ResourceCanvasHistory;
label: string;
snapshot: ResourceCanvasLayoutSnapshot;
};
export function undoResourceCanvasHistory(
history: ResourceCanvasHistory,
currentSnapshot: ResourceCanvasLayoutSnapshot,
): ResourceCanvasHistoryStep | null {
const entry = history.undoStack.at(-1);
if (!entry) {
return null;
}
return {
history: {
undoStack: history.undoStack.slice(0, -1),
redoStack: [
...history.redoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
{
...entry,
snapshot: currentSnapshot,
},
],
nextEntryId: history.nextEntryId,
},
label: entry.label,
snapshot: entry.snapshot,
};
}
export function redoResourceCanvasHistory(
history: ResourceCanvasHistory,
currentSnapshot: ResourceCanvasLayoutSnapshot,
): ResourceCanvasHistoryStep | null {
const entry = history.redoStack.at(-1);
if (!entry) {
return null;
}
return {
history: {
undoStack: [
...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
{
...entry,
snapshot: currentSnapshot,
},
],
redoStack: history.redoStack.slice(0, -1),
nextEntryId: history.nextEntryId,
},
label: entry.label,
snapshot: entry.snapshot,
};
}
export function clearResourceCanvasHistory(
history: ResourceCanvasHistory,
): ResourceCanvasHistory {
if (history.undoStack.length === 0 && history.redoStack.length === 0) {
return history;
}
return { ...history, undoStack: [], redoStack: [] };
}
export function canUndoResourceCanvasHistory(history: ResourceCanvasHistory) {
return history.undoStack.length > 0;
}
export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) {
return history.redoStack.length > 0;
}
/**
* 只回写和快照不一致的位置,避免撤销时把没动过的卡片也当成一次手动摆放写回后端。
*/
export function resolveResourceCanvasRestoreEntries(
snapshot: ResourceCanvasLayoutSnapshot,
current: readonly ResourceCanvasLayoutSnapshotEntry[],
): ResourceCanvasLayoutSnapshotEntry[] {
const currentById = new Map(
current.map((entry) => [entry.resourceId, entry] as const),
);
return snapshot.entries.filter((entry) => {
const existing = currentById.get(entry.resourceId);
if (!existing) {
return false;
}
return (
existing.section !== entry.section ||
existing.x !== entry.x ||
existing.y !== entry.y
);
});
}
@@ -29,11 +29,13 @@ import {
Pause,
Play,
Plus,
Redo2,
RotateCcw,
Search,
Settings2,
SlidersHorizontal,
Sparkles,
Undo2,
X,
ZoomOut,
} from 'lucide-react';
@@ -80,6 +82,18 @@ import {
RESOURCE_REFERENCE_FILTERS,
type ResourceReferenceFilter,
} from '../../features/project-workspace/resourceReferences';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
captureResourceCanvasSnapshot,
clearResourceCanvasHistory,
createResourceCanvasHistory,
pushResourceCanvasHistory,
redoResourceCanvasHistory,
resolveResourceCanvasRestoreEntries,
type ResourceCanvasLayoutSnapshot,
undoResourceCanvasHistory,
} from '../../features/resource-canvas/resourceCanvasHistoryModel';
import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel';
import {
canNormalizeResourceIntoManifestAsset,
@@ -1195,6 +1209,10 @@ export default function ProjectDevelopmentView({
useState<QuickEditPanelState | null>(null);
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
useState<CanvasMarqueeState | null>(null);
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
const [resourceCanvasHistory, setResourceCanvasHistory] = useState(
createResourceCanvasHistory,
);
const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
null,
);
@@ -2966,6 +2984,7 @@ export default function ProjectDevelopmentView({
setSelectedResourceIds([]);
setQuickEditPanel(null);
setQuickEditSourceLayer(null);
setResourceCanvasHistory(clearResourceCanvasHistory);
setActiveResourceCategory(null);
const defaultViewports = defaultResourceCanvasViewports();
resourceCanvasViewportTargetsRef.current = defaultViewports;
@@ -3246,6 +3265,91 @@ export default function ProjectDevelopmentView({
}
}, [resourceCardPreviews.previews, stopActiveCardMedia]);
const applyResourceCanvasLayoutSnapshot = useCallback(
(snapshot: ResourceCanvasLayoutSnapshot) => {
resolveResourceCanvasRestoreEntries(
snapshot,
activeResourceLayout.layout.positions,
).forEach((entry) => {
activeResourceLayout.commitPosition(
entry.resourceId,
entry.section,
entry.x,
entry.y,
);
});
},
[activeResourceLayout],
);
const undoSaveResourceCanvasLayout = useCallback(() => {
const step = undoResourceCanvasHistory(
resourceCanvasHistory,
captureResourceCanvasSnapshot(activeResourceLayout.layout.positions),
);
if (!step) {
return;
}
setResourceCanvasHistory(step.history);
applyResourceCanvasLayoutSnapshot(step.snapshot);
}, [
activeResourceLayout.layout.positions,
applyResourceCanvasLayoutSnapshot,
resourceCanvasHistory,
]);
const redoResourceCanvasLayout = useCallback(() => {
const step = redoResourceCanvasHistory(
resourceCanvasHistory,
captureResourceCanvasSnapshot(activeResourceLayout.layout.positions),
);
if (!step) {
return;
}
setResourceCanvasHistory(step.history);
applyResourceCanvasLayoutSnapshot(step.snapshot);
}, [
activeResourceLayout.layout.positions,
applyResourceCanvasLayoutSnapshot,
resourceCanvasHistory,
]);
const canUndoResourceCanvas = canUndoResourceCanvasHistory(
resourceCanvasHistory,
);
const canRedoResourceCanvas = canRedoResourceCanvasHistory(
resourceCanvasHistory,
);
useEffect(() => {
const handleHistoryShortcut = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
return;
}
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = (key === 'z' && event.shiftKey) || key === 'y';
if (!isUndo && !isRedo) {
return;
}
const target = event.target;
if (
target instanceof HTMLElement &&
target.closest('input, textarea, [contenteditable="true"]')
) {
return;
}
event.preventDefault();
if (isUndo) {
undoSaveResourceCanvasLayout();
} else {
redoResourceCanvasLayout();
}
};
window.addEventListener('keydown', handleHistoryShortcut);
return () => window.removeEventListener('keydown', handleHistoryShortcut);
}, [redoResourceCanvasLayout, undoSaveResourceCanvasLayout]);
const handleResourceSelect = useCallback(
(resourceId: string, options: { append?: boolean } = {}) => {
if (skipNextResourceCardClickRef.current === resourceId) {
@@ -3591,6 +3695,15 @@ export default function ProjectDevelopmentView({
: null;
setResourceCardDragPreview(null);
if (commit && drag.changed && preview?.resourceId === drag.resourceId) {
// 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标。
setResourceCanvasHistory((history) =>
pushResourceCanvasHistory(history, {
label: '移动资源卡',
snapshot: captureResourceCanvasSnapshot(
activeResourceLayout.layout.positions,
),
}),
);
activeResourceLayout.commitPosition(
drag.resourceId,
drag.section,
@@ -5010,6 +5123,26 @@ export default function ProjectDevelopmentView({
onWheel={handleResourceBookWheel}
/>
<div className="game-resource-book-zoom">
<button
type="button"
className="game-resource-book-zoom-button"
aria-label="撤销画布操作"
title="撤销(Ctrl/Cmd+Z"
disabled={!canUndoResourceCanvas}
onClick={undoSaveResourceCanvasLayout}
>
<Undo2 size={16} aria-hidden="true" />
</button>
<button
type="button"
className="game-resource-book-zoom-button"
aria-label="重做画布操作"
title="重做(Ctrl/Cmd+Shift+Z"
disabled={!canRedoResourceCanvas}
onClick={redoResourceCanvasLayout}
>
<Redo2 size={16} aria-hidden="true" />
</button>
<button
type="button"
className="game-resource-book-zoom-button"
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
captureResourceCanvasSnapshot,
clearResourceCanvasHistory,
createResourceCanvasHistory,
MAX_RESOURCE_CANVAS_HISTORY_STEPS,
pushResourceCanvasHistory,
redoResourceCanvasHistory,
resolveResourceCanvasRestoreEntries,
undoResourceCanvasHistory,
} from '../src/features/resource-canvas/resourceCanvasHistoryModel';
function positions(
entries: Array<[string, number, number, boolean?]>,
): ReturnType<typeof captureResourceCanvasSnapshot>['entries'] {
return entries.map(([resourceId, x, y, manuallyPlaced = true]) => ({
resourceId,
section: 'document',
x,
y,
manuallyPlaced,
}));
}
describe('resource canvas history model', () => {
it('按 resourceId 排序落快照,map 顺序不影响相等判定', () => {
const left = captureResourceCanvasSnapshot(
positions([
['asset:b', 2, 2],
['asset:a', 1, 1],
]),
);
const right = captureResourceCanvasSnapshot(
positions([
['asset:a', 1, 1],
['asset:b', 2, 2],
]),
);
expect(left.entries.map((entry) => entry.resourceId)).toEqual([
'asset:a',
'asset:b',
]);
expect(left).toEqual(right);
});
it('push 连续相同快照不产生新历史条目', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([['asset:a', 1, 1]]),
);
const first = pushResourceCanvasHistory(createResourceCanvasHistory(), {
label: '移动卡片',
snapshot,
});
const second = pushResourceCanvasHistory(first, {
label: '移动卡片',
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
});
expect(first.undoStack).toHaveLength(1);
expect(second).toBe(first);
});
it('push 后重做分支失效,撤回栈有界', () => {
let history = createResourceCanvasHistory();
for (
let index = 0;
index < MAX_RESOURCE_CANVAS_HISTORY_STEPS + 5;
index += 1
) {
history = pushResourceCanvasHistory(history, {
label: `移动 ${index}`,
snapshot: captureResourceCanvasSnapshot(
positions([['asset:a', index, index]]),
),
});
}
expect(history.undoStack).toHaveLength(MAX_RESOURCE_CANVAS_HISTORY_STEPS);
expect(history.undoStack[0]?.label).toBe('移动 5');
const undone = undoResourceCanvasHistory(
history,
captureResourceCanvasSnapshot(positions([['asset:a', 99, 99]])),
)!;
expect(canRedoResourceCanvasHistory(undone.history)).toBe(true);
const pushed = pushResourceCanvasHistory(undone.history, {
label: '新的移动',
snapshot: captureResourceCanvasSnapshot(
positions([['asset:a', 120, 120]]),
),
});
expect(canRedoResourceCanvasHistory(pushed)).toBe(false);
});
it('撤销与重做互为逆操作,并回填当前快照', () => {
const moved = captureResourceCanvasSnapshot(
positions([['asset:a', 10, 20]]),
);
let history = pushResourceCanvasHistory(createResourceCanvasHistory(), {
label: '移动卡片',
snapshot: moved,
});
const current = captureResourceCanvasSnapshot(
positions([['asset:a', 60, 70]]),
);
const undone = undoResourceCanvasHistory(history, current)!;
expect(undone.snapshot).toEqual(moved);
expect(undone.label).toBe('移动卡片');
expect(canUndoResourceCanvasHistory(undone.history)).toBe(false);
const redone = redoResourceCanvasHistory(undone.history, moved)!;
expect(redone.snapshot).toEqual(current);
expect(canRedoResourceCanvasHistory(redone.history)).toBe(false);
expect(canUndoResourceCanvasHistory(redone.history)).toBe(true);
history = redone.history;
expect(history.redoStack).toHaveLength(0);
});
it('空历史撤销/重做返回 null,清空后两个栈都为空', () => {
const empty = createResourceCanvasHistory();
expect(undoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
expect(redoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
const history = pushResourceCanvasHistory(empty, {
label: '移动卡片',
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
});
const cleared = clearResourceCanvasHistory(history);
expect(cleared.undoStack).toHaveLength(0);
expect(cleared.redoStack).toHaveLength(0);
expect(clearResourceCanvasHistory(cleared)).toBe(cleared);
});
it('只把与当前不一致的位置写回后端', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([
['asset:a', 10, 20],
['asset:b', 30, 40],
]),
);
const restore = resolveResourceCanvasRestoreEntries(
snapshot,
positions([
['asset:a', 10, 20],
['asset:b', 99, 98],
['asset:c', 1, 1],
]),
);
expect(restore.map((entry) => entry.resourceId)).toEqual(['asset:b']);
});
it('快照里不存在的资源不会在撤销时被新建出来', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([['asset:deleted', 10, 20]]),
);
expect(
resolveResourceCanvasRestoreEntries(snapshot, positions([])),
).toEqual([]);
});
});