Browse Source

工具优化

feature/handleReconstruction
yun 5 hours ago
parent
commit
3119f72ad2
  1. 10
      src/components/WorkflowCanvas.tsx
  2. 173
      src/components/canvas/ActiveImageEditHost.tsx
  3. 11
      src/components/media/MediaCropOverlay.tsx
  4. 11
      src/components/media/MediaOutpaintingOverlay.tsx
  5. 14
      src/utils/nodeDimensions.ts

10
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() {
}}
/>
)}
<ActiveImageEditHost />
</ViewportPortal>
<ActiveImageEditHost />
</ReactFlow>
</NodeDoubleClickZoomProvider>
</NodeHeaderProvider>

173
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<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}

11
src/components/media/MediaCropOverlay.tsx

@ -303,9 +303,14 @@ export function MediaCropOverlay({
}, [currentAspect, 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) => {

11
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) => {

14
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 };
}

Loading…
Cancel
Save