From 8a18b1b181491f4ce62d95a35146d08717180bca 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 10:51:52 +0800 Subject: [PATCH 01/26] =?UTF-8?q?=E8=B0=83=E6=95=B4=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=8A=82=E7=82=B9=E5=8F=98=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E6=9D=BE=E5=BC=80=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动和缩放期间仅更新预览临时变换 指针松开时提交最终变换,取消操作不写入 State 补充 UI 编辑器拖动变换提交边界文档 --- .../components/preview/PreviewWorkspace.tsx | 20 ++++++++++ .../components/preview/UiTreeRenderer.tsx | 14 ++++--- .../preview/useNodeTransformInteraction.ts | 37 +++++++++++++++---- ...【UI编辑器】拖动变换提交边界-2026-09-03.md | 14 +++++++ 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 docs/【UI编辑器】拖动变换提交边界-2026-09-03.md diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index ec88d9571..f7d528b3e 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -24,6 +24,7 @@ import { } from 'react'; import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry'; +import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; @@ -48,6 +49,9 @@ export function PreviewWorkspace({ const [renderMode, setRenderMode] = useState('editor-overlay'); const [showFrame, setShowFrame] = useState(false); + const [previewTransforms, setPreviewTransforms] = useState< + ReadonlyMap + >(new Map()); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; @@ -55,6 +59,20 @@ export function PreviewWorkspace({ isPageRoot: boolean; } | null>(null); const tree = canvas.tree ?? null; + const updatePreviewTransform = useCallback( + (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { + setPreviewTransforms((current) => { + const next = new Map(current); + if (transform) next.set(nodeId, transform); + else next.delete(nodeId); + return next; + }); + }, + [], + ); + useEffect(() => { + setPreviewTransforms(new Map()); + }, [activeImageId]); const activeImagePixelWidth = activeImage?.pixel_size[0]; const activeImagePixelHeight = activeImage?.pixel_size[1]; const activeImagePixelsPerUnit = activeImage?.pixels_per_unit; @@ -88,6 +106,7 @@ export function PreviewWorkspace({ spaceHeld, tree, viewportRef, + onPreviewTransform: updatePreviewTransform, }); const { onNodePointerDown, @@ -382,6 +401,7 @@ export function PreviewWorkspace({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={canvas.hiddenNodeIds} + previewTransforms={previewTransforms} selectedNodeId={canvas.selectedNodeId} resources={{ previewUrls, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index ad91c0571..806c64e33 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -32,6 +32,7 @@ type UiTreeRendererProps = { renderMode: UiEditorRenderMode; showFrame: boolean; hiddenNodeIds: ReadonlySet; + previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; @@ -77,6 +78,7 @@ function RenderNode({ renderMode, showFrame, hiddenNodeIds, + previewTransforms, selectedNodeId, resources, onSelectNode, @@ -94,14 +96,15 @@ function RenderNode({ isRoot?: boolean; parentContainer?: UiNode['layout']['container']; }) { + const activePreviewTransforms = previewTransforms ?? new Map(); if (hiddenNodeIds.has(node.id)) return null; + const layout = activePreviewTransforms.has(node.id) + ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } + : node.layout; let geometry; try { - geometry = controlLayoutToPreviewCss( - node.layout, - parentContainer !== undefined, - ); + geometry = controlLayoutToPreviewCss(layout, parentContainer !== undefined); } catch { // Keep malformed nodes isolated from the rest of the tree. return null; @@ -120,7 +123,7 @@ function RenderNode({ style={{ ...geometry, ...(parentContainer - ? childInContainerToPreviewCss(node.layout, parentContainer) + ? childInContainerToPreviewCss(layout, parentContainer) : {}), ...containerToPreviewCss(node.layout.container), ...(isFrameVisible @@ -189,6 +192,7 @@ function RenderNode({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={hiddenNodeIds} + previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 1d22c3531..3208dfba8 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -29,6 +29,7 @@ type GestureBase = { startClientY: number; startTransform: UiNode['layout']['transform']; hasMoved: boolean; + pendingTransform?: UiNode['layout']['transform']; }; type ActiveGesture = @@ -85,6 +86,7 @@ export function useNodeTransformInteraction({ spaceHeld, tree, viewportRef, + onPreviewTransform, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -92,14 +94,21 @@ export function useNodeTransformInteraction({ spaceHeld: boolean; tree: UITree | null; viewportRef: RefObject; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; }) { const activeGestureRef = useRef(null); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; - if (gesture) releasePointer(gesture.target, gesture.pointerId); + if (gesture) { + releasePointer(gesture.target, gesture.pointerId); + onPreviewTransform?.(gesture.nodeId, null); + } activeGestureRef.current = null; - }, []); + }, [onPreviewTransform]); useEffect(() => cancelGesture, [cancelGesture]); @@ -185,18 +194,31 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + onPreviewTransform?.(gesture.nodeId, nextTransform); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], ); const onNodePointerUp = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); + const gesture = activeGestureRef.current; + if ( + event.type !== 'pointercancel' && + gesture?.hasMoved && + gesture.pendingTransform + ) { + canvas.updateNodeTransform( + gesture.treeId, + gesture.nodeId, + gesture.pendingTransform, + ); + } cancelGesture(); }, - [acceptsGestureEvent, cancelGesture], + [acceptsGestureEvent, cancelGesture, canvas], ); const onNodeResizePointerDown = useCallback( @@ -310,9 +332,10 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + onPreviewTransform?.(gesture.nodeId, nextTransform); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], ); return { diff --git a/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md new file mode 100644 index 000000000..6988d039d --- /dev/null +++ b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md @@ -0,0 +1,14 @@ +# UI 编辑器拖动变换提交边界 + +## 当前约定 + +UI 编辑器预览中的节点拖动和缩放在指针移动期间只更新预览层的临时变换,不写入编辑器 State。指针松开时才把最后一次有效变换提交到 State,因此一次拖动或缩放只产生一次正式编辑更新。 + +指针取消、页面切换、树切换、组件卸载或没有超过拖动阈值时,不提交变换,并清理临时预览值。指针松开后的最终变换属于正常 State 修改,会参与脏状态、保存和后端持久化;仅拖动期间的临时变换不会进入这些流程。资产文件也不会因该交互被删除。 + +## 实现边界 + +- `useNodeTransformInteraction` 保存手势起始变换和最后一次有效变换。 +- `UiTreeRenderer` 通过 `previewTransforms` 渲染临时变换。 +- `canvas.updateNodeTransform` 仅在 `pointerup` 提交,`pointercancel` 不提交。 +- 拖动和缩放继续共用单指针捕获与有限数校验。 -- 2.52.0 From c519f65ac25d2f707474fb88f115b59176ff0154 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 10:58:54 +0800 Subject: [PATCH 02/26] =?UTF-8?q?=E8=A1=A5=E5=85=85=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确撤销与重做的 State 范围、事务边界和生命周期 明确连续控件与节点变换的低频提交规则 明确资产文件、副作用、快捷键和验收标准 --- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/【UI编辑器】撤销重做规范-2026-09-03.md diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md new file mode 100644 index 000000000..51b9c9c85 --- /dev/null +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -0,0 +1,69 @@ +# UI 编辑器撤销与重做规范 + +## 目标 + +UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被撤销的编辑,降低误操作返工成本,同时保持现有保存、AI 工作流和资产文件行为不变。 + +## 适用范围 + +撤销历史属于当前 UI 编辑会话,历史只保存可序列化的编辑器 `State` 快照,不保存页面临时状态。 + +纳入历史的操作: + +- 设计图名称、描述、角色和从属关系修改; +- 设计图、精灵、字体资源的新增、删除和元数据修改; +- 节点新增、删除、移动、Transform、Layout、元数据、子节点显示模式修改; +- 组件新增、删除、排序和字段修改; +- AI suggest、recognize、merge、bind,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- 节点拖动或缩放,按一次按下到松开的连续操作作为一条记录。 + +不纳入历史的操作: + +- 选择项、隐藏节点、工作流步骤、面板展开状态和画布视口等 UI 临时状态; +- 打开/切换项目、服务端重新加载和清空编辑器;这些操作替换 State 后重置历史; +- 保存、自动保存、生成代码、发布请求本身;撤销只改变本地 State,后续保存才同步远端; +- 素材上传、AI 生成等已发生的外部副作用;若副作用同时落地了本地 State,只撤销本地 State 变化; +- no-op、锁定、校验失败或目标不存在的操作。 + +导入资源被撤销时只回退编辑器 State 中的资源记录和引用,不删除已经写入磁盘的资产文件;重做恢复原资源 ID 与文件引用。 + +## 历史模型 + +- 栈按会话全局维护,最多保留最近 100 条事务;超限丢弃最旧记录。 +- 每条记录保存 `before` 与 `after` 的完整结构化 State 快照,快照不复制二进制文件内容。 +- 提交前后快照相同则不产生记录。 +- 撤销将当前 State 恢复为记录的 `before`,并把记录移入 redo 栈;重做恢复 `after`。 +- 撤销后发生新的有效编辑时清空 redo 栈。 +- 撤销/重做恢复 State 时不得再次写入历史。 + +## 事务边界 + +- 普通字段、按钮和列表操作一次成功调用对应一条记录。 +- 节点拖动/缩放期间只更新预览层临时变换;松开时提交最终变换并生成一条记录。取消、卸载、切换资源、未越过阈值或无变化不提交。 +- AI 和批量操作通过事务边界合并内部多次 State 替换,整次成功调用只生成一条记录;失败不留下部分历史。 +- 颜色选择器、九宫格边界拖动等连续控件在交互期间使用本地 draft 预览,释放或确认时一次提交。 + +## 用户入口 + +- 桌面端页面工具栏提供撤销和重做按钮。 +- 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 +- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- 无可撤销或重做记录时按钮禁用,并提供可访问名称。 + +## 脏状态与选择 + +撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 + +## 验收标准 + +1. 单次字段编辑可撤销和重做。 +2. 连续多次编辑按逆序撤销。 +3. 节点拖动/缩放一次手势只产生一条记录,取消和零变化不产生记录。 +4. AI/批量操作一次调用只产生一条记录,失败不产生部分记录。 +5. 撤销后新编辑清空 redo。 +6. 加载/切换/清空重置历史;保存/自动保存不清空历史。 +7. 撤销/重做资源导入不删除资产文件,并恢复原资源引用。 +8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 +9. no-op、锁定、校验失败和不存在目标不进入历史。 +10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 + -- 2.52.0 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 03/26] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20State=20=E6=92=A4=E9=94=80=E9=87=8D?= =?UTF-8?q?=E5=81=9A=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', + ); + }); }); -- 2.52.0 From 15caf1cafc4a092ee15f85c247b3c63873f7fe72 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:05:04 +0800 Subject: [PATCH 04/26] =?UTF-8?q?=E6=94=B6=E6=95=9B=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=94=81=E4=B8=8E=E5=8E=86=E5=8F=B2=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增统一的加锁并包裹历史事务操作 将 AI 建议、识别、合并和批量绑定改用统一入口 --- .../src/features/ui-editor/useUiEditorState.ts | 7 +++++++ .../src/view/ui-editor/useUiEditorPage.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 4 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 cf85bd10b..7d3966fd1 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 @@ -658,6 +658,12 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [], ); + const runWithStateLockedAndHistory = useCallback( + async (operation: (snapshot: State) => Promise): Promise => + runInHistoryTransaction(() => runWithStateLocked(operation)), + [runInHistoryTransaction, runWithStateLocked], + ); + const setImageName = useCallback( (id: UIDesignImageId, name: string): UiEditorOperationResult => { const blocked = guard(); @@ -1544,6 +1550,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { endHistoryTransaction, cancelHistoryTransaction, runInHistoryTransaction, + runWithStateLockedAndHistory, resetHistory, isLocked, 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 e7a085ec6..29dc33c6a 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 @@ -942,7 +942,7 @@ export function useUiEditorSession( setSuggestionStatus(null); setIsSuggesting(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const suggestions = await invoke( 'suggest_ui_design_semantic', { projectPath, state: snapshot }, @@ -965,7 +965,7 @@ export function useUiEditorSession( setRecognitionStatus(null); setIsRecognizing(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const result = await invoke('recognize_ui', { projectPath, state: snapshot, @@ -990,7 +990,7 @@ export function useUiEditorSession( setMergeStatus(null); setIsMerging(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const result = await invoke('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.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const allSpriteIds = Object.keys(snapshot.sprite_assets); const batches: string[][] = []; for ( @@ -1228,6 +1228,11 @@ export function useUiEditorSession( deleteNode, openClearDialog: () => setClearOpen(true), }, + history: { + ...editor.historyState, + undo: editor.undo, + redo: editor.redo, + }, inspector: { isLocked: editor.isLocked, projectPath, -- 2.52.0 From e379459820fc873a7964f310ee8f4d54f709bc73 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:05:58 +0800 Subject: [PATCH 05/26] =?UTF-8?q?=E9=99=8D=E4=BD=8E=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=A2=9C=E8=89=B2=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 颜色拖动期间使用本地草稿预览 指针释放时一次提交最终颜色 State --- .../Inspector/Components/TextPanel.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index 9285614f4..b3b9f0d9c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { type RgbaColor, RgbaColorPicker } from 'react-colorful'; import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing'; @@ -21,19 +21,25 @@ export function TextPanel({ onChange, }: TextEditorProps) { const [colorOpen, setColorOpen] = useState(false); - const rgba: RgbaColor = { + const [colorDraft, setColorDraft] = useState(null); + const committedRgba: RgbaColor = { r: component.color[0] ?? 255, g: component.color[1] ?? 255, b: component.color[2] ?? 255, a: (component.color[3] ?? 255) / 255, }; + const rgba = colorDraft ?? committedRgba; + useEffect(() => setColorDraft(null), [component.color]); const bestFit = 'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null; - const updateColor = (next: RgbaColor) => + const commitColor = (next: RgbaColor = rgba) => { + if (!colorDraft) return; + setColorDraft(null); onChange({ ...component, color: [next.r, next.g, next.b, Math.round(next.a * 255)], }); + }; return (
@@ -172,22 +178,22 @@ export function TextPanel({ {colorOpen && !readOnly ? (
- +
commitColor()} + onPointerCancel={() => setColorDraft(null)} + > + +
onChange({ ...component, - color: [ - component.color[0], - component.color[1], - component.color[2], - Number(event.target.value), - ], + color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)], }) } /> -- 2.52.0 From 0544e78996360db1dcf775a8ffe158774be9103d 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:06:47 +0800 Subject: [PATCH 06/26] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=8E=86=E5=8F=B2=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清空编辑器时重置撤销与重做栈 补充清空操作不会进入历史的测试 --- .../src/features/ui-editor/useUiEditorState.ts | 5 +++-- apps/ai-game-creator-shell/tests/uiEditorState.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 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 7d3966fd1..13ffa45e8 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 @@ -1520,9 +1520,10 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const clearState = useCallback((): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; - commit(cloneState(EMPTY_UI_EDITOR_STATE)); + resetHistory(); + applyState(cloneState(EMPTY_UI_EDITOR_STATE)); return { ok: true, value: undefined }; - }, [commit, guard]); + }, [applyState, guard, resetHistory]); const replaceState = useCallback( (nextState: State, options: UiEditorReplaceStateOptions = {}) => { diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index fed357201..8962e441a 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -666,6 +666,16 @@ describe('useUiEditorState', () => { canUndo: false, canRedo: false, }); + + act(() => { + result.current.setImageName('page', '清空前'); + result.current.clearState(); + }); + expect(result.current.state).toEqual(EMPTY_UI_EDITOR_STATE); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: false, + }); }); it('groups multiple replacements into one history transaction and rolls back failures', async () => { -- 2.52.0 From dc0ea0eda0d8d26f7a7f7dc9d01d951ac6337b7f 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:07:19 +0800 Subject: [PATCH 07/26] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在编辑器头部增加撤销与重做按钮及禁用态 增加桌面 Cmd/Ctrl 快捷键并保留文本控件原生行为 --- .../src/view/ui-editor/index.tsx | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 84267aad5..26cd2ba1f 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, Redo2, Undo2 } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; @@ -82,6 +82,37 @@ export default function UiEditorPage({ if (session.save.isDirty) setGenerateSuccess(null); }, [session.save.isDirty]); + useEffect(() => { + const isEditableTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest( + 'input, textarea, select, button, a, [contenteditable="true"], [role="button"]', + ), + ); + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && session.history.undo()) { + event.preventDefault(); + } else if (isRedo && session.history.redo()) { + event.preventDefault(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [session.history]); + async function save(afterReturn = false) { if (await session.save.save()) { if (afterReturn) { @@ -155,6 +186,26 @@ export default function UiEditorPage({ {resourceLabel ?? 'UI 设计'}
+ + {walletEntry ? (
{walletEntry}
) : null} -- 2.52.0 From 90623f136d047a17b6bf86866a2133d6834503c5 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:08:52 +0800 Subject: [PATCH 08/26] =?UTF-8?q?=E7=99=BB=E8=AE=B0=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将撤销重做规格加入文档总览入口 --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 8492cccb9..9ffe3c1d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) - [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md) - [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md) +- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md) ## 图片画布与媒体 -- 2.52.0 From 093a2ace76b1ffcf91a6093b9b0581943696fee7 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:51:07 +0800 Subject: [PATCH 09/26] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E7=8A=B6=E6=80=81=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在预览变换实际变化时创建新 Map\n避免取消不存在手势时触发预览树重渲染 --- .../src/view/ui-editor/components/preview/PreviewWorkspace.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index f7d528b3e..7a5228442 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -62,6 +62,8 @@ export function PreviewWorkspace({ const updatePreviewTransform = useCallback( (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { setPreviewTransforms((current) => { + if (!transform && !current.has(nodeId)) return current; + if (transform && current.get(nodeId) === transform) return current; const next = new Map(current); if (transform) next.set(nodeId, transform); else next.delete(nodeId); -- 2.52.0 From f79ccccf44f3af0e161f7e459e70b1fbdf63963f 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:51:34 +0800 Subject: [PATCH 10/26] =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=89=8B=E5=8A=BF=E6=B8=85=E7=90=86=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 ref 读取最新预览回调\n避免回调身份变化时重复取消进行中的手势 --- .../components/preview/useNodeTransformInteraction.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 3208dfba8..d2ac9e82a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -100,15 +100,18 @@ export function useNodeTransformInteraction({ ) => void; }) { const activeGestureRef = useRef(null); + // Keep cleanup stable while still invoking the latest preview callback. + const onPreviewTransformRef = useRef(onPreviewTransform); + onPreviewTransformRef.current = onPreviewTransform; const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; if (gesture) { releasePointer(gesture.target, gesture.pointerId); - onPreviewTransform?.(gesture.nodeId, null); + onPreviewTransformRef.current?.(gesture.nodeId, null); } activeGestureRef.current = null; - }, [onPreviewTransform]); + }, []); useEffect(() => cancelGesture, [cancelGesture]); -- 2.52.0 From e616e2c89950a9588a5dbc98923d62be64fb29ab 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:51:58 +0800 Subject: [PATCH 11/26] =?UTF-8?q?=E6=B8=85=E7=90=86=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E5=BA=A6=E8=BE=93=E5=85=A5=E7=9A=84=E9=A2=9C=E8=89=B2=E8=8D=89?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 透明度修改提交前显式清除颜色草稿\n避免无效更新后残留未提交颜色 --- .../components/Inspector/Components/TextPanel.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index b3b9f0d9c..ea5c1ed8a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -190,12 +190,13 @@ export function TextPanel({ max={255} step={1} value={Math.round(rgba.a * 255)} - onChange={(event) => + onChange={(event) => { + setColorDraft(null); onChange({ ...component, color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)], - }) - } + }); + }} />
) : null} -- 2.52.0 From 95f5904b92796df5aaf9da413fb9ec6945505ef8 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:54:27 +0800 Subject: [PATCH 12/26] =?UTF-8?q?=E6=94=BE=E5=AE=BD=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=BF=AB=E6=8D=B7=E9=94=AE=E7=84=A6=E7=82=B9=E9=99=90?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅将文本输入控件视为可编辑目标\n保持工具栏按钮聚焦时仍可使用撤销重做快捷键 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 26cd2ba1f..da336c2af 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -87,9 +87,7 @@ export default function UiEditorPage({ const element = target instanceof HTMLElement ? target : null; return Boolean( element?.isContentEditable || - element?.closest( - 'input, textarea, select, button, a, [contenteditable="true"], [role="button"]', - ), + element?.closest('input, textarea, select, [contenteditable="true"]'), ); }; const onKeyDown = (event: KeyboardEvent) => { -- 2.52.0 From 37c465dbc3a2027fd371b892bfa572aa0ce9e1bd 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:54:59 +0800 Subject: [PATCH 13/26] =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=BF=AB=E6=8D=B7=E9=94=AE=E7=9B=91=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按稳定的撤销重做回调绑定全局监听\n尊重已被其他处理器阻止的键盘事件 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index da336c2af..a4c3f8220 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -77,6 +77,8 @@ export default function UiEditorPage({ Boolean(session.save.loadError) || session.save.persistedRevision === null || session.save.isLocked; + const historyUndo = session.history.undo; + const historyRedo = session.history.redo; useEffect(() => { if (session.save.isDirty) setGenerateSuccess(null); @@ -92,6 +94,7 @@ export default function UiEditorPage({ }; const onKeyDown = (event: KeyboardEvent) => { if ( + event.defaultPrevented || isEditableTarget(event.target) || (!event.ctrlKey && !event.metaKey) ) { @@ -101,15 +104,15 @@ export default function UiEditorPage({ const isRedo = (event.key.toLowerCase() === 'z' && event.shiftKey) || (event.ctrlKey && event.key.toLowerCase() === 'y'); - if (isUndo && session.history.undo()) { + if (isUndo && historyUndo()) { event.preventDefault(); - } else if (isRedo && session.history.redo()) { + } else if (isRedo && historyRedo()) { event.preventDefault(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [session.history]); + }, [historyRedo, historyUndo]); async function save(afterReturn = false) { if (await session.save.save()) { -- 2.52.0 From b07c8a5e2593d9ee4b317fc34083c33de4056ff8 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 12:00:36 +0800 Subject: [PATCH 14/26] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E7=8A=B6=E6=80=81=E7=9B=B8=E7=AD=89=E6=80=A7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用可提前退出的递归比较替代完整状态序列化\n减少每次编辑提交的无谓 JSON 字符串构造 --- .../features/ui-editor/useUiEditorState.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 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 13ffa45e8..36c49730b 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 @@ -237,7 +237,30 @@ function cloneState(state: State): State { } function sameResource(left: T, right: T): boolean { - return JSON.stringify(left) === JSON.stringify(right); + if (Object.is(left, right)) return true; + if ( + typeof left !== 'object' || + left === null || + typeof right !== 'object' || + right === null + ) { + return false; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => sameResource(value, right[index])); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + sameResource(leftRecord[key], rightRecord[key]), + ); } function visitComponents(nodes: Node[], visit: (component: Component) => void) { @@ -540,7 +563,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const commit = useCallback( (nextState: State) => { const current = stateRef.current; - if (JSON.stringify(current) === JSON.stringify(nextState)) return false; + if (sameResource(current, nextState)) return false; if (transactionRef.current === null) { undoStackRef.current.push({ before: cloneState(current), @@ -576,7 +599,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (transaction === null) return; transactionRef.current = null; const current = stateRef.current; - if (JSON.stringify(transaction.before) === JSON.stringify(current)) { + if (sameResource(transaction.before, current)) { return; } undoStackRef.current.push({ -- 2.52.0 From d44c924f5eee204f0beb798d4c024ba7abb547c2 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 12:14:21 +0800 Subject: [PATCH 15/26] =?UTF-8?q?=E7=AE=80=E5=8C=96=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=8E=86=E5=8F=B2=E6=8F=90=E4=BA=A4=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除撤销事务 API 与额外事务状态 让绑定每个 batch 独立提交一条撤销记录 同步更新规格和独立提交测试 --- .../features/ui-editor/useUiEditorState.ts | 81 +++---------------- .../src/view/ui-editor/useUiEditorPage.ts | 8 +- .../tests/uiEditorState.test.ts | 35 +++----- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 8 +- 4 files changed, 28 insertions(+), 104 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 36c49730b..f15be0431 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 @@ -545,7 +545,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const isLockedRef = useRef(false); const undoStackRef = useRef>([]); const redoStackRef = useRef>([]); - 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 (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; + 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 (operation: (snapshot: State) => Promise): Promise => - 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, 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 29dc33c6a..abc6c8159 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 @@ -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( '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('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('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 ( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 8962e441a..df8e47bdd 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -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', + '中间', ); }); }); diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index 51b9c9c85..f3912e000 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -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。 - -- 2.52.0 From 074ee615ae8ed2db61309904f4bc378ba7a7dd8a 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 12:17:25 +0800 Subject: [PATCH 16/26] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8F=90=E4=BA=A4=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 断言 pointermove 仅更新预览\n断言 pointerup 才提交一次节点变换 --- .../tests/useNodeTransformInteraction.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 2b79ab689..6482858fe 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -125,7 +125,7 @@ describe('useNodeTransformInteraction', () => { }); expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled(); - expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); + expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled(); act(() => { result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8)); @@ -135,6 +135,7 @@ describe('useNodeTransformInteraction', () => { 'se', ); }); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); -- 2.52.0 From d6f0c2642fb6e37f0306b12fcdf2e46ef23eb688 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 12:17:36 +0800 Subject: [PATCH 17/26] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E7=BB=93=E6=9D=9F=E6=80=81=E8=8D=89?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 RgbaColorPicker 的 onChangeEnd 处理指针与键盘结束\n关闭颜色弹层时提交仍存在的草稿 --- .../Inspector/Components/TextPanel.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index ea5c1ed8a..d6321de42 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -40,6 +40,10 @@ export function TextPanel({ color: [next.r, next.g, next.b, Math.round(next.a * 255)], }); }; + const toggleColorOpen = () => { + if (colorOpen) commitColor(); + setColorOpen((open) => !open); + }; return (
@@ -165,7 +169,7 @@ export function TextPanel({ type="button" className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40" disabled={readOnly} - onClick={() => setColorOpen((open) => !open)} + onClick={toggleColorOpen} > {colorOpen && !readOnly ? (
-
commitColor()} - onPointerCancel={() => setColorDraft(null)} - > - +
setColorDraft(null)}> +
Date: Thu, 3 Sep 2026 13:13:43 +0800 Subject: [PATCH 18/26] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E5=9B=9E=E9=80=80=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用空的预览变换映射,避免节点递归渲染重复分配 --- .../view/ui-editor/components/preview/UiTreeRenderer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 806c64e33..90e7caf15 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -71,6 +71,11 @@ const RESIZE_HANDLES: ReadonlyArray<{ { id: 'w', left: '0%', top: '50%', cursor: 'ew-resize' }, ]; +const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap< + NodeId, + UiNode['layout']['transform'] +> = new Map(); + function RenderNode({ node, isRoot, @@ -96,7 +101,7 @@ function RenderNode({ isRoot?: boolean; parentContainer?: UiNode['layout']['container']; }) { - const activePreviewTransforms = previewTransforms ?? new Map(); + const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS; if (hiddenNodeIds.has(node.id)) return null; const layout = activePreviewTransforms.has(node.id) ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } -- 2.52.0 From d60d93a32710f735d9e373ad29e9bb402f58b370 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 13:14:18 +0800 Subject: [PATCH 19/26] =?UTF-8?q?=E7=AE=80=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单次读取节点预览变换,移除重复 Map 查找和非空断言 --- .../src/view/ui-editor/components/preview/UiTreeRenderer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 90e7caf15..38d1b9454 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -103,8 +103,9 @@ function RenderNode({ }) { const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS; if (hiddenNodeIds.has(node.id)) return null; - const layout = activePreviewTransforms.has(node.id) - ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } + const previewTransform = activePreviewTransforms.get(node.id); + const layout = previewTransform + ? { ...node.layout, transform: previewTransform } : node.layout; let geometry; -- 2.52.0 From 071998fee9641ee86cc7b729bdc319e9164ed424 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 13:14:43 +0800 Subject: [PATCH 20/26] =?UTF-8?q?=E6=8F=90=E5=8F=96=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8E=86=E5=8F=B2=E4=B8=8A=E9=99=90=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为撤销历史容量 100 增加命名常量,便于理解和调节内存权衡 --- .../src/features/ui-editor/useUiEditorState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f15be0431..2ef8569e5 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 @@ -33,6 +33,8 @@ export const EMPTY_UI_EDITOR_STATE: State = { font_assets: {}, }; +const MAX_HISTORY_LENGTH = 100; + export type UiEditorOperationFailureReason = | 'locked' | 'duplicate' @@ -567,7 +569,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { before: cloneState(current), after: cloneState(nextState), }); - if (undoStackRef.current.length > 100) { + if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { undoStackRef.current.shift(); } redoStackRef.current = []; -- 2.52.0 From cb86ab8be98bf669f4cc10dec54c621df90d32b3 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 13:15:01 +0800 Subject: [PATCH 21/26] =?UTF-8?q?=E5=87=8F=E5=B0=91=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8E=86=E5=8F=B2=E5=BF=AB=E7=85=A7=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交历史时复用已克隆的 nextState,避免每次编辑产生冗余深拷贝 --- .../src/features/ui-editor/useUiEditorState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2ef8569e5..6b14446ec 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 @@ -567,7 +567,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (sameResource(current, nextState)) return false; undoStackRef.current.push({ before: cloneState(current), - after: cloneState(nextState), + after: nextState, }); if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { undoStackRef.current.shift(); -- 2.52.0 From 6b332e8c1216f3d3ed2f340dae9603caba60d911 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 13:15:17 +0800 Subject: [PATCH 22/26] =?UTF-8?q?=E9=98=BB=E6=AD=A2=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E9=87=8D=E5=A4=8D=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 忽略 keydown 重复事件,避免长按撤销或重做键耗尽历史记录 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index a4c3f8220..241185424 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -94,6 +94,7 @@ export default function UiEditorPage({ }; const onKeyDown = (event: KeyboardEvent) => { if ( + event.repeat || event.defaultPrevented || isEditableTarget(event.target) || (!event.ctrlKey && !event.metaKey) -- 2.52.0 From 08caa1f29f3abf924361d916259ebc22ed466234 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 13:15:49 +0800 Subject: [PATCH 23/26] =?UTF-8?q?=E9=98=B2=E6=AD=A2=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E5=BE=AA=E7=8E=AF=E9=80=92=E5=BD=92=E6=BA=A2?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 sameResource 增加对象对访问跟踪,遇到循环结构时安全终止递归 --- .../features/ui-editor/useUiEditorState.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 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 6b14446ec..f87b140bf 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 @@ -238,7 +238,11 @@ function cloneState(state: State): State { return structuredClone(state); } -function sameResource(left: T, right: T): boolean { +function sameResource( + left: T, + right: T, + seenPairs = new WeakMap>(), +): boolean { if (Object.is(left, right)) return true; if ( typeof left !== 'object' || @@ -248,10 +252,22 @@ function sameResource(left: T, right: T): boolean { ) { return false; } + const leftObject = left as object; + const rightObject = right as object; + let seenRightObjects = seenPairs.get(leftObject); + if (seenRightObjects?.has(rightObject)) return true; + if (!seenRightObjects) { + seenRightObjects = new WeakSet(); + seenPairs.set(leftObject, seenRightObjects); + } + seenRightObjects.add(rightObject); + if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right)) return false; if (left.length !== right.length) return false; - return left.every((value, index) => sameResource(value, right[index])); + return left.every((value, index) => + sameResource(value, right[index], seenPairs), + ); } const leftRecord = left as Record; const rightRecord = right as Record; @@ -261,7 +277,7 @@ function sameResource(left: T, right: T): boolean { return leftKeys.every( (key) => Object.prototype.hasOwnProperty.call(rightRecord, key) && - sameResource(leftRecord[key], rightRecord[key]), + sameResource(leftRecord[key], rightRecord[key], seenPairs), ); } -- 2.52.0 From 79d3e5e6deac0338ffbbc98550f836174e231cb2 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 13:17:15 +0800 Subject: [PATCH 24/26] =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=8D=B8=E8=BD=BD=E6=97=B6=E7=9A=84=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件卸载前提交仍未关闭的颜色草稿,避免切换面板或删除组件导致颜色丢失 --- .../Inspector/Components/TextPanel.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index d6321de42..d576a7ccb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type RgbaColor, RgbaColorPicker } from 'react-colorful'; import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing'; @@ -22,6 +22,11 @@ export function TextPanel({ }: TextEditorProps) { const [colorOpen, setColorOpen] = useState(false); const [colorDraft, setColorDraft] = useState(null); + const colorDraftRef = useRef(null); + const componentRef = useRef(component); + const onChangeRef = useRef(onChange); + componentRef.current = component; + onChangeRef.current = onChange; const committedRgba: RgbaColor = { r: component.color[0] ?? 255, g: component.color[1] ?? 255, @@ -29,11 +34,26 @@ export function TextPanel({ a: (component.color[3] ?? 255) / 255, }; const rgba = colorDraft ?? committedRgba; - useEffect(() => setColorDraft(null), [component.color]); + useEffect(() => { + colorDraftRef.current = null; + setColorDraft(null); + }, [component.color]); + useEffect( + () => () => { + const draft = colorDraftRef.current; + if (!draft) return; + onChangeRef.current({ + ...componentRef.current, + color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)], + }); + }, + [], + ); const bestFit = 'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null; const commitColor = (next: RgbaColor = rgba) => { - if (!colorDraft) return; + if (!colorDraftRef.current) return; + colorDraftRef.current = null; setColorDraft(null); onChange({ ...component, @@ -182,10 +202,18 @@ export function TextPanel({ {colorOpen && !readOnly ? (
-
setColorDraft(null)}> +
{ + colorDraftRef.current = null; + setColorDraft(null); + }} + > { + colorDraftRef.current = next; + setColorDraft(next); + }} onChangeEnd={commitColor} />
@@ -196,6 +224,7 @@ export function TextPanel({ step={1} value={Math.round(rgba.a * 255)} onChange={(event) => { + colorDraftRef.current = null; setColorDraft(null); onChange({ ...component, -- 2.52.0 From e4680d6f7013f06739819da15d92535ebe87c743 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 13:38:19 +0800 Subject: [PATCH 25/26] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预览拖动和缩放时同步解析保持子节点页面矩形的变换 将检查器的子节点保持选项传入预览交互并补充回归测试 --- .../components/preview/PreviewWorkspace.tsx | 1 + .../preview/useNodeTransformInteraction.ts | 133 +++++++++++++++++- .../src/view/ui-editor/useUiEditorPage.ts | 1 + .../useNodeTransformInteraction.test.tsx | 111 +++++++++++++++ 4 files changed, 240 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 7a5228442..c2cae9af5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -107,6 +107,7 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, onPreviewTransform: updatePreviewTransform, }); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index d2ac9e82a..74f652acb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -9,9 +9,12 @@ import { import { findNodePageContext, type PageRect, + pageRectFromSize, type ResizeAxis, type ResizeHandle, resizePageRect, + resolveChildrenTransformsForParentRect, + resolvePageRect, resolveProportionalResizeAxis, setOffsetsForPageRect, } from '../../../../features/ui-editor/nodeTransformGeometry'; @@ -30,6 +33,7 @@ type GestureBase = { startTransform: UiNode['layout']['transform']; hasMoved: boolean; pendingTransform?: UiNode['layout']['transform']; + previewNodeIds: string[]; }; type ActiveGesture = @@ -79,12 +83,69 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function previewNodeIds( + tree: UITree | null, + nodeId: string, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, +) { + if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId]; + const context = findNodePageContext( + tree.root, + nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + return context + ? [nodeId, ...context.node.children.map((child) => child.id)] + : [nodeId]; +} + +function emitPreviewTransforms( + gesture: ActiveGesture, + tree: UITree | null, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, + onPreviewTransform: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void, +) { + onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null); + if ( + !gesture.pendingTransform || + !keepChildrenUnchanged || + !tree || + !logicalSize + ) { + return; + } + const context = findNodePageContext( + tree.root, + gesture.nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + if (!context) return; + const newNodeRect = resolvePageRect( + gesture.pendingTransform, + context.parentRect, + ); + if (!isFiniteRect(newNodeRect)) return; + for (const child of resolveChildrenTransformsForParentRect( + context.node.children, + context.rect, + newNodeRect, + )) { + onPreviewTransform(child.id, child.transform); + } +} + export function useNodeTransformInteraction({ activeImageId, canvas, logicalSize, spaceHeld, tree, + keepChildrenUnchanged, viewportRef, onPreviewTransform, }: { @@ -93,6 +154,7 @@ export function useNodeTransformInteraction({ logicalSize: { width: number; height: number } | null; spaceHeld: boolean; tree: UITree | null; + keepChildrenUnchanged: boolean; viewportRef: RefObject; onPreviewTransform?: ( nodeId: string, @@ -109,6 +171,11 @@ export function useNodeTransformInteraction({ if (gesture) { releasePointer(gesture.target, gesture.pointerId); onPreviewTransformRef.current?.(gesture.nodeId, null); + for (const nodeId of gesture.previewNodeIds) { + if (nodeId !== gesture.nodeId) { + onPreviewTransformRef.current?.(nodeId, null); + } + } } activeGestureRef.current = null; }, []); @@ -163,9 +230,22 @@ export function useNodeTransformInteraction({ startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodePointerMove = useCallback( @@ -198,9 +278,23 @@ export function useNodeTransformInteraction({ return; } gesture.pendingTransform = nextTransform; - onPreviewTransform?.(gesture.nodeId, nextTransform); + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); const onNodePointerUp = useCallback( @@ -276,9 +370,22 @@ export function useNodeTransformInteraction({ parentRect: context.parentRect, ratioAxis: null, hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodeResizePointerMove = useCallback( @@ -336,9 +443,23 @@ export function useNodeTransformInteraction({ } gesture.hasMoved = true; gesture.pendingTransform = nextTransform; - onPreviewTransform?.(gesture.nodeId, nextTransform); + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); return { 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 abc6c8159..e00d86176 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 @@ -1214,6 +1214,7 @@ export function useUiEditorSession( tree: treeForActiveImage ?? null, selectedNode: selectedNodeContext?.node ?? null, selectedNodeId, + keepChildrenUnchanged, hiddenNodeIds, focusRequest, status, diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 6482858fe..bd5f2071a 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -44,6 +44,17 @@ const pageTree: UITree = { root: node('root', [child]), }; +function transformedNode( + id: string, + transform: UiNode['layout']['transform'], + children: UiNode[] = [], +) { + return { + ...node(id, children), + layout: { ...node(id).layout, transform }, + }; +} + function gestureTarget() { const target = document.createElement('div'); Object.assign(target, { @@ -83,10 +94,17 @@ function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, + keepChildrenUnchanged = false, + onPreviewTransform, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; + keepChildrenUnchanged?: boolean; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -99,6 +117,8 @@ function renderInteraction({ spaceHeld: false, tree: currentTree, viewportRef, + keepChildrenUnchanged, + onPreviewTransform, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -106,6 +126,97 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('previews stable child page rectangles when the parent moves', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 1, + 'parent', + expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }), + ); + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + + it('previews stable child page rectangles when the parent resizes', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodeResizePointerDown( + pointerEvent(target, 1, 0, 0), + parent, + 'nw', + ); + result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + it('makes drag and resize mutually exclusive, then permits the next gesture', () => { const { result, canvasProjection } = renderInteraction(); const dragTarget = gestureTarget(); -- 2.52.0 From 9fd160efc5fa5f2b6ea8db041f6b0c92168d7898 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 14:37:28 +0800 Subject: [PATCH 26/26] =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=BB=91=E5=AE=9A=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批处理中的前置绑定批次跳过历史,最后一批使用非跳过替换统一形成撤销边界 为跳过后记录历史的状态基线补充撤销回归测试 --- .../features/ui-editor/useUiEditorState.ts | 17 +++++++- .../src/view/ui-editor/useUiEditorPage.ts | 4 +- .../tests/uiEditorState.test.ts | 43 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 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 f87b140bf..a65ed50dc 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 @@ -563,6 +563,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const isLockedRef = useRef(false); const undoStackRef = useRef>([]); const redoStackRef = useRef>([]); + const pendingHistoryBeforeRef = useRef(null); stateRef.current = state; const syncHistoryState = useCallback(() => { @@ -580,15 +581,20 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const commit = useCallback( (nextState: State) => { const current = stateRef.current; - if (sameResource(current, nextState)) return false; + const before = pendingHistoryBeforeRef.current ?? current; + if (sameResource(before, nextState)) { + pendingHistoryBeforeRef.current = null; + return false; + } undoStackRef.current.push({ - before: cloneState(current), + before: cloneState(before), after: nextState, }); if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { undoStackRef.current.shift(); } redoStackRef.current = []; + pendingHistoryBeforeRef.current = null; syncHistoryState(); applyState(nextState); return true; @@ -599,6 +605,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const resetHistory = useCallback(() => { undoStackRef.current = []; redoStackRef.current = []; + pendingHistoryBeforeRef.current = null; syncHistoryState(); }, [syncHistoryState]); @@ -606,6 +613,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (isLockedRef.current) return false; const entry = undoStackRef.current.pop(); if (!entry) return false; + pendingHistoryBeforeRef.current = null; redoStackRef.current.push(entry); applyState(cloneState(entry.before)); syncHistoryState(); @@ -616,6 +624,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (isLockedRef.current) return false; const entry = redoStackRef.current.pop(); if (!entry) return false; + pendingHistoryBeforeRef.current = null; undoStackRef.current.push(entry); applyState(cloneState(entry.after)); syncHistoryState(); @@ -642,6 +651,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { try { return await operation(snapshot); } finally { + pendingHistoryBeforeRef.current = null; isLockedRef.current = false; setIsLocked(false); } @@ -1518,6 +1528,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { resetHistory(); applyState(next); } else if (options.history === 'skip') { + if (!pendingHistoryBeforeRef.current) { + pendingHistoryBeforeRef.current = cloneState(stateRef.current); + } applyState(next); } else { commit(next); 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 e00d86176..b2c98484b 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 @@ -1028,7 +1028,9 @@ export function useUiEditorSession( spriteIds, }); current = applyBindingResult(current, result); - editor.replaceState(current); + editor.replaceState(current, { + history: index < batches.length - 1 ? 'skip' : 'record', + }); } setBindingStatus( `组件绑定完成(${batches.length}/${batches.length})。`, diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index df8e47bdd..89c4cdf3e 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -705,4 +705,47 @@ describe('useUiEditorState', () => { '中间', ); }); + + it('records one history entry after skipped replacement batches', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.replaceState( + { + ...initial, + ui_design_images: { page: image('第一批') }, + }, + { history: 'skip' }, + ); + result.current.replaceState( + { + ...initial, + ui_design_images: { page: image('最终') }, + }, + { history: 'record' }, + ); + }); + + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '最终', + ); + 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, + }); + }); }); -- 2.52.0