简化 UI 编辑器历史提交边界

移除撤销事务 API 与额外事务状态

让绑定每个 batch 独立提交一条撤销记录

同步更新规格和独立提交测试
This commit is contained in:
2026-09-03 12:14:21 +08:00
parent b07c8a5e25
commit d44c924f5e
4 changed files with 28 additions and 104 deletions
@@ -545,7 +545,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_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 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 <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;
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 <T>(operation: (snapshot: State) => Promise<T>): Promise<T> =>
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,
@@ -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<UIDesignSuggestionTreeNode[]>(
'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<RecognitionDTO>('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<MergeDTO>('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 (
@@ -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',
'中间',
);
});
});
@@ -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。