From 9121e8979892e4d3dc511e7e89eb9e66cbfd8748 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 15:43:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BB=84=E7=BB=87=E6=93=8D=E4=BD=9C=E6=92=A4=E9=94=80?= =?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=88WP4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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(输入框内不拦截) 项目切换时清空布局历史,避免跨项目回滚 --- .../resourceCanvasHistoryModel.ts | 208 ++++++++++++++++++ .../src/view/project-development/index.tsx | 133 +++++++++++ .../tests/resourceCanvasHistoryModel.test.ts | 166 ++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts new file mode 100644 index 000000000..bd5d4202a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts @@ -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 + ); + }); +} 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 ed5e575aa..0b53f3800 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 @@ -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(null); const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); + /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ + const [resourceCanvasHistory, setResourceCanvasHistory] = useState( + createResourceCanvasHistory, + ); const [uiEditorRoute, setUiEditorRoute] = useState( 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} />
+ +