2999 lines
109 KiB
TypeScript
2999 lines
109 KiB
TypeScript
"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>
|
||
);
|
||
}
|