简化 UI 编辑器历史提交边界
移除撤销事务 API 与额外事务状态 让绑定每个 batch 独立提交一条撤销记录 同步更新规格和独立提交测试
This commit is contained in:
@@ -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。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user