Files
infinite-canvas/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx
T
HouYunFei 7a27684e3c feat(app): 添加版本更新弹窗功能
- 在顶部导航栏和用户状态组件中集成版本更新弹窗
- 实现版本检查钩子,支持检查最新版本和更新日志
- 解析 CHANGELOG.md 文件并展示版本更新时间线
- 添加版本更新弹窗 UI 组件,显示当前版本和最新版本
- 在 Dockerfile 中更新 bun 版本依赖
- 调整 Next.js 配置以解析版本信息并注入环境变量
- 更新待测试文档中的版本更新功能描述
2026-05-20 15:25:57 +08:00

2389 lines
106 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, Keyboard, List, LogOut, Menu, MessageSquare, Plus, Redo2, Settings2, Trash2, Undo2, Upload } from "lucide-react";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { createId } from "@/lib/id";
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useAssetStore } from "@/stores/use-asset-store";
import { useUserStore } from "@/stores/use-user-store";
import { UserStatusActions } from "@/components/user-status-actions";
import { cropDataUrl } from "../utils/canvas-image-data";
import { App, Button, Dropdown, Modal } from "antd";
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
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 CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type SelectionBox, type ViewportTransform } from "../types";
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;
};
const UPLOADED_IMAGE_MAX_SIDE = 640;
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) => 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 icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
</div>
</div>
);
}
function ConnectionCreateOption({
icon,
title,
description,
onClick,
}: {
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 hover:bg-white/10"
onClick={onClick}
>
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-white/10 text-stone-200">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-base font-semibold leading-5 text-stone-100">
{title}
</span>
{description ? <span className="mt-1 block truncate text-sm text-stone-500">{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 = useAiConfigStore((state) => state.config);
const openConfigDialog = useConfigDialogStore((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 user = useUserStore((state) => state.user);
const logout = useUserStore((state) => state.clearSession);
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 [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 [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 [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,
}), [activeChatId, backgroundMode, chatSessions]);
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);
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,
};
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
) 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]);
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode });
}, [activeChatId, backgroundMode, chatSessions, connections, nodes, projectId, projectLoaded, updateProject]);
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) return;
if (toolbarHideTimerRef.current) {
clearTimeout(toolbarHideTimerRef.current);
toolbarHideTimerRef.current = null;
}
setToolbarNodeId(nodeId);
}, []);
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, pending: PendingConnectionCreate) => {
const metadata = type === CanvasNodeType.Config ? { model: config.imageModel || config.model, size: config.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: createId(), ...connection }]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
setDialogNodeId(newNode.id);
setPendingConnectionCreate(null);
setConnecting(null);
}, [config.imageModel, config.model, config.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 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: config.imageModel || config.model,
size: config.size,
count: 3,
} : undefined;
const newNode = createCanvasNode(type, targetPosition, configMetadata);
setNodes((prev) => [...prev, newNode]);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
setDialogNodeId(newNode.id);
},
[config.imageModel, config.model, config.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);
setRunningNodeId((current) => current && allIds.has(current) ? null : current);
setContextMenu((current) => current && allIds.has(current.nodeId) ? null : current);
cleanupAssetImages({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions });
}, [chatSessions, cleanupAssetImages, 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);
setRunningNodeId(null);
deselectCanvas();
setClearConfirmOpen(false);
cleanupAssetImages({ projectId, nodes: [], chatSessions: [] });
}, [cleanupAssetImages, 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);
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 = fitImageNodeSize(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 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 ? { ...node, metadata: { ...node.metadata, ...(patch || {}) } } : node)));
}, []);
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return;
const link = document.createElement("a");
link.href = node.metadata.content;
link.download = `canvas-image-${node.id}.${imageExtension(node.metadata.content)}`;
link.click();
}, []);
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.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 = createId();
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: createId(), 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(config, node, "image"), count: "1" };
if (!generationConfig.baseUrl.trim() || !generationConfig.model.trim() || !generationConfig.apiKey.trim()) {
openConfigDialog(true);
return;
}
const childId = createId();
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const title = buildAngleLabel(params);
const prompt = buildAnglePrompt(params);
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 },
}]);
setConnections((prev) => [...prev, { id: createId(), 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 = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
setNodes((prev) => prev.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...imageMetadata(uploaded), prompt } } : 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);
}
}, [config, 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/")) return;
if (target?.nodeId) {
const image = await uploadImage(file);
const size = fitImageNodeSize(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, 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 createImageFileNode(file, position);
}
uploadTargetRef.current = null;
event.target.value = "";
},
[createImageFileNode, 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/"));
if (!file) return;
const pos = screenToCanvas(event.clientX, event.clientY);
void createImageFileNode(file, pos);
},
[createImageFileNode, 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(config, sourceNode, mode);
if (!generationConfig.baseUrl.trim() || !generationConfig.model.trim() || !generationConfig.apiKey.trim()) {
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 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, ...generationContext.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 = createId();
const childIds = count > 1 ? Array.from({ length: count }, () => createId()) : [];
const targetIds = count > 1 ? childIds : [rootId];
pendingChildIds = [rootId, ...childIds];
const rootNode: CanvasNodeData = {
id: rootId,
type: CanvasNodeType.Image,
title: effectivePrompt.slice(0, 32) || "Generated Image",
position: {
x: parentPosition.x + parentConfig.width + gap,
y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2,
},
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, 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 },
}));
const batchConnections = [{ id: createId(), fromNodeId: nodeId, toNodeId: rootId }, ...childIds.map((childId) => ({ id: createId(), fromNodeId: rootId, toNodeId: childId }))];
setNodes((prev) => [
...prev.map((node) =>
node.id === nodeId
? isConfigNode ? {
...node,
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, 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,
),
rootNode,
...childNodes,
]);
setConnections((prev) => [...prev, ...batchConnections]);
setSelectedNodeIds(new Set([nodeId]));
setSelectedConnectionId(null);
setDialogNodeId(nodeId);
let hasSuccess = 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 = fitImageNodeSize(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 : "生成失败";
setNodes((prev) => prev.map((node) => node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node));
return false;
}
}));
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 === rootId && !hasSuccess ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } }
: 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 }, () => createId()) : [];
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: createId(), 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 : "生成失败";
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);
}
},
[config, openConfigDialog],
);
const handleRetryNode = useCallback(
async (node: CanvasNodeData) => {
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
const generationConfig = { ...buildGenerationConfig(config, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" };
if (!generationConfig.baseUrl.trim() || !generationConfig.model.trim() || !generationConfig.apiKey.trim()) {
openConfigDialog(true);
return;
}
const context = await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || ""));
const prompt = context.prompt.trim();
if (!prompt) {
message.warning("找不到提示词,无法重试");
return;
}
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) {
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;
}
const image = context.referenceImages.length
? await requestEdit(generationConfig, prompt, context.referenceImages).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 = fitImageNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
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 },
} : item));
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item));
} finally {
setRunningNodeId(null);
}
},
[config, 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: config.imageModel || config.model,
size: config.size,
count: 3,
});
const connection = { id: createId(), 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);
}, [config.imageModel, config.model, config.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 = fitImageNodeSize(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);
setDialogNodeId(node.id);
}, [screenToCanvas, size.height, size.width]);
const handleAssetInsert = useCallback((payload: InsertAssetPayload) => {
if (payload.kind === "text") {
insertAssistantText(payload.content);
} else {
insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey });
}
setAssetPickerOpen(false);
}, [insertAssistantImage, insertAssistantText]);
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}
userName={user?.username || "访客"}
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}
onOpenConfig={() => openConfigDialog(false)}
onLogout={logout}
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)}
renderPanel={(panelNode) => (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
/>
)}
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 ? 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)}
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}
onAddImage={() => createNode(CanvasNodeType.Image)}
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}
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/*" 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={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,
userName,
onTitleDraftChange,
onStartTitleEditing,
onFinishTitleEditing,
onCancelTitleEditing,
canUndo,
canRedo,
onHome,
onProjects,
onCreateProject,
onDeleteProject,
onImportImage,
onUndo,
onRedo,
onOpenConfig,
onLogout,
assistantCollapsed,
onExpandAssistant,
}: {
title: string;
titleDraft: string;
isTitleEditing: boolean;
userName: string;
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;
onOpenConfig: () => void;
onLogout: () => void;
assistantCollapsed: boolean;
onExpandAssistant: () => void;
}) {
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
const initial = (userName.trim()[0] || "U").toUpperCase();
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
version={appVersion}
theme={colorTheme}
onThemeChange={setTheme}
onOpenConfig={onOpenConfig}
userName={userName}
initial={initial}
accountOpen={accountOpen}
onAccountOpenChange={setAccountOpen}
accountRef={accountRef}
getPopupContainer={(node) => node.parentElement || document.body}
iconStyle={{ color: theme.node.text }}
gitHubClassName="size-11 text-base"
gitHubStyle={{ color: theme.node.text }}
versionStyle={{ color: theme.node.text }}
avatarStyle={{ borderColor: theme.toolbar.border, color: theme.node.text }}
userLabel={initial}
menuItems={[
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
{ type: "divider" },
{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: () => { setShortcutsOpen(true); setAccountOpen(false); } },
{ type: "divider" },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: () => { setAccountOpen(false); onLogout(); } },
]}
/>
{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 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 };
}
async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
return Promise.all(nodes.map(async (node) => {
const content = node.metadata?.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 fitImageNodeSize(width: number, height: number, maxWidth = UPLOADED_IMAGE_MAX_SIDE, maxHeight = UPLOADED_IMAGE_MAX_SIDE) {
const safeWidth = Math.max(1, width);
const safeHeight = Math.max(1, height);
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight);
return {
width: safeWidth * scale,
height: safeHeight * scale,
};
}
function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
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,
};
}
function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : config.textModel;
return {
...config,
model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model,
quality: config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
count: String(node?.metadata?.count || 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 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)}`;
}