实现 UI 编辑器 State 撤销重做核心
在统一 State 提交入口增加 100 条快照历史与 no-op 过滤 提供撤销、重做、事务分组及加载重置历史 API 补充 State 历史、事务回滚和加载清理测试
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user