ai-chatbot/components/canvas/CanvasWorkspace.tsx

2999 lines
109 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 dynamic from "next/dynamic";
import { Rnd } from "react-rnd";
import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { AgentSlotNode } from "./nodes/AgentSlotNode";
import { TextNode } from "./nodes/TextNode";
import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
import { downloadCanvasSelectionZip } from "@/lib/client/canvas-zip-download";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas";
import {
AlertTriangle,
Check,
Download,
Flag,
GitBranch,
Heart,
HelpCircle,
ImagePlus,
LayoutTemplate,
LayoutGrid,
Lightbulb,
Loader2,
Minus,
Pin,
Plus,
RefreshCw,
Star,
Tag,
type LucideIcon,
UserRound,
Video,
Wand2,
Zap,
} from "lucide-react";
import {
getVideoCanvasMarkerColor,
getVideoCanvasSymbolMarker,
getVideoCanvasTaskMarker,
normalizeVideoCanvasMarkers,
VIDEO_CANVAS_MARKER_METADATA_KEY,
VIDEO_CANVAS_PRIORITY_MARKERS,
} from "@/lib/canvas/video-canvas-markers";
import {
getCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state";
import {
dispatchCanvasAgentOpenChatPanelRequest,
dispatchCanvasAgentSlotFillRequest,
storeCanvasAgentSlotFillRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import { inferOriginalImageUrl } from "@/lib/image-url";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
type ImageNodeModule = typeof import("./nodes/ImageNode");
type Model3DNodeModule = typeof import("./nodes/Model3DNode");
type World3DNodeModule = typeof import("./nodes/World3DNode");
let imageNodeModulePromise: Promise<ImageNodeModule> | null = null;
let model3DNodeModulePromise: Promise<Model3DNodeModule> | null = null;
let world3DNodeModulePromise: Promise<World3DNodeModule> | null = null;
function loadImageNodeModule() {
imageNodeModulePromise ??= import("./nodes/ImageNode");
return imageNodeModulePromise;
}
function loadModel3DNodeModule() {
model3DNodeModulePromise ??= import("./nodes/Model3DNode");
return model3DNodeModulePromise;
}
function loadWorld3DNodeModule() {
world3DNodeModulePromise ??= import("./nodes/World3DNode");
return world3DNodeModulePromise;
}
function preloadCanvas3DNodeRenderers() {
void loadModel3DNodeModule();
void loadWorld3DNodeModule();
}
function preloadCanvasImageNodeRenderer() {
void loadImageNodeModule();
}
function CanvasImageNodeLoading() {
return (
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f8f9fb] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
正在加载图片节点
</div>
);
}
function Canvas3DNodeLoading() {
return (
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f6f7f9] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
正在加载 3D 预览
</div>
);
}
const ImageNode = dynamic(
() => loadImageNodeModule().then((module) => module.ImageNode),
{
loading: () => <CanvasImageNodeLoading />,
ssr: false,
}
);
const Model3DNode = dynamic(
() => loadModel3DNodeModule().then((module) => module.Model3DNode),
{
loading: () => <Canvas3DNodeLoading />,
ssr: false,
}
);
const World3DNode = dynamic(
() => loadWorld3DNodeModule().then((module) => module.World3DNode),
{
loading: () => <Canvas3DNodeLoading />,
ssr: false,
}
);
const CANVAS_NO_DRAG_SELECTOR =
'[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *';
const CANVAS_DRAG_CANCEL_SELECTOR = [
"input",
"textarea",
"select",
"button",
"a",
'[contenteditable="true"]',
'[role="textbox"]',
CANVAS_NO_DRAG_SELECTOR,
].join(", ");
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
idea: Lightbulb,
question: HelpCircle,
warning: AlertTriangle,
pin: Pin,
heart: Heart,
bolt: Zap,
};
function isImageFile(file: File) {
return file.type.startsWith("image/");
}
function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) {
if (!dataTransfer) return [];
const seen = new Set<string>();
const files: File[] = [];
const pushFile = (file: File | null) => {
if (!file || !isImageFile(file)) return;
const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
if (seen.has(key)) return;
seen.add(key);
files.push(file);
};
Array.from(dataTransfer.files || []).forEach(pushFile);
Array.from(dataTransfer.items || []).forEach((item) => {
if (item.kind === "file") {
pushFile(item.getAsFile());
}
});
return files;
}
function isEditablePasteTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return Boolean(target.closest(CANVAS_DRAG_CANCEL_SELECTOR));
}
function getNodeVideoCanvasMarkers(node: CanvasNode) {
const metadata = (node.data as { metadata?: unknown } | undefined)?.metadata;
return normalizeVideoCanvasMarkers(
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)[VIDEO_CANVAS_MARKER_METADATA_KEY]
: undefined
);
}
function VideoCanvasNodeMarkers({ node }: { node: CanvasNode }) {
const markers = getNodeVideoCanvasMarkers(node);
const tagColor = getVideoCanvasMarkerColor(markers.tagColor);
const flagColor = getVideoCanvasMarkerColor(markers.flagColor);
const starColor = getVideoCanvasMarkerColor(markers.starColor);
const avatarColor = getVideoCanvasMarkerColor(markers.avatarColor);
const task = getVideoCanvasTaskMarker(markers.task);
const priority = VIDEO_CANVAS_PRIORITY_MARKERS.find(
(entry) => entry.value === markers.priority
);
const symbol = getVideoCanvasSymbolMarker(markers.symbol);
const SymbolIcon = markers.symbol
? VIDEO_CANVAS_SYMBOL_ICON_MAP[markers.symbol]
: null;
const hasMarkers = Boolean(
tagColor || flagColor || starColor || avatarColor || task || priority || symbol
);
if (!hasMarkers) {
return null;
}
return (
<div className="pointer-events-none absolute -top-4 left-4 z-30 flex items-center gap-1.5">
{tagColor ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{ backgroundColor: tagColor.color, color: "#ffffff" }}
title={`标签:${tagColor.label}`}
>
<Tag className="h-3.5 w-3.5" />
</span>
) : null}
{priority ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white font-semibold text-[12px] text-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{ backgroundColor: priority.color }}
title={`优先级:${priority.value}`}
>
{priority.value}
</span>
) : null}
{task ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white p-[2px] shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{
background: `conic-gradient(#10b981 ${task.progress * 3.6}deg, #dfe6f3 0deg)`,
}}
title={`任务:${task.label}`}
>
<span className="flex h-full w-full items-center justify-center rounded-full bg-white text-[#10b981]">
{task.progress >= 100 ? <Check className="h-3 w-3" /> : null}
</span>
</span>
) : null}
{flagColor ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{ backgroundColor: flagColor.color, color: "#ffffff" }}
title={`旗帜:${flagColor.label}`}
>
<Flag className="h-3.5 w-3.5" fill="currentColor" />
</span>
) : null}
{starColor ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{ backgroundColor: starColor.color, color: "#ffffff" }}
title={`星标:${starColor.label}`}
>
<Star className="h-3.5 w-3.5" fill="currentColor" />
</span>
) : null}
{avatarColor ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{ backgroundColor: avatarColor.color, color: "#ffffff" }}
title={`人像:${avatarColor.label}`}
>
<UserRound className="h-3.5 w-3.5" />
</span>
) : null}
{symbol && SymbolIcon ? (
<span
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
style={{ backgroundColor: symbol.soft, color: symbol.color }}
title={`符号:${symbol.label}`}
>
<SymbolIcon className="h-3.5 w-3.5" />
</span>
) : null}
</div>
);
}
// 性能优化:判断节点是否在视口内
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;
// 拖动画布时会先走 GPU 位移预览,保留更大的热区避免快速平移时露白。
const buffer = 800;
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";
canvasToolbarConfig?: CanvasToolbarConfig;
layerSplitEnabled?: boolean;
angleEditEnabled?: boolean;
tripo3dEnabled?: boolean;
worldlabs3dEnabled?: boolean;
batchProcessingEnabled?: boolean;
showEmptyState?: boolean;
showZoomControls?: boolean;
onBlankCanvasDoubleClick?: (payload: {
screenPosition: Position;
canvasPosition: Position;
}) => void;
}
export function CanvasWorkspace({
occlusionRight = 0,
workspaceLabel = "画布",
emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas",
canvasToolbarConfig: rawCanvasToolbarConfig,
layerSplitEnabled = false,
angleEditEnabled = false,
tripo3dEnabled = false,
worldlabs3dEnabled = false,
batchProcessingEnabled = true,
showEmptyState = true,
showZoomControls = true,
onBlankCanvasDoubleClick,
}: CanvasWorkspaceProps) {
const canvasToolbarConfig = useMemo(
() => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig),
[rawCanvasToolbarConfig]
);
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 = "#8fa3ff";
const WORKFLOW_EDGE_ACTIVE_COLOR = "#5b76fe";
const WORKFLOW_HANDLE_SIZE = 16;
const isWorkflowCanvas = workspaceVariant === "video";
const overlayPanelClassName = isWorkflowCanvas
? "rounded-full border border-[#dbe2ee] bg-white px-3.5 py-1.5 text-xs font-medium text-[#627089] shadow-[0_0_0_1px_rgba(219,226,238,0.32),0_12px_28px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950"
: "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-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.32),0_12px_28px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950"
: "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 };
};
type AgentSlotContextMenuState = {
nodeId: string;
label: string;
x: number;
y: number;
} | null;
const containerRef = useRef<HTMLDivElement>(null);
const contentLayerRef = useRef<HTMLDivElement>(null);
const contextImageInputRef = useRef<HTMLInputElement>(null);
const contextVideoInputRef = useRef<HTMLInputElement>(null);
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
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 [agentSlotContextMenu, setAgentSlotContextMenu] =
useState<AgentSlotContextMenuState>(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 canvasImageSelectionSession = useCanvasStore(
(state) => state.canvasImageSelectionSession
);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
const setViewport = useCanvasStore((state) => state.setViewport);
const hasImageNodes = useMemo(
() => nodes.some((node) => node.type === "image"),
[nodes]
);
const has3DNodes = useMemo(
() => nodes.some((node) => node.type === "model3d" || node.type === "world3d"),
[nodes]
);
const viewportRef = useRef(viewport);
const pendingViewportRef = useRef<typeof viewport | null>(null);
const pendingViewportRafRef = useRef<number | null>(null);
const panPreviewRef = useRef<{
startClientX: number;
startClientY: number;
startViewport: typeof viewport;
currentViewport: typeof viewport;
offsetX: number;
offsetY: number;
rafId: number | null;
} | null>(null);
// 性能优化:监听容器大小变化
React.useEffect(() => {
viewportRef.current = viewport;
}, [viewport]);
React.useEffect(() => {
if (hasImageNodes) {
preloadCanvasImageNodeRenderer();
return;
}
if (typeof window === "undefined") return;
const connection = (
navigator as Navigator & {
connection?: { effectiveType?: string; saveData?: boolean };
}
).connection;
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
return;
}
const idleWindow = window as typeof window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
let idleId: number | null = null;
const warmupTimer = window.setTimeout(() => {
if (document.visibilityState !== "visible") return;
if (typeof idleWindow.requestIdleCallback === "function") {
idleId = idleWindow.requestIdleCallback(preloadCanvasImageNodeRenderer, {
timeout: 12000,
});
return;
}
preloadCanvasImageNodeRenderer();
}, 9000);
return () => {
window.clearTimeout(warmupTimer);
if (idleId !== null) {
idleWindow.cancelIdleCallback?.(idleId);
}
};
}, [hasImageNodes]);
React.useEffect(() => {
if (has3DNodes) {
preloadCanvas3DNodeRenderers();
return;
}
if (typeof window === "undefined") return;
const connection = (
navigator as Navigator & {
connection?: { effectiveType?: string; saveData?: boolean };
}
).connection;
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
return;
}
const idleWindow = window as typeof window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
let idleId: number | null = null;
const warmupTimer = window.setTimeout(() => {
if (document.visibilityState !== "visible") return;
if (typeof idleWindow.requestIdleCallback === "function") {
idleId = idleWindow.requestIdleCallback(preloadCanvas3DNodeRenderers, {
timeout: 15000,
});
return;
}
preloadCanvas3DNodeRenderers();
}, 12000);
return () => {
window.clearTimeout(warmupTimer);
if (idleId !== null) {
idleWindow.cancelIdleCallback?.(idleId);
}
};
}, [has3DNodes]);
React.useEffect(() => {
return () => {
if (pendingViewportRafRef.current !== null) {
window.cancelAnimationFrame(pendingViewportRafRef.current);
}
const panPreview = panPreviewRef.current;
if (panPreview && panPreview.rafId !== null) {
window.cancelAnimationFrame(panPreview.rafId);
}
};
}, []);
const resetContentLayerTransform = useCallback(() => {
const layer = contentLayerRef.current;
if (!layer) return;
layer.style.transform = "translate3d(0px, 0px, 0px)";
layer.style.willChange = "";
}, []);
const schedulePanPreviewTransform = useCallback((offsetX: number, offsetY: number) => {
const session = panPreviewRef.current;
if (!session) return;
session.offsetX = offsetX;
session.offsetY = offsetY;
if (session.rafId !== null) return;
session.rafId = window.requestAnimationFrame(() => {
const activeSession = panPreviewRef.current;
if (!activeSession) return;
activeSession.rafId = null;
const layer = contentLayerRef.current;
if (!layer) return;
layer.style.transform = `translate3d(${activeSession.offsetX}px, ${activeSession.offsetY}px, 0px)`;
layer.style.willChange = "transform";
});
}, []);
const finishPanPreview = useCallback(() => {
const session = panPreviewRef.current;
if (!session) return null;
if (session.rafId !== null) {
window.cancelAnimationFrame(session.rafId);
}
const finalViewport = session.currentViewport;
panPreviewRef.current = null;
return finalViewport;
}, []);
const cancelPanPreview = useCallback(() => {
const session = panPreviewRef.current;
if (session && session.rafId !== null) {
window.cancelAnimationFrame(session.rafId);
}
panPreviewRef.current = null;
resetContentLayerTransform();
}, [resetContentLayerTransform]);
const scheduleViewport = useCallback(
(nextViewport: typeof viewport) => {
pendingViewportRef.current = nextViewport;
viewportRef.current = nextViewport;
if (pendingViewportRafRef.current !== null) return;
pendingViewportRafRef.current = window.requestAnimationFrame(() => {
pendingViewportRafRef.current = null;
const queuedViewport = pendingViewportRef.current;
pendingViewportRef.current = null;
if (queuedViewport) {
setViewport(queuedViewport);
}
});
},
[setViewport]
);
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);
}, []);
// Browser zoom/history gestures can fire before React handlers if nested viewers stop propagation.
// Capture-phase non-passive handling keeps wheel gestures scoped to the canvas.
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventTrackpadHistorySwipe = (event: WheelEvent) => {
if (event.cancelable) {
event.preventDefault();
}
};
const wheelListenerOptions: AddEventListenerOptions = {
capture: true,
passive: false,
};
container.addEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions);
return () => {
container.removeEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions);
};
}, []);
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(() => {
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" &&
isCanvasImageReadyForAi(node)
),
[nodes, selectedNodeIds]
);
const selectedPendingImageUploadCount = useMemo(
() =>
nodes.filter(
(node) =>
selectedNodeIds.includes(node.id) &&
node.type === "image" &&
isCanvasImageUploadPending(node)
).length,
[nodes, selectedNodeIds]
);
const selectedImageLocalEditItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageLocalEdit"
);
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageLayoutReplace"
);
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const toggleCanvasImageSelection = useCanvasStore(
(state) => state.toggleCanvasImageSelection
);
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 requestImageNodeAction = useCanvasStore(
(state) => state.requestImageNodeAction
);
const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId);
const centerTimeoutRef = useRef<number | null>(null);
const clearLatestGeneratedImageHighlights = useCallback(() => {
const currentNodes = useCanvasStore.getState().nodes;
const clearedAt = Date.now();
currentNodes.forEach((node) => {
if (node.type !== "image") return;
const data = node.data as Record<string, any>;
const metadata = (data?.metadata || {}) as Record<string, any>;
if (metadata.latestGeneratedHighlight !== true) return;
updateNode(node.id, {
data: {
...data,
metadata: {
...metadata,
latestGeneratedHighlight: false,
latestGeneratedClearedAt: clearedAt,
},
} as any,
});
});
}, [updateNode]);
const handleAgentSlotDataChange = useCallback(
(node: CanvasNode, updates: Partial<AgentSlotNodeData>) => {
const currentData = (node.data || {}) as AgentSlotNodeData;
updateNode(node.id, {
data: {
...currentData,
...updates,
metadata: {
...(currentData.metadata || {}),
...(updates.metadata || {}),
},
},
});
},
[updateNode]
);
const openAgentSlotContextMenu = useCallback(
(event: React.MouseEvent, node: CanvasNode) => {
if (node.type !== "agent-slot") return;
event.preventDefault();
event.stopPropagation();
const rect = containerRef.current?.getBoundingClientRect();
const menuWidth = 236;
const menuHeight = 240;
const rawX = rect ? event.clientX - rect.left : event.clientX;
const rawY = rect ? event.clientY - rect.top : event.clientY;
const label =
String((node.data as AgentSlotNodeData | undefined)?.label || "").trim() ||
"Agent 槽位";
clearLatestGeneratedImageHighlights();
setSelectedEdgeId(null);
selectNode(node.id, false);
setAgentSlotContextMenu({
nodeId: node.id,
label,
x: Math.max(8, Math.min(rawX, Math.max(8, containerSize.width - menuWidth - 8))),
y: Math.max(8, Math.min(rawY, Math.max(8, containerSize.height - menuHeight - 8))),
});
},
[
clearLatestGeneratedImageHighlights,
containerSize.height,
containerSize.width,
selectNode,
]
);
const updateAgentSlotFromContextMenu = useCallback(
(updates: Partial<AgentSlotNodeData>) => {
if (!agentSlotContextMenu) return;
const node = nodes.find((item) => item.id === agentSlotContextMenu.nodeId);
if (!node || node.type !== "agent-slot") return;
handleAgentSlotDataChange(node, updates);
if (updates.label) {
setAgentSlotContextMenu((current) =>
current ? { ...current, label: String(updates.label) } : current
);
}
},
[agentSlotContextMenu, handleAgentSlotDataChange, nodes]
);
const updateAgentSlotTypeFromContextMenu = useCallback(
(slotType: NonNullable<AgentSlotNodeData["slotType"]>) => {
if (!agentSlotContextMenu) return;
const node = nodes.find((item) => item.id === agentSlotContextMenu.nodeId);
if (!node || node.type !== "agent-slot") return;
const data = (node.data || {}) as AgentSlotNodeData;
const currentLabel = String(data.label || "").trim();
const defaultLabels = new Set(["图片槽位", "视频槽位", "UI 槽位", "Agent 槽位"]);
const defaults =
slotType === "video"
? { label: "视频槽位", aspectRatio: "16:9" }
: slotType === "ui-screen"
? { label: "UI 槽位", aspectRatio: "16:9" }
: { label: "图片槽位", aspectRatio: "1:1" };
const nextLabel =
!currentLabel || defaultLabels.has(currentLabel) ? defaults.label : currentLabel;
handleAgentSlotDataChange(node, {
slotType,
label: nextLabel,
aspectRatio: defaults.aspectRatio,
});
setAgentSlotContextMenu((current) =>
current ? { ...current, label: nextLabel } : current
);
},
[agentSlotContextMenu, handleAgentSlotDataChange, nodes]
);
const requestFillAgentSlotFromContextMenu = useCallback(() => {
if (!agentSlotContextMenu || typeof window === "undefined") return;
const detail = {
nodeId: agentSlotContextMenu.nodeId,
label: agentSlotContextMenu.label,
};
const targetWindow = window as CanvasAgentPendingRequestWindow;
selectNode(agentSlotContextMenu.nodeId, false);
storeCanvasAgentSlotFillRequest(targetWindow, detail);
dispatchCanvasAgentOpenChatPanelRequest(targetWindow, {
reason: "agent-slot-fill",
nodeId: detail.nodeId,
});
dispatchCanvasAgentSlotFillRequest(targetWindow, detail);
setAgentSlotContextMenu(null);
}, [agentSlotContextMenu, selectNode]);
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 handleBlankCanvasDoubleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!isWorkflowCanvas || !onBlankCanvasDoubleClick) return;
if (hasBlockingDialogOpen()) return;
if (isPointOnNodeElement(event.target)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
clearBrowserTextSelection();
const viewportPosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
clearLatestGeneratedImageHighlights();
onBlankCanvasDoubleClick({
screenPosition: {
x: event.clientX,
y: event.clientY,
},
canvasPosition: {
x: (viewportPosition.x - viewport.x) / viewport.zoom,
y: (viewportPosition.y - viewport.y) / viewport.zoom,
},
});
},
[
clearBrowserTextSelection,
hasBlockingDialogOpen,
isPointOnNodeElement,
isWorkflowCanvas,
onBlankCanvasDoubleClick,
viewport.x,
viewport.y,
viewport.zoom,
clearLatestGeneratedImageHighlights,
]
);
const fitAllImageNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport(
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
containerSize, occlusionRight
);
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
const fitAllCanvasNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
if (!(event.target instanceof Element) || event.target.closest(
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
contextCanvasPointRef.current = {
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
};
setBlankContextPosition({ x: event.clientX, y: event.clientY });
setAgentSlotContextMenu(null);
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
const current = useCanvasStore.getState().viewport;
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
const centerY = containerSize.height / 2;
const ratio = zoom / current.zoom;
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
}, [containerSize, occlusionRight, setViewport]);
React.useEffect(() => {
if (isWorkflowCanvas) return;
const handleFitShortcut = (event: KeyboardEvent) => {
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
'input, textarea, [contenteditable="true"], [role="textbox"]'
))) return;
const active = document.activeElement;
if (active && active !== document.body && !containerRef.current?.contains(active)
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
event.preventDefault();
fitAllCanvasNodesIntoViewport();
setBlankContextPosition(null);
};
window.addEventListener("keydown", handleFitShortcut);
return () => window.removeEventListener("keydown", handleFitShortcut);
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
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, renderedMergeTrunks } = useMemo(() => {
type RenderedWorkflowEdgeBase = {
edge: CanvasEdge;
sourcePoint: Position;
targetPoint: Position;
};
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const baseEdges = edges
.map((edge) => {
const sourceNode = nodeById.get(edge.source);
const targetNode = nodeById.get(edge.target);
if (!sourceNode || !targetNode) return null;
return {
edge,
sourcePoint: toScreenPosition(getNodeOutputAnchor(sourceNode)),
targetPoint: toScreenPosition(getNodeInputAnchor(targetNode)),
};
})
.filter((item): item is RenderedWorkflowEdgeBase => Boolean(item));
const incomingEdges = new Map<string, RenderedWorkflowEdgeBase[]>();
baseEdges.forEach((item) => {
const group = incomingEdges.get(item.edge.target) ?? [];
group.push(item);
incomingEdges.set(item.edge.target, group);
});
const mergePointByTarget = new Map<string, Position>();
const renderedMergeTrunks: {
targetNodeId: string;
targetPoint: Position;
mergePoint: Position;
path: string;
edgeIds: string[];
}[] = [];
incomingEdges.forEach((items, targetNodeId) => {
if (items.length <= 1) return;
const targetPoint = items[0].targetPoint;
const averageSourceX =
items.reduce((total, item) => total + item.sourcePoint.x, 0) / items.length;
const averageDeltaX =
items.reduce((total, item) => total + Math.abs(targetPoint.x - item.sourcePoint.x), 0) /
items.length;
const mergeDistance = Math.max(56, Math.min(180, averageDeltaX * 0.32));
const direction = averageSourceX <= targetPoint.x ? -1 : 1;
const mergePoint = {
x: targetPoint.x + direction * mergeDistance,
y: targetPoint.y,
};
mergePointByTarget.set(targetNodeId, mergePoint);
renderedMergeTrunks.push({
targetNodeId,
targetPoint,
mergePoint,
path: buildWorkflowPath(mergePoint, targetPoint),
edgeIds: items.map((item) => item.edge.id),
});
});
const renderedEdges = baseEdges.map((item) => {
const mergePoint = mergePointByTarget.get(item.edge.target);
const visualTargetPoint = mergePoint ?? item.targetPoint;
return {
...item,
mergePoint,
path: buildWorkflowPath(item.sourcePoint, visualTargetPoint),
midPoint: {
x: (item.sourcePoint.x + visualTargetPoint.x) / 2,
y: (item.sourcePoint.y + visualTargetPoint.y) / 2,
},
};
});
return { renderedEdges, renderedMergeTrunks };
}, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]);
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null);
setBlankContextPosition(null);
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
return;
}
clearLatestGeneratedImageHighlights();
setSelectedEdgeId(null);
if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
const now = Date.now();
const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320;
lastBlankCanvasClickAtRef.current = now;
clearSelection();
if (isDoubleBlankClick) {
fitAllImageNodesIntoViewport();
}
}
}, [canvasImageSelectionSession, clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
// 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
return;
}
clearLatestGeneratedImageHighlights();
const shouldPanBlankWorkflowCanvas =
isWorkflowCanvas &&
tool === "select" &&
e.button === 0 &&
!e.altKey &&
!e.shiftKey &&
!isPointOnNodeElement(e.target);
if (
(tool === 'pan' && e.button === 0) ||
e.button === 1 ||
(e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas
) {
cancelPanPreview();
const startViewport = viewportRef.current;
setIsPanning(true);
setPanStart({ x: e.clientX - startViewport.x, y: e.clientY - startViewport.y });
panPreviewRef.current = {
startClientX: e.clientX,
startClientY: e.clientY,
startViewport,
currentViewport: startViewport,
offsetX: 0,
offsetY: 0,
rafId: null,
};
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);
}
}, [cancelPanPreview, canvasImageSelectionSession, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) {
if (isPanning) {
setIsPanning(false);
cancelPanPreview();
}
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();
const session = panPreviewRef.current;
const currentViewport = viewportRef.current;
const nextViewport = session
? {
...session.startViewport,
x: session.startViewport.x + e.clientX - session.startClientX,
y: session.startViewport.y + e.clientY - session.startClientY,
}
: {
...currentViewport,
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
};
if (session) {
session.currentViewport = nextViewport;
viewportRef.current = nextViewport;
schedulePanPreviewTransform(
nextViewport.x - session.startViewport.x,
nextViewport.y - session.startViewport.y,
);
} else {
scheduleViewport(nextViewport);
}
return;
}
if (isMarqueeSelecting) {
e.preventDefault();
setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY));
}
}, [cancelPanPreview, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart.x, panStart.y, schedulePanPreviewTransform, scheduleViewport, toCanvasPosition, viewport.x, viewport.y, viewport.zoom]);
// 处理画布平移结束
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
if (e && !isEventFromCanvasContainer(e.target) && !isPanning) 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;
}
const finalPanViewport = isPanning ? finishPanPreview() : null;
if (finalPanViewport) {
setViewport(finalPanViewport);
resetContentLayerTransform();
}
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, finishPanPreview, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, marqueeEnd, marqueeStart, nodes, resetContentLayerTransform, selectNodes, setViewport, 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;
clearLatestGeneratedImageHighlights();
if (e.cancelable) {
e.preventDefault();
}
e.stopPropagation();
// mac: 双指滚动默认平移,Cmd/Ctrl+滚轮才缩放(保留触控板体验)
// windows/linux: 鼠标滚轮默认缩放画布,不触发整页缩放
const shouldZoom = isMacPlatform ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
const currentViewport = viewportRef.current;
if (!shouldZoom) {
scheduleViewport({
...currentViewport,
x: currentViewport.x - e.deltaX,
y: currentViewport.y - e.deltaY,
});
return;
}
const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05;
const newZoom = Math.max(0.1, Math.min(4, currentViewport.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 / currentViewport.zoom;
const newX = mouseX - (mouseX - currentViewport.x) * scaleChange;
const newY = mouseY - (mouseY - currentViewport.y) * scaleChange;
scheduleViewport({ ...currentViewport, x: newX, y: newY, zoom: newZoom });
}, [clearLatestGeneratedImageHighlights, hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, scheduleViewport]);
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;
clearLatestGeneratedImageHighlights();
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 (!isImageFile(file)) continue;
if (file.size > IMAGE_UPLOAD_MAX_BYTES) {
toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
continue;
}
const dimensions = await getImageDimensionsFromFile(file);
const cleanName = file.name.replace(/\.[^.]+$/, "");
const previewUrl = URL.createObjectURL(file);
const placeholderId = addNode({
type: "image",
position: { x: baseX + index * spacing, y: baseY + index * spacing },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: previewUrl,
status: "generating",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl,
[LOCAL_UPLOAD_PENDING_METADATA_KEY]: true,
},
},
connections: [],
});
void imageGenerationService.uploadImage(file).then((result) => {
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,
},
},
});
URL.revokeObjectURL(previewUrl);
toast.success("图片已添加到画布");
return;
}
updateNode(placeholderId, {
data: {
src: previewUrl,
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
uploadError: result.error || "图片上传失败",
[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl,
[LOCAL_UPLOAD_PENDING_METADATA_KEY]: false,
},
},
});
toast.error(result.error || "图片上传失败");
}).catch(() => {
updateNode(placeholderId, {
data: {
src: previewUrl,
status: "failed",
prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
uploadError: "图片上传失败",
[LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl,
[LOCAL_UPLOAD_PENDING_METADATA_KEY]: false,
},
},
});
toast.error("图片上传失败");
});
}
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
const getContextUploadClientPoint = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
const current = viewportRef.current;
return {
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
};
}, []);
const pasteCanvasText = useCallback((text: string, point: Position) => {
if (!text.trim()) return;
addNode({
type: "text", position: point, size: { width: 320, height: 160 },
data: { content: text, color: "#111827" }, connections: [],
});
}, [addNode]);
const uploadContextVideos = useCallback(async (files: File[]) => {
if (!files.length || isContextVideoUploading) return;
const position = { ...contextCanvasPointRef.current };
setIsContextVideoUploading(true);
try {
for (const [index, file] of files.entries()) {
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
toast.error("请选择 MP4、MOV 或 WebM 视频");
continue;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("视频文件不能超过 50MB");
continue;
}
const loadingToast = toast.loading(`正在上传 ${file.name}`);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-video-process");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
addNode({
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
size: { width: 320, height: 180 },
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
connections: [],
});
toast.success("视频已添加到画布");
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
} finally {
toast.dismiss(loadingToast);
}
}
} finally {
setIsContextVideoUploading(false);
}
}, [addNode, isContextVideoUploading]);
const pasteFromContextMenu = useCallback(async () => {
const pastePoint = { ...contextCanvasPointRef.current };
const clientPoint = getContextUploadClientPoint();
try {
const files: File[] = [];
let text = "";
if (typeof navigator.clipboard?.read === "function") {
const items = await navigator.clipboard.read();
for (const [index, item] of items.entries()) {
const imageType = item.types.find((type) => type.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
} else if (item.types.includes("text/plain")) {
text += await (await item.getType("text/plain")).text();
}
}
} else if (typeof navigator.clipboard?.readText === "function") {
text = await navigator.clipboard.readText();
} else {
throw new Error("Clipboard unavailable");
}
if (files.length) {
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
} else if (text.trim()) {
pasteCanvasText(text, pastePoint);
} else {
toast.info("剪贴板中没有可粘贴的图片或文字");
}
} catch {
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
}
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
React.useEffect(() => {
if (!blankContextPosition) return;
const close = () => setBlankContextPosition(null);
window.addEventListener("resize", close);
return () => window.removeEventListener("resize", close);
}, [blankContextPosition]);
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 = extractImageFilesFromDataTransfer(e.dataTransfer);
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 = extractImageFilesFromDataTransfer(e.dataTransfer);
if (files.length > 0) {
await processDroppedFiles(files, e.clientX, e.clientY);
}
}, [isFileDrag, processDroppedFiles]);
React.useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const activeElement = document.activeElement;
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
const container = containerRef.current;
if (!container) return;
if (
activeElement instanceof HTMLElement &&
activeElement !== document.body &&
!container.contains(activeElement) && !fromContextMenu
) {
return;
}
const files = extractImageFilesFromDataTransfer(e.clipboardData);
const text = e.clipboardData?.getData("text/plain") || "";
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
if (fromContextMenu) {
setBlankContextPosition(null);
if (files.length) {
const point = getContextUploadClientPoint();
await processDroppedFiles(files, point.x, point.y);
} else {
pasteCanvasText(text, { ...contextCanvasPointRef.current });
}
return;
}
if (files.length === 0) {
const current = viewportRef.current;
pasteCanvasText(text, {
x: (rect.width / 2 - current.x) / current.zoom,
y: (rect.height / 2 - current.y) / current.zoom,
});
return;
}
await processDroppedFiles(
files,
rect.left + rect.width / 2,
rect.top + rect.height / 2
);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, 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 = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${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);
const toastId = toast.loading("正在准备所选图片...");
try {
const result = await downloadCanvasSelectionZip({ folderName, files,
selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`,
onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }),
});
if (result.failedCount) toast.warning(`已开始下载 ${result.successCount} 张图片,${result.failedCount} 张未能读取;ZIP 内附失败清单`, { id: toastId, duration: 10000 });
else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId });
} catch (error) {
toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId });
} finally {
setIsDownloadSubmitting(false);
}
}, [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 isCanvasImageSelectionCandidate = Boolean(
canvasImageSelectionSession?.allowedNodeIds.includes(node.id)
);
const isCanvasImageSelectionSelected = Boolean(
canvasImageSelectionSession?.selectedNodeIds.includes(node.id)
);
const isSelected =
!canvasImageSelectionSession && 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 imageHandleSize = IMAGE_HANDLE_SIZE * viewport.zoom;
const imageHandleOffset = imageHandleSize / 2;
const imageSelectionStrokeWidth = SELECTION_STROKE_WIDTH * viewport.zoom;
const imageResizeHandleStyles = isImageNode
? {
topRight: {
width: imageHandleSize,
height: imageHandleSize,
right: -imageHandleOffset,
top: -imageHandleOffset,
borderRadius: 0,
border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
bottomRight: {
width: imageHandleSize,
height: imageHandleSize,
right: -imageHandleOffset,
bottom: -imageHandleOffset,
borderRadius: 0,
border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
bottomLeft: {
width: imageHandleSize,
height: imageHandleSize,
left: -imageHandleOffset,
bottom: -imageHandleOffset,
borderRadius: 0,
border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
topLeft: {
width: imageHandleSize,
height: imageHandleSize,
left: -imageHandleOffset,
top: -imageHandleOffset,
borderRadius: 0,
border: `${imageSelectionStrokeWidth}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={() => {
clearLatestGeneratedImageHighlights();
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();
}}
onResizeStart={() => {
clearLatestGeneratedImageHighlights();
}}
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={canvasImageSelectionSession ? false : enableImageResizeHandles}
resizeHandleStyles={imageResizeHandleStyles}
disableDragging={tool !== 'select' || Boolean(canvasImageSelectionSession)}
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
className={cn(
"absolute",
canvasImageSelectionSession
? isCanvasImageSelectionCandidate
? "cursor-crosshair"
: "cursor-default"
: tool === "select" && "cursor-pointer"
)}
data-canvas-node-id={node.id}
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
onContextMenu={(event: React.MouseEvent) => {
if (node.type === "agent-slot") {
openAgentSlotContextMenu(event, node);
}
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (canvasImageSelectionSession) {
if (isCanvasImageSelectionCandidate) {
toggleCanvasImageSelection(node.id);
}
return;
}
setAgentSlotContextMenu(null);
clearLatestGeneratedImageHighlights();
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 border-[#d7dff1] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_12px_22px_rgba(22,24,29,0.1)] transition-all",
tool === "draw"
? "scale-100 opacity-100 ring-4 ring-[#5b76fe]/14"
: "scale-95 opacity-90"
)}
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 border-[#d7dff1] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_12px_22px_rgba(22,24,29,0.1)] transition-all",
tool === "draw"
? "scale-100 opacity-100 ring-4 ring-[#5b76fe]/14"
: "scale-95 opacity-90"
)}
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}
{isWorkflowCanvas ? <VideoCanvasNodeMarkers node={node} /> : null}
{node.type === 'image' && (
<ImageNode
batchProcessingEnabled={batchProcessingEnabled}
canvasToolbarConfig={canvasToolbarConfig}
angleEditEnabled={angleEditEnabled}
isLayerSplitEnabled={layerSplitEnabled}
isSelected={isSelected}
node={node}
tripo3dEnabled={tripo3dEnabled}
worldlabs3dEnabled={worldlabs3dEnabled}
/>
)}
{node.type === 'agent-slot' && (
<AgentSlotNode
node={node}
isSelected={isSelected}
onDataChange={(updates) => handleAgentSlotDataChange(node, updates)}
/>
)}
{node.type === 'text' && (
<TextNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'ui-screen' && (
<UiScreenNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'video' && (
<VideoNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'workflow' && (
<WorkflowNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'model3d' && (
<Model3DNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'world3d' && (
<World3DNode
canvasToolbarConfig={canvasToolbarConfig}
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>
{isCanvasImageSelectionCandidate ? (
<div
className={cn(
"pointer-events-none absolute inset-0 z-[70] border-2",
isCanvasImageSelectionSelected
? "border-[#168cff] bg-[#168cff]/10"
: "border-dashed border-[#168cff]/70"
)}
>
<span
className={cn(
"absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full border-2 shadow-sm",
isCanvasImageSelectionSelected
? "border-[#168cff] bg-[#168cff] text-white"
: "border-white bg-white/95 text-[#168cff]"
)}
>
{isCanvasImageSelectionSelected ? (
<Check className="h-4 w-4" strokeWidth={3} />
) : null}
</span>
</div>
) : null}
</Rnd>
);
};
const agentSlotContextNode = agentSlotContextMenu
? nodes.find((node) => node.id === agentSlotContextMenu.nodeId)
: null;
const agentSlotContextData =
agentSlotContextNode?.type === "agent-slot"
? ((agentSlotContextNode.data || {}) as AgentSlotNodeData)
: null;
const agentSlotContextType = agentSlotContextData?.slotType || "image";
const agentSlotContextFillPolicy =
agentSlotContextData?.fillPolicy || "replace-empty";
return (
<div
ref={containerRef}
data-canvas-workspace-root="true"
className={cn(
"relative w-full h-full overflow-hidden overscroll-none touch-none",
isWorkflowCanvas ? "bg-[#050607] text-white" : "bg-background",
tool === 'pan' && "cursor-grab",
tool === 'draw' && "cursor-crosshair",
isPanning && "cursor-grabbing",
canvasImageSelectionSession && "cursor-crosshair"
)}
style={
isWorkflowCanvas
? undefined
: { backgroundColor: "var(--canvas-surface-background, #f4f4f5)" }
}
onClick={handleCanvasClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleBlankCanvasDoubleClick}
onContextMenu={handleBlankCanvasContextMenu}
onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{!isWorkflowCanvas && (
<>
<input
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
aria-label="从右键菜单上传图片"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
const point = getContextUploadClientPoint();
void processDroppedFiles(files, point.x, point.y);
}}
/>
<input
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
aria-label="从右键菜单上传视频"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void uploadContextVideos(files);
}}
/>
<CanvasBlankContextMenu
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
onClose={() => setBlankContextPosition(null)}
onUploadImage={() => contextImageInputRef.current?.click()}
onUploadVideo={() => contextVideoInputRef.current?.click()}
onPaste={() => { void pasteFromContextMenu(); }}
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
onFitAll={fitAllCanvasNodesIntoViewport}
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
/>
</>
)}
{isWorkflowCanvas ? (
<>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(circle at top left, rgba(91,118,254,0.08), transparent 24%), radial-gradient(circle at bottom right, rgba(164,176,220,0.05), transparent 28%)",
}}
/>
</>
) : 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-white dark:bg-zinc-950"
: "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>
</>
)}
<div
ref={contentLayerRef}
className="absolute inset-0 z-[2]"
style={{
contain: "layout paint style",
overflow: "visible",
transform: "translate3d(0px, 0px, 0px)",
transformOrigin: "0 0",
}}
>
{isWorkflowCanvas ? (
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
{renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => {
const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId));
return (
<g key={`merge-trunk-${targetNodeId}`} style={{ pointerEvents: "none" }}>
{isSelected ? (
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
strokeLinecap="round"
strokeWidth={10}
/>
) : null}
<path
d={path}
fill="none"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeLinecap="round"
strokeWidth={isSelected ? 4 : 3}
/>
</g>
);
})}
{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>
</div>
{/* 性能指示器(节点数量大于 50 时显示) */}
{nodes.length >= 50 && (
<div className={cn("absolute top-4 right-4", overlayPanelClassName)}>
渲染: {visibleNodes.length}/{nodes.length}
</div>
)}
{/* 空状态提示 */}
{showEmptyState && nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{isWorkflowCanvas ? (
<div className="rounded-[26px] border border-[#dbe2ee] bg-white px-7 py-5 text-center shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_24px_52px_rgba(22,24,29,0.08)] dark:border-zinc-800 dark:bg-zinc-950">
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-[#f6f8ff] text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-800 dark:bg-zinc-950">
<GitBranch className="h-5 w-5" />
</div>
<p className="text-lg font-semibold text-[#16181d]">{workspaceLabel}还是空的</p>
<p className="mt-2 max-w-[360px] text-sm leading-6 text-[#647086]">
{emptyStateDescription}
</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>
)}
{/* 选中图片快捷操作 */}
{!canvasImageSelectionSession &&
selectedPendingImageUploadCount > 0 &&
selectedImageNodes.length === 0 && (
<div
className="absolute bottom-16 left-4 z-30 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 shadow-sm"
data-canvas-overlay
>
图片仍在本地,上传完成后可进行 AI 处理
</div>
)}
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<div
className={cn(
"absolute left-4 z-30 flex items-center gap-2",
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
)}
data-canvas-overlay
data-canvas-selection-actions="true"
>
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
<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 border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "local-edit")
}
title={selectedImageLocalEditItem.label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Wand2}
item={selectedImageLocalEditItem}
/>
{selectedImageLocalEditItem.label}
</Button>
) : null}
{isWorkflowCanvas &&
selectedImageNodes.length >= 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "layout-replace", {
args: {
sourceNodeIds: selectedImageNodes
.slice(0, 5)
.map((selectedNode) => selectedNode.id),
},
})
}
title={selectedImageLayoutReplaceItem.label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={LayoutTemplate}
item={selectedImageLayoutReplaceItem}
/>
{selectedImageLayoutReplaceItem.label}
</Button>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size={isWorkflowCanvas ? "sm" : "icon"}
className={cn(
isWorkflowCanvas
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
)}
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
aria-label={
isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
}
>
{!isWorkflowCanvas && isDownloadSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CanvasToolbarIcon
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
fallback={Download}
referenceName={isWorkflowCanvas ? undefined : "image-download"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/>
)}
{isWorkflowCanvas
? isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
: null}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</TooltipContent>
</Tooltip>
</div>
)}
{agentSlotContextMenu ? (
<div
className="absolute z-40 w-[236px] overflow-hidden rounded-lg border border-border/70 bg-background/96 p-1.5 text-foreground shadow-xl backdrop-blur"
data-canvas-overlay
style={{
left: agentSlotContextMenu.x,
top: agentSlotContextMenu.y,
}}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="truncate px-2 py-1.5 text-[11px] text-muted-foreground">
{agentSlotContextMenu.label}
</div>
<button
type="button"
className="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted"
onClick={requestFillAgentSlotFromContextMenu}
>
<Wand2 className="h-4 w-4 text-[#5b76fe]" />
<span>填充此槽位</span>
</button>
<div className="my-1 h-px bg-border/70" />
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextType === "image" && "bg-muted"
)}
onClick={() => updateAgentSlotTypeFromContextMenu("image")}
>
<ImagePlus className="h-4 w-4 text-[#5b76fe]" />
<span>设为图片槽</span>
{agentSlotContextType === "image" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextType === "video" && "bg-muted"
)}
onClick={() => updateAgentSlotTypeFromContextMenu("video")}
>
<Video className="h-4 w-4 text-[#5b76fe]" />
<span>设为视频槽</span>
{agentSlotContextType === "video" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextType === "ui-screen" && "bg-muted"
)}
onClick={() => updateAgentSlotTypeFromContextMenu("ui-screen")}
>
<LayoutGrid className="h-4 w-4 text-[#5b76fe]" />
<span>设为 UI 槽</span>
{agentSlotContextType === "ui-screen" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<div className="my-1 h-px bg-border/70" />
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextFillPolicy === "append-version" && "bg-muted"
)}
onClick={() => updateAgentSlotFromContextMenu({ fillPolicy: "append-version" })}
>
<Plus className="h-4 w-4 text-[#5b76fe]" />
<span>保留版本</span>
{agentSlotContextFillPolicy === "append-version" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
<button
type="button"
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-muted",
agentSlotContextFillPolicy === "replace-existing" && "bg-muted"
)}
onClick={() => updateAgentSlotFromContextMenu({ fillPolicy: "replace-existing" })}
>
<RefreshCw className="h-4 w-4 text-[#5b76fe]" />
<span>允许替换</span>
{agentSlotContextFillPolicy === "replace-existing" ? (
<Check className="ml-auto h-3.5 w-3.5 text-emerald-500" />
) : null}
</button>
</div>
) : null}
{showZoomControls ? (
<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={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut").label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Minus}
item={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut")}
/>
</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={getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label}
>
{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={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn").label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Plus}
item={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn")}
/>
</Button>
</div>
) : null}
</div>
);
}