From ec94a4fe004e99be2890ff0ad1ba2c872725f638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 14 Sep 2026 18:05:50 +0800 Subject: [PATCH] =?UTF-8?q?UI=E7=BC=96=E8=BE=91=E5=99=A8=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=A4=9A=E6=A0=91=E9=A2=84=E8=A7=88=E4=B8=8E=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预览画布同时渲染全部界面树并支持树级拖动 拆分显示框线、原图和组件开关 预览节点选择与左侧当前图片解耦 --- .../ui-editor/components/InputSidebar.tsx | 4 +- .../components/preview/PreviewWorkspace.tsx | 264 ++++++++++++------ .../components/preview/UiTreeRenderer.tsx | 15 +- .../src/view/ui-editor/index.tsx | 58 ++-- .../src/view/ui-editor/useUiEditorPage.ts | 80 ++++-- 5 files changed, 264 insertions(+), 157 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index b84ca5252..6c2b83cf5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -90,10 +90,8 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { // programmatic notification as a fresh user selection would clear // the highlight before it can be painted. Only mutate selection // state when the target actually differs from the current one. - const sameImage = input.activeImageId === treeId; const sameNode = input.selectedNodeId === nodeId; - if (!sameImage) input.selectDesignImage(treeId); - if (!sameImage || !sameNode) input.selectNode(nodeId); + if (!sameNode) input.selectNode(nodeId); }} onToggleNodeVisibility={(nodeId) => input.toggleNodePreviewVisibility(nodeId) 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 6bd4c674a..91c0c24a1 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 @@ -38,7 +38,7 @@ import { isPreviewZoomInteractiveTarget, previewZoomUsesMetaModifier, } from './previewZoomKeyboard'; -import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; +import { UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; /** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */ @@ -57,29 +57,34 @@ function fitEditorViewport( }; } -export type { UiEditorRenderMode } from './UiTreeRenderer'; - export function PreviewWorkspace({ canvas, - renderMode: controlledRenderMode, - onRenderModeChange: _onRenderModeChange, - showFrame: controlledShowFrame, - onShowFrameChange: _onShowFrameChange, + showFrame, + showOriginImage, + showComponent, }: { canvas: UiEditorCanvasProjection; - renderMode?: UiEditorRenderMode; - onRenderModeChange?: (mode: UiEditorRenderMode) => void; - showFrame?: boolean; - onShowFrameChange?: (show: boolean) => void; + showFrame: boolean; + showOriginImage: boolean; + showComponent: boolean; }) { - const { activeImage, activeImageId, previewUrls } = canvas; - const activePreviewUrl = activeImageId - ? previewUrls[activeImageId] - : undefined; + const { activeImage, activeImageId, previewUrls, uiTrees, images } = canvas; + const tree = canvas.tree ?? null; const viewportElementRef = useRef(null); const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); + const didInitialFitRef = useRef(false); const panRef = useRef | null>(null); + const treeDragRef = useRef<{ + treeId: string; + pointerId: number; + startX: number; + startY: number; + origin: [number, number]; + } | null>(null); + const [previewTreeOffsets, setPreviewTreeOffsets] = useState< + ReadonlyMap + >(new Map()); const previewFocusedRef = useRef(false); const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( @@ -88,8 +93,6 @@ export function PreviewWorkspace({ const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const canvasSizeRef = useRef(canvasSize); const [spaceHeld, setSpaceHeld] = useState(false); - const renderMode = controlledRenderMode ?? 'editor-overlay'; - const showFrame = controlledShowFrame ?? false; const [previewTransforms, setPreviewTransforms] = useState< ReadonlyMap >(new Map()); @@ -99,7 +102,6 @@ export function PreviewWorkspace({ y: number; isPageRoot: boolean; } | null>(null); - const tree = canvas.tree ?? null; const updatePreviewTransform = useCallback( (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { setPreviewTransforms((current) => { @@ -141,6 +143,38 @@ export function PreviewWorkspace({ } return { width, height }; }, [activeImagePixelHeight, activeImagePixelsPerUnit, activeImagePixelWidth]); + const treeLayouts = useMemo( + () => + uiTrees.map((item) => { + const image = images[item.src_ui_design]; + const ppu = image?.pixels_per_unit ?? 0; + const width = image ? image.pixel_size[0] / ppu : 0; + const height = image ? image.pixel_size[1] / ppu : 0; + return { + tree: item, + image, + width, + height, + validOffset: Boolean(item.root.offset?.min), + }; + }), + [images, uiTrees], + ); + const allBounds = useMemo(() => { + const valid = treeLayouts.filter( + (item) => item.validOffset && item.width > 0 && item.height > 0, + ); + if (valid.length === 0) return null; + const minX = Math.min(...valid.map(({ tree }) => tree.root.offset.min[0])); + const minY = Math.min(...valid.map(({ tree }) => tree.root.offset.min[1])); + const maxX = Math.max( + ...valid.map(({ tree, width }) => tree.root.offset.min[0] + width), + ); + const maxY = Math.max( + ...valid.map(({ tree, height }) => tree.root.offset.min[1] + height), + ); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [treeLayouts]); const nodeInteractions = useNodeTransformInteraction({ activeImageId, @@ -172,40 +206,27 @@ export function PreviewWorkspace({ }, []); const fitToCanvas = useCallback(() => { - if (!logicalSize) return; + if (!allBounds) return; const element = viewportElementRef.current; setViewport( - fitEditorViewport( - { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height }, - { - width: element?.clientWidth || canvasSizeRef.current.width, - height: element?.clientHeight || canvasSizeRef.current.height, - }, - ), + fitEditorViewport(allBounds, { + width: element?.clientWidth || canvasSizeRef.current.width, + height: element?.clientHeight || canvasSizeRef.current.height, + }), ); - }, [logicalSize, setViewport]); + }, [allBounds, setViewport]); useEffect(() => { - if (!activeImageId || !logicalSize) return; + if (!allBounds) return; + if (didInitialFitRef.current) return; + didInitialFitRef.current = true; const element = viewportElementRef.current; const canvasSize = { width: element?.clientWidth || canvasSizeRef.current.width, height: element?.clientHeight || canvasSizeRef.current.height, }; - setViewport( - fitEditorViewport( - { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height }, - canvasSize, - ), - ); - }, [ - activeImageId, - activePreviewUrl, - canvasSize.height, - canvasSize.width, - logicalSize, - setViewport, - ]); + setViewport(fitEditorViewport(allBounds, canvasSize)); + }, [allBounds, canvasSize.height, canvasSize.width, setViewport]); const scaleViewportFromCenter = useCallback( (nextScale: number) => { @@ -389,6 +410,53 @@ export function PreviewWorkspace({ } }; + const handleTreePointerDown = ( + event: ReactPointerEvent, + treeId: string, + rootId: string, + origin: [number, number], + ) => { + if (event.button !== 0) return; + const hit = (event.target as Element).closest('[data-node-id]'); + if (!hit || hit.getAttribute('data-node-id') !== rootId) return; + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + treeDragRef.current = { + treeId, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + origin, + }; + }; + const handleTreePointerMove = (event: ReactPointerEvent) => { + const drag = treeDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const next: [number, number] = [ + drag.origin[0] + + (event.clientX - drag.startX) / viewportRef.current.scale, + drag.origin[1] + + (event.clientY - drag.startY) / viewportRef.current.scale, + ]; + setPreviewTreeOffsets((current) => new Map(current).set(drag.treeId, next)); + }; + const handleTreePointerUp = (event: ReactPointerEvent) => { + if (treeDragRef.current?.pointerId !== event.pointerId) return; + const drag = treeDragRef.current; + if (drag) { + const next = previewTreeOffsets.get(drag.treeId); + if (next) canvas.setTreeOffset(drag.treeId, next); + setPreviewTreeOffsets((current) => { + const result = new Map(current); + result.delete(drag.treeId); + return result; + }); + } + treeDragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId); + }; + const handleWheel = (event: React.WheelEvent) => { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); @@ -421,15 +489,9 @@ export function PreviewWorkspace({ }); }; - useEffect(() => { - if (renderMode !== 'editor-overlay') setContextMenu(null); - }, [renderMode]); - return (
- {activeImage && - logicalSize && - (renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? ( + {treeLayouts.length > 0 && allBounds ? (
-
- {renderMode === 'editor-overlay' ? ( - {activeImage.metadata.name} - ) : ( -
- )} - -
+ {treeLayouts + .filter((item) => item.validOffset) + .map(({ tree: item, image, width, height }) => { + const offset = + previewTreeOffsets.get(item.src_ui_design) ?? + item.root.offset.min; + return ( +
+ handleTreePointerDown( + event, + item.src_ui_design, + item.root.id, + offset, + ) + } + onPointerMove={handleTreePointerMove} + onPointerUp={handleTreePointerUp} + onPointerCancel={handleTreePointerUp} + > + {showOriginImage && previewUrls[item.src_ui_design] ? ( + {image?.metadata.name + ) : null} + +
+ ); + })} {contextMenu ? ( 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 c4ca88a09..cef8c3a01 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 @@ -29,8 +29,8 @@ type NodePointerDown = ( type UiTreeRendererProps = { tree: UITree | null; - renderMode: UiEditorRenderMode; showFrame: boolean; + showComponent: boolean; hiddenNodeIds: ReadonlySet; previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; @@ -83,8 +83,8 @@ function RenderNode({ node, isRoot, parentContainer, - renderMode, showFrame, + showComponent, hiddenNodeIds, previewTransforms, selectedNodeId, @@ -124,8 +124,8 @@ function RenderNode({ return null; } - const isEditorOverlay = renderMode === 'editor-overlay'; - const isFrameVisible = isEditorOverlay || showFrame; + const isSelected = selectedNodeId === node.id; + const isFrameVisible = showFrame || isSelected; const receivesPointerGesture = parentContainer === undefined; const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const exclusiveVisibleChildId = @@ -170,7 +170,6 @@ function RenderNode({ } } onContextMenu={(event) => { - if (!isEditorOverlay) return; event.preventDefault(); event.stopPropagation(); onSelectNode(node.id); @@ -191,10 +190,10 @@ function RenderNode({ {node.metadata.name} ) : null} - {node.component ? ( + {showComponent && node.component ? ( ) : null} - {renderMode === 'final-preview' && selectedNodeId === node.id ? ( + {isSelected ? ( nodeId === exclusiveVisibleChildId} @@ -212,8 +211,8 @@ function RenderNode({ ? node.layout.container : undefined } - renderMode={renderMode} showFrame={showFrame} + showComponent={showComponent} hiddenNodeIds={hiddenNodeIds} previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} 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 c40179b7e..ff8b9f87a 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 @@ -12,10 +12,7 @@ import { EditorDialogs } from './components/EditorDialogs'; import { ImportOverview } from './components/ImportOverview'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; -import { - PreviewWorkspace, - type UiEditorRenderMode, -} from './components/preview/PreviewWorkspace'; +import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; import { SeparationOverview } from './components/SeparationOverview'; import { ToolNavigation } from './components/ToolNavigation'; @@ -75,9 +72,9 @@ export default function UiEditorPage({ const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); - const [renderMode, setRenderMode] = - useState('editor-overlay'); - const [showFrame, setShowFrame] = useState(false); + const [showFrame, setShowFrame] = useState(true); + const [showOriginImage, setShowOriginImage] = useState(true); + const [showComponent, setShowComponent] = useState(false); const overview = session.input.activeStep === 'structure-recognition' ? ( {resourceLabel ?? 'UI 设计'}
-
- - -
- {renderMode === 'final-preview' ? ( -