From f79724cb173cd08f18f89f1b06731b1d5b11a52e 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 11:01:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=20State=20=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E6=A0=B8=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在统一 State 提交入口增加 100 条快照历史与 no-op 过滤 提供撤销、重做、事务分组及加载重置历史 API 补充 State 历史、事务回滚和加载清理测试 --- .../features/ui-editor/useUiEditorState.ts | 141 +++++++++++++++++- .../src/view/ui-editor/useUiEditorPage.ts | 2 +- .../tests/uiEditorState.test.ts | 107 +++++++++++++ 3 files changed, 245 insertions(+), 5 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 5bfe37adf..cf85bd10b 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 @@ -45,6 +45,15 @@ export type UiEditorOperationResult = | { ok: true; value: T } | { ok: false; reason: UiEditorOperationFailureReason }; +export type UiEditorHistoryState = { + canUndo: boolean; + canRedo: boolean; +}; + +export type UiEditorReplaceStateOptions = { + history?: 'record' | 'reset' | 'skip'; +}; + type UiEditorOperationFailure = Extract; export type NodeMetadataPatch = Partial< @@ -505,15 +514,123 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { return next; }); const [isLocked, setIsLocked] = useState(false); + const [historyState, setHistoryState] = useState({ + canUndo: false, + canRedo: false, + }); const stateRef = useRef(state); const isLockedRef = useRef(false); + const undoStackRef = useRef>([]); + const redoStackRef = useRef>([]); + const transactionRef = useRef<{ before: State } | null>(null); stateRef.current = state; - const commit = useCallback((nextState: State) => { + const syncHistoryState = useCallback(() => { + setHistoryState({ + canUndo: undoStackRef.current.length > 0, + canRedo: redoStackRef.current.length > 0, + }); + }, []); + + const applyState = useCallback((nextState: State) => { stateRef.current = nextState; setState(nextState); }, []); + const commit = useCallback( + (nextState: State) => { + const current = stateRef.current; + if (JSON.stringify(current) === JSON.stringify(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(); + } + applyState(nextState); + return true; + }, + [applyState, syncHistoryState], + ); + + 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 (JSON.stringify(transaction.before) === JSON.stringify(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; + const entry = undoStackRef.current.pop(); + if (!entry) return false; + redoStackRef.current.push(entry); + applyState(cloneState(entry.before)); + syncHistoryState(); + return true; + }, [applyState, syncHistoryState]); + + const redo = useCallback(() => { + if (isLockedRef.current || transactionRef.current !== null) return false; + const entry = redoStackRef.current.pop(); + if (!entry) return false; + undoStackRef.current.push(entry); + applyState(cloneState(entry.after)); + syncHistoryState(); + return true; + }, [applyState, syncHistoryState]); + const guard = useCallback((): UiEditorOperationFailure | null => { // Every semantic write exits before reading or committing State while locked. return isLockedRef.current ? { ok: false, reason: 'locked' } : null; @@ -1402,17 +1519,33 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { }, [commit, guard]); const replaceState = useCallback( - (nextState: State) => { + (nextState: State, options: UiEditorReplaceStateOptions = {}) => { const next = cloneState(nextState); synchronizeDesignImageTrees(next); - commit(next); + if (options.history === 'reset') { + resetHistory(); + applyState(next); + } else if (options.history === 'skip') { + applyState(next); + } else { + commit(next); + } }, - [commit], + [applyState, commit, resetHistory], ); return { state, + historyState, + undo, + redo, + beginHistoryTransaction, + endHistoryTransaction, + cancelHistoryTransaction, + runInHistoryTransaction, + resetHistory, + isLocked, runWithStateLocked, setImageName, 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 ae17854be..e7a085ec6 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 @@ -274,7 +274,7 @@ export function useUiEditorSession( .load(resourceId) .then(({ state, revision }) => { if (!cancelled) { - replaceEditorState(state); + replaceEditorState(state, { history: 'reset' }); const ids = Object.keys( state.ui_design_images, ).sort() as UIDesignImageId[]; diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 43c9dac4d..fed357201 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -603,4 +603,111 @@ describe('useUiEditorState', () => { }), ]); }); + + it('records, undoes, redoes, and clears redo after a new edit', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageName('page', '第一次'); + }); + expect(result.current.historyState).toEqual({ + canUndo: true, + canRedo: false, + }); + + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: true, + }); + + act(() => { + expect(result.current.redo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '第一次', + ); + + act(() => { + result.current.setImageName('page', '第二次'); + expect(result.current.redo()).toBe(false); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '第二次', + ); + }); + + it('does not record no-op edits and resets history when replacing loaded state', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageName('page', 'Page'); + }); + expect(result.current.historyState.canUndo).toBe(false); + + act(() => { + result.current.setImageName('page', '编辑后'); + result.current.replaceState(initial, { history: 'reset' }); + }); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: false, + }); + }); + + it('groups multiple replacements into one history transaction and rolls back failures', async () => { + 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('最终') }, + }); + }); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '最终', + ); + act(() => { + 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', + ); + }); });