From 3119f72ad25eeb9e8e25a75963b9636a97a59a17 Mon Sep 17 00:00:00 2001 From: yun Date: Mon, 13 Jul 2026 17:55:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/WorkflowCanvas.tsx | 10 +- src/components/canvas/ActiveImageEditHost.tsx | 173 ++++++++---------- src/components/media/MediaCropOverlay.tsx | 11 +- .../media/MediaOutpaintingOverlay.tsx | 11 +- src/utils/nodeDimensions.ts | 14 ++ 5 files changed, 106 insertions(+), 113 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index bedf7dbd..19c92eaa 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -36,6 +36,7 @@ import { EdgeToolbar } from "./EdgeToolbar"; import { NodeDoubleClickZoomProvider } from "./nodes/NodeDoubleClickZoomContext"; import { NodeType, NanoBananaNodeData, HandleType, PromptNodeData, SmartTextNodeData, LLMGenerateNodeData, PromptConstructorNodeData, AvailableVariable, SelectedModel, WorkflowNode } from "@/types"; import { DEFAULT_NODE_DIMENSIONS, defaultNodeDimensions } from "@/store/utils/nodeDefaults"; +import { getWorkflowNodeStyleDimensions } from "@/utils/nodeDimensions"; import { NodeHeaderProvider } from "./nodes/NodeHeaderContext"; import { NodeHandleMenuProvider, type NodeHandleMenuRequest } from "./nodes/NodeHandleMenuContext"; import { ControlPanel } from "./nodes/ControlPanel"; @@ -207,13 +208,6 @@ function getAssetLibraryItemMediaKind(item: UserFileItem): "image" | "video" | " }); } -function getWorkflowNodeStyleDimensions(node: Node): { width: number; height: number } | null { - const width = typeof node.style?.width === "number" ? node.style.width : null; - const height = typeof node.style?.height === "number" ? node.style.height : null; - if (width === null || height === null) return null; - return { width, height }; -} - function getWorkflowNodeDimensions(node: Node): { width: number; height: number } { const dimensions = getWorkflowNodeStyleDimensions(node); if (dimensions) return dimensions; @@ -3094,8 +3088,8 @@ export function WorkflowCanvas() { }} /> )} + - diff --git a/src/components/canvas/ActiveImageEditHost.tsx b/src/components/canvas/ActiveImageEditHost.tsx index 390b3f68..4a46b910 100644 --- a/src/components/canvas/ActiveImageEditHost.tsx +++ b/src/components/canvas/ActiveImageEditHost.tsx @@ -1,6 +1,6 @@ "use client"; -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, type CSSProperties } from "react"; import { useViewport } from "@xyflow/react"; import { ImageEditSession } from "@/components/media/image-edit/ImageEditSession"; import type { ImageEditTool } from "@/components/media/image-edit/types"; @@ -10,6 +10,8 @@ import { useNodeToolStore } from "@/store/nodeToolStore"; import { useWorkflowStore } from "@/store/workflowStore"; import { useModelStore } from "@/store/modelStore"; import { getNodeImageSource } from "@/utils/getNodeImageSource"; +import { getWorkflowNodeStyleDimensions } from "@/utils/nodeDimensions"; +import type { WorkflowNode } from "@/types"; const IMAGE_EDIT_TOOLS: ReadonlySet = new Set([ "crop", @@ -18,13 +20,6 @@ const IMAGE_EDIT_TOOLS: ReadonlySet = new Set([ "splitGrid", ]); -interface ScreenRect { - left: number; - top: number; - width: number; - height: number; -} - function asImageDimensions(value: unknown): { width: number; height: number } | null { if (!value || typeof value !== "object") return null; const dimensions = value as { width?: unknown; height?: unknown }; @@ -33,64 +28,42 @@ function asImageDimensions(value: unknown): { width: number; height: number } | : null; } -function rectsEqual(a: ScreenRect | null, b: ScreenRect | null): boolean { - if (a === b) return true; - if (!a || !b) return false; - return a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height; -} - /** - * 追踪一个 DOM 元素在屏幕(viewport)坐标系中的实时矩形。 - * 因为宿主用 position:fixed 定位,getBoundingClientRect 的坐标可直接使用。 - * 触发重测的信号:画布平移/缩放(viewport)、节点拖动(外部 revision)、 - * 元素自身尺寸变化(ResizeObserver)、窗口 resize/scroll。 + * 多视角浮动面板:挂在媒体框下沿中点。宿主已随 ViewportPortal 一起被父级 + * transform 施加缩放,若不处理面板会随 zoom 放大;此处 scale(1/zoom) 反向 + * 抵消,让面板在任意缩放下保持恒定视觉尺寸,锚点仍贴节点下沿。 */ -function useElementScreenRect( - element: HTMLElement | null, - revision: number -): ScreenRect | null { - const [rect, setRect] = useState(null); - const { x, y, zoom } = useViewport(); - - useLayoutEffect(() => { - if (!element) { - setRect(null); - return; - } - - const measure = () => { - const domRect = element.getBoundingClientRect(); - const next: ScreenRect = { - left: domRect.left, - top: domRect.top, - width: domRect.width, - height: domRect.height, - }; - setRect((prev) => (rectsEqual(prev, next) ? prev : next)); - }; - - measure(); - - const resizeObserver = new ResizeObserver(measure); - resizeObserver.observe(element); - window.addEventListener("resize", measure); - window.addEventListener("scroll", measure, true); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", measure); - window.removeEventListener("scroll", measure, true); - }; - // Re-measure whenever the element, the canvas viewport, or the node position changes. - }, [element, x, y, zoom, revision]); - - return rect; +function MultiAnglePanelAnchor({ + node, + imageSource, + onClose, +}: { + node: WorkflowNode; + imageSource: string; + onClose: () => void; +}) { + const { zoom } = useViewport(); + return ( +
+ +
+ ); } /** * 画布级单例宿主:读取全局 nodeToolStore,为当前激活的图片编辑工具 - * (裁剪 / 扩图 / 擦除 / 多宫格)渲染唯一一份编辑 UI,定位在目标节点 - * 媒体框的屏幕矩形之上。节点本身不再渲染这些工具的 overlay。 + * (裁剪 / 扩图 / 擦除 / 多宫格)渲染唯一一份编辑 UI。节点本身不再渲染 + * 这些工具的 overlay。 + * + * 宿主渲染在 内部,因此使用 flow 坐标(node.position)+ + * 节点样式尺寸定位,缩放/平移由 portal 的父级 transform 统一施加,工具与 + * 图片天然锁步、零留影,无需逐帧 getBoundingClientRect 重测。 */ export function ActiveImageEditHost() { const activeNodeId = useNodeToolStore((state) => state.activeNodeId); @@ -105,6 +78,31 @@ export function ActiveImageEditHost() { const node = useWorkflowStore((state) => targetNodeId ? state.nodes.find((candidate) => candidate.id === targetNodeId) ?? null : null ); + // 组内子节点的 position 是相对父组的偏移;宿主在 ViewportPortal 里是 portal 的 + // 直接子元素、不在组 DOM 内,需沿 parentId 累加父组坐标才是画布绝对位置。 + // 根节点无 parentId,偏移为 0。只读父链坐标(深度 1-2),不建 Map、不订阅全量。 + const parentOffsetX = useWorkflowStore((state) => { + let x = 0; + let parentId = node?.parentId ?? null; + while (parentId) { + const parent = state.nodes.find((candidate) => candidate.id === parentId); + if (!parent) break; + x += parent.position.x; + parentId = parent.parentId ?? null; + } + return x; + }); + const parentOffsetY = useWorkflowStore((state) => { + let y = 0; + let parentId = node?.parentId ?? null; + while (parentId) { + const parent = state.nodes.find((candidate) => candidate.id === parentId); + if (!parent) break; + y += parent.position.y; + parentId = parent.parentId ?? null; + } + return y; + }); const outpaintingModel = useModelStore((state) => state.byKind.outpainting[0] ?? null); const inpaintingModel = useModelStore((state) => state.byKind.inpainting[0] ?? null); @@ -116,13 +114,9 @@ export function ActiveImageEditHost() { const imageElRef = useRef(null); imageElRef.current = imageEl; - // 节点位置作为重测信号:拖动时 position 变化触发 useElementScreenRect 重算。 - const position = node?.position; - const positionRevision = position ? position.x * 100000 + position.y : 0; - const rect = useElementScreenRect(mediaFrameEl, positionRevision); - const imageSource = getNodeImageSource(node); const dimensions = useMemo(() => asImageDimensions(node?.data?.dimensions), [node?.data?.dimensions]); + const styleDims = node ? getWorkflowNodeStyleDimensions(node) : null; // 活动节点已被删除:清理悬空的工具状态。 useLayoutEffect(() => { @@ -131,32 +125,25 @@ export function ActiveImageEditHost() { } }, [closeTool, node, targetNodeId]); - if (!node || !imageSource || !mediaFrameEl || !rect) { + if (!node || !imageSource || !mediaFrameEl || !styleDims) { return null; } - // 多视角:节点下方的浮动面板(屏幕坐标系,恒定尺寸,不随缩放变化)。 + // flow 坐标系定位:媒体框在图片显示态铺满节点(w-full h-full、无 header), + // 故节点绝对位置 + 节点样式尺寸即媒体框在画布坐标中的框,无需额外偏移。 + const frameStyle: CSSProperties = { + position: "absolute", + transform: `translate(${node.position.x + parentOffsetX}px, ${node.position.y + parentOffsetY}px)`, + width: styleDims.width, + height: styleDims.height, + zIndex: 10020, + pointerEvents: "none", + }; + if (isMultiAngle) { return ( -
-
- -
+
+
); } @@ -166,19 +153,7 @@ export function ActiveImageEditHost() { } return ( -
+
{ - const rect = overlayRef.current?.getBoundingClientRect(); - if (!rect) return { x: 0, y: 0 }; - return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const el = overlayRef.current; + const rect = el?.getBoundingClientRect(); + if (!rect || !el) return { x: 0, y: 0 }; + // rect 是缩放后的屏幕尺寸,clientWidth 是未缩放的布局尺寸,二者比值即当前 + // 有效缩放。除以它把指针位移还原到 overlay 自身坐标系(缩放无关)。 + const scaleX = el.clientWidth > 0 ? rect.width / el.clientWidth : 1; + const scaleY = el.clientHeight > 0 ? rect.height / el.clientHeight : 1; + return { x: (event.clientX - rect.left) / scaleX, y: (event.clientY - rect.top) / scaleY }; }, []); const handlePointerDown = useCallback((event: React.PointerEvent, mode: DragMode) => { diff --git a/src/components/media/MediaOutpaintingOverlay.tsx b/src/components/media/MediaOutpaintingOverlay.tsx index 55748789..a3e273db 100644 --- a/src/components/media/MediaOutpaintingOverlay.tsx +++ b/src/components/media/MediaOutpaintingOverlay.tsx @@ -170,9 +170,14 @@ export function MediaOutpaintingOverlay({ }, [measureBounds]); const pointerToOverlay = useCallback((event: PointerEvent | React.PointerEvent) => { - const rect = overlayRef.current?.getBoundingClientRect(); - if (!rect) return { x: 0, y: 0 }; - return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const el = overlayRef.current; + const rect = el?.getBoundingClientRect(); + if (!rect || !el) return { x: 0, y: 0 }; + // rect 是缩放后的屏幕尺寸,clientWidth 是未缩放的布局尺寸,二者比值即当前 + // 有效缩放。除以它把指针位移还原到 overlay 自身坐标系(缩放无关)。 + const scaleX = el.clientWidth > 0 ? rect.width / el.clientWidth : 1; + const scaleY = el.clientHeight > 0 ? rect.height / el.clientHeight : 1; + return { x: (event.clientX - rect.left) / scaleX, y: (event.clientY - rect.top) / scaleY }; }, []); const handlePointerDown = useCallback((event: React.PointerEvent, mode: DragMode) => { diff --git a/src/utils/nodeDimensions.ts b/src/utils/nodeDimensions.ts index 6f96cdb9..69b91b6c 100644 --- a/src/utils/nodeDimensions.ts +++ b/src/utils/nodeDimensions.ts @@ -380,3 +380,17 @@ export function calculateNodeSizeForFullBleed( height: Math.round(height), }; } + +/** + * 节点尺寸真相源:node.style.{width,height}(由 workflowStore / canvasAutoLayout 写入)。 + * 仅在两者都是数字时返回,否则返回 null(调用方回退到 defaultNodeDimensions)。 + * 抽到 utils 供 WorkflowCanvas 与画布级工具宿主共用,避免二者互相 import 形成循环依赖。 + */ +export function getWorkflowNodeStyleDimensions(node: { + style?: { width?: unknown; height?: unknown } | null; +}): { width: number; height: number } | null { + const width = typeof node.style?.width === "number" ? node.style.width : null; + const height = typeof node.style?.height === "number" ? node.style.height : null; + if (width === null || height === null) return null; + return { width, height }; +}