From 8a18b1b181491f4ce62d95a35146d08717180bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 10:51:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E8=8A=82=E7=82=B9=E5=8F=98=E6=8D=A2=E4=B8=BA=E6=9D=BE?= =?UTF-8?q?=E5=BC=80=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动和缩放期间仅更新预览临时变换 指针松开时提交最终变换,取消操作不写入 State 补充 UI 编辑器拖动变换提交边界文档 --- .../components/preview/PreviewWorkspace.tsx | 20 ++++++++++ .../components/preview/UiTreeRenderer.tsx | 14 ++++--- .../preview/useNodeTransformInteraction.ts | 37 +++++++++++++++---- ...【UI编辑器】拖动变换提交边界-2026-09-03.md | 14 +++++++ 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 docs/【UI编辑器】拖动变换提交边界-2026-09-03.md diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index ec88d9571..f7d528b3e 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -24,6 +24,7 @@ import { } from 'react'; import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry'; +import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; @@ -48,6 +49,9 @@ export function PreviewWorkspace({ const [renderMode, setRenderMode] = useState('editor-overlay'); const [showFrame, setShowFrame] = useState(false); + const [previewTransforms, setPreviewTransforms] = useState< + ReadonlyMap + >(new Map()); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; @@ -55,6 +59,20 @@ export function PreviewWorkspace({ isPageRoot: boolean; } | null>(null); const tree = canvas.tree ?? null; + const updatePreviewTransform = useCallback( + (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { + setPreviewTransforms((current) => { + const next = new Map(current); + if (transform) next.set(nodeId, transform); + else next.delete(nodeId); + return next; + }); + }, + [], + ); + useEffect(() => { + setPreviewTransforms(new Map()); + }, [activeImageId]); const activeImagePixelWidth = activeImage?.pixel_size[0]; const activeImagePixelHeight = activeImage?.pixel_size[1]; const activeImagePixelsPerUnit = activeImage?.pixels_per_unit; @@ -88,6 +106,7 @@ export function PreviewWorkspace({ spaceHeld, tree, viewportRef, + onPreviewTransform: updatePreviewTransform, }); const { onNodePointerDown, @@ -382,6 +401,7 @@ export function PreviewWorkspace({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={canvas.hiddenNodeIds} + previewTransforms={previewTransforms} selectedNodeId={canvas.selectedNodeId} resources={{ previewUrls, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index ad91c0571..806c64e33 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -32,6 +32,7 @@ type UiTreeRendererProps = { renderMode: UiEditorRenderMode; showFrame: boolean; hiddenNodeIds: ReadonlySet; + previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; @@ -77,6 +78,7 @@ function RenderNode({ renderMode, showFrame, hiddenNodeIds, + previewTransforms, selectedNodeId, resources, onSelectNode, @@ -94,14 +96,15 @@ function RenderNode({ isRoot?: boolean; parentContainer?: UiNode['layout']['container']; }) { + const activePreviewTransforms = previewTransforms ?? new Map(); if (hiddenNodeIds.has(node.id)) return null; + const layout = activePreviewTransforms.has(node.id) + ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } + : node.layout; let geometry; try { - geometry = controlLayoutToPreviewCss( - node.layout, - parentContainer !== undefined, - ); + geometry = controlLayoutToPreviewCss(layout, parentContainer !== undefined); } catch { // Keep malformed nodes isolated from the rest of the tree. return null; @@ -120,7 +123,7 @@ function RenderNode({ style={{ ...geometry, ...(parentContainer - ? childInContainerToPreviewCss(node.layout, parentContainer) + ? childInContainerToPreviewCss(layout, parentContainer) : {}), ...containerToPreviewCss(node.layout.container), ...(isFrameVisible @@ -189,6 +192,7 @@ function RenderNode({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={hiddenNodeIds} + previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 1d22c3531..3208dfba8 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -29,6 +29,7 @@ type GestureBase = { startClientY: number; startTransform: UiNode['layout']['transform']; hasMoved: boolean; + pendingTransform?: UiNode['layout']['transform']; }; type ActiveGesture = @@ -85,6 +86,7 @@ export function useNodeTransformInteraction({ spaceHeld, tree, viewportRef, + onPreviewTransform, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -92,14 +94,21 @@ export function useNodeTransformInteraction({ spaceHeld: boolean; tree: UITree | null; viewportRef: RefObject; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; }) { const activeGestureRef = useRef(null); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; - if (gesture) releasePointer(gesture.target, gesture.pointerId); + if (gesture) { + releasePointer(gesture.target, gesture.pointerId); + onPreviewTransform?.(gesture.nodeId, null); + } activeGestureRef.current = null; - }, []); + }, [onPreviewTransform]); useEffect(() => cancelGesture, [cancelGesture]); @@ -185,18 +194,31 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + onPreviewTransform?.(gesture.nodeId, nextTransform); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], ); const onNodePointerUp = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); + const gesture = activeGestureRef.current; + if ( + event.type !== 'pointercancel' && + gesture?.hasMoved && + gesture.pendingTransform + ) { + canvas.updateNodeTransform( + gesture.treeId, + gesture.nodeId, + gesture.pendingTransform, + ); + } cancelGesture(); }, - [acceptsGestureEvent, cancelGesture], + [acceptsGestureEvent, cancelGesture, canvas], ); const onNodeResizePointerDown = useCallback( @@ -310,9 +332,10 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + onPreviewTransform?.(gesture.nodeId, nextTransform); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], ); return { diff --git a/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md new file mode 100644 index 000000000..6988d039d --- /dev/null +++ b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md @@ -0,0 +1,14 @@ +# UI 编辑器拖动变换提交边界 + +## 当前约定 + +UI 编辑器预览中的节点拖动和缩放在指针移动期间只更新预览层的临时变换,不写入编辑器 State。指针松开时才把最后一次有效变换提交到 State,因此一次拖动或缩放只产生一次正式编辑更新。 + +指针取消、页面切换、树切换、组件卸载或没有超过拖动阈值时,不提交变换,并清理临时预览值。指针松开后的最终变换属于正常 State 修改,会参与脏状态、保存和后端持久化;仅拖动期间的临时变换不会进入这些流程。资产文件也不会因该交互被删除。 + +## 实现边界 + +- `useNodeTransformInteraction` 保存手势起始变换和最后一次有效变换。 +- `UiTreeRenderer` 通过 `previewTransforms` 渲染临时变换。 +- `canvas.updateNodeTransform` 仅在 `pointerup` 提交,`pointercancel` 不提交。 +- 拖动和缩放继续共用单指针捕获与有限数校验。