From 8d13e868bd9b9efed7a6088eb59052ab23548613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Fri, 4 Sep 2026 11:24:21 +0800 Subject: [PATCH 1/2] =?UTF-8?q?AGC=20=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20X=20=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE?= =?UTF-8?q?=20(#276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 为 AGC 更新提示增加可访问的 X 关闭按钮。 - 复用现有 dismiss 逻辑,手动关闭后立即移除提示。 - 增加关闭按钮的 hover、禁用和图标样式。 ## 验证 - npm --prefix apps/ai-game-creator-shell run typecheck - npm exec vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts - npm run check:encoding - git diff --check Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/276 Reviewed-by: 段舒康 Co-authored-by: 孔令弘 Co-committed-by: 孔令弘 --- .../src/components/AppUpdateNotice.tsx | 12 ++++++++++- apps/ai-game-creator-shell/src/styles.css | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 7cc1c8444..fa9630719 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -1,5 +1,5 @@ import { listen } from '@tauri-apps/api/event'; -import { Download, LoaderCircle } from 'lucide-react'; +import { Download, LoaderCircle, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { APP_VERSION } from '../app/appMetadata'; @@ -117,6 +117,16 @@ export function AppUpdateNotice() { > {isDownloading ? '正在下载…' : '下载更新'} + {downloadState !== 'idle' ? (
Date: Fri, 4 Sep 2026 16:30:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?Fix/=E4=BF=AE=E5=A4=8Dui=20editor=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=80=BB=E8=BE=91=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #266 当前实现: 按下就重新选择 实际上应该: 松开而且没有发生拖动才重新选择 before: ![shotmd-1788407266-compressed.webp](/attachments/251eccf6-ae02-4ae6-b9d2-4eb1fbf67df3) after: ![shotmd-1788418953-compressed.webp](/attachments/5c05b506-ae08-4cb1-83c8-8da8e1590a7f) Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/270 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../components/preview/PreviewWorkspace.tsx | 8 ++ .../components/preview/UiTreeRenderer.tsx | 41 ++++-- .../preview/useNodeTransformInteraction.ts | 108 ++++++++++++--- .../tests/uiEditorPreview.test.tsx | 40 +++++- .../useNodeTransformInteraction.test.tsx | 128 ++++++++++++++++++ ...方案】UI编辑器Godot容器布局模型-2026-08-18.md | 8 +- 6 files changed, 299 insertions(+), 34 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index c2cae9af5..75a1efce3 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 @@ -110,6 +110,8 @@ export function PreviewWorkspace({ keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, onPreviewTransform: updatePreviewTransform, + previewRef: viewportElementRef, + selectedNodeId: canvas.selectedNodeId, }); const { onNodePointerDown, @@ -118,6 +120,9 @@ export function PreviewWorkspace({ onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodePointerCancel, + onNodeResizePointerCancel, + consumeNodeClick, } = nodeInteractions; const setViewport = useCallback((next: CanvasViewport) => { @@ -412,13 +417,16 @@ export function PreviewWorkspace({ fontFaces: canvas.fontFaces, }} onSelectNode={canvas.selectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={handleNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={canvas.selectExclusiveChild} viewportScale={viewport.scale} /> diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 38d1b9454..34f624152 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -36,6 +36,7 @@ type UiTreeRendererProps = { selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; + consumeNodeClick?: () => boolean; onNodeContextMenu: ( event: ReactMouseEvent, node: UiNode, @@ -44,6 +45,7 @@ type UiTreeRendererProps = { onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; + onNodePointerCancel: (event: ReactPointerEvent) => void; onNodeResizePointerDown: ( event: ReactPointerEvent, node: UiNode, @@ -51,6 +53,7 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; + onNodeResizePointerCancel: (event: ReactPointerEvent) => void; onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -87,13 +90,16 @@ function RenderNode({ selectedNodeId, resources, onSelectNode, + consumeNodeClick = () => false, onNodeContextMenu, onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel, onSelectExclusiveChild, viewportScale, }: Omit & { @@ -118,6 +124,8 @@ function RenderNode({ const isEditorOverlay = renderMode === 'editor-overlay'; const isFrameVisible = isEditorOverlay || showFrame; + const receivesPointerGesture = parentContainer === undefined; + const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) @@ -125,7 +133,7 @@ function RenderNode({ return (
{ - event.stopPropagation(); - onSelectNode(node.id); - }} + onClick={ + hasDirectPointerGesture + ? (event) => { + event.stopPropagation(); + consumeNodeClick(); + } + : (event) => { + event.stopPropagation(); + if (consumeNodeClick()) return; + onSelectNode(node.id); + } + } onContextMenu={(event) => { if (!isEditorOverlay) return; event.preventDefault(); @@ -158,11 +174,13 @@ function RenderNode({ onNodeContextMenu(event, node, Boolean(isRoot)); }} onPointerDown={ - parentContainer ? undefined : (event) => onNodePointerDown(event, node) + receivesPointerGesture + ? (event) => onNodePointerDown(event, node) + : undefined } - onPointerMove={parentContainer ? undefined : onNodePointerMove} - onPointerUp={parentContainer ? undefined : onNodePointerUp} - onPointerCancel={parentContainer ? undefined : onNodePointerUp} + onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined} + onPointerUp={receivesPointerGesture ? onNodePointerUp : undefined} + onPointerCancel={receivesPointerGesture ? onNodePointerCancel : undefined} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( @@ -202,13 +220,16 @@ function RenderNode({ selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={onNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={onSelectExclusiveChild} viewportScale={viewportScale} /> @@ -238,7 +259,7 @@ function RenderNode({ } onPointerMove={onNodeResizePointerMove} onPointerUp={onNodeResizePointerUp} - onPointerCancel={onNodeResizePointerUp} + onPointerCancel={onNodeResizePointerCancel} /> )) : null} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 74f652acb..9b79de2d7 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -31,6 +31,7 @@ type GestureBase = { startClientX: number; startClientY: number; startTransform: UiNode['layout']['transform']; + hitNodeId: string; hasMoved: boolean; pendingTransform?: UiNode['layout']['transform']; previewNodeIds: string[]; @@ -83,6 +84,41 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function findNodeById(node: UiNode, nodeId: string): UiNode | null { + if (node.id === nodeId) return node; + for (const child of node.children) { + const match = findNodeById(child, nodeId); + if (match) return match; + } + return null; +} + +function resolveHitNodeId( + event: ReactPointerEvent, + fallback: string, +) { + const target = event.target; + if (!(target instanceof Element)) return fallback; + return ( + target.closest('[data-node-id]')?.dataset.nodeId ?? fallback + ); +} + +function isInsidePreview( + event: ReactPointerEvent, + previewRef: RefObject | undefined, +) { + const preview = previewRef?.current; + if (!preview) return true; + const rect = preview.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); +} + function previewNodeIds( tree: UITree | null, nodeId: string, @@ -148,6 +184,8 @@ export function useNodeTransformInteraction({ keepChildrenUnchanged, viewportRef, onPreviewTransform, + previewRef, + selectedNodeId, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -160,11 +198,14 @@ export function useNodeTransformInteraction({ nodeId: string, transform: UiNode['layout']['transform'] | null, ) => void; + previewRef?: RefObject; + selectedNodeId?: string | null; }) { const activeGestureRef = useRef(null); // Keep cleanup stable while still invoking the latest preview callback. const onPreviewTransformRef = useRef(onPreviewTransform); onPreviewTransformRef.current = onPreviewTransform; + const suppressNextNodeClickRef = useRef(false); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; @@ -178,10 +219,16 @@ export function useNodeTransformInteraction({ } } activeGestureRef.current = null; + suppressNextNodeClickRef.current = false; }, []); useEffect(() => cancelGesture, [cancelGesture]); + useEffect(() => { + window.addEventListener('blur', cancelGesture); + return () => window.removeEventListener('blur', cancelGesture); + }, [cancelGesture]); + useEffect(() => { const gesture = activeGestureRef.current; if ( @@ -217,22 +264,29 @@ export function useNodeTransformInteraction({ ) { return; } + const selectedNode = + selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null; + const dragNode = + selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node; + if (!isFiniteTransform(dragNode.layout.transform)) return; event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; + event.preventDefault(); activeGestureRef.current = { kind: 'drag', treeId: activeImageId, - nodeId: node.id, + nodeId: dragNode.id, pointerId: event.pointerId, target: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, - startTransform: structuredClone(node.layout.transform), + startTransform: structuredClone(dragNode.layout.transform), + hitNodeId: resolveHitNodeId(event, node.id), hasMoved: false, previewNodeIds: previewNodeIds( tree, - node.id, + dragNode.id, logicalSize, keepChildrenUnchanged, ), @@ -240,10 +294,10 @@ export function useNodeTransformInteraction({ }, [ activeImageId, - canvas, keepChildrenUnchanged, logicalSize, spaceHeld, + selectedNodeId, tree, ], ); @@ -268,6 +322,7 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const nextTransform = structuredClone(gesture.startTransform); nextTransform.offset_min[0] += logicalDeltaX; nextTransform.offset_min[1] += logicalDeltaY; @@ -302,11 +357,11 @@ export function useNodeTransformInteraction({ if (!acceptsGestureEvent(event)) return; event.stopPropagation(); const gesture = activeGestureRef.current; - if ( - event.type !== 'pointercancel' && - gesture?.hasMoved && - gesture.pendingTransform - ) { + if (!gesture) return; + const cleanPointerUpInside = + !gesture?.hasMoved && isInsidePreview(event, previewRef); + if (cleanPointerUpInside) canvas.selectNode(gesture.hitNodeId); + if (gesture.hasMoved && gesture.pendingTransform) { canvas.updateNodeTransform( gesture.treeId, gesture.nodeId, @@ -314,8 +369,19 @@ export function useNodeTransformInteraction({ ); } cancelGesture(); + if (gesture.hasMoved || cleanPointerUpInside) + suppressNextNodeClickRef.current = true; }, - [acceptsGestureEvent, cancelGesture, canvas], + [acceptsGestureEvent, cancelGesture, canvas, previewRef], + ); + + const onNodePointerCancel = useCallback( + (event: ReactPointerEvent) => { + if (!acceptsGestureEvent(event)) return; + event.stopPropagation(); + cancelGesture(); + }, + [acceptsGestureEvent, cancelGesture], ); const onNodeResizePointerDown = useCallback( @@ -355,7 +421,7 @@ export function useNodeTransformInteraction({ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'resize', treeId: activeImageId, @@ -366,6 +432,7 @@ export function useNodeTransformInteraction({ startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), + hitNodeId: node.id, startRect: context.rect, parentRect: context.parentRect, ratioAxis: null, @@ -378,14 +445,7 @@ export function useNodeTransformInteraction({ ), }; }, - [ - activeImageId, - canvas, - keepChildrenUnchanged, - logicalSize, - spaceHeld, - tree, - ], + [activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree], ); const onNodeResizePointerMove = useCallback( @@ -442,6 +502,7 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; gesture.pendingTransform = nextTransform; emitPreviewTransforms( gesture, @@ -463,11 +524,18 @@ export function useNodeTransformInteraction({ ); return { + consumeNodeClick: useCallback(() => { + if (!suppressNextNodeClickRef.current) return false; + suppressNextNodeClickRef.current = false; + return true; + }, []), onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp: onNodePointerUp, + onNodeResizePointerCancel: onNodePointerCancel, }; } diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 5b47a87c3..6f79be210 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UITree } from '../src/features/ui-editor/types/UITree'; @@ -9,6 +9,10 @@ import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer'; import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu'; +afterEach(() => { + cleanup(); +}); + function node(id: string, children: UiNode[] = []): UiNode { return { id, @@ -62,6 +66,7 @@ function renderTree( options: { showFrame?: boolean; selectedNodeId?: string | null; + onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; } = {}, ) { @@ -73,20 +78,41 @@ function renderTree( hiddenNodeIds={hiddenNodeIds} selectedNodeId={options.selectedNodeId ?? null} resources={resources} - onSelectNode={vi.fn()} + onSelectNode={options.onSelectNode ?? vi.fn()} onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()} onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); } describe('UI tree preview visibility', () => { + it('disables native text selection on preview node layers', () => { + const rendered = renderTree('editor-overlay', new Set()); + + expect( + rendered.container + .querySelector('[data-node-id="child"]') + ?.classList.contains('select-none'), + ).toBe(true); + }); + + it('does not use click alone to select directly draggable nodes', () => { + const onSelectNode = vi.fn(); + const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + fireEvent.click( + rendered.container.querySelector('[data-node-id="child"]')!, + ); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + it('shares node menu restrictions, disabled actions, and dismissal behavior', () => { const onClose = vi.fn(); const onInsertChild = vi.fn(); @@ -150,9 +176,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -227,9 +255,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -299,9 +329,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={onNodePointerDown} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} onSelectExclusiveChild={onSelectChild} viewportScale={1} />, @@ -331,9 +363,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index bd5f2071a..7434c355e 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -70,12 +70,14 @@ function pointerEvent( pointerId: number, clientX: number, clientY: number, + eventTarget: EventTarget = target, ) { return { button: 0, clientX, clientY, currentTarget: target, + target: eventTarget, pointerId, preventDefault: vi.fn(), shiftKey: false, @@ -96,6 +98,7 @@ function renderInteraction({ tree = pageTree, keepChildrenUnchanged = false, onPreviewTransform, + selectedNodeId = null, }: { activeImageId?: string | null; scale?: number; @@ -105,6 +108,7 @@ function renderInteraction({ nodeId: string, transform: UiNode['layout']['transform'] | null, ) => void; + selectedNodeId?: string | null; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -119,6 +123,7 @@ function renderInteraction({ viewportRef, keepChildrenUnchanged, onPreviewTransform, + selectedNodeId, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -126,6 +131,95 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('moves the current selection when dragging another node without selecting the hit node', () => { + const selected = transformedNode('selected', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [80, 70], + }); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [selected, child]), + }; + const { result, canvasProjection } = renderInteraction({ + tree, + selectedNodeId: 'selected', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith( + 'page', + 'selected', + expect.objectContaining({ + offset_min: [24, 20], + offset_max: [84, 70], + }), + ); + }); + + it('prevents native text selection when starting a node drag', () => { + const { result } = renderInteraction(); + const target = gestureTarget(); + const event = pointerEvent(target, 1, 0, 0); + + act(() => { + result.current.onNodePointerDown(event, child); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('cleans preview transforms for the selected parent when dragging from a child', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + selectedNodeId: 'parent', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown( + pointerEvent(target, 1, 0, 0), + nestedChild, + ); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + result.current.onNodePointerCancel(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenCalledWith('parent', null); + expect(onPreviewTransform).toHaveBeenCalledWith('child', null); + }); + it('previews stable child page rectangles when the parent moves', () => { const nestedChild = transformedNode('child', { anchor_min: [0, 0], @@ -250,6 +344,40 @@ describe('useNodeTransformInteraction', () => { expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); + it('defers selection until a clean pointerup and suppresses follow-up click', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + act(() => { + result.current.onNodePointerUp(pointerEvent(target, 1, 1, 1)); + }); + expect(canvasProjection.selectNode).toHaveBeenCalledWith('child'); + expect(result.current.consumeNodeClick()).toBe(true); + expect(result.current.consumeNodeClick()).toBe(false); + }); + + it('does not select after a moved gesture or pointer cancellation', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(true); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child); + result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(false); + }); + it('cancels a gesture when its tree changes and ignores its later events', () => { const { result, rerender, canvasProjection } = renderInteraction(); const target = gestureTarget(); diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index f9852c817..ec137834f 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -1,6 +1,6 @@ # UI 编辑器 Godot 容器布局模型 -更新时间:`2026-08-18` +更新时间:`2026-09-03` ## 范围 @@ -40,3 +40,9 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa ## 编辑器交互 布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。 + +Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 + +取消、失焦、树切换或无效几何导致的手势终止会同时清理下一次 click 抑制状态;只有正常 pointerup 才会根据拖动结果抑制后续 click。 + +预览节点层禁止浏览器原生文字选择;拖动手势开始时阻止默认行为,避免 pointer drag 被浏览器解释为文本选取。