From d44c924f5eee204f0beb798d4c024ba7abb547c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:14:21 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E5=8C=96=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8E=86=E5=8F=B2=E6=8F=90=E4=BA=A4=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除撤销事务 API 与额外事务状态 让绑定每个 batch 独立提交一条撤销记录 同步更新规格和独立提交测试 --- .../features/ui-editor/useUiEditorState.ts | 81 +++---------------- .../src/view/ui-editor/useUiEditorPage.ts | 8 +- .../tests/uiEditorState.test.ts | 35 +++----- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 8 +- 4 files changed, 28 insertions(+), 104 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 36c49730b..f15be0431 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -545,7 +545,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const isLockedRef = useRef(false); const undoStackRef = useRef>([]); const redoStackRef = useRef>([]); - const transactionRef = useRef<{ before: State } | null>(null); stateRef.current = state; const syncHistoryState = useCallback(() => { @@ -564,17 +563,15 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { (nextState: State) => { const current = stateRef.current; if (sameResource(current, nextState)) return false; - if (transactionRef.current === null) { - undoStackRef.current.push({ - before: cloneState(current), - after: cloneState(nextState), - }); - if (undoStackRef.current.length > 100) { - undoStackRef.current.shift(); - } - redoStackRef.current = []; - syncHistoryState(); + undoStackRef.current.push({ + before: cloneState(current), + after: cloneState(nextState), + }); + if (undoStackRef.current.length > 100) { + undoStackRef.current.shift(); } + redoStackRef.current = []; + syncHistoryState(); applyState(nextState); return true; }, @@ -584,58 +581,11 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const resetHistory = useCallback(() => { undoStackRef.current = []; redoStackRef.current = []; - transactionRef.current = null; syncHistoryState(); }, [syncHistoryState]); - const beginHistoryTransaction = useCallback(() => { - if (transactionRef.current === null) { - transactionRef.current = { before: cloneState(stateRef.current) }; - } - }, []); - - const endHistoryTransaction = useCallback(() => { - const transaction = transactionRef.current; - if (transaction === null) return; - transactionRef.current = null; - const current = stateRef.current; - if (sameResource(transaction.before, current)) { - return; - } - undoStackRef.current.push({ - before: transaction.before, - after: cloneState(current), - }); - if (undoStackRef.current.length > 100) undoStackRef.current.shift(); - redoStackRef.current = []; - syncHistoryState(); - }, [syncHistoryState]); - - const cancelHistoryTransaction = useCallback(() => { - const transaction = transactionRef.current; - if (transaction === null) return; - transactionRef.current = null; - applyState(transaction.before); - syncHistoryState(); - }, [applyState, syncHistoryState]); - - const runInHistoryTransaction = useCallback( - async (operation: () => Promise): Promise => { - beginHistoryTransaction(); - try { - const result = await operation(); - endHistoryTransaction(); - return result; - } catch (error) { - cancelHistoryTransaction(); - throw error; - } - }, - [beginHistoryTransaction, cancelHistoryTransaction, endHistoryTransaction], - ); - const undo = useCallback(() => { - if (isLockedRef.current || transactionRef.current !== null) return false; + if (isLockedRef.current) return false; const entry = undoStackRef.current.pop(); if (!entry) return false; redoStackRef.current.push(entry); @@ -645,7 +595,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { }, [applyState, syncHistoryState]); const redo = useCallback(() => { - if (isLockedRef.current || transactionRef.current !== null) return false; + if (isLockedRef.current) return false; const entry = redoStackRef.current.pop(); if (!entry) return false; undoStackRef.current.push(entry); @@ -681,12 +631,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [], ); - const runWithStateLockedAndHistory = useCallback( - async (operation: (snapshot: State) => Promise): Promise => - runInHistoryTransaction(() => runWithStateLocked(operation)), - [runInHistoryTransaction, runWithStateLocked], - ); - const setImageName = useCallback( (id: UIDesignImageId, name: string): UiEditorOperationResult => { const blocked = guard(); @@ -1570,11 +1514,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { historyState, undo, redo, - beginHistoryTransaction, - endHistoryTransaction, - cancelHistoryTransaction, - runInHistoryTransaction, - runWithStateLockedAndHistory, resetHistory, isLocked, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 29dc33c6a..abc6c8159 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -942,7 +942,7 @@ export function useUiEditorSession( setSuggestionStatus(null); setIsSuggesting(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const suggestions = await invoke( 'suggest_ui_design_semantic', { projectPath, state: snapshot }, @@ -965,7 +965,7 @@ export function useUiEditorSession( setRecognitionStatus(null); setIsRecognizing(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const result = await invoke('recognize_ui', { projectPath, state: snapshot, @@ -990,7 +990,7 @@ export function useUiEditorSession( setMergeStatus(null); setIsMerging(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const result = await invoke('merge_ui', { state: snapshot }); editor.replaceState(applyMergeResult(snapshot, result)); setSelectedNodeId(null); @@ -1008,7 +1008,7 @@ export function useUiEditorSession( setBindingStatus(null); setIsBinding(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const allSpriteIds = Object.keys(snapshot.sprite_assets); const batches: string[][] = []; for ( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 8962e441a..df8e47bdd 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -678,23 +678,21 @@ describe('useUiEditorState', () => { }); }); - it('groups multiple replacements into one history transaction and rolls back failures', async () => { + it('records each replacement as an independent history entry', () => { const initial: State = { ...structuredClone(EMPTY_UI_EDITOR_STATE), ui_design_images: { page: image('Page') }, }; const { result } = renderHook(() => useUiEditorState(initial)); - await act(async () => { - await result.current.runInHistoryTransaction(async () => { - result.current.replaceState({ - ...initial, - ui_design_images: { page: image('中间') }, - }); - result.current.replaceState({ - ...initial, - ui_design_images: { page: image('最终') }, - }); + act(() => { + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('中间') }, + }); + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('最终') }, }); }); expect(result.current.state.ui_design_images.page?.metadata.name).toBe( @@ -704,20 +702,7 @@ describe('useUiEditorState', () => { expect(result.current.undo()).toBe(true); }); expect(result.current.state.ui_design_images.page?.metadata.name).toBe( - 'Page', - ); - - await expect( - result.current.runInHistoryTransaction(async () => { - result.current.replaceState({ - ...initial, - ui_design_images: { page: image('失败中间态') }, - }); - throw new Error('失败'); - }), - ).rejects.toThrow('失败'); - expect(result.current.state.ui_design_images.page?.metadata.name).toBe( - 'Page', + '中间', ); }); }); diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index 51b9c9c85..f3912e000 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -14,7 +14,8 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 设计图、精灵、字体资源的新增、删除和元数据修改; - 节点新增、删除、移动、Transform、Layout、元数据、子节点显示模式修改; - 组件新增、删除、排序和字段修改; -- AI suggest、recognize、merge、bind,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- AI suggest、recognize、merge,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- bind 按后端批次逐次提交,每个成功 batch 作为一条独立记录,便于逐批撤销; - 节点拖动或缩放,按一次按下到松开的连续操作作为一条记录。 不纳入历史的操作: @@ -40,7 +41,7 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 普通字段、按钮和列表操作一次成功调用对应一条记录。 - 节点拖动/缩放期间只更新预览层临时变换;松开时提交最终变换并生成一条记录。取消、卸载、切换资源、未越过阈值或无变化不提交。 -- AI 和批量操作通过事务边界合并内部多次 State 替换,整次成功调用只生成一条记录;失败不留下部分历史。 +- 每次有效 `commit` 或 `replaceState` 都直接生成一条记录;bind 的每个成功 batch 独立提交。失败 batch 不产生记录,已完成的前序 batch 保留。 - 颜色选择器、九宫格边界拖动等连续控件在交互期间使用本地 draft 预览,释放或确认时一次提交。 ## 用户入口 @@ -59,11 +60,10 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 1. 单次字段编辑可撤销和重做。 2. 连续多次编辑按逆序撤销。 3. 节点拖动/缩放一次手势只产生一条记录,取消和零变化不产生记录。 -4. AI/批量操作一次调用只产生一条记录,失败不产生部分记录。 +4. AI suggest/recognize/merge 一次调用只产生一条记录;bind 每个成功 batch 产生一条记录,失败不产生该 batch 记录。 5. 撤销后新编辑清空 redo。 6. 加载/切换/清空重置历史;保存/自动保存不清空历史。 7. 撤销/重做资源导入不删除资产文件,并恢复原资源引用。 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 -