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