ai-chatbot/components/canvas/CanvasWorkspace.tsx
Your Name be5b9aef12
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
chore: sync latest workspace changes
2026-04-05 02:08:26 +08:00

1604 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useRef, useState, useCallback, useMemo } from "react";
import { Rnd } from "react-rnd";
import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode";
import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode";
import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { World3DNode } from "./nodes/World3DNode";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas";
import { Download, GitBranch, Minus, Plus } from "lucide-react";
// 性能优化:判断节点是否在视口内
function isNodeInViewport(
node: CanvasNode,
viewport: { x: number; y: number; zoom: number },
containerSize: { width: number; height: number }
): boolean {
const nodeScreenX = node.position.x * viewport.zoom + viewport.x;
const nodeScreenY = node.position.y * viewport.zoom + viewport.y;
const nodeScreenWidth = node.size.width * viewport.zoom;
const nodeScreenHeight = node.size.height * viewport.zoom;
// 添加缓冲区(200px),使即将进入视口的节点也被渲染
const buffer = 200;
return (
nodeScreenX + nodeScreenWidth > -buffer &&
nodeScreenX < containerSize.width + buffer &&
nodeScreenY + nodeScreenHeight > -buffer &&
nodeScreenY < containerSize.height + buffer
);
}
interface CanvasWorkspaceProps {
occlusionRight?: number;
workspaceLabel?: string;
emptyStateDescription?: string;
workspaceVariant?: "canvas" | "video";
}
export function CanvasWorkspace({
occlusionRight = 0,
workspaceLabel = "画布",
emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas",
}: CanvasWorkspaceProps) {
const SNAP_THRESHOLD = 10;
const SNAP_GAP = 12;
const SELECTION_COLOR = "#27a8ff";
const SELECTION_STROKE_WIDTH = 2;
const IMAGE_HANDLE_SIZE = 9.6;
const IMAGE_HANDLE_OFFSET = IMAGE_HANDLE_SIZE / 2;
const SNAP_GUIDE_COLOR = "#ff8a3d";
const WORKFLOW_EDGE_COLOR = "#94a3b8";
const WORKFLOW_EDGE_ACTIVE_COLOR = "#334155";
const WORKFLOW_HANDLE_SIZE = 16;
const isWorkflowCanvas = workspaceVariant === "video";
const overlayPanelClassName = isWorkflowCanvas
? "rounded-full border border-border/60 bg-background/92 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
: "bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground";
const zoomOverlayClassName = isWorkflowCanvas
? "absolute bottom-4 right-4 rounded-full border border-border/60 bg-background/92 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
: "absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50";
type SnapGuideLines = {
vertical?: { x: number; top: number; bottom: number };
horizontal?: { y: number; left: number; right: number };
};
const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
const [marqueeStart, setMarqueeStart] = useState<Position | null>(null);
const [marqueeEnd, setMarqueeEnd] = useState<Position | null>(null);
const [isFileDragActive, setIsFileDragActive] = useState(false);
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [connectionDraft, setConnectionDraft] = useState<{
sourceId: string;
current: Position;
} | null>(null);
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
const dragCounterRef = useRef(0);
const dragPointerRef = useRef<Position>({ x: 0, y: 0 });
const suppressCanvasClickRef = useRef(false);
const lastBlankCanvasClickAtRef = useRef(0);
const groupDragRef = useRef<{
nodeId: string;
startX: number;
startY: number;
originPositions: Record<string, Position>;
} | null>(null);
// Store state
const nodes = useCanvasStore(canvasSelectors.nodes);
const edges = useCanvasStore(canvasSelectors.edges);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
// 性能优化:监听容器大小变化
React.useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setContainerSize({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
});
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
// macOS 触控板横向双指滑动会触发浏览器前进/后退,必须使用非 passive 监听阻止默认行为
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventTrackpadHistorySwipe = (event: WheelEvent) => {
if (event.cancelable) {
event.preventDefault();
}
};
container.addEventListener("wheel", preventTrackpadHistorySwipe, { passive: false });
return () => {
container.removeEventListener("wheel", preventTrackpadHistorySwipe);
};
}, []);
const isFileDrag = useCallback((dataTransfer?: DataTransfer | null) => {
if (!dataTransfer) return false;
const types = Array.from(dataTransfer.types || []);
if (
types.includes("Files") ||
types.includes("application/x-moz-file") ||
types.includes("public.file-url")
) {
return true;
}
const items = dataTransfer.items;
if (items && Array.from(items).some((item) => item.kind === "file")) {
return true;
}
return false;
}, []);
// 防止浏览器默认打开文件
React.useEffect(() => {
const handleWindowDragOver = (e: DragEvent) => {
if (isFileDrag(e.dataTransfer)) {
e.preventDefault();
}
};
const handleWindowDrop = (e: DragEvent) => {
if (isFileDrag(e.dataTransfer)) {
e.preventDefault();
}
};
window.addEventListener("dragover", handleWindowDragOver);
window.addEventListener("drop", handleWindowDrop);
return () => {
window.removeEventListener("dragover", handleWindowDragOver);
window.removeEventListener("drop", handleWindowDrop);
};
}, [isFileDrag]);
// 性能优化:只渲染视口内的节点(虚拟化)
const visibleNodes = useMemo(() => {
// 如果节点数量少于 50,直接渲染所有节点
if (nodes.length < 50) return nodes;
return nodes.filter(node =>
isNodeInViewport(node, viewport, containerSize) ||
selectedNodeIds.includes(node.id) // 选中的节点总是渲染
);
}, [nodes, viewport, containerSize, selectedNodeIds]);
const selectedImageNodes = useMemo(
() =>
nodes.filter(
(node) =>
selectedNodeIds.includes(node.id) &&
node.type === "image" &&
Boolean((node.data as any)?.src)
),
[nodes, selectedNodeIds]
);
// Store actions
const setViewport = useCanvasStore((state) => state.setViewport);
const selectNode = useCanvasStore((state) => state.selectNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const addNode = useCanvasStore((state) => state.addNode);
const addEdge = useCanvasStore((state) => state.addEdge);
const deleteEdge = useCanvasStore((state) => state.deleteEdge);
const moveNode = useCanvasStore((state) => state.moveNode);
const resizeNode = useCanvasStore((state) => state.resizeNode);
const pushHistory = useCanvasStore((state) => state.pushHistory);
const updateNode = useCanvasStore((state) => state.updateNode);
const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId);
const centerTimeoutRef = useRef<number | null>(null);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === "undefined") return false;
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
}, []);
const hasBlockingDialogOpen = useCallback(() => {
if (typeof document === "undefined") return false;
return Boolean(document.querySelector('[role="dialog"][data-state="open"]'));
}, []);
const getSnapPreview = useCallback(
(
node: CanvasNode,
target: Position,
ignoreNodeIds: Set<string> = new Set(),
): { position: Position; guides: SnapGuideLines | null } => {
if (node.type !== "image") {
return { position: target, guides: null };
}
const candidates = nodes.filter(
(item) =>
item.type === "image" &&
item.id !== node.id &&
!ignoreNodeIds.has(item.id),
);
if (candidates.length === 0) {
return { position: target, guides: null };
}
let bestMatchX: {
offset: number;
delta: number;
guide?: SnapGuideLines["vertical"];
} = {
offset: 0,
delta: Number.POSITIVE_INFINITY,
};
let bestMatchY: {
offset: number;
delta: number;
guide?: SnapGuideLines["horizontal"];
} = {
offset: 0,
delta: Number.POSITIVE_INFINITY,
};
const selfLeft = target.x;
const selfCenterX = target.x + node.size.width / 2;
const selfRight = target.x + node.size.width;
const selfTop = target.y;
const selfCenterY = target.y + node.size.height / 2;
const selfBottom = target.y + node.size.height;
const pickBestX = (delta: number, guide?: SnapGuideLines["vertical"]) => {
const abs = Math.abs(delta);
if (abs <= SNAP_THRESHOLD && abs < bestMatchX.delta) {
bestMatchX = {
offset: delta,
delta: abs,
guide,
};
}
};
const pickBestY = (delta: number, guide?: SnapGuideLines["horizontal"]) => {
const abs = Math.abs(delta);
if (abs <= SNAP_THRESHOLD && abs < bestMatchY.delta) {
bestMatchY = {
offset: delta,
delta: abs,
guide,
};
}
};
for (const candidate of candidates) {
const targetLeft = candidate.position.x;
const targetCenterX = candidate.position.x + candidate.size.width / 2;
const targetRight = candidate.position.x + candidate.size.width;
const targetTop = candidate.position.y;
const targetCenterY = candidate.position.y + candidate.size.height / 2;
const targetBottom = candidate.position.y + candidate.size.height;
pickBestX(targetLeft - selfLeft, {
x: targetLeft,
top: Math.min(selfTop, targetTop),
bottom: Math.max(selfBottom, targetBottom),
});
pickBestX(targetCenterX - selfCenterX, {
x: targetCenterX,
top: Math.min(selfTop, targetTop),
bottom: Math.max(selfBottom, targetBottom),
});
pickBestX(targetRight - selfRight, {
x: targetRight,
top: Math.min(selfTop, targetTop),
bottom: Math.max(selfBottom, targetBottom),
});
pickBestX(targetRight + SNAP_GAP - selfLeft);
pickBestX(targetLeft - SNAP_GAP - selfRight);
pickBestY(targetTop - selfTop, {
y: targetTop,
left: Math.min(selfLeft, targetLeft),
right: Math.max(selfRight, targetRight),
});
pickBestY(targetCenterY - selfCenterY, {
y: targetCenterY,
left: Math.min(selfLeft, targetLeft),
right: Math.max(selfRight, targetRight),
});
pickBestY(targetBottom - selfBottom, {
y: targetBottom,
left: Math.min(selfLeft, targetLeft),
right: Math.max(selfRight, targetRight),
});
pickBestY(targetBottom + SNAP_GAP - selfTop);
pickBestY(targetTop - SNAP_GAP - selfBottom);
}
const snappedPosition = {
x: target.x + (Number.isFinite(bestMatchX.delta) ? bestMatchX.offset : 0),
y: target.y + (Number.isFinite(bestMatchY.delta) ? bestMatchY.offset : 0),
};
return {
position: snappedPosition,
guides: {
vertical: bestMatchX.guide,
horizontal: bestMatchY.guide,
},
};
},
[nodes, SNAP_GAP],
);
const getSnappedCanvasPosition = useCallback(
(
node: CanvasNode,
target: Position,
ignoreNodeIds: Set<string> = new Set(),
): Position => {
return getSnapPreview(node, target, ignoreNodeIds).position;
},
[getSnapPreview],
);
const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
}, []);
const isPointOnNodeElement = useCallback((target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
return !!target.closest("[data-canvas-node-id], [data-canvas-overlay]");
}, []);
const isEventFromCanvasContainer = useCallback((target: EventTarget | null) => {
if (!(target instanceof Node) || !containerRef.current) return false;
return containerRef.current.contains(target);
}, []);
const clearBrowserTextSelection = useCallback(() => {
if (typeof window === "undefined") return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
selection.removeAllRanges();
}, []);
const fitAllImageNodesIntoViewport = useCallback(() => {
const imageNodes = nodes.filter(
(node) => node.type === "image" && Boolean((node.data as any)?.src)
);
if (imageNodes.length === 0) return;
if (containerSize.width <= 0 || containerSize.height <= 0) return;
const padding = 80;
const minX = Math.min(...imageNodes.map((node) => node.position.x));
const minY = Math.min(...imageNodes.map((node) => node.position.y));
const maxX = Math.max(
...imageNodes.map((node) => node.position.x + node.size.width)
);
const maxY = Math.max(
...imageNodes.map((node) => node.position.y + node.size.height)
);
const contentWidth = Math.max(1, maxX - minX + padding * 2);
const contentHeight = Math.max(1, maxY - minY + padding * 2);
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
const fitZoomX = visibleWidth / contentWidth;
const fitZoomY = containerSize.height / contentHeight;
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const visibleCenterX = visibleWidth / 2;
const nextViewportX = visibleCenterX - centerX * targetZoom;
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
setViewport({
x: nextViewportX,
y: nextViewportY,
zoom: targetZoom,
});
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({
x: node.position.x,
y: node.position.y + node.size.height / 2,
}),
[]
);
const getNodeOutputAnchor = useCallback(
(node: CanvasNode): Position => ({
x: node.position.x + node.size.width,
y: node.position.y + node.size.height / 2,
}),
[]
);
const toScreenPosition = useCallback(
(point: Position): Position => ({
x: point.x * viewport.zoom + viewport.x,
y: point.y * viewport.zoom + viewport.y,
}),
[viewport.x, viewport.y, viewport.zoom]
);
const buildWorkflowPath = useCallback((source: Position, target: Position) => {
const deltaX = Math.abs(target.x - source.x);
const controlOffset = Math.max(48, Math.min(180, deltaX * 0.5));
return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`;
}, []);
const renderedEdges = useMemo(() => {
return edges
.map((edge) => {
const sourceNode = nodes.find((node) => node.id === edge.source);
const targetNode = nodes.find((node) => node.id === edge.target);
if (!sourceNode || !targetNode) return null;
const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode));
const targetPoint = toScreenPosition(getNodeInputAnchor(targetNode));
return {
edge,
sourcePoint,
targetPoint,
path: buildWorkflowPath(sourcePoint, targetPoint),
midPoint: {
x: (sourcePoint.x + targetPoint.x) / 2,
y: (sourcePoint.y + targetPoint.y) / 2,
},
};
})
.filter(
(
item
): item is {
edge: CanvasEdge;
sourcePoint: Position;
targetPoint: Position;
path: string;
midPoint: Position;
} => Boolean(item)
);
}, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]);
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
return;
}
setSelectedEdgeId(null);
if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
const now = Date.now();
const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320;
lastBlankCanvasClickAtRef.current = now;
clearSelection();
if (isDoubleBlankClick) {
fitAllImageNodesIntoViewport();
}
}
}, [clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
// 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
return;
}
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
e.preventDefault();
return;
}
if (tool === "select" && e.button === 0 && !isPointOnNodeElement(e.target)) {
e.preventDefault();
clearBrowserTextSelection();
const start = toCanvasPosition(e.clientX, e.clientY);
setIsMarqueeSelecting(true);
setMarqueeStart(start);
setMarqueeEnd(start);
}
}, [clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) {
if (isPanning) setIsPanning(false);
return;
}
if (!isEventFromCanvasContainer(e.target)) return;
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
if (connectionDraft) {
e.preventDefault();
setConnectionDraft((current) =>
current
? {
...current,
current: {
x: (e.clientX - viewport.x - (containerRef.current?.getBoundingClientRect().left || 0)) /
viewport.zoom,
y: (e.clientY - viewport.y - (containerRef.current?.getBoundingClientRect().top || 0)) /
viewport.zoom,
},
}
: null
);
return;
}
if (isPanning) {
e.preventDefault();
setViewport({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
});
return;
}
if (isMarqueeSelecting) {
e.preventDefault();
setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY));
}
}, [connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition, viewport.x, viewport.y, viewport.zoom]);
// 处理画布平移结束
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
if (e && !isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
if (e) {
const releaseTarget = document.elementFromPoint(e.clientX, e.clientY);
const handleEl =
releaseTarget instanceof HTMLElement
? releaseTarget.closest("[data-canvas-input-handle]")
: null;
const targetNodeId =
handleEl instanceof HTMLElement ? handleEl.dataset.nodeId || null : null;
if (targetNodeId && targetNodeId !== connectionDraft.sourceId) {
addEdge({
source: connectionDraft.sourceId,
target: targetNodeId,
type: "default",
});
setSelectedEdgeId(null);
}
}
setConnectionDraft(null);
return;
}
setIsPanning(false);
setSnapGuides(null);
clearBrowserTextSelection();
if (isMarqueeSelecting && marqueeStart && marqueeEnd) {
const left = Math.min(marqueeStart.x, marqueeEnd.x);
const top = Math.min(marqueeStart.y, marqueeEnd.y);
const right = Math.max(marqueeStart.x, marqueeEnd.x);
const bottom = Math.max(marqueeStart.y, marqueeEnd.y);
const minSize = 4;
if (right - left < minSize && bottom - top < minSize) {
clearSelection();
} else {
const selectedIds = nodes
.filter((node) => {
const x = node.position.x * viewport.zoom + viewport.x;
const y = node.position.y * viewport.zoom + viewport.y;
const width = node.size.width * viewport.zoom;
const height = node.size.height * viewport.zoom;
const nodeRight = x + width;
const nodeBottom = y + height;
return !(nodeRight < left || x > right || nodeBottom < top || y > bottom);
})
.map((node) => node.id);
if (selectedIds.length > 0) {
selectNodes(selectedIds);
suppressCanvasClickRef.current = true;
} else {
clearSelection();
}
}
}
setIsMarqueeSelecting(false);
setMarqueeStart(null);
setMarqueeEnd(null);
}, [addEdge, clearBrowserTextSelection, clearSelection, connectionDraft, isEventFromCanvasContainer, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]);
// 新增图片后自动居中展示
const centerNodeInView = useCallback((nodeId: string) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
const width = containerSize.width || 0;
const height = containerSize.height || 0;
if (width === 0 || height === 0) return;
const zoom = viewport.zoom;
const centerX = node.position.x + node.size.width / 2;
const centerY = node.position.y + node.size.height / 2;
const newX = width / 2 - centerX * zoom;
const newY = height / 2 - centerY * zoom;
setViewport({ x: newX, y: newY });
}, [containerSize.width, containerSize.height, nodes, setViewport, viewport.zoom]);
React.useEffect(() => {
if (!lastAddedNodeId) return;
if (containerSize.width === 0 || containerSize.height === 0) return;
if (centerTimeoutRef.current) {
window.clearTimeout(centerTimeoutRef.current);
}
centerTimeoutRef.current = window.setTimeout(() => {
centerNodeInView(lastAddedNodeId);
clearLastAddedNodeId();
}, 80);
return () => {
if (centerTimeoutRef.current) {
window.clearTimeout(centerTimeoutRef.current);
centerTimeoutRef.current = null;
}
};
}, [lastAddedNodeId, centerNodeInView, clearLastAddedNodeId, containerSize.width, containerSize.height]);
// 处理滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (e.cancelable) {
e.preventDefault();
}
e.stopPropagation();
// mac: 双指滚动默认平移,Cmd/Ctrl+滚轮才缩放(保留触控板体验)
// windows/linux: 鼠标滚轮默认缩放画布,不触发整页缩放
const shouldZoom = isMacPlatform ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
if (!shouldZoom) {
setViewport({
x: viewport.x - e.deltaX,
y: viewport.y - e.deltaY,
});
return;
}
const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05;
const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * zoomFactor));
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const scaleChange = newZoom / viewport.zoom;
const newX = mouseX - (mouseX - viewport.x) * scaleChange;
const newY = mouseY - (mouseY - viewport.y) * scaleChange;
setViewport({ x: newX, y: newY, zoom: newZoom });
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]);
const getImageDimensionsFromFile = useCallback((file: File): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> => {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
URL.revokeObjectURL(url);
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}, []);
const processDroppedFiles = useCallback(async (files: File[], dropClientX: number, dropClientY: number) => {
if (files.length === 0) return;
const rect = containerRef.current?.getBoundingClientRect();
const baseX = rect ? (dropClientX - rect.left - viewport.x) / viewport.zoom : 100;
const baseY = rect ? (dropClientY - rect.top - viewport.y) / viewport.zoom : 100;
const spacing = 20;
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
if (!file.type.startsWith("image/")) continue;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
continue;
}
const dimensions = await getImageDimensionsFromFile(file);
const cleanName = file.name.replace(/\.[^.]+$/, "");
const placeholderId = addNode({
type: "image",
position: { x: baseX + index * spacing, y: baseY + index * spacing },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: "",
status: "generating",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
try {
const result = await imageGenerationService.uploadImage(file);
if (result.success && result.data?.url) {
updateNode(placeholderId, {
data: {
src: result.data.url,
assetId: result.data.assetId,
status: "completed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
});
toast.success("图片已添加到画布");
} else {
updateNode(placeholderId, {
data: {
src: "",
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
},
});
toast.error(result.error || "图片上传失败");
}
} catch (_error) {
updateNode(placeholderId, {
data: {
src: "",
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
},
});
toast.error("图片上传失败");
}
}
}, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
React.useEffect(() => {
const handleWindowDrop = async (e: DragEvent) => {
if (!isFileDrag(e.dataTransfer)) return;
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = e.clientX || dragPointerRef.current.x + rect.left;
const y = e.clientY || dragPointerRef.current.y + rect.top;
const inContainer = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (!inContainer) return;
const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/"));
if (files.length === 0) return;
e.preventDefault();
dragCounterRef.current = 0;
setIsFileDragActive(false);
await processDroppedFiles(files, x, y);
};
window.addEventListener("drop", handleWindowDrop);
return () => window.removeEventListener("drop", handleWindowDrop);
}, [isFileDrag, processDroppedFiles]);
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragCounterRef.current += 1;
setIsFileDragActive(true);
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
}, [isFileDrag, toCanvasPosition]);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) {
setIsFileDragActive(false);
}
}, [isFileDrag]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
}, [isFileDrag, toCanvasPosition]);
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsFileDragActive(false);
const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/"));
if (files.length > 0) {
await processDroppedFiles(files, e.clientX, e.clientY);
}
}, [isFileDrag, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
toast.error("请先框选要下载的图片");
return;
}
if (isDownloadSubmitting) {
return;
}
if (selectedImageNodes.length > 50) {
toast.warning("当前选择图片较多,正在打包,请稍候");
}
const now = new Date();
const folderName = `canvas-images-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
const browserOrigin = typeof window !== "undefined" ? window.location.origin : "";
const files = selectedImageNodes
.map((node, index) => {
const nodeData = node.data as any;
const rawImageUrl = String(nodeData?.src || "").trim();
if (!rawImageUrl) return null;
let imageUrl = rawImageUrl;
if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`;
}
const name =
String(nodeData?.title || "").trim() ||
String(nodeData?.prompt || "").trim() ||
`canvas-image-${index + 1}`;
return { url: imageUrl, name };
})
.filter((item): item is { url: string; name: string } => Boolean(item));
if (files.length === 0) {
toast.error("当前没有可下载的图片");
return;
}
setIsDownloadSubmitting(true);
try {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/canvas/download-zip";
form.style.display = "none";
const payloadInput = document.createElement("input");
payloadInput.type = "hidden";
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName,
files,
});
form.appendChild(payloadInput);
document.body.appendChild(form);
form.submit();
form.remove();
toast.success("已开始打包下载,请留意浏览器下载栏");
} catch (_error) {
toast.error("下载失败,请稍后重试");
} finally {
window.setTimeout(() => {
setIsDownloadSubmitting(false);
}, 800);
}
}, [isDownloadSubmitting, selectedImageNodes]);
React.useEffect(() => {
if (!selectedEdgeId) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
deleteEdge(selectedEdgeId);
setSelectedEdgeId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [deleteEdge, selectedEdgeId]);
// 渲染节点
const renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id);
const showWorkflowHandles = isWorkflowCanvas && (tool === "draw" || isSelected);
const isImageNode = node.type === "image";
const enableImageResizeHandles =
isSelected && tool === "select" && isImageNode
? {
top: false,
right: false,
bottom: false,
left: false,
topRight: true,
bottomRight: true,
bottomLeft: true,
topLeft: true,
}
: isSelected && tool === "select";
const imageResizeHandleStyles = isImageNode
? {
topRight: {
width: IMAGE_HANDLE_SIZE,
height: IMAGE_HANDLE_SIZE,
right: -IMAGE_HANDLE_OFFSET,
top: -IMAGE_HANDLE_OFFSET,
borderRadius: 0,
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
bottomRight: {
width: IMAGE_HANDLE_SIZE,
height: IMAGE_HANDLE_SIZE,
right: -IMAGE_HANDLE_OFFSET,
bottom: -IMAGE_HANDLE_OFFSET,
borderRadius: 0,
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
bottomLeft: {
width: IMAGE_HANDLE_SIZE,
height: IMAGE_HANDLE_SIZE,
left: -IMAGE_HANDLE_OFFSET,
bottom: -IMAGE_HANDLE_OFFSET,
borderRadius: 0,
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
topLeft: {
width: IMAGE_HANDLE_SIZE,
height: IMAGE_HANDLE_SIZE,
left: -IMAGE_HANDLE_OFFSET,
top: -IMAGE_HANDLE_OFFSET,
borderRadius: 0,
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
}
: undefined;
return (
<Rnd
key={node.id}
position={{
x: node.position.x * viewport.zoom + viewport.x,
y: node.position.y * viewport.zoom + viewport.y,
}}
size={{
width: node.size.width * viewport.zoom,
height: node.size.height * viewport.zoom,
}}
scale={1}
onDragStart={() => {
setSnapGuides(null);
if (!isSelected) {
selectNode(node.id);
groupDragRef.current = null;
return;
}
if (selectedNodeIds.length > 1) {
const originPositions: Record<string, Position> = {};
nodes.forEach((item) => {
if (selectedNodeIds.includes(item.id)) {
originPositions[item.id] = { ...item.position };
}
});
groupDragRef.current = {
nodeId: node.id,
startX: node.position.x * viewport.zoom + viewport.x,
startY: node.position.y * viewport.zoom + viewport.y,
originPositions,
};
} else {
groupDragRef.current = null;
}
}}
onDrag={(e, d) => {
const group = groupDragRef.current;
if (group && group.nodeId === node.id && selectedNodeIds.length > 1) {
const deltaCanvasX = (d.x - group.startX) / viewport.zoom;
const deltaCanvasY = (d.y - group.startY) / viewport.zoom;
const origin = group.originPositions[node.id];
const rawAnchor = origin
? {
x: origin.x + deltaCanvasX,
y: origin.y + deltaCanvasY,
}
: {
x: node.position.x + deltaCanvasX,
y: node.position.y + deltaCanvasY,
};
const preview = getSnapPreview(
node,
rawAnchor,
new Set(selectedNodeIds),
);
setSnapGuides(preview.guides);
selectedNodeIds.forEach((id) => {
const itemOrigin = group.originPositions[id];
if (!itemOrigin) return;
moveNode(id, {
x: itemOrigin.x + deltaCanvasX,
y: itemOrigin.y + deltaCanvasY,
});
});
return;
}
if (selectedNodeIds.length > 1) return;
const rawPosition = {
x: (d.x - viewport.x) / viewport.zoom,
y: (d.y - viewport.y) / viewport.zoom,
};
const preview = getSnapPreview(node, rawPosition);
setSnapGuides(preview.guides);
}}
onDragStop={(e, d) => {
setSnapGuides(null);
const group = groupDragRef.current;
if (group && group.nodeId === node.id && selectedNodeIds.length > 1) {
const deltaCanvasX = (d.x - group.startX) / viewport.zoom;
const deltaCanvasY = (d.y - group.startY) / viewport.zoom;
const origin = group.originPositions[node.id];
const rawAnchor = origin
? {
x: origin.x + deltaCanvasX,
y: origin.y + deltaCanvasY,
}
: {
x: node.position.x + deltaCanvasX,
y: node.position.y + deltaCanvasY,
};
const snappedAnchor = getSnappedCanvasPosition(
node,
rawAnchor,
new Set(selectedNodeIds),
);
const offsetX = origin ? snappedAnchor.x - origin.x : deltaCanvasX;
const offsetY = origin ? snappedAnchor.y - origin.y : deltaCanvasY;
selectedNodeIds.forEach((id) => {
const itemOrigin = group.originPositions[id];
if (!itemOrigin) return;
moveNode(id, {
x: itemOrigin.x + offsetX,
y: itemOrigin.y + offsetY,
});
});
groupDragRef.current = null;
pushHistory();
return;
}
const newX = (d.x - viewport.x) / viewport.zoom;
const newY = (d.y - viewport.y) / viewport.zoom;
const snapped = getSnappedCanvasPosition(node, { x: newX, y: newY });
moveNode(node.id, snapped);
groupDragRef.current = null;
pushHistory();
}}
onResizeStop={(e, direction, ref, delta, position) => {
const newWidth = parseInt(ref.style.width) / viewport.zoom;
const newHeight = parseInt(ref.style.height) / viewport.zoom;
const newX = (position.x - viewport.x) / viewport.zoom;
const newY = (position.y - viewport.y) / viewport.zoom;
resizeNode(node.id, { width: newWidth, height: newHeight });
moveNode(node.id, { x: newX, y: newY });
pushHistory();
}}
bounds="parent"
enableResizing={enableImageResizeHandles}
resizeHandleStyles={imageResizeHandleStyles}
disableDragging={tool !== 'select'}
cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'}
className={cn(
"absolute",
tool === "select" && "cursor-pointer"
)}
data-canvas-node-id={node.id}
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setSelectedEdgeId(null);
selectNode(node.id, e.shiftKey);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="group relative w-full h-full"
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
>
{showWorkflowHandles ? (
<>
<button
type="button"
data-canvas-no-drag="true"
data-canvas-input-handle="true"
data-node-id={node.id}
className={cn(
"absolute top-1/2 z-20 rounded-full border-2 border-background/95 bg-foreground shadow-[0_10px_20px_-14px_rgba(15,23,42,0.7)] transition-all",
tool === "draw"
? "scale-100 opacity-100 ring-4 ring-primary/10"
: "scale-90 opacity-75"
)}
style={{
left: -(WORKFLOW_HANDLE_SIZE / 2),
marginTop: -(WORKFLOW_HANDLE_SIZE / 2),
width: WORKFLOW_HANDLE_SIZE,
height: WORKFLOW_HANDLE_SIZE,
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
aria-label="输入连接点"
/>
<button
type="button"
data-canvas-no-drag="true"
className={cn(
"absolute top-1/2 z-20 rounded-full border-2 border-background/95 bg-foreground shadow-[0_10px_20px_-14px_rgba(15,23,42,0.7)] transition-all",
tool === "draw"
? "scale-100 opacity-100 ring-4 ring-primary/10"
: "scale-90 opacity-75"
)}
style={{
right: -(WORKFLOW_HANDLE_SIZE / 2),
marginTop: -(WORKFLOW_HANDLE_SIZE / 2),
width: WORKFLOW_HANDLE_SIZE,
height: WORKFLOW_HANDLE_SIZE,
}}
onMouseDown={(event) => {
if (!isWorkflowCanvas) return;
event.preventDefault();
event.stopPropagation();
const outputAnchor = getNodeOutputAnchor(node);
setConnectionDraft({
sourceId: node.id,
current: outputAnchor,
});
}}
aria-label="输出连接点"
/>
</>
) : null}
{node.type === 'image' && (
<ImageNode node={node} isSelected={isSelected} />
)}
{node.type === 'text' && (
<TextNode node={node} isSelected={isSelected} />
)}
{node.type === 'video' && (
<VideoNode node={node} isSelected={isSelected} />
)}
{node.type === 'workflow' && (
<WorkflowNode node={node} isSelected={isSelected} />
)}
{node.type === 'model3d' && (
<Model3DNode node={node} isSelected={isSelected} />
)}
{node.type === 'world3d' && (
<World3DNode node={node} isSelected={isSelected} />
)}
{node.type === 'message' && (
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
<span className="text-sm">{(node.data as any).content}</span>
</div>
)}
</motion.div>
</Rnd>
);
};
return (
<div
ref={containerRef}
className={cn(
"relative w-full h-full overflow-hidden overscroll-none touch-none bg-background",
tool === 'pan' && "cursor-grab",
tool === 'draw' && "cursor-crosshair",
isPanning && "cursor-grabbing"
)}
onClick={handleCanvasClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{isWorkflowCanvas ? (
<>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-foreground opacity-[0.028]" />
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(circle at top left, rgba(59,130,246,0.08), transparent 24%), radial-gradient(circle at bottom right, rgba(148,163,184,0.1), transparent 26%)",
}}
/>
</>
) : null}
{isFileDragActive && (
<div
className={cn(
"absolute inset-0 z-30 flex items-center justify-center pointer-events-none border-2 border-dashed",
isWorkflowCanvas
? "rounded-[32px] border-primary/30 bg-primary/5"
: "rounded-lg border-primary/40 bg-primary/5"
)}
>
<div
className={cn(
"px-4 py-2 text-sm text-primary shadow-sm",
isWorkflowCanvas
? "rounded-full border border-primary/20 bg-background/92 backdrop-blur-xl"
: "rounded-md bg-background/90"
)}
>
松开鼠标即可添加图片到画布
</div>
</div>
)}
{isMarqueeSelecting && marqueeStart && marqueeEnd && (
<div
className="absolute z-20 pointer-events-none border border-primary/50 bg-primary/10"
style={{
left: Math.min(marqueeStart.x, marqueeEnd.x),
top: Math.min(marqueeStart.y, marqueeEnd.y),
width: Math.abs(marqueeEnd.x - marqueeStart.x),
height: Math.abs(marqueeEnd.y - marqueeStart.y),
}}
/>
)}
{snapGuides?.vertical && (
<>
<div
className="absolute z-20 pointer-events-none"
style={{
left: snapGuides.vertical.x * viewport.zoom + viewport.x,
top: snapGuides.vertical.top * viewport.zoom + viewport.y,
width: 1,
height: Math.max(
1,
(snapGuides.vertical.bottom - snapGuides.vertical.top) * viewport.zoom,
),
backgroundColor: SNAP_GUIDE_COLOR,
}}
/>
<div
className="absolute z-20 pointer-events-none"
style={{
left: snapGuides.vertical.x * viewport.zoom + viewport.x - 4,
top: snapGuides.vertical.top * viewport.zoom + viewport.y - 4,
color: SNAP_GUIDE_COLOR,
fontSize: 11,
lineHeight: "11px",
}}
>
x
</div>
<div
className="absolute z-20 pointer-events-none"
style={{
left: snapGuides.vertical.x * viewport.zoom + viewport.x - 4,
top:
snapGuides.vertical.bottom * viewport.zoom + viewport.y - 4,
color: SNAP_GUIDE_COLOR,
fontSize: 11,
lineHeight: "11px",
}}
>
x
</div>
</>
)}
{snapGuides?.horizontal && (
<>
<div
className="absolute z-20 pointer-events-none"
style={{
left: snapGuides.horizontal.left * viewport.zoom + viewport.x,
top: snapGuides.horizontal.y * viewport.zoom + viewport.y,
width: Math.max(
1,
(snapGuides.horizontal.right - snapGuides.horizontal.left) * viewport.zoom,
),
height: 1,
backgroundColor: SNAP_GUIDE_COLOR,
}}
/>
<div
className="absolute z-20 pointer-events-none"
style={{
left: snapGuides.horizontal.left * viewport.zoom + viewport.x - 4,
top: snapGuides.horizontal.y * viewport.zoom + viewport.y - 4,
color: SNAP_GUIDE_COLOR,
fontSize: 11,
lineHeight: "11px",
}}
>
x
</div>
<div
className="absolute z-20 pointer-events-none"
style={{
left: snapGuides.horizontal.right * viewport.zoom + viewport.x - 4,
top: snapGuides.horizontal.y * viewport.zoom + viewport.y - 4,
color: SNAP_GUIDE_COLOR,
fontSize: 11,
lineHeight: "11px",
}}
>
x
</div>
</>
)}
{isWorkflowCanvas ? (
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
{renderedEdges.map(({ edge, path }) => {
const isSelected = selectedEdgeId === edge.id;
return (
<g key={edge.id}>
{isSelected ? (
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
strokeLinecap="round"
strokeWidth={10}
style={{ pointerEvents: "none" }}
/>
) : null}
<path
d={path}
fill="none"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeDasharray={edge.type === "reference" ? "6 6" : undefined}
strokeLinecap="round"
strokeWidth={isSelected ? 4 : 3}
className="cursor-pointer transition-all"
style={{ pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
setSelectedEdgeId(edge.id);
clearSelection();
}}
onDoubleClick={(event) => {
event.stopPropagation();
deleteEdge(edge.id);
setSelectedEdgeId(null);
}}
/>
</g>
);
})}
{connectionDraft ? (() => {
const sourceNode = nodes.find((node) => node.id === connectionDraft.sourceId);
if (!sourceNode) return null;
const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode));
const targetPoint = toScreenPosition(connectionDraft.current);
return (
<g>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke="rgba(59, 130, 246, 0.14)"
strokeLinecap="round"
strokeWidth={8}
style={{ pointerEvents: "none" }}
/>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke={WORKFLOW_EDGE_COLOR}
strokeDasharray="8 6"
strokeLinecap="round"
strokeWidth={3}
style={{ pointerEvents: "none" }}
/>
<circle
cx={targetPoint.x}
cy={targetPoint.y}
r={5}
fill="white"
stroke={WORKFLOW_EDGE_ACTIVE_COLOR}
strokeWidth={2}
style={{ pointerEvents: "none" }}
/>
</g>
);
})() : null}
</svg>
) : null}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<div className="absolute inset-0 z-[2]" style={{ overflow: 'visible' }}>
{visibleNodes.map(renderNode)}
</div>
{/* 性能指示器(节点数量大于 50 时显示) */}
{nodes.length >= 50 && (
<div className={cn("absolute top-4 right-4", overlayPanelClassName)}>
渲染: {visibleNodes.length}/{nodes.length}
</div>
)}
{/* 空状态提示 */}
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{isWorkflowCanvas ? (
<div className="rounded-[30px] border border-border/60 bg-background/90 px-8 py-6 text-center shadow-[0_24px_60px_-34px_rgba(15,23,42,0.35)] backdrop-blur-xl">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-border/60 bg-background text-foreground shadow-sm">
<GitBranch className="h-5 w-5" />
</div>
<p className="text-lg font-semibold text-foreground">{workspaceLabel}还是空的</p>
<p className="mt-2 max-w-[360px] text-sm leading-6 text-muted-foreground">
{emptyStateDescription}
</p>
<p className="mt-3 text-xs text-muted-foreground/80">
可以先从左上角模板起手,也可以直接添加节点后用 `L` 连线。
</p>
</div>
) : (
<div className="text-center text-muted-foreground">
<p className="text-lg mb-2">{workspaceLabel}还是空的</p>
<p className="text-sm">{emptyStateDescription}</p>
</div>
)}
</div>
)}
{/* 缩放控制器 */}
{selectedImageNodes.length > 0 && (
<div className="absolute bottom-16 left-4 z-30" data-canvas-overlay>
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
isWorkflowCanvas &&
"rounded-full bg-background/92 backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
)}
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
title="下载为文件夹压缩包"
>
<Download className="h-3.5 w-3.5" />
{isDownloadSubmitting ? "打包中..." : `下载文件夹(${selectedImageNodes.length})`}
</Button>
</div>
)}
<div
className={cn(
zoomOverlayClassName,
isWorkflowCanvas
? "flex items-center gap-1.5 px-1.5 py-1.5"
: "flex items-center gap-1 px-1 py-1 border border-border/50"
)}
data-canvas-overlay
>
<Button
variant="ghost"
size="icon"
className={cn("h-7 w-7", isWorkflowCanvas && "rounded-full")}
onClick={() => {
const newZoom = Math.max(0.1, viewport.zoom - 0.1);
setViewport({ zoom: newZoom });
}}
title="缩小"
>
<Minus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 min-w-[50px] text-xs font-medium",
isWorkflowCanvas && "rounded-full px-3"
)}
onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })}
title="重置缩放"
>
{Math.round(viewport.zoom * 100)}%
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-7 w-7", isWorkflowCanvas && "rounded-full")}
onClick={() => {
const newZoom = Math.min(4, viewport.zoom + 0.1);
setViewport({ zoom: newZoom });
}}
title="放大"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}