Files
infinite-canvas/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx
T
2026-05-27 13:02:36 +08:00

2728 lines
144 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { useParams, useRouter } from "next/navigation";
import { Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { nanoid } from "nanoid";
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { cropDataUrl } from "../utils/canvas-image-data";
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
import { App, Button, Dropdown, Modal } from "antd";
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog";
import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation";
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar";
import { InfiniteCanvas } from "../components/infinite-canvas";
import { Minimap } from "../components/canvas-mini-map";
import { CanvasNode } from "../components/canvas-node";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../components/canvas-node-prompt-panel";
import { CanvasToolbar } from "../components/canvas-toolbar";
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
import { useCanvasStore } from "../stores/use-canvas-store";
import {
CanvasNodeType,
type CanvasAssistantImage,
type CanvasAssistantSession,
type CanvasConnection,
type CanvasImageGenerationType,
type CanvasNodeData,
type CanvasNodeMetadata,
type ConnectionHandle,
type ContextMenuState,
type Position,
type SelectionBox,
type ViewportTransform,
} from "../types";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio } from "@/types/media";
type CanvasClipboard = {
nodes: CanvasNodeData[];
connections: CanvasConnection[];
};
type PendingConnectionCreate = {
connection: ConnectionHandle;
position: Position;
};
type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
};
const VIDEO_NODE_MAX_WIDTH = 420;
const VIDEO_NODE_MAX_HEIGHT = 420;
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return {
id,
type,
title: spec.title,
position: {
x: position.x - spec.width / 2,
y: position.y - spec.height / 2,
},
width: spec.width,
height: spec.height,
metadata: { ...spec.metadata, ...metadata },
};
}
export default function CanvasPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return <CanvasRefreshShell />;
return <InfiniteCanvasPage />;
}
function CanvasRefreshShell() {
return (
<main className="relative h-full min-h-0 overflow-hidden bg-background text-foreground">
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, var(--border) 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<div className="absolute bottom-5 left-1/2 z-50 flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
{Array.from({ length: 7 }).map((_, index) => (
<div key={index} className="size-8 rounded-md bg-current opacity-10" />
))}
</div>
<div className="absolute bottom-24 left-6 z-50 h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute left-7 top-7 h-5 w-12 rounded-sm bg-current opacity-10" />
<div className="absolute left-28 top-16 h-6 w-16 rounded-sm bg-current opacity-10" />
<div className="absolute bottom-7 left-16 h-8 w-20 rounded-sm bg-current opacity-10" />
<div className="absolute inset-5 rounded border border-current opacity-15" />
</div>
<div className="absolute bottom-5 left-5 z-50 flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="h-1 flex-1 rounded-full bg-current opacity-10" />
<div className="h-4 w-10 rounded bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
</div>
</main>
);
}
function ConnectionCreateMenu({ pending, onCreate, onClose }: { pending: PendingConnectionCreate; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
data-connection-create-menu
style={{ left: pending.position.x, top: pending.position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
</span>
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="grid gap-1">
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
</div>
</div>
);
}
function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
return (
<button type="button" className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition" style={{ color: theme.node.text }} onClick={onClick} onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)} onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}>
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
{description ? <span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>{description}</span> : null}
</span>
</button>
);
}
function InfiniteCanvasPage() {
const { message } = App.useApp();
const params = useParams<{ id: string }>();
const router = useRouter();
const projectId = params.id;
const containerRef = useRef<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
const clipboardRef = useRef<CanvasClipboard | null>(null);
const historyRef = useRef<{ past: CanvasHistoryEntry[]; future: CanvasHistoryEntry[] }>({ past: [], future: [] });
const lastHistoryRef = useRef<CanvasHistoryEntry | null>(null);
const historyCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const applyingHistoryRef = useRef(false);
const historyPausedRef = useRef(false);
const didInitialCenterRef = useRef(false);
const rafRef = useRef<number | null>(null);
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const nodeDraggingRef = useRef(false);
const dragRef = useRef<{
isDraggingNode: boolean;
hasMoved: boolean;
startX: number;
startY: number;
initialSelectedNodes: { id: string; x: number; y: number }[];
}>({
isDraggingNode: false,
hasMoved: false,
startX: 0,
startY: 0,
initialSelectedNodes: [],
});
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const addAsset = useAssetStore((state) => state.addAsset);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const hydrated = useCanvasStore((state) => state.hydrated);
const createProject = useCanvasStore((state) => state.createProject);
const openProject = useCanvasStore((state) => state.openProject);
const updateProject = useCanvasStore((state) => state.updateProject);
const renameProject = useCanvasStore((state) => state.renameProject);
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId));
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [size, setSize] = useState({ width: 1200, height: 720 });
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [connectingParams, setConnectingParams] = useState<ConnectionHandle | null>(null);
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [runningNodeId, setRunningNodeId] = useState<string | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
const [showImageInfo, setShowImageInfo] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [assetPickerTab, setAssetPickerTab] = useState<AssetPickerTab>("my-assets");
const [projectLoaded, setProjectLoaded] = useState(false);
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [editRequestNonce, setEditRequestNonce] = useState(0);
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
const [assistantMounted, setAssistantMounted] = useState(false);
const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
const [isNodeDragging, setIsNodeDragging] = useState(false);
const nodesRef = useRef(nodes);
const connectionsRef = useRef(connections);
const selectedNodeIdsRef = useRef(selectedNodeIds);
const viewportRef = useRef(viewport);
const connectingParamsRef = useRef(connectingParams);
const connectionTargetNodeIdRef = useRef(connectionTargetNodeId);
const selectionBoxRef = useRef(selectionBox);
const pendingConnectionCreateRef = useRef(pendingConnectionCreate);
const createHistoryEntry = useCallback(
(): CanvasHistoryEntry => ({
nodes: nodesRef.current,
connections: connectionsRef.current,
chatSessions,
activeChatId,
backgroundMode,
showImageInfo,
}),
[activeChatId, backgroundMode, chatSessions, showImageInfo],
);
const cleanupCanvasFiles = useCallback(
(extra?: unknown) => {
cleanupAssetImages({ extra, history: historyRef.current, lastHistory: lastHistoryRef.current });
},
[cleanupAssetImages],
);
useEffect(() => {
if (!hydrated) return;
setProjectLoaded(false);
const project = openProject(projectId);
if (!project) {
router.replace("/canvas");
return;
}
const restore = async () => {
const restoredNodes = await hydrateCanvasImages(resetInterruptedGeneration(project.nodes));
const restoredSessions = await hydrateAssistantImages(project.chatSessions || []);
setNodes(restoredNodes);
setConnections(project.connections);
setChatSessions(restoredSessions);
setActiveChatId(project.activeChatId || null);
setBackgroundMode(project.backgroundMode);
setShowImageInfo(project.showImageInfo || false);
setViewport(project.viewport);
historyRef.current = { past: [], future: [] };
if (historyCommitTimerRef.current) {
clearTimeout(historyCommitTimerRef.current);
historyCommitTimerRef.current = null;
}
lastHistoryRef.current = {
nodes: restoredNodes,
connections: project.connections,
chatSessions: restoredSessions,
activeChatId: project.activeChatId || null,
backgroundMode: project.backgroundMode,
showImageInfo: project.showImageInfo || false,
};
setHistoryState({ canUndo: false, canRedo: false });
setProjectLoaded(true);
};
void restore();
}, [hydrated, openProject, projectId, router]);
useEffect(() => {
if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return;
const next = createHistoryEntry();
const previous = lastHistoryRef.current;
if (previous?.nodes === next.nodes && previous.connections === next.connections && previous.chatSessions === next.chatSessions && previous.activeChatId === next.activeChatId && previous.backgroundMode === next.backgroundMode && previous.showImageInfo === next.showImageInfo) return;
if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current);
historyCommitTimerRef.current = setTimeout(() => {
const current = createHistoryEntry();
const last = lastHistoryRef.current;
if (!last) return;
historyRef.current.past = [...historyRef.current.past.slice(-49), last];
historyRef.current.future = [];
setHistoryState({ canUndo: true, canRedo: false });
lastHistoryRef.current = current;
historyCommitTimerRef.current = null;
}, 180);
return () => {
if (historyCommitTimerRef.current) {
clearTimeout(historyCommitTimerRef.current);
historyCommitTimerRef.current = null;
}
};
}, [activeChatId, backgroundMode, chatSessions, connections, createHistoryEntry, nodes, projectLoaded, showImageInfo]);
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo });
}, [activeChatId, backgroundMode, chatSessions, connections, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
useEffect(() => {
if (!dialogNodeId) setNodeImageSettingsOpen(false);
}, [dialogNodeId]);
useEffect(() => {
if (!projectLoaded) return;
if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current);
viewportSaveTimerRef.current = setTimeout(() => {
updateProject(projectId, { viewport: viewportRef.current });
viewportSaveTimerRef.current = null;
}, 500);
return () => {
if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current);
};
}, [projectId, projectLoaded, updateProject, viewport]);
useLayoutEffect(() => {
nodesRef.current = nodes;
connectionsRef.current = connections;
selectedNodeIdsRef.current = selectedNodeIds;
viewportRef.current = viewport;
connectingParamsRef.current = connectingParams;
connectionTargetNodeIdRef.current = connectionTargetNodeId;
pendingConnectionCreateRef.current = pendingConnectionCreate;
}, [nodes, connections, selectedNodeIds, viewport, connectingParams, connectionTargetNodeId, pendingConnectionCreate]);
useLayoutEffect(() => {
selectionBoxRef.current = selectionBox;
}, [selectionBox]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const updateSize = () => {
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
if (!didInitialCenterRef.current) {
didInitialCenterRef.current = true;
setViewport({ x: rect.width / 2, y: rect.height / 2, k: 1 });
}
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(el);
return () => resizeObserver.disconnect();
}, []);
const screenToCanvas = useCallback((clientX: number, clientY: number) => {
const rect = containerRef.current?.getBoundingClientRect();
const currentViewport = viewportRef.current;
const localX = clientX - (rect?.left || 0);
const localY = clientY - (rect?.top || 0);
return {
x: (localX - currentViewport.x) / currentViewport.k,
y: (localY - currentViewport.y) / currentViewport.k,
};
}, []);
const getCanvasCenter = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
return screenToCanvas((rect?.left || 0) + (rect?.width || size.width) / 2, (rect?.top || 0) + (rect?.height || size.height) / 2);
}, [screenToCanvas, size.height, size.width]);
const setConnecting = useCallback((next: ConnectionHandle | null) => {
connectingParamsRef.current = next;
setConnectingParams(next);
if (!next) {
connectionTargetNodeIdRef.current = null;
setConnectionTargetNodeId(null);
}
}, []);
const keepNodeToolbar = useCallback(
(nodeId: string) => {
if (nodeDraggingRef.current || nodeImageSettingsOpen) return;
if (toolbarHideTimerRef.current) {
clearTimeout(toolbarHideTimerRef.current);
toolbarHideTimerRef.current = null;
}
setToolbarNodeId(nodeId);
},
[nodeImageSettingsOpen],
);
const hideNodeToolbar = useCallback(() => {
if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current);
toolbarHideTimerRef.current = setTimeout(() => {
setToolbarNodeId(null);
toolbarHideTimerRef.current = null;
}, 120);
}, []);
const connectNodes = useCallback(
(current: ConnectionHandle, targetNodeId: string) => {
if (current.nodeId === targetNodeId) return;
const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType);
if (!connection) {
message.warning("配置节点之间不能连接");
return;
}
const { fromNodeId, toNodeId } = connection;
const exists = connectionsRef.current.some((conn) => conn.fromNodeId === fromNodeId && conn.toNodeId === toNodeId);
if (!exists) {
setConnections((prev) => [...prev, { id: `conn-${Date.now()}`, fromNodeId, toNodeId }]);
}
setContextMenu(null);
},
[message],
);
const createConnectedNode = useCallback(
(type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio, pending: PendingConnectionCreate) => {
const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined;
const newNode = createCanvasNode(type, pending.position, metadata);
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
if (!connection) {
message.warning("配置节点之间不能连接");
return;
}
setNodes((prev) => [...prev, newNode]);
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
setPendingConnectionCreate(null);
setConnecting(null);
},
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting],
);
const cancelPendingConnectionCreate = useCallback(() => {
setPendingConnectionCreate(null);
setConnecting(null);
}, [setConnecting]);
const getConnectableNodeAtPoint = useCallback(
(clientX: number, clientY: number, current: ConnectionHandle) => {
const world = screenToCanvas(clientX, clientY);
return (
[...nodesRef.current]
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
.reverse()
.find(
(node) =>
node.id !== current.nodeId &&
Boolean(normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) &&
world.x >= node.position.x &&
world.x <= node.position.x + node.width &&
world.y >= node.position.y &&
world.y <= node.position.y + node.height,
)?.id || null
);
},
[screenToCanvas],
);
const visibleNodes = useMemo(() => {
const padding = 280;
const rect = containerRef.current?.getBoundingClientRect();
const width = rect?.width || size.width;
const height = rect?.height || size.height;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + width / viewport.k + padding * 2;
const viewBottom = viewTop + height / viewport.k + padding * 2;
return nodes.filter((node) => !isHiddenBatchChild(node, nodes, collapsingBatchIds) && node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom);
}, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null);
const batchChildCountById = useMemo(() => {
const map = new Map<string, number>();
nodes.forEach((node) => {
if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0);
});
return map;
}, [nodes]);
const batchMotionById = useMemo(() => {
const map = new Map<string, { x: number; y: number; index: number }>();
nodes.forEach((node) => {
const rootId = node.metadata?.batchRootId;
if (!rootId) return;
const root = nodeById.get(rootId);
const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0;
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
});
return map;
}, [nodeById, nodes]);
const relatedHighlight = useMemo(() => {
const nodeIds = new Set<string>();
const connectionIds = new Set<string>();
if (!activeNodeId) return { nodeIds, connectionIds };
nodeIds.add(activeNodeId);
connections.forEach((connection) => {
if (connection.fromNodeId !== activeNodeId && connection.toNodeId !== activeNodeId) return;
connectionIds.add(connection.id);
nodeIds.add(connection.fromNodeId);
nodeIds.add(connection.toNodeId);
});
return { nodeIds, connectionIds };
}, [activeNodeId, connections]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();
nodes.forEach((node) => {
if (node.type !== CanvasNodeType.Config) return;
map.set(node.id, buildNodeGenerationInputs(node.id, nodes, connections));
});
return map;
}, [connections, nodes]);
const createNode = useCallback(
(type: CanvasNodeType, position?: Position) => {
const targetPosition = position || getCanvasCenter();
const configMetadata =
type === CanvasNodeType.Config
? {
model: effectiveConfig.imageModel || effectiveConfig.model,
size: effectiveConfig.size,
count: 3,
}
: undefined;
const newNode = createCanvasNode(type, targetPosition, configMetadata);
setNodes((prev) => [...prev, newNode]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
},
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
);
const deleteNodes = useCallback(
(ids: Set<string>) => {
if (!ids.size) return;
const allIds = new Set(ids);
nodesRef.current.forEach((node) => {
if (ids.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => allIds.add(childId));
});
setNodes((prev) => {
const next = prev.filter((node) => !allIds.has(node.id));
return next.map((node) => {
const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId));
if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node;
const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0];
const primaryNode = next.find((item) => item.id === primaryImageId);
return {
...node,
metadata: {
...node.metadata,
batchChildIds: childIds,
primaryImageId,
content: primaryNode?.metadata?.content || node.metadata.content,
naturalWidth: primaryNode?.metadata?.naturalWidth || node.metadata.naturalWidth,
naturalHeight: primaryNode?.metadata?.naturalHeight || node.metadata.naturalHeight,
},
};
});
});
setConnections((prev) => prev.filter((conn) => !allIds.has(conn.fromNodeId) && !allIds.has(conn.toNodeId)));
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setHoveredNodeId((current) => (current && allIds.has(current) ? null : current));
setToolbarNodeId((current) => (current && allIds.has(current) ? null : current));
setDialogNodeId((current) => (current && allIds.has(current) ? null : current));
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
setAngleNodeId((current) => (current && allIds.has(current) ? null : current));
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
setContextMenu((current) => (current && allIds.has(current.nodeId) ? null : current));
cleanupCanvasFiles({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions });
},
[chatSessions, cleanupCanvasFiles, projectId],
);
const deselectCanvas = useCallback(() => {
cancelPendingConnectionCreate();
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setContextMenu(null);
setSelectionBox(null);
setHoveredNodeId(null);
setToolbarNodeId(null);
setDialogNodeId(null);
setEditingNodeId(null);
}, [cancelPendingConnectionCreate]);
const clearCanvas = useCallback(() => {
setNodes([]);
setConnections([]);
setInfoNodeId(null);
setCropNodeId(null);
setAngleNodeId(null);
setPreviewNodeId(null);
setRunningNodeId(null);
deselectCanvas();
setClearConfirmOpen(false);
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
}, [cleanupCanvasFiles, deselectCanvas, projectId]);
const duplicateNode = useCallback((nodeId: string) => {
const source = nodesRef.current.find((node) => node.id === nodeId);
if (!source) return;
const id = `${source.type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const next: CanvasNodeData = {
...source,
id,
title: `${source.title} Copy`,
position: { x: source.position.x + 36, y: source.position.y + 36 },
};
setNodes((prev) => [...prev, next]);
setSelectedNodeIds(new Set([id]));
setSelectedConnectionId(null);
setDialogNodeId(id);
}, []);
const copySelectedNodes = useCallback(() => {
const selectedIds = selectedNodeIdsRef.current;
if (!selectedIds.size) return;
const copiedNodes = nodesRef.current
.filter((node) => selectedIds.has(node.id))
.map((node) => ({
...node,
position: { ...node.position },
metadata: node.metadata ? { ...node.metadata } : undefined,
}));
if (!copiedNodes.length) return;
clipboardRef.current = {
nodes: copiedNodes,
connections: connectionsRef.current.filter((connection) => selectedIds.has(connection.fromNodeId) && selectedIds.has(connection.toNodeId)).map((connection) => ({ ...connection })),
};
}, []);
const pasteCopiedNodes = useCallback(() => {
const clipboard = clipboardRef.current;
if (!clipboard?.nodes.length) return false;
const center = getCanvasCenter();
const bounds = clipboard.nodes.reduce(
(acc, node) => ({
left: Math.min(acc.left, node.position.x),
top: Math.min(acc.top, node.position.y),
right: Math.max(acc.right, node.position.x + node.width),
bottom: Math.max(acc.bottom, node.position.y + node.height),
}),
{ left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
);
const dx = center.x - (bounds.left + bounds.right) / 2;
const dy = center.y - (bounds.top + bounds.bottom) / 2;
const idMap = new Map<string, string>();
const nextNodes = clipboard.nodes.map((node, index) => {
const id = `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`;
idMap.set(node.id, id);
return {
...node,
id,
title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`,
position: {
x: node.position.x + dx,
y: node.position.y + dy,
},
metadata: node.metadata ? { ...node.metadata } : undefined,
};
});
const nextConnections = clipboard.connections.flatMap((connection, index) => {
const fromNodeId = idMap.get(connection.fromNodeId);
const toNodeId = idMap.get(connection.toNodeId);
if (!fromNodeId || !toNodeId) return [];
return [
{
...connection,
id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`,
fromNodeId,
toNodeId,
},
];
});
setNodes((prev) => [...prev, ...nextNodes]);
setConnections((prev) => [...prev, ...nextConnections]);
setSelectedNodeIds(new Set(nextNodes.map((node) => node.id)));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(nextNodes[0]?.id || null);
return true;
}, [getCanvasCenter]);
const resetViewport = useCallback(() => {
setViewport({ x: size.width / 2, y: size.height / 2, k: 1 });
setContextMenu(null);
}, [size.height, size.width]);
const setZoomScale = useCallback(
(scale: number) => {
const nextScale = Math.min(Math.max(scale, 0.05), 5);
setViewport((prev) => ({
x: size.width / 2 - ((size.width / 2 - prev.x) / prev.k) * nextScale,
y: size.height / 2 - ((size.height / 2 - prev.y) / prev.k) * nextScale,
k: nextScale,
}));
setContextMenu(null);
},
[size.height, size.width],
);
const applyHistory = useCallback((entry: CanvasHistoryEntry) => {
if (historyCommitTimerRef.current) {
clearTimeout(historyCommitTimerRef.current);
historyCommitTimerRef.current = null;
}
applyingHistoryRef.current = true;
setNodes(entry.nodes);
setConnections(entry.connections);
setChatSessions(entry.chatSessions);
setActiveChatId(entry.activeChatId);
setBackgroundMode(entry.backgroundMode);
setShowImageInfo(entry.showImageInfo);
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setContextMenu(null);
setTimeout(() => {
lastHistoryRef.current = entry;
applyingHistoryRef.current = false;
setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 });
});
}, []);
const undoCanvas = useCallback(() => {
const previous = historyRef.current.past.pop();
const current = lastHistoryRef.current;
if (!previous || !current) return;
historyRef.current.future.push(current);
applyHistory(previous);
}, [applyHistory]);
const redoCanvas = useCallback(() => {
const next = historyRef.current.future.pop();
const current = lastHistoryRef.current;
if (!next || !current) return;
historyRef.current.past.push(current);
applyHistory(next);
}, [applyHistory]);
const createAndOpenProject = useCallback(() => {
const id = createProject(`无限画布 ${useCanvasStore.getState().projects.length + 1}`);
router.push(`/canvas/${id}`);
}, [createProject, router]);
const deleteCurrentProject = useCallback(() => {
deleteProjects([projectId]);
cleanupAssetImages();
router.push("/canvas");
}, [cleanupAssetImages, deleteProjects, projectId, router]);
const handleCanvasMouseDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
setContextMenu(null);
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
if (event.button !== 0) return;
if (!event.ctrlKey && !event.metaKey) {
setSelectionBox(null);
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
return;
}
const world = screenToCanvas(event.clientX, event.clientY);
const nextSelectionBox = {
startWorldX: world.x,
startWorldY: world.y,
currentWorldX: world.x,
currentWorldY: world.y,
additive: event.shiftKey,
initialSelectedNodeIds: event.shiftKey ? Array.from(selectedNodeIdsRef.current) : [],
};
selectionBoxRef.current = nextSelectionBox;
setSelectionBox(nextSelectionBox);
if (!event.shiftKey) {
setSelectedNodeIds(new Set());
}
setSelectedConnectionId(null);
},
[cancelPendingConnectionCreate, screenToCanvas],
);
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
setContextMenu(null);
setHoveredNodeId(null);
setToolbarNodeId(null);
setSelectedConnectionId(null);
const currentSelected = selectedNodeIdsRef.current;
const currentNodes = nodesRef.current;
const nextSelected = new Set(currentSelected);
if (event.shiftKey || event.metaKey || event.ctrlKey) {
if (nextSelected.has(nodeId)) {
nextSelected.delete(nodeId);
} else {
nextSelected.add(nodeId);
}
} else if (!nextSelected.has(nodeId)) {
nextSelected.clear();
nextSelected.add(nodeId);
}
setSelectedNodeIds(nextSelected);
const dragIds = new Set(nextSelected);
currentNodes.forEach((node) => {
if (nextSelected.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
});
dragRef.current = {
isDraggingNode: true,
hasMoved: false,
startX: event.clientX,
startY: event.clientY,
initialSelectedNodes: currentNodes.filter((node) => dragIds.has(node.id)).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y })),
};
historyPausedRef.current = true;
nodeDraggingRef.current = true;
setIsNodeDragging(true);
}, []);
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (!dragRef.current.isDraggingNode) return;
const wasClick = !dragRef.current.hasMoved && dragRef.current.initialSelectedNodes.length === 1;
const clickedNodeId = dragRef.current.initialSelectedNodes[0]?.id;
const currentViewport = viewportRef.current;
const dx = clientX == null ? 0 : (clientX - dragRef.current.startX) / currentViewport.k;
const dy = clientY == null ? 0 : (clientY - dragRef.current.startY) / currentViewport.k;
const initialPositions = dragRef.current.initialSelectedNodes;
historyPausedRef.current = false;
nodeDraggingRef.current = false;
setIsNodeDragging(false);
if (dragRef.current.hasMoved && clientX != null && clientY != null) {
setNodes((prev) =>
prev.map((node) => {
const initial = initialPositions.find((item) => item.id === node.id);
if (!initial) return node;
return { ...node, position: { x: initial.x + dx, y: initial.y + dy } };
}),
);
}
dragRef.current.isDraggingNode = false;
dragRef.current.hasMoved = false;
dragRef.current.initialSelectedNodes = [];
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
if (clickedNode?.type === CanvasNodeType.Text) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else {
setDialogNodeId(clickedNodeId);
}
}
}, []);
const handleGlobalMouseMove = useCallback(
(event: MouseEvent) => {
const currentViewport = viewportRef.current;
if (dragRef.current.isDraggingNode) {
const dx = (event.clientX - dragRef.current.startX) / currentViewport.k;
const dy = (event.clientY - dragRef.current.startY) / currentViewport.k;
const initialPositions = dragRef.current.initialSelectedNodes;
if (Math.abs(event.clientX - dragRef.current.startX) > 3 || Math.abs(event.clientY - dragRef.current.startY) > 3) {
dragRef.current.hasMoved = true;
}
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setNodes((prev) =>
prev.map((node) => {
const initial = initialPositions.find((item) => item.id === node.id);
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
}),
);
rafRef.current = null;
});
return;
}
if (connectingParamsRef.current && !pendingConnectionCreateRef.current) {
const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, connectingParamsRef.current);
connectionTargetNodeIdRef.current = targetNodeId;
setConnectionTargetNodeId(targetNodeId);
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
}
},
[finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas],
);
const handleGlobalPointerMove = useCallback(
(event: PointerEvent) => {
const currentSelection = selectionBoxRef.current;
if (!currentSelection) return;
if (event.buttons === 0) {
selectionBoxRef.current = null;
setSelectionBox(null);
return;
}
const world = screenToCanvas(event.clientX, event.clientY);
const rectX = Math.min(currentSelection.startWorldX, world.x);
const rectY = Math.min(currentSelection.startWorldY, world.y);
const rectW = Math.abs(world.x - currentSelection.startWorldX);
const rectH = Math.abs(world.y - currentSelection.startWorldY);
const nextSelected = new Set<string>(currentSelection.additive ? currentSelection.initialSelectedNodeIds : []);
nodesRef.current
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
.forEach((node) => {
const intersects = rectX < node.position.x + node.width && rectX + rectW > node.position.x && rectY < node.position.y + node.height && rectY + rectH > node.position.y;
if (intersects) nextSelected.add(node.id);
});
const nextSelectionBox = { ...currentSelection, currentWorldX: world.x, currentWorldY: world.y };
selectionBoxRef.current = nextSelectionBox;
setSelectionBox(nextSelectionBox);
setSelectedNodeIds(nextSelected);
},
[screenToCanvas],
);
const handleGlobalMouseUp = useCallback(
(event: MouseEvent) => {
finishNodeDrag(event.clientX, event.clientY);
selectionBoxRef.current = null;
setSelectionBox(null);
if (pendingConnectionCreateRef.current) return;
const currentConnection = connectingParamsRef.current;
if (currentConnection) {
const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, currentConnection) || connectionTargetNodeIdRef.current;
if (targetNodeId) {
connectNodes(currentConnection, targetNodeId);
setConnecting(null);
} else {
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
setPendingConnectionCreate({ connection: currentConnection, position: screenToCanvas(event.clientX, event.clientY) });
}
}
},
[connectNodes, finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas, setConnecting],
);
useEffect(() => {
const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY);
const cancelNodeDrag = () => finishNodeDrag();
window.addEventListener("mousemove", handleGlobalMouseMove);
window.addEventListener("mouseup", handleGlobalMouseUp);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", cancelNodeDrag);
window.addEventListener("blur", cancelNodeDrag);
window.addEventListener("pointermove", handleGlobalPointerMove);
return () => {
window.removeEventListener("mousemove", handleGlobalMouseMove);
window.removeEventListener("mouseup", handleGlobalMouseUp);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", cancelNodeDrag);
window.removeEventListener("blur", cancelNodeDrag);
window.removeEventListener("pointermove", handleGlobalPointerMove);
};
}, [finishNodeDrag, handleGlobalMouseMove, handleGlobalMouseUp, handleGlobalPointerMove]);
const createImageFileNode = useCallback(async (file: File, position: Position) => {
const image = await uploadImage(file);
const size = fitNodeSize(image.width, image.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newNode: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: file.name,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: imageMetadata(image),
};
setNodes((prev) => [...prev, newNode]);
setSelectedNodeIds(new Set([id]));
setSelectedConnectionId(null);
setDialogNodeId(id);
}, []);
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
const video = await uploadMediaFile(file, "video");
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((prev) => [
...prev,
{
id,
type: CanvasNodeType.Video,
title: file.name,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: videoMetadata(video),
},
]);
setSelectedNodeIds(new Set([id]));
setSelectedConnectionId(null);
setDialogNodeId(id);
}, []);
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
const audio = await uploadMediaFile(file, "audio");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setNodes((prev) => [
...prev,
{
id,
type: CanvasNodeType.Audio,
title: file.name,
position: { x: position.x - spec.width / 2, y: position.y - spec.height / 2 },
width: spec.width,
height: spec.height,
metadata: audioMetadata(audio),
},
]);
setSelectedNodeIds(new Set([id]));
setSelectedConnectionId(null);
}, []);
const createTextNodeFromClipboard = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return false;
const node = {
...createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }),
title: trimmed.slice(0, 32) || "剪切板文本",
};
setNodes((prev) => [...prev, node]);
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setContextMenu(null);
setDialogNodeId(node.id);
return true;
},
[getCanvasCenter],
);
const pasteSystemClipboard = useCallback(async () => {
if (!navigator.clipboard) return;
const items = await navigator.clipboard.read();
const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/")));
if (imageItem) {
const imageType = imageItem.types.find((type) => type.startsWith("image/"));
if (!imageType) return;
const blob = await imageItem.getType(imageType);
const file = new File([blob], "clipboard-image.png", { type: imageType });
void createImageFileNode(file, getCanvasCenter());
message.success("已从剪切板添加图片");
return;
}
const text = await navigator.clipboard.readText();
if (createTextNodeFromClipboard(text)) message.success("已从剪切板添加文本");
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return;
const key = event.key.toLowerCase();
const isModifierShortcut = event.metaKey || event.ctrlKey;
if (isModifierShortcut && !event.altKey && key === "z") {
event.preventDefault();
if (event.shiftKey) redoCanvas();
else undoCanvas();
return;
}
if (isModifierShortcut && !event.altKey && key === "y") {
event.preventDefault();
redoCanvas();
return;
}
if (isModifierShortcut && !event.altKey && key === "a") {
event.preventDefault();
setSelectedNodeIds(new Set(nodesRef.current.map((node) => node.id)));
setSelectedConnectionId(null);
setContextMenu(null);
setSelectionBox(null);
return;
}
if (isModifierShortcut && !event.altKey && key === "c") {
event.preventDefault();
copySelectedNodes();
return;
}
if (isModifierShortcut && !event.altKey && key === "v") {
event.preventDefault();
if (!pasteCopiedNodes()) void pasteSystemClipboard();
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
if (selectedNodeIdsRef.current.size) {
deleteNodes(new Set(selectedNodeIdsRef.current));
} else if (selectedConnectionId) {
setConnections((prev) => prev.filter((conn) => conn.id !== selectedConnectionId));
setSelectedConnectionId(null);
}
}
if (event.key === "Escape") {
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setContextMenu(null);
setSelectionBox(null);
setConnecting(null);
setHoveredNodeId(null);
setToolbarNodeId(null);
setDialogNodeId(null);
setEditingNodeId(null);
setInfoNodeId(null);
setCropNodeId(null);
setPendingConnectionCreate(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [copySelectedNodes, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]);
const handleConnectStart = useCallback(
(event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => {
event.stopPropagation();
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
setConnecting({ nodeId, handleType });
connectionTargetNodeIdRef.current = null;
setConnectionTargetNodeId(null);
setSelectedConnectionId(null);
},
[screenToCanvas, setConnecting],
);
const handleNodeResize = useCallback((nodeId: string, width: number, height: number, position?: Position) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node)));
}, []);
const toggleNodeFreeResize = useCallback((nodeId: string) => {
setNodes((prev) =>
prev.map((node) => {
if (node.id !== nodeId) return node;
const freeResize = !node.metadata?.freeResize;
if (freeResize || node.type !== CanvasNodeType.Image) return { ...node, metadata: { ...node.metadata, freeResize } };
const ratio = (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1);
const height = node.width / ratio;
return { ...node, height, position: { x: node.position.x, y: node.position.y + node.height / 2 - height / 2 }, metadata: { ...node.metadata, freeResize } };
}),
);
}, []);
const handleNodeContentChange = useCallback((nodeId: string, content: string) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node)));
}, []);
const toggleBatchExpanded = useCallback((nodeId: string) => {
const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded);
if (isExpanded) {
setCollapsingBatchIds((prev) => new Set(prev).add(nodeId));
window.setTimeout(() => {
setCollapsingBatchIds((prev) => {
const next = new Set(prev);
next.delete(nodeId);
return next;
});
}, 320);
} else {
setOpeningBatchIds((prev) => new Set(prev).add(nodeId));
window.setTimeout(() => {
setOpeningBatchIds((prev) => {
const next = new Set(prev);
next.delete(nodeId);
return next;
});
}, 260);
}
setNodes((prev) =>
prev.map((node) => {
if (node.id !== nodeId) return node;
return { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } };
}),
);
}, []);
const setBatchPrimary = useCallback((child: CanvasNodeData) => {
const rootId = child.metadata?.batchRootId;
if (!rootId || !child.metadata?.content) return;
setNodes((prev) =>
prev.map((node) =>
node.id === rootId
? {
...node,
width: child.width,
height: child.height,
metadata: {
...node.metadata,
content: child.metadata?.content,
primaryImageId: child.id,
naturalWidth: child.metadata?.naturalWidth,
naturalHeight: child.metadata?.naturalHeight,
freeResize: child.metadata?.freeResize,
},
}
: node,
),
);
}, []);
const openTextEditor = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setDialogNodeId(node.id);
setEditingNodeId(node.id);
setEditRequestNonce((value) => value + 1);
}, []);
const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node)));
}, []);
const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node)));
}, []);
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
}, []);
const saveNodeAsset = useCallback(
async (node: CanvasNodeData) => {
if (node.type === CanvasNodeType.Text) {
const content = node.metadata?.content?.trim();
if (!content) return message.error("没有可保存的文本");
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
message.success("已加入我的素材");
return;
}
if (node.type === CanvasNodeType.Video) {
if (!node.metadata?.content) return message.error("没有可保存的视频");
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
message.success("已加入我的素材");
return;
}
if (!node.metadata?.content) return message.error("没有可保存的图片");
const dataUrl = node.metadata.storageKey ? "" : node.metadata.content;
addAsset({
kind: "image",
title: node.metadata?.prompt?.slice(0, 24) || "画布图片",
coverUrl: node.metadata.content,
tags: [],
source: "Canvas",
data: {
dataUrl,
storageKey: node.metadata.storageKey,
width: node.metadata.naturalWidth || node.width,
height: node.metadata.naturalHeight || node.height,
bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl),
mimeType: node.metadata.mimeType || "image/png",
},
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
});
message.success("已加入我的素材");
},
[addAsset, message],
);
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
if (!node.metadata?.content) return;
const cropped = await cropDataUrl(node.metadata.content, crop);
const image = await uploadImage(cropped);
const width = Math.min(node.width, Math.max(220, image.width));
const childId = nanoid();
const child: CanvasNodeData = {
id: childId,
type: CanvasNodeType.Image,
title: "Cropped Image",
position: { x: node.position.x + node.width + 96, y: node.position.y },
width,
height: width * (image.height / image.width),
metadata: {
...imageMetadata(image),
prompt: node.metadata?.prompt,
},
};
setNodes((prev) => [...prev, child]);
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setDialogNodeId(childId);
setCropNodeId(null);
}, []);
const generateAngleNode = useCallback(
async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
const childId = nanoid();
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const title = buildAngleLabel(params);
const prompt = buildAnglePrompt(params);
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [
{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey },
]);
setAngleNodeId(null);
setRunningNodeId(childId);
setNodes((prev) => [
...prev,
{
id: childId,
type: CanvasNodeType.Image,
title,
position: { x: node.position.x + node.width + 96, y: node.position.y },
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata },
},
]);
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setDialogNodeId(childId);
try {
const image = await requestEdit(generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }]).then(
(items) => items[0],
);
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
} finally {
setRunningNodeId(null);
}
},
[effectiveConfig, openConfigDialog],
);
const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node)));
}, []);
const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => {
uploadTargetRef.current = { nodeId, position };
imageInputRef.current?.click();
}, []);
const handleImageInputChange = useCallback(
async (event: ReactChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
if (target?.nodeId) {
if (isAudioFile(file)) {
const audio = await uploadMediaFile(file, "audio");
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Audio, title: file.name, position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined } } : node)));
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
uploadTargetRef.current = null;
event.target.value = "";
return;
}
if (file.type.startsWith("video/")) {
const video = await uploadMediaFile(file, "video");
const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((node) => (node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: file.name, position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined } } : node)));
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
uploadTargetRef.current = null;
event.target.value = "";
return;
}
const image = await uploadImage(file);
const size = fitNodeSize(image.width, image.height);
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
? {
...node,
type: CanvasNodeType.Image,
title: file.name,
width: size.width,
height: size.height,
metadata: {
...node.metadata,
...imageMetadata(image),
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
}
: node,
),
);
setSelectedNodeIds(new Set([target.nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(target.nodeId);
} else {
const position = target?.position || screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
}
uploadTargetRef.current = null;
event.target.value = "";
},
[createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas, size.height, size.width],
);
const handleDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
if (!file) return;
const pos = screenToCanvas(event.clientX, event.clientY);
void (isAudioFile(file) ? createAudioFileNode(file, pos) : file.type.startsWith("video/") ? createVideoFileNode(file, pos) : createImageFileNode(file, pos));
},
[createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas],
);
const pasteAssistantImage = useCallback(
(file: File) => {
const position = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
void createImageFileNode(file, position);
message.success("已从剪切板添加图片");
},
[createImageFileNode, message, screenToCanvas, size.height, size.width],
);
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
setChatSessions(sessions);
setActiveChatId(activeId);
}, []);
const startTitleEditing = useCallback(() => {
setTitleDraft(currentProject?.title || "未命名画布");
setTitleEditing(true);
}, [currentProject?.title]);
const finishTitleEditing = useCallback(() => {
const nextTitle = titleDraft.trim();
if (nextTitle) renameProject(projectId, nextTitle);
setTitleEditing(false);
}, [projectId, renameProject, titleDraft]);
const preventCanvasContextMenu = useCallback((event: ReactMouseEvent) => {
if ((event.target as HTMLElement).closest("[data-node-id]")) return;
event.preventDefault();
setContextMenu(null);
}, []);
const handleGenerateNode = useCallback(
async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
const generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
setRunningNodeId(nodeId);
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationContext = await hydrateNodeGenerationContext(
buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt),
);
const effectivePrompt = generationContext.prompt.trim();
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
if (!effectivePrompt && mode === "text") {
setRunningNodeId(null);
return;
}
let pendingChildIds: string[] = [];
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
if (mode === "image") {
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const isEmptyImageNode = isImageNode && !sourceNode?.metadata?.content;
const sourceReference =
isImageNode && sourceNode?.metadata?.content
? [{ id: sourceNode.id, name: `${sourceNode.title || sourceNode.id}.png`, type: sourceNode.metadata.mimeType || "image/png", dataUrl: sourceNode.metadata.content, storageKey: sourceNode.metadata.storageKey }]
: [];
const referenceImages = sourceReference.length ? sourceReference : generationContext.referenceImages;
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages);
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text];
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
const gap = 96;
const rowGap = 36;
const rootId = isEmptyImageNode ? nodeId : nanoid();
const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : [];
const targetIds = count > 1 ? childIds : [rootId];
pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds];
const rootNode: CanvasNodeData = {
id: rootId,
type: CanvasNodeType.Image,
title: effectivePrompt.slice(0, 32) || "Generated Image",
position: {
x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap,
y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2,
},
width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width,
height: isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height,
metadata: {
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
isBatchRoot: count > 1,
batchChildIds: count > 1 ? childIds : undefined,
batchUsesReferenceImages: referenceImages.length > 0,
...generationMetadata,
imageBatchExpanded: count > 1 ? true : undefined,
},
};
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
type: CanvasNodeType.Image,
title: effectivePrompt.slice(0, 32) || "Generated Image",
position: {
x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36),
y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + rowGap),
},
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata },
}));
const batchConnections = [...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
setNodes((prev) => [
...prev.map((node) =>
node.id === nodeId
? isConfigNode
? {
...node,
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
}
: isEmptyImageNode
? {
...node,
position: rootNode.position,
width: rootNode.width,
height: rootNode.height,
title: rootNode.title,
metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined },
}
: isImageNode
? {
...node,
metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined },
}
: {
...node,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Prompt",
width: parentConfig.width,
height: parentConfig.height,
metadata: { ...node.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
}
: node,
),
...(isEmptyImageNode ? [] : [rootNode]),
...childNodes,
]);
setConnections((prev) => [...prev, ...batchConnections]);
setSelectedNodeIds(new Set([nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(nodeId);
let hasSuccess = false;
let hasFailure = false;
await Promise.all(
targetIds.map(async (targetId) => {
try {
const image = referenceImages.length
? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages).then((items) => items[0])
: await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
setNodes((prev) => {
const root = prev.find((node) => node.id === rootId);
return prev.map((node) => {
if (node.id !== targetId && node.id !== rootId) return node;
const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 };
if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId))
return {
...node,
position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 },
width: imageSize.width,
height: imageSize.height,
metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId },
};
if (node.id === targetId)
return {
...node,
position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 },
width: imageSize.width,
height: imageSize.height,
metadata: { ...node.metadata, ...imageMetadata(uploaded) },
};
return node;
});
});
hasSuccess = true;
if (isConfigNode) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)));
return true;
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
hasFailure = true;
setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
return false;
}
}),
);
if (hasFailure) message.error(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
setNodes((prev) =>
prev.map((node) =>
node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
: node.id === nodeId && isEmptyImageNode
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
: node.id === rootId && !hasSuccess
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } }
: node,
),
);
return;
}
if (mode === "video") {
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
const videoId = isEmptyVideoNode ? nodeId : nanoid();
const parent = sourceNode?.position || { x: 0, y: 0 };
const videoNode: CanvasNodeData = {
id: videoId,
type: CanvasNodeType.Video,
title: effectivePrompt.slice(0, 32) || "Generated Video",
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: isEmptyVideoNode ? sourceNode.width : spec.width,
height: isEmptyVideoNode ? sourceNode.height : spec.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) },
};
pendingChildIds = [videoId];
setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]));
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios));
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext) } } : node)));
return;
}
let streamed = "";
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1;
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text];
const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : [];
pendingChildIds = childIds;
if (isConfigNode || editingTextNode) {
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
type: CanvasNodeType.Text,
title: prompt.slice(0, 32) || "Generated Text",
position: {
x: parentPosition.x + parentConfig.width + 96,
y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36),
},
width: textConfig.width,
height: textConfig.height,
metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 },
}));
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
}
const answers = await Promise.all(
(childIds.length ? childIds : [nodeId]).map((targetNodeId) => {
let localStreamed = "";
return requestImageQuestion(generationConfig, buildNodeChatMessages({ ...generationContext, prompt: effectivePrompt }), (text) => {
localStreamed = text;
streamed = text;
if (isConfigNode) return;
setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node)));
}).then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed }));
}),
);
const answerByNodeId = new Map(answers.map((item) => [item.nodeId, item.content]));
setNodes((prev) =>
prev.map((node) =>
childIds.includes(node.id)
? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } }
: node.id === nodeId && isConfigNode
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } }
: node.id === nodeId && !editingTextNode
? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } }
: node,
),
);
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
message.error(errorDetails);
setNodes((prev) =>
prev.map((node) => (node.id === nodeId || pendingChildIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } }) : node)),
);
} finally {
setRunningNodeId(null);
}
},
[effectiveConfig, openConfigDialog],
);
const handleRetryNode = useCallback(
async (node: CanvasNodeData) => {
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null;
const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined;
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
const generationConfig =
hasSavedImageMetadata && savedImageMetadata
? {
...effectiveConfig,
model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model,
quality: savedImageMetadata.quality || effectiveConfig.quality,
size: savedImageMetadata.size || effectiveConfig.size,
count: "1",
}
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || ""));
const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim();
if (!prompt) {
message.warning("找不到提示词,无法重试");
return;
}
const generationType = savedImageMetadata?.generationType;
const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
const retryReferenceImages =
hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : [];
if (useReferenceImages && !retryReferenceImages) {
message.error("参考图片已丢失,无法继续重试");
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item)));
return;
}
const retryImages = retryReferenceImages || [];
setRunningNodeId(node.id);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item)));
try {
if (node.type === CanvasNodeType.Text) {
if (!context) return;
let streamed = "";
const answer = await requestImageQuestion(generationConfig, buildNodeChatMessages({ ...context, prompt }), (text) => {
streamed = text;
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item)));
});
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item)));
return;
}
if (node.type === CanvasNodeType.Video) {
const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || [], context?.referenceAudios || []));
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark } } : item)));
return;
}
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
const uploadedImage = await uploadImage(image.dataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const generationMetadata = savedImageMetadata?.generationType
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
setNodes((prev) =>
prev.map((item) =>
item.id === node.id
? {
...item,
type: CanvasNodeType.Image,
width: imageSize.width,
height: imageSize.height,
metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata },
}
: item,
),
);
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
message.error(errorDetails);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
} finally {
setRunningNodeId(null);
}
},
[effectiveConfig, message, openConfigDialog],
);
const generateImageFromTextNode = useCallback(
(node: CanvasNodeData) => {
const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim();
if (!prompt) {
message.warning("文本节点为空,无法生图");
return;
}
const sourceNode = nodesRef.current.find((item) => item.id === node.id);
if (!sourceNode) return;
const nodeSize = getNodeSpec(CanvasNodeType.Config);
const configNode = createCanvasNode(
CanvasNodeType.Config,
{
x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2,
y: sourceNode.position.y + sourceNode.height / 2,
},
{
prompt: "",
model: effectiveConfig.imageModel || effectiveConfig.model,
size: effectiveConfig.size,
count: 3,
},
);
const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id };
const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(configNode);
const nextConnections = [...connectionsRef.current, connection];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set([configNode.id]));
setSelectedConnectionId(null);
setDialogNodeId(configNode.id);
},
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message],
);
const insertAssistantImage = useCallback(
async (image: CanvasAssistantImage) => {
const storedImage = image.storageKey ? { url: image.dataUrl, storageKey: image.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(image.dataUrl);
const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage;
const config = fitNodeSize(meta.width, meta.height);
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: image.prompt.slice(0, 32) || "Generated Image",
position: { x: center.x - config.width / 2, y: center.y - config.height / 2 },
width: config.width,
height: config.height,
metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: image.prompt },
};
setNodes((prev) => [...prev, node]);
setSelectedNodeIds(new Set([id]));
setSelectedConnectionId(null);
setDialogNodeId(id);
},
[screenToCanvas, size.height, size.width],
);
const insertAssistantText = useCallback(
(text: string) => {
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
const node = {
...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }),
title: text.slice(0, 32) || "Assistant Text",
};
setNodes((prev) => [...prev, node]);
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
},
[screenToCanvas, size.height, size.width],
);
const handleAssetInsert = useCallback(
(payload: InsertAssetPayload) => {
if (payload.kind === "text") {
insertAssistantText(payload.content);
} else if (payload.kind === "video") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const nextSize = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => [...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - nextSize.width / 2, y: center.y - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height } }]);
setSelectedNodeIds(new Set([id]));
} else {
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
}
setAssetPickerOpen(false);
},
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
);
if (!projectLoaded) return <CanvasRefreshShell />;
return (
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<section className="relative min-w-0 flex-1 overflow-hidden">
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft}
onStartTitleEditing={startTitleEditing}
onFinishTitleEditing={finishTitleEditing}
onCancelTitleEditing={() => setTitleEditing(false)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
onHome={() => router.push("/")}
onProjects={() => router.push("/canvas")}
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onUndo={undoCanvas}
onRedo={redoCanvas}
assistantCollapsed={assistantCollapsed}
onExpandAssistant={() => {
setAssistantMounted(true);
setAssistantCollapsed(false);
}}
/>
<InfiniteCanvas
containerRef={containerRef}
viewport={viewport}
backgroundMode={backgroundMode}
onViewportChange={(next) => {
setViewport(next);
setContextMenu(null);
}}
onCanvasMouseDown={handleCanvasMouseDown}
onCanvasDeselect={deselectCanvas}
onContextMenu={preventCanvasContextMenu}
onDrop={handleDrop}
>
<svg className="absolute left-0 top-0 h-[10000px] w-[10000px] overflow-visible" style={{ pointerEvents: "none", transform: "translateZ(0)", zIndex: 0 }}>
{connections
.filter((connection) => {
const from = nodeById.get(connection.fromNodeId);
const to = nodeById.get(connection.toNodeId);
return Boolean(from && to && !isHiddenBatchConnectionEndpoint(from, nodes) && !isHiddenBatchConnectionEndpoint(to, nodes));
})
.map((connection) => {
const from = nodeById.get(connection.fromNodeId);
const to = nodeById.get(connection.toNodeId);
if (!from || !to) return null;
return (
<ConnectionPath
key={connection.id}
connection={connection}
from={from}
to={to}
active={selectedConnectionId === connection.id || relatedHighlight.connectionIds.has(connection.id)}
onSelect={() => {
setSelectedConnectionId(connection.id);
setSelectedNodeIds(new Set());
setContextMenu(null);
}}
/>
);
})}
{connectingParams ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} /> : null}
</svg>
{visibleNodes.map((node) => (
<CanvasNode
key={node.id}
data={node}
scale={viewport.k}
isSelected={selectedNodeIds.has(node.id)}
isRelated={relatedHighlight.nodeIds.has(node.id)}
isFocusRelated={activeNodeId === node.id}
isConnectionTarget={connectionTargetNodeId === node.id}
isConnecting={Boolean(connectingParams)}
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
showPanel={dialogNodeId === node.id && !selectionBox}
batchCount={batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
batchClosing={Boolean(node.metadata?.batchRootId && collapsingBatchIds.has(node.metadata.batchRootId))}
batchOpening={openingBatchIds.has(node.id)}
batchRecovering={collapsingBatchIds.has(node.id)}
batchMotion={batchMotionById.get(node.id)}
showImageInfo={showImageInfo}
renderPanel={(panelNode) => (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
)}
renderNodeContent={(contentNode) => (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
inputs={configInputsById.get(contentNode.id) || []}
onConfigChange={handleConfigNodeChange}
onTextInputChange={handleNodeContentChange}
onGenerate={(nodeId) => {
const target = nodesRef.current.find((item) => item.id === nodeId);
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || "");
}}
/>
)}
onMouseDown={handleNodeMouseDown}
onHoverStart={(nodeId) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
keepNodeToolbar(nodeId);
}}
onHoverEnd={(nodeId) => {
setHoveredNodeId((current) => (current === nodeId ? null : current));
hideNodeToolbar();
}}
onConnectStart={handleConnectStart}
onResize={handleNodeResize}
onContentChange={handleNodeContentChange}
onToggleBatch={toggleBatchExpanded}
onSetBatchPrimary={setBatchPrimary}
onRetry={(node) => void handleRetryNode(node)}
onGenerateImage={generateImageFromTextNode}
onContextMenu={(event, id) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id });
}}
/>
))}
{selectionBox ? (
<div
className="pointer-events-none absolute z-[100] border"
style={{
left: Math.min(selectionBox.startWorldX, selectionBox.currentWorldX),
top: Math.min(selectionBox.startWorldY, selectionBox.currentWorldY),
width: Math.abs(selectionBox.currentWorldX - selectionBox.startWorldX),
height: Math.abs(selectionBox.currentWorldY - selectionBox.startWorldY),
borderColor: theme.canvas.selectionStroke,
background: theme.canvas.selectionFill,
}}
/>
) : null}
{pendingConnectionCreate ? <ConnectionCreateMenu pending={pendingConnectionCreate} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
</InfiniteCanvas>
<CanvasNodeHoverToolbar
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
viewport={viewport}
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)}
onEditText={openTextEditor}
onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
onGenerateImage={generateImageFromTextNode}
onUpload={(node) => handleUploadRequest(node.id)}
onDownload={downloadNodeImage}
onSaveAsset={(node) => void saveNodeAsset(node)}
onCrop={(node) => setCropNodeId(node.id)}
onAngle={(node) => setAngleNodeId(node.id)}
onViewImage={(node) => setPreviewNodeId(node.id)}
onRetry={(node) => void handleRetryNode(node)}
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
onDelete={(node) => deleteNodes(new Set([node.id]))}
/>
<CanvasToolbar
selectedCount={selectedNodeIds.size}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => createNode(CanvasNodeType.Image)}
onAddVideo={() => createNode(CanvasNodeType.Video)}
onAddAudio={() => createNode(CanvasNodeType.Audio)}
onAddText={() => createNode(CanvasNodeType.Text)}
onAddConfig={() => createNode(CanvasNodeType.Config)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onUpload={() => handleUploadRequest()}
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)}
onDeselect={deselectCanvas}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenAssetLibrary={() => {
setAssetPickerTab("library");
setAssetPickerOpen(true);
}}
onOpenMyAssets={() => {
setAssetPickerTab("my-assets");
setAssetPickerOpen(true);
}}
/>
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} onViewportChange={setViewport} /> : null}
<CanvasZoomControls scale={viewport.k} onScaleChange={setZoomScale} onReset={resetViewport} isMiniMapOpen={isMiniMapOpen} onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)} />
{contextMenu ? (
<CanvasNodeContextMenu
menu={contextMenu}
onClose={() => setContextMenu(null)}
onDuplicate={() => {
duplicateNode(contextMenu.nodeId);
setContextMenu(null);
}}
onDelete={() => {
deleteNodes(new Set([contextMenu.nodeId]));
setContextMenu(null);
}}
/>
) : null}
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
{angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null}
<Modal
title="图片详情"
open={Boolean(previewNode?.metadata?.content)}
centered
onCancel={() => setPreviewNodeId(null)}
footer={null}
width="auto"
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}
>
{previewNode?.metadata?.content ? (
<img
src={previewNode.metadata.content}
alt={previewNode.title || "图片"}
style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }}
/>
) : null}
</Modal>
<Modal
title="清空画布?"
open={clearConfirmOpen}
centered
onCancel={() => setClearConfirmOpen(false)}
footer={
<>
<Button onClick={() => setClearConfirmOpen(false)}></Button>
<Button danger type="primary" onClick={clearCanvas}>
</Button>
</>
}
>
<p className="text-sm opacity-60">线</p>
</Modal>
<AssetPickerModal open={assetPickerOpen} defaultTab={assetPickerTab} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
</section>
{assistantMounted ? (
<CanvasAssistantPanel
nodes={nodes}
selectedNodeIds={selectedNodeIds}
sessions={chatSessions}
activeSessionId={activeChatId}
onSelectNodeIds={setSelectedNodeIds}
onSessionsChange={handleAssistantSessionsChange}
onInsertImage={insertAssistantImage}
onInsertText={insertAssistantText}
onPasteImage={pasteAssistantImage}
onCollapseStart={() => setAssistantCollapsed(true)}
onCollapse={() => setAssistantMounted(false)}
/>
) : null}
</main>
);
}
function CanvasTopBar({
title,
titleDraft,
isTitleEditing,
onTitleDraftChange,
onStartTitleEditing,
onFinishTitleEditing,
onCancelTitleEditing,
canUndo,
canRedo,
onHome,
onProjects,
onCreateProject,
onDeleteProject,
onImportImage,
onUndo,
onRedo,
assistantCollapsed,
onExpandAssistant,
}: {
title: string;
titleDraft: string;
isTitleEditing: boolean;
onTitleDraftChange: (value: string) => void;
onStartTitleEditing: () => void;
onFinishTitleEditing: () => void;
onCancelTitleEditing: () => void;
canUndo: boolean;
canRedo: boolean;
onHome: () => void;
onProjects: () => void;
onCreateProject: () => void;
onDeleteProject: () => void;
onImportImage: () => void;
onUndo: () => void;
onRedo: () => void;
assistantCollapsed: boolean;
onExpandAssistant: () => void;
}) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null);
const accountRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
useEffect(() => {
if (!isTitleEditing) return;
const close = (event: PointerEvent) => {
if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing();
};
document.addEventListener("pointerdown", close, true);
return () => document.removeEventListener("pointerdown", close, true);
}, [isTitleEditing, onFinishTitleEditing]);
useEffect(() => {
if (!accountOpen) return;
const close = (event: PointerEvent) => {
if (!accountRef.current?.contains(event.target as Node)) setAccountOpen(false);
};
document.addEventListener("pointerdown", close, true);
return () => document.removeEventListener("pointerdown", close, true);
}, [accountOpen]);
return (
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
{ type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
{ type: "divider" },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
],
}}
>
<button type="button" className="grid size-9 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<Menu className="size-5" />
</button>
</Dropdown>
<div ref={titleRef} className="flex min-w-0 items-center gap-2">
{isTitleEditing ? (
<input
autoFocus
value={titleDraft}
onChange={(event) => onTitleDraftChange(event.target.value)}
onBlur={onFinishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") onFinishTitleEditing();
if (event.key === "Escape") onCancelTitleEditing();
}}
className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none"
style={{ color: theme.node.text }}
/>
) : (
<button
type="button"
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
onDoubleClick={onStartTitleEditing}
title="双击修改画布名称"
>
{title}
</button>
)}
</div>
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<UserStatusActions
variant="canvas"
accountOpen={accountOpen}
onAccountOpenChange={setAccountOpen}
accountRef={accountRef}
getPopupContainer={(node) => node.parentElement || document.body}
onOpenShortcuts={() => {
setShortcutsOpen(true);
setAccountOpen(false);
}}
/>
{assistantCollapsed ? (
<>
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
icon={<MessageSquare className="size-4" />}
onClick={onExpandAssistant}
>
</Button>
</>
) : null}
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["拖动画布"]} value="平移视图" />
<Shortcut keys={["滚轮"]} value="缩放画布" />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
</div>
</Modal>
</>
);
}
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
return (
<span className="flex min-w-36 items-center justify-between gap-8">
<span>{text}</span>
<span className="text-xs opacity-45">{shortcut}</span>
</span>
);
}
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{keys.map((key, index) => (
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
{index ? <span className="text-xs opacity-35">+</span> : null}
<kbd
className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]"
style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}
>
{key}
</kbd>
</span>
))}
</span>
<span className="text-right text-sm opacity-55">{value}</span>
</div>
);
}
function imageExtension(dataUrl: string) {
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
}
function audioExtension(mimeType?: string) {
return mimeType?.includes("wav") ? "wav" : "mp3";
}
function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
}
function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs };
}
function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs };
}
function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
return {
generationType: type,
model: config.model,
size: config.size,
quality: config.quality,
count,
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
};
}
function referenceUrl(image: ReferenceImage) {
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
}
function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
return [
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
];
}
async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
if (metadata.generationType !== "edit") return [];
if (!metadata.references?.length) return null;
const references = await Promise.all(
metadata.references.map(async (url, index) => {
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
}),
);
return references.every(Boolean) ? (references as ReferenceImage[]) : null;
}
async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
return Promise.all(
nodes.map(async (node) => {
const content = node.metadata?.content;
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
if (node.type !== CanvasNodeType.Image || !content) return node;
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
if (!content.startsWith("data:image/")) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
}),
);
}
async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
const hydrateItem = async <T extends { dataUrl?: string; storageKey?: string }>(item: T) => {
if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) };
if (item.dataUrl?.startsWith("data:image/")) {
const image = await uploadImage(item.dataUrl);
return { ...item, dataUrl: image.url, storageKey: image.storageKey };
}
return item;
};
return Promise.all(
sessions.map(async (session) => ({
...session,
messages: await Promise.all(
session.messages.map(async (message) => ({
...message,
references: await Promise.all((message.references || []).map(hydrateItem)),
images: await Promise.all((message.images || []).map(hydrateItem)),
})),
),
})),
);
}
function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
const safePatch = patch || {};
const next = { ...node, metadata: { ...node.metadata, ...safePatch } };
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null;
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
}
function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
const first = nodes.find((node) => node.id === firstNodeId);
const second = nodes.find((node) => node.id === secondNodeId);
if (!first || !second || first.id === second.id) return null;
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
return { fromNodeId: first.id, toNodeId: second.id };
}
function getInputSummary(inputs: NodeGenerationInput[]) {
return {
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: inputs.filter((input) => input.type === "image").length,
videoCount: inputs.filter((input) => input.type === "video").length,
audioCount: inputs.filter((input) => input.type === "audio").length,
};
}
function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : config.textModel;
return {
...config,
model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model,
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count),
};
}
function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node));
}
function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
const visited = new Set<string>();
while (queue.length) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
const node = nodes.find((item) => item.id === id);
if (node?.type === CanvasNodeType.Config) return node;
connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId));
}
return null;
}
function sourceNodeReferenceImages(node: CanvasNodeData | null) {
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
return [
{
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
},
];
}
function isAudioFile(file: File) {
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
}
function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
const root = nodes.find((item) => item.id === rootId);
if (root && collapsingBatchIds?.has(rootId)) return false;
return Boolean(root && !root.metadata?.imageBatchExpanded);
}
function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
const root = nodes.find((item) => item.id === rootId);
return Boolean(root && !root.metadata?.imageBatchExpanded);
}
function buildAngleLabel(params: CanvasImageAngleParams) {
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle}` : `向左旋转 ${Math.abs(params.horizontalAngle)}`;
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle}` : `仰视 ${Math.abs(params.pitchAngle)}`;
return `AI 多角度:${horizontal}${pitch},镜头距离 ${params.cameraDistance.toFixed(1)}${params.wideAngle ? "广角" : "标准"}镜头`;
}
function buildAnglePrompt(params: CanvasImageAngleParams) {
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}`;
}