From 754964c57eca28ba984c6cc8f93f93f9e83c161a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 15:14:09 +0800 Subject: [PATCH 01/35] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E4=B8=8E=E8=8A=82=E7=82=B9=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E7=9A=84=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/ui_editor/commands/binding.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index e59d90fb1..4585eee85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#" * 对每个 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 * 纯结构节点可以返回空数组并标为 NoProblem。 +* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 + "#; #[derive(Clone, Debug, Deserialize, JsonSchema)] -- 2.52.0 From a2876eaf65a0339b93d0dedd5805e55d267694e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:28:56 +0800 Subject: [PATCH 02/35] =?UTF-8?q?=E8=A1=A5=E5=85=85=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20Delete=20=E5=BF=AB=E6=8D=B7=E9=94=AE?= =?UTF-8?q?=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确 Delete 快捷键触发范围与交互控件过滤 规定复用 deleteNode、子树删除、撤销与脏状态语义 补充删除后无效选区清理和验收标准 --- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index f3912e000..f4d4b7d3d 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -49,12 +49,16 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 桌面端页面工具栏提供撤销和重做按钮。 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 - `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 +- `Delete` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 +删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。 + ## 验收标准 1. 单次字段编辑可撤销和重做。 @@ -67,3 +71,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 +11. `Delete` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。 -- 2.52.0 From 5898d3808322eba46473519af90237d401aa529f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:35:58 +0800 Subject: [PATCH 03/35] =?UTF-8?q?=E6=94=AF=E6=8C=81=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20Delete=20=E5=88=A0=E9=99=A4=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用现有 deleteNode 命令处理 Delete 快捷键 过滤交互控件、修饰键和重复按键事件 清理删除子树后的无效节点选区并补充页面测试 --- .../src/view/ui-editor/index.tsx | 36 +++++++++++++++---- .../src/view/ui-editor/useUiEditorPage.ts | 10 +++++- .../tests/uiEditorPage.test.ts | 17 +++++++++ 3 files changed, 56 insertions(+), 7 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 241185424..3379750e2 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 @@ -79,24 +79,48 @@ export default function UiEditorPage({ session.save.isLocked; const historyUndo = session.history.undo; const historyRedo = session.history.redo; + const selectedNodeId = session.input.selectedNodeId; + const activeImageId = session.input.activeImageId; + const deleteNode = session.input.deleteNode; useEffect(() => { if (session.save.isDirty) setGenerateSuccess(null); }, [session.save.isDirty]); useEffect(() => { - const isEditableTarget = (target: EventTarget | null) => { - const element = target instanceof HTMLElement ? target : null; + const isInteractiveTarget = (target: EventTarget | null) => { + const element = target instanceof Element ? target : null; return Boolean( - element?.isContentEditable || - element?.closest('input, textarea, select, [contenteditable="true"]'), + (target instanceof HTMLElement && target.isContentEditable) || + element?.closest( + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"]', + ), ); }; const onKeyDown = (event: KeyboardEvent) => { + if ( + event.key === 'Delete' && + !event.repeat && + !event.defaultPrevented && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + !isInteractiveTarget(event.target) + ) { + if (selectedNodeId && activeImageId) { + const result = deleteNode(selectedNodeId, activeImageId); + if (result?.ok) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } if ( event.repeat || event.defaultPrevented || - isEditableTarget(event.target) || + isInteractiveTarget(event.target) || (!event.ctrlKey && !event.metaKey) ) { return; @@ -113,7 +137,7 @@ export default function UiEditorPage({ }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [historyRedo, historyUndo]); + }, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]); async function save(afterReturn = false) { if (await session.save.save()) { 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 b2c98484b..25969e843 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 @@ -437,6 +437,9 @@ export function useUiEditorSession( ); return next.size === current.size ? current : next; }); + setSelectedNodeId((current) => + current && !validNodeIds.has(current) ? null : current, + ); }, [editor.state.ui_trees]); const isNodePreviewVisible = useCallback( @@ -881,12 +884,17 @@ export function useUiEditorSession( function deleteNode(nodeId: NodeId, treeId = activeImageId) { if (!treeId) return; + const tree = editor.state.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; + const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; const result = editor.deleteNode(treeId, nodeId); if (!result.ok) { setStatus('无法删除该节点。'); return result; } - if (selectedNodeId === nodeId) setSelectedNodeId(null); + if (deletedNodeIds?.has(selectedNodeId ?? '')) setSelectedNodeId(null); return result; } diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6c21bc416..7bd964430 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -473,6 +473,23 @@ describe('UiEditorPage', () => { expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); + it('clears selection when deleting a node removes the selected descendant', async () => { + const { result } = await renderLoadedSession(stateWithPages(['page'])); + const rootId = 'page-root'; + let parentId: string | undefined; + let childId: string | undefined; + act(() => { + parentId = result.current.input.insertNode(rootId, 'page')?.value; + childId = result.current.input.insertNode(parentId!, 'page')?.value; + result.current.input.selectNode(childId!); + }); + + act(() => result.current.input.deleteNode(parentId!, 'page')); + + expect(result.current.canvas.selectedNodeId).toBeNull(); + expect(result.current.history.canUndo).toBe(true); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; -- 2.52.0 From 2d884f1bc549d083c4e25b393854af0900e1d7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:37:00 +0800 Subject: [PATCH 04/35] =?UTF-8?q?=E8=A1=A5=E5=85=85=20Delete=20=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E9=A1=B5=E9=9D=A2=E9=AA=8C=E6=94=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证页面选中节点可通过 Delete 删除 覆盖真实树面板选择与删除后的渲染结果 --- .../tests/uiEditorPage.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 7bd964430..e55cb9fcd 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -490,6 +490,30 @@ describe('UiEditorPage', () => { expect(result.current.history.canUndo).toBe(true); }); + it('deletes the selected node from the page with Delete', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-keyboard', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; -- 2.52.0 From 21aa8948712ed4000496d948a0767a807cb874dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:54:12 +0800 Subject: [PATCH 05/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20macOS=20=E5=88=A0=E9=99=A4=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 macOS 标准 Delete 键上报的 Backspace 事件 保留原有修饰键、重复事件和交互控件过滤 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 2 +- 1 file changed, 1 insertion(+), 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 3379750e2..45f371aa6 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 @@ -99,7 +99,7 @@ export default function UiEditorPage({ }; const onKeyDown = (event: KeyboardEvent) => { if ( - event.key === 'Delete' && + (event.key === 'Delete' || event.key === 'Backspace') && !event.repeat && !event.defaultPrevented && !event.ctrlKey && -- 2.52.0 From d456b97eaf6f876bd853a3854eabd0f2b241c524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:55:00 +0800 Subject: [PATCH 06/35] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E8=81=9A=E7=84=A6=E6=97=B6=E6=92=A4=E9=94=80=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将删除快捷键的交互控件过滤与撤销重做的可编辑目标过滤分离 避免按钮和链接聚焦时意外屏蔽 Ctrl/Cmd+Z/Y --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 9 ++++++++- 1 file changed, 8 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 45f371aa6..8476fbe08 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 @@ -88,6 +88,13 @@ export default function UiEditorPage({ }, [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, [contenteditable="true"]'), + ); + }; const isInteractiveTarget = (target: EventTarget | null) => { const element = target instanceof Element ? target : null; return Boolean( @@ -120,7 +127,7 @@ export default function UiEditorPage({ if ( event.repeat || event.defaultPrevented || - isInteractiveTarget(event.target) || + isEditableTarget(event.target) || (!event.ctrlKey && !event.metaKey) ) { return; -- 2.52.0 From 4886dffda29c0100115d68ecb0d0df8d8aff7bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:56:13 +0800 Subject: [PATCH 07/35] =?UTF-8?q?=E7=A8=B3=E5=AE=9A=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=88=A0=E9=99=A4=E5=9B=9E=E8=B0=83=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 useCallback 保持 deleteNode 在输入未变化时的函数引用稳定 减少全局键盘监听器不必要的解绑与重新注册 --- .../src/view/ui-editor/useUiEditorPage.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) 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 25969e843..0fa171077 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 @@ -175,6 +175,8 @@ export function useUiEditorSession( initialFurthestStepIndex = 0, ) { const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const editorDeleteNode = editor.deleteNode; + const editorUiTrees = editor.state.ui_trees; const replaceEditorState = editor.replaceState; const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [loadError, setLoadError] = useState(null); @@ -882,21 +884,24 @@ export function useUiEditorSession( return result; } - function deleteNode(nodeId: NodeId, treeId = activeImageId) { - if (!treeId) return; - const tree = editor.state.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; - const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; - const result = editor.deleteNode(treeId, nodeId); - if (!result.ok) { - setStatus('无法删除该节点。'); + const deleteNode = useCallback( + (nodeId: NodeId, treeId = activeImageId) => { + if (!treeId) return; + const tree = editorUiTrees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; + const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; + const result = editorDeleteNode(treeId, nodeId); + if (!result.ok) { + setStatus('无法删除该节点。'); + return result; + } + if (deletedNodeIds?.has(selectedNodeId ?? '')) setSelectedNodeId(null); return result; - } - if (deletedNodeIds?.has(selectedNodeId ?? '')) setSelectedNodeId(null); - return result; - } + }, + [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId], + ); function selectSprite(id: SpriteAssetId) { setSelectedNodeId(null); -- 2.52.0 From fb058adb1732f0a5aab3953d5e8fa1c2bd88a7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:56:44 +0800 Subject: [PATCH 08/35] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E8=8A=82=E7=82=B9=E9=80=89=E5=8C=BA=E5=88=A4?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在存在选中节点时查询删除节点集合 避免使用空字符串作为节点 ID 查询值 --- .../src/view/ui-editor/useUiEditorPage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0fa171077..d6f10938a 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 @@ -897,7 +897,9 @@ export function useUiEditorSession( setStatus('无法删除该节点。'); return result; } - if (deletedNodeIds?.has(selectedNodeId ?? '')) setSelectedNodeId(null); + if (selectedNodeId != null && deletedNodeIds?.has(selectedNodeId)) { + setSelectedNodeId(null); + } return result; }, [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId], -- 2.52.0 From 20a065e540de4bef30f725441b6e2d06a2068da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 13:00:04 +0800 Subject: [PATCH 09/35] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=BF=AB=E6=8D=B7=E9=94=AE=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录 macOS Backspace 删除节点快捷键 明确按钮链接焦点下撤销重做与删除快捷键的差异 --- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index f4d4b7d3d..9efab4673 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -48,9 +48,9 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 桌面端页面工具栏提供撤销和重做按钮。 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 -- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。 - UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 -- `Delete` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 +- `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 @@ -71,4 +71,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 -11. `Delete` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。 +11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。 -- 2.52.0 From fc37bfbceb679e425153e1912c3eb14e47107429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 15:26:21 +0800 Subject: [PATCH 10/35] =?UTF-8?q?=E8=A1=A5=E5=85=85UI=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=AE=8C=E6=88=90=E9=80=9A=E7=9F=A5=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增工作流终态通知弹窗交互约定 明确三步骤反馈文案与验收边界 --- ...计】UI编辑器工作流完成通知弹窗-2026-09-04.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md diff --git a/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md new file mode 100644 index 000000000..93077ef12 --- /dev/null +++ b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md @@ -0,0 +1,41 @@ +# UI 编辑器工作流完成通知弹窗 + +## 目标 + +UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。 + +## 交互约定 + +- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。 +- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。 +- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。 +- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。 +- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。 +- 绑定动作包含多个批次时,只在最终批次结束后通知一次。 + +## 文案 + +弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。 + +成功状态的基线文案: + +- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。 +- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。 +- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。 + +失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。 + +## 实现边界 + +- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。 +- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。 +- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。 + +## 验收 + +1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。 +2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。 +3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。 +4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。 +5. 重新运行后新的终态会再次弹出通知。 + -- 2.52.0 From e7345508df96a772c432c021e7c9948c721505d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 15:31:35 +0800 Subject: [PATCH 11/35] =?UTF-8?q?=E6=8F=90=E5=8F=96UI=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E9=80=9A=E7=9F=A5=E6=96=87=E6=A1=88=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分通知类型与文案辅助函数 修复组件快速刷新导出约束 --- .../components/WorkflowCompletionModal.tsx | 40 +++++++++++++++++++ .../components/workflowCompletionNotice.ts | 20 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx new file mode 100644 index 000000000..fbda3f214 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -0,0 +1,40 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { WorkflowCompletionNotice } from './workflowCompletionNotice'; +import { workflowStepLabel } from './workflowCompletionNotice'; + +export function WorkflowCompletionModal({ + notice, + onClose, +}: { + notice: WorkflowCompletionNotice | null; + onClose: () => void; +}) { + if (!notice) return null; + const stepLabel = workflowStepLabel(notice.step); + const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败'; + return ( + +

+ {stepLabel} + {outcomeLabel} +

+

+ {notice.message} +

+
+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts new file mode 100644 index 000000000..02fd867f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts @@ -0,0 +1,20 @@ +import type { UiEditorStepId } from '../model'; + +export type WorkflowCompletionNotice = { + step: UiEditorStepId; + outcome: 'success' | 'failure'; + message: string; +}; + +export function appendWorkflowCheckPrompt(message: string): string { + const trimmed = message.trim(); + if (!trimmed) return '请检查。'; + if (trimmed.includes('请检查')) return trimmed; + return `${trimmed.replace(/[。!?!?]+$/u, '')},请检查。`; +} + +export function workflowStepLabel(step: UiEditorStepId): string { + if (step === 'reference-analysis') return '分析参考图'; + if (step === 'structure-recognition') return '识别界面结构'; + return '绑定视觉素材'; +} -- 2.52.0 From 620c2c501522e57efa781f2fedaf5e41189308f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 15:31:54 +0800 Subject: [PATCH 12/35] =?UTF-8?q?=E6=8E=A5=E5=85=A5UI=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E7=BB=88=E6=80=81=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为三项工作流动作增加成功失败通知 扩展结果数量与请检查提示 在编辑器页面挂载阻塞通知弹窗 --- .../src/view/ui-editor/index.tsx | 5 ++ .../src/view/ui-editor/useUiEditorPage.ts | 79 ++++++++++++++++--- 2 files changed, 75 insertions(+), 9 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 8476fbe08..de1f21527 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 @@ -16,6 +16,7 @@ import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; import { ToolNavigation } from './components/ToolNavigation'; import { WorkflowActionCard } from './components/WorkflowActionCard'; +import { WorkflowCompletionModal } from './components/WorkflowCompletionModal'; import { UI_EDITOR_STEPS, type UiEditorStepId } from './model'; import { type UiEditorWorkflowProjection, @@ -329,6 +330,10 @@ export default function UiEditorPage({ + setReturnConfirmOpen(false)} 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 d6f10938a..6a7eefab2 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 @@ -10,7 +10,10 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; -import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; +import { + getStageStatusOverview, + type StageStatusField, +} from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; @@ -54,6 +57,10 @@ import { prerequisiteIssuesForStep, type UiEditorPrerequisiteIssue, } from './components/WorkflowChecks'; +import { + appendWorkflowCheckPrompt, + type WorkflowCompletionNotice, +} from './components/workflowCompletionNotice'; import { type PendingResourceRemoval, removalHasDownstreamReferences, @@ -248,6 +255,8 @@ export function useUiEditorSession( const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); const [hasBound, setHasBound] = useState(false); + const [completionNotice, setCompletionNotice] = + useState(null); useEffect(() => { setActiveStep(initialStep); @@ -954,6 +963,7 @@ export function useUiEditorSession( async function suggestUiDesignSemantics() { if (isSuggesting || isWorkflowBusy) return; + setCompletionNotice(null); setSuggestionStatus(null); setIsSuggesting(true); try { @@ -964,12 +974,26 @@ export function useUiEditorSession( ); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); setHasSuggested(true); - setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`); + const message = appendWorkflowCheckPrompt( + `参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`, + ); + setSuggestionStatus(message); + setCompletionNotice({ + step: 'reference-analysis', + outcome: 'success', + message, + }); }); } catch (cause) { - setSuggestionStatus( + const message = appendWorkflowCheckPrompt( cause instanceof Error ? cause.message : String(cause), ); + setSuggestionStatus(message); + setCompletionNotice({ + step: 'reference-analysis', + outcome: 'failure', + message, + }); } finally { setIsSuggesting(false); } @@ -977,6 +1001,7 @@ export function useUiEditorSession( async function recognizeUi() { if (isRecognizing || isWorkflowBusy) return; + setCompletionNotice(null); setRecognitionStatus(null); setIsRecognizing(true); try { @@ -985,15 +1010,34 @@ export function useUiEditorSession( projectPath, state: snapshot, }); - editor.replaceState(applyRecognitionResult(snapshot, result)); + const nextState = applyRecognitionResult(snapshot, result); + editor.replaceState(nextState); setHasRecognized(true); setSelectedNodeId(null); - setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`); + const overview = getStageStatusOverview( + nextState.ui_trees, + 'layout_status', + ); + const message = appendWorkflowCheckPrompt( + `界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`, + ); + setRecognitionStatus(message); + setCompletionNotice({ + step: 'structure-recognition', + outcome: 'success', + message, + }); }); } catch (cause) { - setRecognitionStatus( + const message = appendWorkflowCheckPrompt( cause instanceof Error ? cause.message : String(cause), ); + setRecognitionStatus(message); + setCompletionNotice({ + step: 'structure-recognition', + outcome: 'failure', + message, + }); } finally { setIsRecognizing(false); } @@ -1020,6 +1064,7 @@ export function useUiEditorSession( async function bindComponents() { if (isBinding || isWorkflowBusy) return; + setCompletionNotice(null); setBindingStatus(null); setIsBinding(true); try { @@ -1047,13 +1092,27 @@ export function useUiEditorSession( history: index < batches.length - 1 ? 'skip' : 'record', }); } - setBindingStatus( - `组件绑定完成(${batches.length}/${batches.length})。`, + const message = appendWorkflowCheckPrompt( + `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, ); + setBindingStatus(message); setHasBound(true); + setCompletionNotice({ + step: 'visual-binding', + outcome: 'success', + message, + }); }); } catch (cause) { - setBindingStatus(cause instanceof Error ? cause.message : String(cause)); + const message = appendWorkflowCheckPrompt( + cause instanceof Error ? cause.message : String(cause), + ); + setBindingStatus(message); + setCompletionNotice({ + step: 'visual-binding', + outcome: 'failure', + message, + }); } finally { setIsBinding(false); } @@ -1318,6 +1377,7 @@ export function useUiEditorSession( isBinding, hasBound, bindingStatus, + completionNotice, bindComponents, requestStepChange, continueToNextStep: () => { @@ -1325,6 +1385,7 @@ export function useUiEditorSession( }, confirmStepChange, cancelStepChange: () => setPendingWorkflowStepChange(null), + dismissCompletionNotice: () => setCompletionNotice(null), }, dialogs: { projectPath, -- 2.52.0 From e6b2539dfecd93376cd7a41633573502f359c256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 15:32:48 +0800 Subject: [PATCH 13/35] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E9=80=9A=E7=9F=A5=E6=96=87=E6=A1=88=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖请检查提示去重 覆盖三项步骤标题映射 --- .../workflowCompletionNotice.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts new file mode 100644 index 000000000..1ea14ec73 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendWorkflowCheckPrompt, + workflowStepLabel, +} from './workflowCompletionNotice'; + +describe('workflow completion notice helpers', () => { + it('appends the review prompt without duplicating it', () => { + expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( + '已应用 3 条建议,请检查。', + ); + expect(appendWorkflowCheckPrompt('已应用 3 条建议,请检查。')).toBe( + '已应用 3 条建议,请检查。', + ); + }); + + it('maps every workflow step to a user-facing label', () => { + expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); + expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); + expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + }); +}); -- 2.52.0 From 7f3cf3b64b23545cade7a5c579aa7ef197896a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 15:34:18 +0800 Subject: [PATCH 14/35] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E9=80=9A=E7=9F=A5=E6=B5=8B=E8=AF=95=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将通知辅助测试纳入AGC测试目录 保持提示文案和步骤映射覆盖 --- .../components => tests}/workflowCompletionNotice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/ai-game-creator-shell/{src/view/ui-editor/components => tests}/workflowCompletionNotice.test.ts (92%) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts similarity index 92% rename from apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts rename to apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts index 1ea14ec73..d583aa737 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.test.ts +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { appendWorkflowCheckPrompt, workflowStepLabel, -} from './workflowCompletionNotice'; +} from '../src/view/ui-editor/components/workflowCompletionNotice'; describe('workflow completion notice helpers', () => { it('appends the review prompt without duplicating it', () => { -- 2.52.0 From fe813802a576287d569f1e78f80b4360692619ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 16:09:19 +0800 Subject: [PATCH 15/35] =?UTF-8?q?=E7=AE=80=E5=8C=96=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=A3=80=E6=9F=A5=E6=8F=90=E7=A4=BA=E6=8B=BC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除请检查文案探测逻辑 终态文案统一直接追加检查提示 --- .../view/ui-editor/components/workflowCompletionNotice.ts | 1 - .../tests/workflowCompletionNotice.test.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts index 02fd867f8..1c0bfbfa0 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts @@ -9,7 +9,6 @@ export type WorkflowCompletionNotice = { export function appendWorkflowCheckPrompt(message: string): string { const trimmed = message.trim(); if (!trimmed) return '请检查。'; - if (trimmed.includes('请检查')) return trimmed; return `${trimmed.replace(/[。!?!?]+$/u, '')},请检查。`; } diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts index d583aa737..01016c4ae 100644 --- a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -6,13 +6,10 @@ import { } from '../src/view/ui-editor/components/workflowCompletionNotice'; describe('workflow completion notice helpers', () => { - it('appends the review prompt without duplicating it', () => { + it('appends the review prompt to a terminal status', () => { expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( '已应用 3 条建议,请检查。', ); - expect(appendWorkflowCheckPrompt('已应用 3 条建议,请检查。')).toBe( - '已应用 3 条建议,请检查。', - ); }); it('maps every workflow step to a user-facing label', () => { -- 2.52.0 From f0299920831d7137a2d27eb36208a7da63c9038d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 18:04:16 +0800 Subject: [PATCH 16/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=86=85=E5=88=A0=E9=99=A4=E5=BF=AB=E6=8D=B7=E9=94=AE=E8=AF=AF?= =?UTF-8?q?=E5=88=A0=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排除 role=dialog 和 aria-modal 弹窗内的 Delete/Backspace 快捷键 补充弹窗焦点下节点不应被删除的回归测试 同步更新 UI 编辑器撤销重做规范 --- .../src/view/ui-editor/index.tsx | 2 +- .../tests/uiEditorPage.test.ts | 28 +++++++++++++++++++ docs/【UI编辑器】撤销重做规范-2026-09-03.md | 1 + 3 files changed, 30 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 de1f21527..16b53de8a 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 @@ -101,7 +101,7 @@ export default function UiEditorPage({ return Boolean( (target instanceof HTMLElement && target.isContentEditable) || element?.closest( - 'button, a, input, textarea, select, [contenteditable="true"], [role="button"]', + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', ), ); }; diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index e55cb9fcd..2c4206167 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -514,6 +514,34 @@ describe('UiEditorPage', () => { await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); }); + it('does not delete a selected node when Delete originates inside a dialog', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-dialog', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + fireEvent.keyDown(dialog, { key: 'Delete' }); + + expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); + dialog.remove(); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index 9efab4673..bd4b6abaa 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -51,6 +51,7 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。 - UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 - `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 +- `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 -- 2.52.0 From efd67911c905fd337f767a483fee65d394a5bcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 18:06:03 +0800 Subject: [PATCH 17/35] =?UTF-8?q?=E6=8F=90=E5=8F=96=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一成功与失败分支的提示拼接、状态更新和终态通知 保持三个工作流步骤现有消息与状态行为不变 --- .../src/view/ui-editor/useUiEditorPage.ts | 77 +++++++++---------- 1 file changed, 35 insertions(+), 42 deletions(-) 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 6a7eefab2..2263bedc6 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 @@ -258,6 +258,17 @@ export function useUiEditorSession( const [completionNotice, setCompletionNotice] = useState(null); + function reportWorkflowCompletion( + step: UiEditorStepId, + outcome: WorkflowCompletionNotice['outcome'], + rawMessage: string, + setStatus: (message: string) => void, + ) { + const message = appendWorkflowCheckPrompt(rawMessage); + setStatus(message); + setCompletionNotice({ step, outcome, message }); + } + useEffect(() => { setActiveStep(initialStep); setFurthestStepIndex(normalizedInitialFurthestStepIndex); @@ -974,26 +985,20 @@ export function useUiEditorSession( ); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); setHasSuggested(true); - const message = appendWorkflowCheckPrompt( + reportWorkflowCompletion( + 'reference-analysis', + 'success', `参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`, + setSuggestionStatus, ); - setSuggestionStatus(message); - setCompletionNotice({ - step: 'reference-analysis', - outcome: 'success', - message, - }); }); } catch (cause) { - const message = appendWorkflowCheckPrompt( + reportWorkflowCompletion( + 'reference-analysis', + 'failure', cause instanceof Error ? cause.message : String(cause), + setSuggestionStatus, ); - setSuggestionStatus(message); - setCompletionNotice({ - step: 'reference-analysis', - outcome: 'failure', - message, - }); } finally { setIsSuggesting(false); } @@ -1018,26 +1023,20 @@ export function useUiEditorSession( nextState.ui_trees, 'layout_status', ); - const message = appendWorkflowCheckPrompt( + reportWorkflowCompletion( + 'structure-recognition', + 'success', `界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`, + setRecognitionStatus, ); - setRecognitionStatus(message); - setCompletionNotice({ - step: 'structure-recognition', - outcome: 'success', - message, - }); }); } catch (cause) { - const message = appendWorkflowCheckPrompt( + reportWorkflowCompletion( + 'structure-recognition', + 'failure', cause instanceof Error ? cause.message : String(cause), + setRecognitionStatus, ); - setRecognitionStatus(message); - setCompletionNotice({ - step: 'structure-recognition', - outcome: 'failure', - message, - }); } finally { setIsRecognizing(false); } @@ -1092,27 +1091,21 @@ export function useUiEditorSession( history: index < batches.length - 1 ? 'skip' : 'record', }); } - const message = appendWorkflowCheckPrompt( + reportWorkflowCompletion( + 'visual-binding', + 'success', `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, + setBindingStatus, ); - setBindingStatus(message); setHasBound(true); - setCompletionNotice({ - step: 'visual-binding', - outcome: 'success', - message, - }); }); } catch (cause) { - const message = appendWorkflowCheckPrompt( + reportWorkflowCompletion( + 'visual-binding', + 'failure', cause instanceof Error ? cause.message : String(cause), + setBindingStatus, ); - setBindingStatus(message); - setCompletionNotice({ - step: 'visual-binding', - outcome: 'failure', - message, - }); } finally { setIsBinding(false); } -- 2.52.0 From 6f0bae7724c8cc14544a7fdb751ef52573a8cca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 18:06:47 +0800 Subject: [PATCH 18/35] =?UTF-8?q?=E8=AE=A9=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E6=A0=87=E9=A2=98=E6=98=A0=E5=B0=84=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E7=A9=B7=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用覆盖全部 UiEditorStepId 的 switch 映射步骤标题 增加 never 穷举检查以便新增步骤触发类型错误 --- .../components/workflowCompletionNotice.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts index 1c0bfbfa0..ff90b6dd5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts @@ -13,7 +13,14 @@ export function appendWorkflowCheckPrompt(message: string): string { } export function workflowStepLabel(step: UiEditorStepId): string { - if (step === 'reference-analysis') return '分析参考图'; - if (step === 'structure-recognition') return '识别界面结构'; - return '绑定视觉素材'; + switch (step) { + case 'reference-analysis': + return '分析参考图'; + case 'structure-recognition': + return '识别界面结构'; + case 'visual-binding': + return '绑定视觉素材'; + } + const exhaustiveCheck: never = step; + return exhaustiveCheck; } -- 2.52.0 From 6f29c60d639d902e365f6b3d56e9be2b2a27f486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 18:07:08 +0800 Subject: [PATCH 19/35] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E9=80=89=E5=8C=BA=E7=9A=84=E4=B8=A5=E6=A0=BC=E7=A9=BA=E5=80=BC?= =?UTF-8?q?=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 selectedNodeId 的宽松不等比较改为 !== null --- .../ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2263bedc6..a580a652d 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 @@ -917,7 +917,7 @@ export function useUiEditorSession( setStatus('无法删除该节点。'); return result; } - if (selectedNodeId != null && deletedNodeIds?.has(selectedNodeId)) { + if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) { setSelectedNodeId(null); } return result; -- 2.52.0 From b4ba3188496a3d3d00a5a805cfb0fc3b283923b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:01:35 +0800 Subject: [PATCH 20/35] =?UTF-8?q?=E8=A1=A5=E5=85=85UI=E6=A0=91=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E5=88=A0=E9=99=A4=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录桌面端快捷删除按钮与右键删除复用规则 明确后代节点确认提示、页面根禁删和底层命令边界 --- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8e886e8a4..48a45ea99 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -58,6 +58,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作 ## 2026-08-19 UI Editor 节点右键菜单 +## 2026-09-05 UI Editor 节点树快捷删除 + +左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,按钮与可见性按钮保持同列紧凑布局,并沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。 + ## 2026-08-20 UI Editor 最终预览互斥子节点 最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 -- 2.52.0 From 8242885c7954abf0a9cb86e8fea438e7829ae188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:17:49 +0800 Subject: [PATCH 21/35] =?UTF-8?q?=E6=96=B0=E5=A2=9EUI=E6=A0=91=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=BF=AB=E6=8D=B7=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在可删除节点行提供桌面端垃圾桶按钮 复用右键删除入口并对后代节点显示确认弹窗 --- .../src/features/ui-editor/treeUtils.ts | 17 ++++ .../components/UiNodeDeleteConfirmModal.tsx | 56 ++++++++++++++ .../view/ui-editor/components/UiTreePanel.tsx | 77 ++++++++++++++++++- 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts index 9e044efb8..52d0fa137 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts @@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set { visitUiNodes(root, (node) => ids.add(node.id)); return ids; } + +export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null { + if (root.id === nodeId) return root; + for (const child of root.children) { + const found = findUiNode(child, nodeId); + if (found) return found; + } + return null; +} + +export function countUiNodeDescendants(root: UiNode): number { + let count = 0; + for (const child of root.children) { + count += 1 + countUiNodeDescendants(child); + } + return count; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx new file mode 100644 index 000000000..08e5362d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx @@ -0,0 +1,56 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { NodeId } from '../../../features/ui-editor/types/NodeId'; + +export type UiNodeDeleteRequest = { + nodeId: NodeId; + nodeLabel: string; + descendantCount: number; +}; + +type UiNodeDeleteConfirmModalProps = { + request: UiNodeDeleteRequest | null; + onCancel: () => void; + onConfirm: (nodeId: NodeId) => void; + disabled?: boolean; +}; + +export function UiNodeDeleteConfirmModal({ + request, + onCancel, + onConfirm, + disabled = false, +}: UiNodeDeleteConfirmModalProps) { + return ( + +

确认删除节点?

+

+ 将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '} + 个后代节点,是否继续? +

+
+ + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 5974e3ef7..ed2169fd3 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown, Eye, EyeOff, Trash2 } from 'lucide-react'; import { type MouseEvent as ReactMouseEvent, useEffect, @@ -13,12 +13,20 @@ import { type TreeApi, } from 'react-arborist'; +import { + countUiNodeDescendants, + findUiNode, +} from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; import { UiNodeContextMenu } from './UiNodeContextMenu'; +import { + UiNodeDeleteConfirmModal, + type UiNodeDeleteRequest, +} from './UiNodeDeleteConfirmModal'; type UiTreePanelProps = { root: UiNode | null; @@ -44,11 +52,17 @@ function TreeRow({ isNodeVisible, onToggleNodeVisibility, onOpenContextMenu, + onRequestDelete, + canDelete, + deleteDisabled, }: NodeRendererProps & { onSelectNode: (id: NodeId) => void; isNodeVisible: (nodeId: NodeId) => boolean; onToggleNodeVisibility: (id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; + onRequestDelete: (id: NodeId) => void; + canDelete: boolean; + deleteDisabled: boolean; }) { const data = node.data; const nodeLabel = data.metadata.name || '未命名节点'; @@ -105,6 +119,21 @@ function TreeRow({ > {isVisible ? : } + ); } @@ -132,6 +161,9 @@ export function UiTreePanel({ x: number; y: number; } | null>(null); + const [pendingDelete, setPendingDelete] = useState< + (UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null + >(null); useEffect(() => { if (!focusRequest) return; @@ -184,6 +216,35 @@ export function UiTreePanel({ const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null; + const requestDelete = (nodeId: NodeId) => { + if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) { + return; + } + const treeId = treeIdForNode(nodeId); + const node = findUiNode(root, nodeId); + if (!treeId || !node) return; + const descendantCount = countUiNodeDescendants(node); + if (descendantCount === 0) { + onDeleteNode(treeId, nodeId); + return; + } + setPendingDelete({ + treeId, + nodeId, + nodeLabel: node.metadata.name || '未命名节点', + descendantCount, + }); + }; + + const confirmDelete = (nodeId: NodeId) => { + if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) { + return; + } + const { treeId } = pendingDelete; + setPendingDelete(null); + onDeleteNode(treeId, nodeId); + }; + return (
nodeId === root.id ? undefined @@ -252,9 +319,15 @@ export function UiTreePanel({ onClose={() => setContextMenu(null)} onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} - onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} + onDelete={requestDelete} /> ) : null} + setPendingDelete(null)} + onConfirm={confirmDelete} + disabled={isLocked} + />
); } -- 2.52.0 From 897683e63f5ecedf66f5660eb70d0ec6613a039b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:17:51 +0800 Subject: [PATCH 22/35] =?UTF-8?q?=E8=A1=A5=E5=85=85UI=E6=A0=91=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E8=AE=A1=E6=95=B0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖嵌套节点定位与后代节点递归计数 --- .../tests/uiTreeUtils.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts new file mode 100644 index 000000000..196077400 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUiNodeDescendants, + findUiNode, +} from '../src/features/ui-editor/treeUtils'; +import type { Node } from '../src/features/ui-editor/types/Node'; + +function node(id: string, children: Node[] = []): Node { + return { + id, + layout: {} as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('ui tree utilities', () => { + it('finds a nested node and counts all descendants', () => { + const nested = node('nested', [node('leaf')]); + const root = node('root', [node('page'), nested]); + + expect(findUiNode(root, 'nested')).toBe(nested); + expect(findUiNode(root, 'missing')).toBeNull(); + expect(countUiNodeDescendants(nested)).toBe(1); + expect(countUiNodeDescendants(root)).toBe(3); + }); +}); -- 2.52.0 From 5b75792d6a9606236e4d199723c6b8f06b0115f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:54:26 +0800 Subject: [PATCH 23/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=86=85=E7=BC=96=E8=BE=91=E5=99=A8=E5=BF=AB=E6=8D=B7=E9=94=AE?= =?UTF-8?q?=E8=AF=AF=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取UI编辑器键盘快捷键处理模块 阻止对话框内撤销重做并保留普通按钮快捷键行为 --- .../src/view/ui-editor/index.tsx | 63 ++------------ .../ui-editor/uiEditorKeyboardShortcuts.ts | 82 +++++++++++++++++++ .../tests/uiEditorKeyboardShortcuts.test.ts | 62 ++++++++++++++ 3 files changed, 153 insertions(+), 54 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts 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 16b53de8a..e60169b1f 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 @@ -18,6 +18,7 @@ import { ToolNavigation } from './components/ToolNavigation'; import { WorkflowActionCard } from './components/WorkflowActionCard'; import { WorkflowCompletionModal } from './components/WorkflowCompletionModal'; import { UI_EDITOR_STEPS, type UiEditorStepId } from './model'; +import { handleUiEditorKeyDown } from './uiEditorKeyboardShortcuts'; import { type UiEditorWorkflowProjection, useUiEditorSession, @@ -89,60 +90,14 @@ export default function UiEditorPage({ }, [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, [contenteditable="true"]'), - ); - }; - const isInteractiveTarget = (target: EventTarget | null) => { - const element = target instanceof Element ? target : null; - return Boolean( - (target instanceof HTMLElement && target.isContentEditable) || - element?.closest( - 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', - ), - ); - }; - const onKeyDown = (event: KeyboardEvent) => { - if ( - (event.key === 'Delete' || event.key === 'Backspace') && - !event.repeat && - !event.defaultPrevented && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey && - !isInteractiveTarget(event.target) - ) { - if (selectedNodeId && activeImageId) { - const result = deleteNode(selectedNodeId, activeImageId); - if (result?.ok) { - event.preventDefault(); - event.stopPropagation(); - return; - } - } - } - if ( - event.repeat || - event.defaultPrevented || - 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 && historyUndo()) { - event.preventDefault(); - } else if (isRedo && historyRedo()) { - event.preventDefault(); - } - }; + const onKeyDown = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId, + activeImageId, + deleteNode, + historyUndo, + historyRedo, + }); window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts new file mode 100644 index 000000000..90657f5b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts @@ -0,0 +1,82 @@ +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; + +type DeleteResult = { ok: boolean } | undefined; + +export type UiEditorKeyboardActions = { + selectedNodeId: NodeId | null; + activeImageId: UIDesignImageId | null; + deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult; + historyUndo: () => boolean; + historyRedo: () => boolean; +}; + +function isEditableTarget(target: EventTarget | null) { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest('input, textarea, select, [contenteditable="true"]'), + ); +} + +function isInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean( + (target instanceof HTMLElement && target.isContentEditable) || + element?.closest( + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', + ), + ); +} + +function isModalTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]')); +} + +export function handleUiEditorKeyDown( + event: KeyboardEvent, + actions: UiEditorKeyboardActions, +) { + if (isModalTarget(event.target)) return; + + if ( + (event.key === 'Delete' || event.key === 'Backspace') && + !event.repeat && + !event.defaultPrevented && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + !isInteractiveTarget(event.target) + ) { + if (actions.selectedNodeId && actions.activeImageId) { + const result = actions.deleteNode( + actions.selectedNodeId, + actions.activeImageId, + ); + if (result?.ok) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + if ( + event.repeat || + event.defaultPrevented || + 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 && actions.historyUndo()) { + event.preventDefault(); + } else if (isRedo && actions.historyRedo()) { + event.preventDefault(); + } +} diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..51ccc3514 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts'; + +describe('ui editor keyboard shortcuts', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + { key: 'z', ctrlKey: true }, + { key: 'z', ctrlKey: true, shiftKey: true }, + { key: 'y', ctrlKey: true }, + ])('does not change history from inside a modal (%o)', (shortcut) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const button = document.createElement('button'); + dialog.append(button); + document.body.append(dialog); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, shortcut); + + expect(historyUndo).not.toHaveBeenCalled(); + expect(historyRedo).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }); + + it('keeps undo available from a non-modal button', () => { + const button = document.createElement('button'); + document.body.append(button); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, { key: 'z', ctrlKey: true }); + + expect(historyUndo).toHaveBeenCalledTimes(1); + window.removeEventListener('keydown', listener); + }); +}); -- 2.52.0 From d8d4501665cc2efe52cfd4acea7d5e1a57617082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 13:54:28 +0800 Subject: [PATCH 24/35] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确对话框打开时暂停编辑器级撤销重做 --- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index bd4b6abaa..86d7e3cd9 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -52,6 +52,7 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 - `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 - `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。 +- `Cmd/Ctrl+Z`、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 -- 2.52.0 From dd6bd0cb7bdc12c15fdfe13457170107be99237f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 14:40:57 +0800 Subject: [PATCH 25/35] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=BC=A9=E6=94=BE=E6=BB=91=E6=9D=86=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增桌面端缩放滑杆交互约定 明确范围、键盘操作与验收标准 --- .../【交互设计】预览画布缩放滑杆-2026-09-05.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/【交互设计】预览画布缩放滑杆-2026-09-05.md diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md new file mode 100644 index 000000000..0a449f5b4 --- /dev/null +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md @@ -0,0 +1,18 @@ +# 预览画布缩放滑杆 + +## 交付目标 + +将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。 + +## 交互约定 + +- 缩放范围为 25%–200%,步进 5%。 +- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。 +- 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。 +- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。 +- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。 +- 不增加 `0`/`1` 快捷键,不新增专门测试。 + +## 验收 + +桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界;运行前端定向类型检查、编码检查和 `git diff --check`。 -- 2.52.0 From 9d53c4424106ac4b1e2b10657f1e9f119051cfb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 14:42:02 +0800 Subject: [PATCH 26/35] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=BC=A9=E6=94=BE=E6=BB=91=E6=9D=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增桌面端可拖动缩放滑杆 保留按钮与视口中心缩放行为 --- .../components/preview/PreviewWorkspace.tsx | 12 ++++++++++++ 1 file changed, 12 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 75a1efce3..4a192e6e2 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 @@ -471,6 +471,18 @@ export function PreviewWorkspace({ > + + actions.zoomToDisplayScale(Number(event.target.value) / 100) + } + /> - {nodeLabel} - - {data.components.length} + + {nodeLabel} - - + + + + + ); } @@ -155,7 +206,9 @@ export function UiTreePanel({ }: UiTreePanelProps) { const treeApiRef = useRef | undefined>(undefined); const treeContainerRef = useRef(null); + const treeSizerRef = useRef(null); const [treeHeight, setTreeHeight] = useState(0); + const [treeWidth, setTreeWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; @@ -182,6 +235,33 @@ export function UiTreePanel({ return () => resizeObserver.disconnect(); }, []); + const treeRows = useMemo(() => { + if (!root) return []; + const rows: Array<{ node: UiNode; depth: number }> = []; + const visit = (node: UiNode, depth: number) => { + rows.push({ node, depth }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + visit(root, 0); + return rows; + }, [root]); + + useEffect(() => { + const sizer = treeSizerRef.current; + const container = treeContainerRef.current; + if (!sizer || !container) return; + const updateWidth = () => { + setTreeWidth( + Math.max(Math.ceil(sizer.scrollWidth), container.clientWidth), + ); + }; + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(sizer); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [treeRows]); + const handleMove: MoveHandler = ({ dragIds, parentId, @@ -250,16 +330,50 @@ export function UiTreePanel({ className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`} onContextMenu={(event) => event.preventDefault()} > -
+
+ {root && treeHeight > 0 ? ( ref={treeApiRef} data={[root]} - width="100%" + width={treeWidth > 0 ? treeWidth : '100%'} + outerElementType={TreeListOuter} height={treeHeight} rowHeight={34} indent={16} openByDefault + renderRow={TreeRowContainer} selection={selectedNodeId ?? undefined} onSelect={(nodes) => { const selected = nodes[0]?.data; -- 2.52.0 From 9deae852ecaebe875a2bda2b2e7eed0aab60a42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 18:46:58 +0800 Subject: [PATCH 29/35] =?UTF-8?q?=E8=A1=A5=E5=85=85=20UI=20=E6=A0=91?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录节点操作列与树横向滚动规则 说明深层节点名称不得截断 --- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 48a45ea99..16659a4dc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -60,7 +60,7 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作 ## 2026-09-05 UI Editor 节点树快捷删除 -左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,按钮与可见性按钮保持同列紧凑布局,并沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。 +左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。 ## 2026-08-20 UI Editor 最终预览互斥子节点 -- 2.52.0 From c50960f4c47db87800d56f98828fae93b83b8f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 19:25:33 +0800 Subject: [PATCH 30/35] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=BF=AB=E6=8D=B7=E7=BC=A9=E6=94=BE=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分滑杆选择范围与快捷加减的核心视口边界 约定跨平台按键、作用域、事件拦截与既有重置行为 补充组件级回归测试和定向验证标准 --- docs/【交互设计】预览画布缩放滑杆-2026-09-05.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md index 0a449f5b4..b251156a9 100644 --- a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md @@ -6,13 +6,19 @@ ## 交互约定 -- 缩放范围为 25%–200%,步进 5%。 +- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。 - 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。 - 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。 - 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。 - 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。 -- 不增加 `0`/`1` 快捷键,不新增专门测试。 +- Windows/Linux 使用 `Ctrl`,macOS 使用 `Cmd`;`+`、`=` 与 `NumpadAdd` 放大,`-` 与 `NumpadSubtract` 缩小,允许按键重复时连续缩放。 +- 快捷键直接复用现有 `+`/`−` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。 +- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。 +- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。 +- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。 +- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。 +- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。 ## 验收 -桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界;运行前端定向类型检查、编码检查和 `git diff --check`。 +桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`。 -- 2.52.0 From af8e1df1395065b7437b7722f8968f970682e2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 19:33:10 +0800 Subject: [PATCH 31/35] =?UTF-8?q?=E5=B0=81=E8=A3=85=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=BF=AB=E6=8D=B7=E7=BC=A9=E6=94=BE=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 识别跨平台加减、适配画布与实际尺寸快捷键 限制快捷键作用域并放行可编辑和可操作目标 增加按键变体、重复输入和默认行为回归测试 --- .../components/preview/previewZoomKeyboard.ts | 100 ++++++++++ .../tests/previewZoomKeyboard.test.ts | 176 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts create mode 100644 apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts new file mode 100644 index 000000000..85941ca1e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts @@ -0,0 +1,100 @@ +export type PreviewZoomShortcut = + | 'fit' + | 'actual-size' + | 'zoom-in' + | 'zoom-out'; + +export type PreviewZoomKeyboardContext = { + hasZoomableViewport: boolean; + isFocused: boolean; + isHovered: boolean; + usesMetaModifier: boolean; +}; + +export type PreviewZoomKeyboardActions = { + fit: () => void; + resetToActualSize: () => void; + zoomIn: () => void; + zoomOut: () => void; +}; + +const INTERACTIVE_TARGET_SELECTOR = [ + 'button', + 'a[href]', + 'input', + 'textarea', + 'select', + 'summary', + '[contenteditable]:not([contenteditable="false"])', + '[role="button"]', + '[role="link"]', + '[role="textbox"]', + '[role="combobox"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="menuitem"]', + '[role="option"]', + '[role="dialog"]', + '[aria-modal="true"]', +].join(', '); + +export function previewZoomUsesMetaModifier(platform: string) { + return /Mac|iPhone|iPad|iPod/i.test(platform); +} + +export function isPreviewZoomInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest(INTERACTIVE_TARGET_SELECTOR)); +} + +function hasPlatformModifier(event: KeyboardEvent, usesMetaModifier: boolean) { + return usesMetaModifier + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function resolvePreviewZoomShortcut( + event: KeyboardEvent, +): PreviewZoomShortcut | null { + if (event.altKey) return null; + if (event.key === '0') return 'fit'; + if (event.key === '1') return 'actual-size'; + if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') { + return 'zoom-in'; + } + if (event.code === 'NumpadSubtract' || event.key === '-') { + return 'zoom-out'; + } + return null; +} + +export function handlePreviewZoomKeyDown( + event: KeyboardEvent, + context: PreviewZoomKeyboardContext, + actions: PreviewZoomKeyboardActions, +) { + if ( + event.defaultPrevented || + !context.hasZoomableViewport || + (!context.isHovered && !context.isFocused) || + !hasPlatformModifier(event, context.usesMetaModifier) || + isPreviewZoomInteractiveTarget(event.target) + ) { + return false; + } + + const shortcut = resolvePreviewZoomShortcut(event); + if (!shortcut) return false; + + event.preventDefault(); + event.stopPropagation(); + if (shortcut === 'fit') actions.fit(); + else if (shortcut === 'actual-size') actions.resetToActualSize(); + else if (shortcut === 'zoom-in') actions.zoomIn(); + else actions.zoomOut(); + return true; +} diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +}); -- 2.52.0 From df72dd4bed5452075d433892e409f4b86d367a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 19:49:27 +0800 Subject: [PATCH 32/35] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=8A=A0=E5=87=8F=E5=BF=AB=E6=8D=B7=E7=BC=A9?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用工具栏中心缩放逻辑并统一加减倍率常量 为预览画布增加悬停与键盘焦点作用域 保留既有适配画布和实际尺寸快捷键行为 --- .../components/preview/PreviewWorkspace.tsx | 112 +++++++--- .../tests/previewWorkspaceZoom.test.tsx | 203 ++++++++++++++++++ packages/image-canvas-core/src/model.ts | 2 + .../image-canvas-react/src/ZoomControls.tsx | 6 +- 4 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx 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 ac317ae3c..a85ecf46d 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 @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, type CanvasViewport, createPanDragState, type DragState, @@ -28,6 +30,11 @@ 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'; +import { + handlePreviewZoomKeyDown, + isPreviewZoomInteractiveTarget, + previewZoomUsesMetaModifier, +} from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; @@ -41,6 +48,8 @@ export function PreviewWorkspace({ const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); const panRef = useRef | null>(null); + const previewFocusedRef = useRef(false); + const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( viewportRef.current, ); @@ -150,6 +159,34 @@ export function PreviewWorkspace({ ); }, [logicalSize, setViewport]); + const scaleViewportFromCenter = useCallback( + (nextScale: number) => { + const element = viewportElementRef.current; + const width = element?.clientWidth || canvasSize.width; + const height = element?.clientHeight || canvasSize.height; + setViewport( + scaleViewportFromScreenPoint({ + viewport: viewportRef.current, + nextScale, + screenPoint: { x: width / 2, y: height / 2 }, + }), + ); + }, + [canvasSize.height, canvasSize.width, setViewport], + ); + + const resetToActualSize = useCallback(() => { + scaleViewportFromCenter(1); + }, [scaleViewportFromCenter]); + + const zoomIn = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR); + }, [scaleViewportFromCenter]); + + const zoomOut = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); + }, [scaleViewportFromCenter]); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; @@ -224,6 +261,9 @@ export function PreviewWorkspace({ ), ); }; + const usesMetaModifier = previewZoomUsesMetaModifier( + window.navigator.platform, + ); const onKeyDown = (event: KeyboardEvent) => { if ( event.code === 'Space' && @@ -232,23 +272,21 @@ export function PreviewWorkspace({ ) { setSpaceHeld(true); } - if (!event.ctrlKey && !event.metaKey) return; - if (event.key === '0') { - event.preventDefault(); - fitToCanvas(); - } else if (event.key === '1' && logicalSize) { - event.preventDefault(); - const element = viewportElementRef.current; - const width = element?.clientWidth ?? 900; - const height = element?.clientHeight ?? 640; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale: 1, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - } + handlePreviewZoomKeyDown( + event, + { + hasZoomableViewport: logicalSize !== null, + isFocused: previewFocusedRef.current, + isHovered: previewHoveredRef.current, + usesMetaModifier, + }, + { + fit: fitToCanvas, + resetToActualSize, + zoomIn, + zoomOut, + }, + ); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') setSpaceHeld(false); @@ -262,9 +300,12 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, setViewport]); + }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { + event.currentTarget.focus({ preventScroll: true }); + } if (event.button === 1 || (event.button === 0 && spaceHeld)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -371,7 +412,27 @@ export function PreviewWorkspace({
{ + previewHoveredRef.current = true; + }} + onPointerLeave={() => { + previewHoveredRef.current = false; + }} + onFocus={() => { + previewFocusedRef.current = true; + }} + onBlur={(event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + previewFocusedRef.current = false; + } + }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -448,18 +509,7 @@ export function PreviewWorkspace({ { - const element = viewportElementRef.current; - const width = element?.clientWidth ?? canvasSize.width; - const height = element?.clientHeight ?? canvasSize.height; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - }} + onScaleFromCenter={scaleViewportFromCenter} > {(actions) => (
diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx new file mode 100644 index 000000000..908086f1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +const root: UiNode = { + id: 'root', + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], +}; + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function renderedScale(container: HTMLElement) { + const transform = ( + container.querySelector('.genarrative-image-canvas__world') as HTMLElement + ).style.transform; + const scale = /scale\(([^)]+)\)/.exec(transform)?.[1]; + if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`); + return Number(scale); +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('PreviewWorkspace quick zoom', () => { + it('uses the toolbar zoom step while the preview is hovered', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const before = renderedScale(rendered.container); + + fireEvent.pointerEnter(preview); + const wasNotCancelled = fireEvent.keyDown(window, { + key: '=', + code: 'Equal', + ctrlKey: true, + cancelable: true, + }); + + expect(wasNotCancelled).toBe(false); + expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16); + }); + + it('requires hover or focus and ignores interactive targets', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const zoomButton = screen.getByRole('button', { name: '放大画布' }); + const initial = renderedScale(rendered.container); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerEnter(preview); + expect( + fireEvent.keyDown(zoomButton, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerLeave(preview); + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '-', + code: 'Minus', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86); + }); + + it('keeps actual-size and fit shortcuts within the preview scope', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const fitted = renderedScale(rendered.container); + + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '1', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(1); + + fireEvent.keyDown(window, { + key: '0', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(fitted); + }); + + it('leaves browser zoom untouched when the preview has no content', () => { + render( + , + ); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts index 3deeac6f8..b6b6c3430 100644 --- a/packages/image-canvas-core/src/model.ts +++ b/packages/image-canvas-core/src/model.ts @@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; +export const CANVAS_ZOOM_IN_FACTOR = 1.16; +export const CANVAS_ZOOM_OUT_FACTOR = 0.86; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const FIT_VIEW_PADDING = 10; diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx index e9f853c26..53e88ccec 100644 --- a/packages/image-canvas-react/src/ZoomControls.tsx +++ b/packages/image-canvas-react/src/ZoomControls.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, canvasDisplayScaleToViewportScale, type CanvasViewport, formatCanvasDisplayScalePercent, @@ -27,8 +29,8 @@ export function ZoomControls({ const actions: CanvasZoomActions = { displayPercent: formatCanvasDisplayScalePercent(viewport.scale), fit: onFit, - zoomIn: () => onScaleFromCenter(viewport.scale * 1.16), - zoomOut: () => onScaleFromCenter(viewport.scale * 0.86), + zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR), + zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR), zoomToDisplayScale: (displayScale) => onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)), }; -- 2.52.0 From 29b0b944bd66aff7640659e660e74e2e47cc7046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 19:54:06 +0800 Subject: [PATCH 33/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=A9=E6=94=BE?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E7=84=A6=E7=82=B9=E6=8B=A6=E6=88=AA=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许缩放滑杆和缩放按钮焦点下继续执行 Delete/Backspace。 修正 UI 树宽度测量重复节点文本并保持预览点击选择语义。 补充缩放焦点快捷键回归测试与交互文档。 --- .../view/ui-editor/components/UiTreePanel.tsx | 52 ++++++------------- .../components/preview/PreviewWorkspace.tsx | 5 +- .../components/preview/UiTreeRenderer.tsx | 9 ++-- .../ui-editor/uiEditorKeyboardShortcuts.ts | 3 +- .../tests/uiEditorKeyboardShortcuts.test.ts | 45 ++++++++++++++++ ...【交互设计】预览画布缩放滑杆-2026-09-05.md | 1 + 6 files changed, 73 insertions(+), 42 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index ae521ce83..14fde9869 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -206,7 +206,6 @@ export function UiTreePanel({ }: UiTreePanelProps) { const treeApiRef = useRef | undefined>(undefined); const treeContainerRef = useRef(null); - const treeSizerRef = useRef(null); const [treeHeight, setTreeHeight] = useState(0); const [treeWidth, setTreeWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ @@ -246,21 +245,29 @@ export function UiTreePanel({ return rows; }, [root]); + const treeContentWidth = useMemo( + () => + treeRows.reduce((width, { node, depth }) => { + const labelWidth = Math.max( + 56, + (node.metadata.name || '未命名节点').length * 8, + ); + return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52); + }, 0), + [treeRows], + ); + useEffect(() => { - const sizer = treeSizerRef.current; const container = treeContainerRef.current; - if (!sizer || !container) return; + if (!container) return; const updateWidth = () => { - setTreeWidth( - Math.max(Math.ceil(sizer.scrollWidth), container.clientWidth), - ); + setTreeWidth(Math.max(treeContentWidth, container.clientWidth)); }; updateWidth(); const resizeObserver = new ResizeObserver(updateWidth); - resizeObserver.observe(sizer); resizeObserver.observe(container); return () => resizeObserver.disconnect(); - }, [treeRows]); + }, [treeContentWidth]); const handleMove: MoveHandler = ({ dragIds, @@ -334,35 +341,6 @@ export function UiTreePanel({ ref={treeContainerRef} className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden" > - {root && treeHeight > 0 ? ( ref={treeApiRef} 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 a85ecf46d..df640566d 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 @@ -512,7 +512,10 @@ export function PreviewWorkspace({ onScaleFromCenter={scaleViewportFromCenter} > {(actions) => ( -
+