实现 UI 编辑器 State 撤销重做核心

在统一 State 提交入口增加 100 条快照历史与 no-op 过滤

提供撤销、重做、事务分组及加载重置历史 API

补充 State 历史、事务回滚和加载清理测试
This commit is contained in:
2026-09-03 11:01:21 +08:00
parent c519f65ac2
commit f79724cb17
3 changed files with 245 additions and 5 deletions
@@ -45,6 +45,15 @@ export type UiEditorOperationResult<T = undefined> =
| { 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<UiEditorOperationResult, { ok: false }>;
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<UiEditorHistoryState>({
canUndo: false,
canRedo: false,
});
const stateRef = useRef(state);
const isLockedRef = useRef(false);
const undoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const redoStackRef = useRef<Array<{ before: State; after: State }>>([]);
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 <T>(operation: () => Promise<T>): Promise<T> => {
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,
@@ -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[];
@@ -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',
);
});
});