|
|
|
@ -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<string> = new Set([ |
|
|
|
"crop", |
|
|
|
@ -18,13 +20,6 @@ const IMAGE_EDIT_TOOLS: ReadonlySet<string> = 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<ScreenRect | null>(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 ( |
|
|
|
<div |
|
|
|
className="pointer-events-auto absolute left-1/2 top-full" |
|
|
|
style={{ |
|
|
|
transform: `translateX(-50%) translateY(12px) scale(${1 / zoom})`, |
|
|
|
transformOrigin: "top center", |
|
|
|
}} |
|
|
|
> |
|
|
|
<NodeMultiAnglePanel node={node} imageSource={imageSource} onClose={onClose} /> |
|
|
|
</div> |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 画布级单例宿主:读取全局 nodeToolStore,为当前激活的图片编辑工具 |
|
|
|
* (裁剪 / 扩图 / 擦除 / 多宫格)渲染唯一一份编辑 UI,定位在目标节点 |
|
|
|
* 媒体框的屏幕矩形之上。节点本身不再渲染这些工具的 overlay。 |
|
|
|
* (裁剪 / 扩图 / 擦除 / 多宫格)渲染唯一一份编辑 UI。节点本身不再渲染 |
|
|
|
* 这些工具的 overlay。 |
|
|
|
* |
|
|
|
* 宿主渲染在 <ViewportPortal> 内部,因此使用 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<HTMLImageElement | null>(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 ( |
|
|
|
<div |
|
|
|
data-active-multi-angle-host |
|
|
|
className="nodrag nopan nowheel" |
|
|
|
style={{ |
|
|
|
position: "fixed", |
|
|
|
left: rect.left, |
|
|
|
top: rect.top, |
|
|
|
width: rect.width, |
|
|
|
height: rect.height, |
|
|
|
zIndex: 10020, |
|
|
|
pointerEvents: "none", |
|
|
|
}} |
|
|
|
> |
|
|
|
<div |
|
|
|
className="pointer-events-auto absolute left-1/2 top-full" |
|
|
|
style={{ transform: "translateX(-50%) translateY(12px)" }} |
|
|
|
> |
|
|
|
<NodeMultiAnglePanel node={node} imageSource={imageSource} onClose={closeTool} /> |
|
|
|
</div> |
|
|
|
<div data-active-multi-angle-host className="nodrag nopan nowheel" style={frameStyle}> |
|
|
|
<MultiAnglePanelAnchor node={node} imageSource={imageSource} onClose={closeTool} /> |
|
|
|
</div> |
|
|
|
); |
|
|
|
} |
|
|
|
@ -166,19 +153,7 @@ export function ActiveImageEditHost() { |
|
|
|
} |
|
|
|
|
|
|
|
return ( |
|
|
|
<div |
|
|
|
data-active-image-edit-host |
|
|
|
className="nodrag nopan nowheel" |
|
|
|
style={{ |
|
|
|
position: "fixed", |
|
|
|
left: rect.left, |
|
|
|
top: rect.top, |
|
|
|
width: rect.width, |
|
|
|
height: rect.height, |
|
|
|
zIndex: 10020, |
|
|
|
pointerEvents: "none", |
|
|
|
}} |
|
|
|
> |
|
|
|
<div data-active-image-edit-host className="nodrag nopan nowheel" style={frameStyle}> |
|
|
|
<div className="pointer-events-auto absolute inset-0"> |
|
|
|
<ImageEditSession |
|
|
|
tool={activeTool as ImageEditTool} |
|
|
|
|