import { BarsOutlined, BgColorsOutlined, CheckOutlined, ClockCircleOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined, DownOutlined, EditOutlined, FileImageOutlined, FileTextOutlined, FolderOpenOutlined, MutedOutlined, PauseCircleOutlined, PictureOutlined, PlayCircleOutlined, ReloadOutlined, SaveOutlined, SearchOutlined, SendOutlined, SoundOutlined, ThunderboltOutlined, UploadOutlined, VideoCameraOutlined, } from "@ant-design/icons"; import { ReactFlow, } from "@xyflow/react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { communityClient } from "../../api/communityClient"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import WorkspacePageShell from "../../components/WorkspacePageShell"; import type { WebCanvasWorkflow, WebCanvasWorkflowNodePackage, } from "../../types"; import type { AssetLibraryCategory } from "../assets/localAssetStore"; import { buildCanvasCommunityCaseInput, buildCanvasWorkflowJson, buildWorkflowFileName, textToDataUrl, } from "./canvasCommunityPublish"; import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence"; import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema"; import { createBlankWorkflow } from "../../data/workflows"; import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory"; import { useCanvasKeyboard } from "./useCanvasKeyboard"; import { useCanvasNodeDrag } from "./useCanvasNodeDrag"; import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration"; import { toHappyHorseDisplayModel, } from "../../utils/happyHorseRouting"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy"; import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility"; import { translateTaskError } from "../../utils/translateTaskError"; import type { CanvasAlignGuide, CanvasAssetSaveSource, CanvasCopiedNode, CanvasConnectorDrag, CanvasConnectorFollowOffset, CanvasFloatingMenuPosition, CanvasImageFocusDrag, CanvasImageFocusSelection, CanvasImageGenerationState, CanvasImageNode, CanvasImageNodeDrag, CanvasImageReferenceItem, CanvasManualLink, CanvasNodeBounds, CanvasNodeKind, CanvasNodePackage, CanvasNodePackageDrag, CanvasNodePort, CanvasNodeResizeDrag, CanvasNodeSize, CanvasOption, CanvasPageProps, CanvasPanDrag, CanvasPoint, CanvasProjectSaveState, CanvasSelectedNode, CanvasSelectionDrag, CanvasStyleCase, CanvasStylePickerTab, CanvasStyleReference, CanvasTextGenerationState, CanvasPromptMentionOption, CanvasPromptMentionState, CanvasTextNode, CanvasTextNodeDrag, CanvasVideoGenerationState, CanvasVideoMode, CanvasVideoNode, CanvasVideoNodeDrag, CanvasViewport, } from "./canvasTypes"; import { assetLibraryCategories, canvasAutoSaveDebounceMs, canvasAutoSaveIdleTimeoutMs, canvasNodeClickMoveThreshold, canvasNodeDefaultSizes, canvasStylePickerCategories, canvasStylePickerTabs, connectorAnchorOutset, connectorFollowRadius, connectorFollowStrength, connectorMaxFollowOffset, defaultImageModel, defaultTextModelId, defaultVideoModel, image4kCapableModels, imageFocusRatioOptions, imageModelOptions, imageRatioOptions, textModelOptions, videoDurationOptions, videoRatioOptions, } from "./canvasConstants"; import { applyImageFocusRatioFromTopLeft, blobToDataUrl, buildCanvasStyleKeywords, buildCopyTitle, clampCanvasPercent, buildReversePromptFromAsset, canvasGenerationProgressStyle, clampCanvasNodeSize, clampCanvasViewportZoom, communityCaseToCanvasStyleCase, createCanvasNodeSize, createStyleReferenceFromCase, delay, doCanvasRectsIntersect, getCanvasLinkIdentity, getCanvasNodeSideDirection, getCanvasPortIdentity, getCanvasSelectionKey, getDefaultImageQuality, getDefaultVideoQuality, getImageQualityOptions, getOptionLabel, getVideoQualityOptions, getWorkflowImageNodeFileName, getWorkflowImageNodePrompt, getWorkflowNodeFocusSelection, getWorkflowNodeMetadataString, getWorkflowNodeStyleReference, hasCanvasOptionValue, moveCanvasNodesForPackageDrag, normalizeCanvasGenerationProgress, normalizeCanvasLinkPorts, normalizeCanvasSelectionRect, normalizeImageFocusSelectionFromAnchor, positionFloatingMenu, resolveImageQuality, resolveVideoQuality, resolveWorkflowImageModel, resolveWorkflowRatio, resolveWorkflowVideoMode, resolveWorkflowVideoModel, waitForImageTaskResult, waitForVideoTaskResult, } from "./canvasUtils"; import { createImageNodesFromWorkflow, createManualLinksFromWorkflow, createNodePackagesFromWorkflow, createTextNodesFromWorkflow, createVideoNodesFromWorkflow, createWorkflowPackagesFromCanvasPackages, formatCanvasProjectUpdatedAt, formatCanvasVideoTime, resolveAssetCategory, } from "./canvasWorkflowDeserialize"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents"; import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ value: option.value, label: option.label, })); // --- Canvas generation keep-alive (survives page refresh / view switch) --- const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g; const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/; function buildNodeMentionOptions( kind: CanvasNodeKind, nodeId: string, imageNodes: CanvasImageNode[], videoNodes: CanvasVideoNode[], textNodes: CanvasTextNode[], getIncoming: (kind: CanvasNodeKind, nid: string) => CanvasNodePort[], manualLinks: CanvasManualLink[], ): CanvasPromptMentionOption[] { const incomingPorts = getIncoming(kind, nodeId); const outgoingPorts: CanvasNodePort[] = []; manualLinks.forEach((link) => { if (link.from.kind === kind && link.from.nodeId === nodeId) { outgoingPorts.push(link.to); } else if (link.to.kind === kind && link.to.nodeId === nodeId) { outgoingPorts.push(link.from); } }); // Source property links if (kind === "text") { imageNodes.forEach((img) => { if (img.sourceTextNodeId && img.sourceTextNodeId === nodeId) { outgoingPorts.push({ kind: "image", nodeId: img.id, side: "left", slot: "center" }); } }); videoNodes.forEach((vid) => { if (vid.sourceTextNodeId && vid.sourceTextNodeId === nodeId) { outgoingPorts.push({ kind: "video", nodeId: vid.id, side: "left", slot: "center" }); } }); } else if (kind === "image") { const self = imageNodes.find((n) => n.id === nodeId); if (self?.sourceTextNodeId) { outgoingPorts.push({ kind: "text", nodeId: self.sourceTextNodeId, side: "right", slot: "center" }); } if (self?.sourceImageNodeId) { outgoingPorts.push({ kind: "image", nodeId: self.sourceImageNodeId, side: "right", slot: "center" }); } } else if (kind === "video") { const self = videoNodes.find((n) => n.id === nodeId); if (self?.sourceTextNodeId) { outgoingPorts.push({ kind: "text", nodeId: self.sourceTextNodeId, side: "right", slot: "center" }); } } const allPorts = [...incomingPorts, ...outgoingPorts]; const options: CanvasPromptMentionOption[] = []; let imageIdx = 0; let videoIdx = 0; let textIdx = 0; const seen = new Set(); for (const port of allPorts) { if (seen.has(`${port.kind}:${port.nodeId}`)) continue; seen.add(`${port.kind}:${port.nodeId}`); if (port.kind === "image") { const node = imageNodes.find((n) => n.id === port.nodeId); imageIdx++; const label = `图片${imageIdx}`; options.push({ token: `@${label}`, label, kind: "image", nodeId: port.nodeId, nodeTitle: node?.title || label, previewUrl: node?.imageUrl, searchText: `${label} ${node?.title || ""} ${node?.prompt || ""}`.toLowerCase(), }); } else if (port.kind === "video") { const node = videoNodes.find((n) => n.id === port.nodeId); videoIdx++; const label = `视频${videoIdx}`; options.push({ token: `@${label}`, label, kind: "video", nodeId: port.nodeId, nodeTitle: node?.title || label, previewUrl: node?.videoUrl, searchText: `${label} ${node?.title || ""} ${node?.prompt || ""}`.toLowerCase(), }); } else if (port.kind === "text") { const node = textNodes.find((n) => n.id === port.nodeId); textIdx++; const label = `文本${textIdx}`; options.push({ token: `@${label}`, label, kind: "text", nodeId: port.nodeId, nodeTitle: node?.title || label, searchText: `${label} ${node?.title || ""} ${node?.prompt || ""}`.toLowerCase(), }); } } return options; } const CAMERA_MOTION_PRESETS = [ { value: "", label: "无运镜" }, { value: "push-in", label: "推近", prompt: "镜头缓慢推近主体" }, { value: "pull-out", label: "拉远", prompt: "镜头缓慢拉远,展现全景" }, { value: "pan-left", label: "左移", prompt: "镜头水平向左平移" }, { value: "pan-right", label: "右移", prompt: "镜头水平向右平移" }, { value: "tilt-up", label: "上仰", prompt: "镜头向上仰拍" }, { value: "tilt-down", label: "下俯", prompt: "镜头向下俯拍" }, { value: "orbit", label: "环绕", prompt: "镜头环绕主体360度旋转" }, { value: "handheld", label: "手持", prompt: "手持摄影风格,轻微晃动增加真实感" }, { value: "fixed", label: "固定机位", prompt: "固定机位,无镜头运动" }, ]; function getCameraMotionPrompt(value: string): string { return CAMERA_MOTION_PRESETS.find((p) => p.value === value)?.prompt || ""; } function CanvasPage({ workflow: rawWorkflow, projectId, projects = [], projectsLoaded = true, onOpenCommunity, onOpenProject, onStartCreate, isAuthenticated, session, onOpenLogin, onSaveWorkflow, onCreateTask, }: CanvasPageProps) { const workflow = rawWorkflow || createBlankWorkflow(); const [contextMenu, setContextMenu] = useState(null); const [nodeMenu, setNodeMenu] = useState(null); const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [textNodes, setTextNodes] = useState([]); const [canvasSelectMenu, setCanvasSelectMenu] = useState(null); const [copiedCanvasNode, setCopiedCanvasNode] = useState(null); const [imageNodeMenu, setImageNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [imageNodes, setImageNodes] = useState([]); const [imageLoadErrors, setImageLoadErrors] = useState>({}); const [imageFocusNodeId, setImageFocusNodeId] = useState(null); const [imageFocusDraft, setImageFocusDraft] = useState(null); const [imageFocusDrag, setImageFocusDrag] = useState(null); const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null); const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState(null); const [stylePickerCases, setStylePickerCases] = useState([]); const [stylePickerLoading, setStylePickerLoading] = useState(false); const [stylePickerError, setStylePickerError] = useState(null); const [stylePickerReloadToken, setStylePickerReloadToken] = useState(0); const [stylePickerTab, setStylePickerTab] = useState("square"); const [stylePickerCategory, setStylePickerCategory] = useState(canvasStylePickerCategories[0]); const [stylePickerSearch, setStylePickerSearch] = useState(""); const [recentStyleCases, setRecentStyleCases] = useState([]); const [styleSelectionToast, setStyleSelectionToast] = useState(null); const [recentProjectsOpen, setRecentProjectsOpen] = useState(false); const [currentProjectTitle, setCurrentProjectTitle] = useState(workflow.title || "未命名项目"); const [projectNameDraft, setProjectNameDraft] = useState(workflow.title || "未命名项目"); const [projectNameEditing, setProjectNameEditing] = useState(false); const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [videoNodes, setVideoNodes] = useState([]); const [selectedNode, setSelectedNode] = useState(null); const [selectedNodes, setSelectedNodes] = useState([]); const [selectionContextMenu, setSelectionContextMenu] = useState(null); const [nodePackages, setNodePackages] = useState([]); const [selectedPackageId, setSelectedPackageId] = useState(null); const [canvasViewport, setCanvasViewport] = useState({ x: 0, y: 0, zoom: 1 }); const [manualLinks, setManualLinks] = useState([]); const [pendingLinkPort, setPendingLinkPort] = useState(null); const [pendingLinkPreviewPoint, setPendingLinkPreviewPoint] = useState(null); const [connectorFollowOffsets, setConnectorFollowOffsets] = useState>({}); const [connectorDrag, setConnectorDrag] = useState(null); const [connectionDropMenu, setConnectionDropMenu] = useState<{ left: number; top: number; originLeft: number; originTop: number; sourcePort: CanvasNodePort } | null>(null); const pendingAutoConnectRef = useRef(null); const [pendingImagePosition, setPendingImagePosition] = useState({ x: 0, y: 0 }); const [pendingImageNodeId, setPendingImageNodeId] = useState(null); const [pendingImageToImageNodeId, setPendingImageToImageNodeId] = useState(null); const [markingPopoverNodeId, setMarkingPopoverNodeId] = useState(null); const [cameraMotionDropdownNodeId, setCameraMotionDropdownNodeId] = useState(null); const [saveAssetSource, setSaveAssetSource] = useState(null); const [saveAssetOpen, setSaveAssetOpen] = useState(false); const [assetName, setAssetName] = useState("文本素材"); const [assetCategory, setAssetCategory] = useState(""); const [assetCategoryOpen, setAssetCategoryOpen] = useState(false); const [coverSourceOpen, setCoverSourceOpen] = useState(false); const [coverLibraryOpen, setCoverLibraryOpen] = useState(false); const [assetSaveMode, setAssetSaveMode] = useState<"create" | "existing">("create"); const [assetCoverUrl, setAssetCoverUrl] = useState(""); const [serverAssets, setServerAssets] = useState([]); const [assetLibraryNotice, setAssetLibraryNotice] = useState(null); const [isSavingAsset, setIsSavingAsset] = useState(false); const [selectedExistingCategory, setSelectedExistingCategory] = useState(""); const coverFileInputRef = useRef(null); const canvasUploadInputRef = useRef(null); const imageNodeInputRef = useRef(null); const canvasRef = useRef(null); const videoGenerationInFlightRef = useRef(new Set()); const canvasReferenceUploadPromisesRef = useRef(new Map>()); const suppressNextPaneClickRef = useRef(false); const canvasAutoSaveTimerRef = useRef(null); const canvasAutoSaveIdleHandleRef = useRef(null); const canvasAutoSaveInFlightRef = useRef(false); const canvasAutoSavePendingRef = useRef(false); const lastAutoSavedWorkflowFingerprintRef = useRef(""); const canvasAutoSaveHydrationRef = useRef(true); const textNodeIdRef = useRef(9); const imageNodeIdRef = useRef(1); const videoNodeIdRef = useRef(1); const { pushSnapshot, undo, redo, canUndo, canRedo } = useCanvasHistory(); const { textGenerationState, imageGenerationState, videoGenerationState, generationToast, setGenerationToast, imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, canvasGenKeepaliveRestoredRef, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, restoreKeepaliveTasks, resetGenerationState, } = useCanvasGeneration({ setImageNodes, setVideoNodes }); const isDirtyRef = useRef(false); const [spacePanning, setSpacePanning] = useState(false); const textNodesRef = useRef(textNodes); textNodesRef.current = textNodes; const imageNodesRef = useRef(imageNodes); imageNodesRef.current = imageNodes; const videoNodesRef = useRef(videoNodes); videoNodesRef.current = videoNodes; const nodePackagesRef = useRef(nodePackages); nodePackagesRef.current = nodePackages; const zoomRef = useRef(canvasViewport.zoom); zoomRef.current = canvasViewport.zoom; const dragCallbacksRef = useRef({ pushHistorySnapshot: () => {}, clearCanvasSelection: () => {}, selectCanvasNode: () => {}, applyCanvasSelection: () => {}, getCanvasPointFromClient: () => ({ x: 0, y: 0 }), getNodesInSelectionRect: () => [], expandCanvasNodePackage: () => {}, }); const { textNodeDrag, setTextNodeDrag, imageNodeDrag, setImageNodeDrag, videoNodeDrag, setVideoNodeDrag, packageDrag, setPackageDrag, selectionDrag, setSelectionDrag, nodeResizeDrag, canvasPanDrag, setCanvasPanDrag, alignGuides, handleNodeResizeStart, } = useCanvasNodeDrag({ zoomRef, textNodesRef, imageNodesRef, videoNodesRef, nodePackagesRef, setTextNodes, setImageNodes, setVideoNodes, setNodePackages, setCanvasViewport, callbacksRef: dragCallbacksRef, suppressNextPaneClickRef, }); const visibleImageModelOptions = useMemo( () => filterImageModelOptionsForSession(imageModelOptions, session), [session], ); const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel; const resolveVisibleImageModel = useCallback( (model: string | undefined | null) => hasCanvasOptionValue(visibleImageModelOptions, model || "") ? String(model) : fallbackVisibleImageModel, [fallbackVisibleImageModel, visibleImageModelOptions], ); // @-mention state per text node const [textNodeMentionStates, setTextNodeMentionStates] = useState>({}); const closeTextNodeMention = (nodeId: string) => { setTextNodeMentionStates((prev) => ({ ...prev, [nodeId]: { open: false, query: "", start: 0, caret: 0, activeIndex: 0 } })); }; const insertTextNodeMention = (nodeId: string, option: CanvasPromptMentionOption, textarea: HTMLTextAreaElement | null, kind?: CanvasNodeKind) => { const state = textNodeMentionStates[nodeId]; if (!state) return; const value = textarea?.value || ""; const nextValue = `${value.slice(0, state.start)}${option.token} ${value.slice(state.caret)}`; const nextCaret = state.start + option.token.length + 1; if (kind === "image") updateImageNodePrompt(nodeId, nextValue); else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue); else updateTextNodePrompt(nodeId, nextValue); closeTextNodeMention(nodeId); setTimeout(() => { if (textarea) { textarea.focus(); textarea.setSelectionRange(nextCaret, nextCaret); } }, 0); }; const getHistorySnapshot = useCallback((): CanvasHistorySnapshot => ({ textNodes: textNodes.map((n) => ({ ...n })), imageNodes: imageNodes.map((n) => ({ ...n })), videoNodes: videoNodes.map((n) => ({ ...n })), manualLinks: manualLinks.map((l) => ({ ...l })), nodePackages: nodePackages.map((p) => ({ ...p, nodeIds: [...p.nodeIds] })), }), [textNodes, imageNodes, videoNodes, manualLinks, nodePackages]); const pushHistorySnapshot = useCallback(() => { pushSnapshot(getHistorySnapshot()); isDirtyRef.current = true; }, [pushSnapshot, getHistorySnapshot]); const applyHistorySnapshot = useCallback((snapshot: CanvasHistorySnapshot) => { setTextNodes(snapshot.textNodes as typeof textNodes); setImageNodes(snapshot.imageNodes as typeof imageNodes); setVideoNodes(snapshot.videoNodes as typeof videoNodes); setManualLinks(snapshot.manualLinks as typeof manualLinks); setNodePackages(snapshot.nodePackages as typeof nodePackages); }, []); // Viewport changes are browsing behavior, not content changes — don't trigger save // (viewport is still persisted in the snapshot when a content change triggers save) // Auto-save status indicator const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle"); const autoSaveStatusTimerRef = useRef(null); // Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition) // — see useEffect below near runCanvasAutoSave const canvasAssets = serverAssets.filter((asset) => asset.imageUrl); const shouldShowEmptyProjectState = projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; const isWaitingForProjects = isAuthenticated && !projectsLoaded; const [projectSaveState, setProjectSaveState] = useState({ status: "idle", message: "", }); const [communityPublishState, setCommunityPublishState] = useState({ status: "idle", message: "", }); useEffect(() => { const normalizedWorkflow = normalizeCanvasWorkflowSchema(workflow); const nextTextNodes = createTextNodesFromWorkflow(normalizedWorkflow); const nextImageNodes = createImageNodesFromWorkflow(normalizedWorkflow); const nextVideoNodes = createVideoNodesFromWorkflow(normalizedWorkflow); resetGenerationState(); videoGenerationInFlightRef.current.clear(); setTextNodes(nextTextNodes); setImageNodes(nextImageNodes); setImageLoadErrors({}); setImageFocusNodeId(null); setImageFocusDraft(null); setImageFocusDrag(null); setStylePickerImageNodeId(null); setVideoNodes(nextVideoNodes); setManualLinks(createManualLinksFromWorkflow(normalizedWorkflow)); setNodePackages(createNodePackagesFromWorkflow(normalizedWorkflow)); setSelectedNode(null); setSelectedNodes([]); setSelectedPackageId(null); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); setConnectorDrag(null); setCanvasViewport(normalizedWorkflow.viewport || { x: 0, y: 0, zoom: 1 }); setRecentProjectsOpen(false); setCurrentProjectTitle(workflow.title || "未命名项目"); setProjectNameDraft(workflow.title || "未命名项目"); setProjectNameEditing(false); setProjectSaveState({ status: "idle", message: "" }); setCommunityPublishState({ status: "idle", message: "" }); canvasAutoSaveHydrationRef.current = true; lastAutoSavedWorkflowFingerprintRef.current = buildCanvasWorkflowJson(normalizedWorkflow); canvasAutoSavePendingRef.current = false; textNodeIdRef.current = nextTextNodes.length + 1; imageNodeIdRef.current = nextImageNodes.length + 1; videoNodeIdRef.current = nextVideoNodes.length + 1; // Reset keepalive flag so tasks can be restored for this project canvasGenKeepaliveRestoredRef.current = false; if (projectId && isAuthenticated) { restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [workflow.id, workflow.nodes, projectId]); useEffect(() => { if (!isAuthenticated) { setServerAssets([]); setAssetLibraryNotice(null); return; } let cancelled = false; assetClient .list() .then((items) => { if (cancelled) return; setServerAssets(items); setAssetLibraryNotice(items.length ? null : "服务器资产库暂无内容"); }) .catch((error) => { if (cancelled) return; setServerAssets([]); setAssetLibraryNotice(error instanceof Error ? error.message : "服务器资产库暂时不可用"); }); return () => { cancelled = true; }; }, [isAuthenticated]); useEffect(() => { if (!stylePickerImageNodeId) return; let cancelled = false; setStylePickerLoading(true); setStylePickerError(null); communityClient .listApprovedCases({ limit: 120, tag: "画布页面社区", sort: "latest" }) .then((items) => { if (cancelled) return; const seen = new Set(); const cases = items.flatMap((item) => { const styleCase = communityCaseToCanvasStyleCase(item); if (!styleCase || seen.has(styleCase.id)) return []; seen.add(styleCase.id); return [styleCase]; }); setStylePickerCases(cases); }) .catch((error) => { if (cancelled) return; setStylePickerCases([]); setStylePickerError(error instanceof Error ? error.message : "社区风格暂时加载失败"); }) .finally(() => { if (!cancelled) setStylePickerLoading(false); }); return () => { cancelled = true; }; }, [stylePickerImageNodeId, stylePickerReloadToken]); useEffect(() => { if (!stylePickerImageNodeId) return undefined; const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { setStylePickerImageNodeId(null); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [stylePickerImageNodeId]); useEffect(() => { if (!styleSelectionToast) return undefined; const timer = window.setTimeout(() => setStyleSelectionToast(null), 2200); return () => window.clearTimeout(timer); }, [styleSelectionToast]); useEffect(() => { if (!recentProjectsOpen) return undefined; const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { setRecentProjectsOpen(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [recentProjectsOpen]); useEffect(() => { if (!imageFocusDrag) return; const updateDraftFromClientPoint = (clientX: number, clientY: number) => { const { rect } = imageFocusDrag; const end = { x: clampCanvasPercent(((clientX - rect.left) / rect.width) * 100), y: clampCanvasPercent(((clientY - rect.top) / rect.height) * 100), }; const ratio = imageFocusDraft?.ratio || "16:9"; const nextSelection = normalizeImageFocusSelectionFromAnchor( { x: imageFocusDrag.startX, y: imageFocusDrag.startY }, end, ratio, rect.width / rect.height, ); if (nextSelection.width < 1 || nextSelection.height < 1) { setImageFocusDraft(null); return; } setImageFocusDraft(nextSelection); }; const handleMove = (event: globalThis.MouseEvent) => { updateDraftFromClientPoint(event.clientX, event.clientY); }; const handleUp = (event: globalThis.MouseEvent) => { updateDraftFromClientPoint(event.clientX, event.clientY); setImageFocusDrag(null); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [imageFocusDrag, imageFocusDraft?.ratio]); useEffect(() => { if (!imageFocusNodeId) return undefined; const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { setImageFocusNodeId(null); setImageFocusDraft(null); setImageFocusDrag(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [imageFocusNodeId]); const saveCanvasAssetToServer = async (type: AssetLibraryCategory) => { if (!isAuthenticated) { onOpenLogin(); return; } setIsSavingAsset(true); setAssetLibraryNotice(null); try { const savedAsset = await assetClient.create({ type, name: saveAssetSource?.name || assetName || "画布素材", description: saveAssetSource?.description || "从画布保存的素材。", url: saveAssetSource?.url, imageUrl: saveAssetSource?.imageUrl || assetCoverUrl, tags: [ saveAssetSource?.kind === "image" ? "图片节点" : saveAssetSource?.kind === "video" ? "视频节点" : "文本节点", ], status: "ready", sourceProjectId: projectId || workflow.id, }); setServerAssets((current) => [savedAsset, ...current.filter((asset) => asset.id !== savedAsset.id)]); setSaveAssetOpen(false); setSaveAssetSource(null); setSelectedExistingCategory(""); setAssetCategoryOpen(false); setCoverSourceOpen(false); setCoverLibraryOpen(false); } catch (error) { setAssetLibraryNotice(error instanceof Error ? error.message : "保存到服务器资产库失败"); } finally { setIsSavingAsset(false); } }; const getTextNodePositionFromClient = (clientX: number, clientY: number) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; const worldX = (clientX - rect.left - canvasViewport.x) / canvasViewport.zoom; const worldY = (clientY - rect.top - canvasViewport.y) / canvasViewport.zoom; return { x: worldX - rect.width / 2, y: worldY - rect.height / 2, }; }; const getCanvasPointFromClient = (clientX: number, clientY: number) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; return { x: clientX - rect.left, y: clientY - rect.top, }; }; const getCanvasWorldPointFromClient = (clientX: number, clientY: number) => { const point = getCanvasPointFromClient(clientX, clientY); return { x: (point.x - canvasViewport.x) / canvasViewport.zoom, y: (point.y - canvasViewport.y) / canvasViewport.zoom, }; }; const isActiveSelectedNode = (kind: CanvasNodeKind, id: string) => selectedNode?.kind === kind && selectedNode.id === id; const isSelectedNode = (kind: CanvasNodeKind, id: string) => selectedNodes.some((node) => node.kind === kind && node.id === id); const isMultiSelectedNode = (kind: CanvasNodeKind, id: string) => selectedNodes.length > 1 && isSelectedNode(kind, id); const findNodePackage = (kind: CanvasNodeKind, id: string): CanvasNodePackage | null => nodePackages.find((pkg) => pkg.nodeIds.some((node) => node.kind === kind && node.id === id) ) || null; const applyCanvasSelection = (nodes: CanvasSelectedNode[]) => { const uniqueNodes = Array.from( new Map(nodes.map((node) => [getCanvasSelectionKey(node), node])).values() ); setSelectedPackageId(null); setSelectedNodes(uniqueNodes); setSelectedNode(uniqueNodes.length === 1 ? uniqueNodes[0] : null); }; const clearCanvasSelection = () => { setSelectedNodes([]); setSelectedNode(null); setSelectedPackageId(null); setSelectionContextMenu(null); }; const selectCanvasNode = (kind: CanvasNodeKind, id: string, addToSelection = false) => { if (addToSelection) { const key = getCanvasSelectionKey({ kind, id }); const exists = selectedNodes.some((n) => getCanvasSelectionKey(n) === key); if (exists) { const next = selectedNodes.filter((n) => getCanvasSelectionKey(n) !== key); applyCanvasSelection(next); } else { applyCanvasSelection([...selectedNodes, { kind, id }]); } return; } applyCanvasSelection([{ kind, id }]); }; const openCanvasSelectionContextMenu = (clientX: number, clientY: number) => { const menuPosition = positionFloatingMenu(clientX, clientY, 260, 180, 6); setSelectionContextMenu({ ...menuPosition, originLeft: clientX, originTop: clientY, }); setContextMenu(null); setNodeMenu(null); closeNodeContextMenus(); setCanvasSelectMenu(null); }; const getCanvasSelectedPackageCount = (nodes = selectedNodes) => { if (!nodes.length) return 0; const selectedKeys = new Set(nodes.map((node) => getCanvasSelectionKey(node))); return nodePackages.filter((nodePackage) => nodePackage.nodeIds.some((node) => selectedKeys.has(getCanvasSelectionKey(node))) ).length; }; const openCanvasNodePackageContextMenu = ( nodePackage: CanvasNodePackage, clientX: number, clientY: number ) => { setSelectedNodes([]); setSelectedNode(null); setSelectedPackageId(nodePackage.id); openCanvasSelectionContextMenu(clientX, clientY); }; const handleCanvasNodePackagePointer = ( event: MouseEvent, nodePackage: CanvasNodePackage ) => { event.preventDefault(); event.stopPropagation(); openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY); }; const startPackageDrag = ( event: React.MouseEvent, nodePackage: CanvasNodePackage, collapsed: boolean ) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const textOrigins: Record = {}; const imageOrigins: Record = {}; const videoOrigins: Record = {}; for (const member of nodePackage.nodeIds) { if (member.kind === "text") { const node = textNodes.find((n) => n.id === member.id); if (node) textOrigins[node.id] = { ...node.position }; } else if (member.kind === "image") { const node = imageNodes.find((n) => n.id === member.id); if (node) imageOrigins[node.id] = { ...node.position }; } else if (member.kind === "video") { const node = videoNodes.find((n) => n.id === member.id); if (node) videoOrigins[node.id] = { ...node.position }; } } setSelectedPackageId(nodePackage.id); setPackageDrag({ packageId: nodePackage.id, collapsed, startX: event.clientX, startY: event.clientY, hasMoved: false, textOrigins, imageOrigins, videoOrigins, collapsedBounds: collapsed ? nodePackage.collapsedBounds : undefined, }); }; const startSelectedNodesDrag = (event: React.MouseEvent) => { if (event.button !== 0 || selectedNodes.length < 2) return; event.preventDefault(); event.stopPropagation(); const textOrigins: Record = {}; const imageOrigins: Record = {}; const videoOrigins: Record = {}; for (const member of selectedNodes) { if (member.kind === "text") { const node = textNodes.find((n) => n.id === member.id); if (node) textOrigins[node.id] = { ...node.position }; } else if (member.kind === "image") { const node = imageNodes.find((n) => n.id === member.id); if (node) imageOrigins[node.id] = { ...node.position }; } else if (member.kind === "video") { const node = videoNodes.find((n) => n.id === member.id); if (node) videoOrigins[node.id] = { ...node.position }; } } setPackageDrag({ packageId: `__selection_drag_${Date.now()}`, collapsed: false, startX: event.clientX, startY: event.clientY, hasMoved: false, textOrigins, imageOrigins, videoOrigins, }); }; const packageSelectedCanvasNodes = () => { const uniqueNodes = Array.from( new Map(selectedNodes.map((node) => [getCanvasSelectionKey(node), node])).values() ); if (uniqueNodes.length < 2) return; pushHistorySnapshot(); const nextPackage: CanvasNodePackage = { id: `canvas-node-package-${Date.now()}`, title: `打包节点 ${nodePackages.length + 1}`, nodeIds: uniqueNodes, updatedAt: new Date().toISOString(), }; setNodePackages((currentPackages) => [...currentPackages, nextPackage]); setSelectedNodes([]); setSelectedNode(null); setSelectedPackageId(nextPackage.id); setSelectionContextMenu(null); setStyleSelectionToast(`已打包 ${uniqueNodes.length} 个节点`); }; const unpackageSelectedCanvasNodes = () => { if (selectedPackageId) { pushHistorySnapshot(); const activePackage = nodePackages.find((nodePackage) => nodePackage.id === selectedPackageId); setNodePackages((currentPackages) => currentPackages.filter((nodePackage) => nodePackage.id !== selectedPackageId) ); setSelectedNodes([]); setSelectedNode(null); setSelectedPackageId(null); setSelectionContextMenu(null); setStyleSelectionToast(activePackage ? `已取消打包「${activePackage.title}」` : "已取消打包"); return; } const uniqueNodes = Array.from( new Map(selectedNodes.map((node) => [getCanvasSelectionKey(node), node])).values() ); if (!uniqueNodes.length) { setSelectionContextMenu(null); return; } const selectedKeys = new Set(uniqueNodes.map((node) => getCanvasSelectionKey(node))); const affectedPackages = nodePackages.filter((nodePackage) => nodePackage.nodeIds.some((node) => selectedKeys.has(getCanvasSelectionKey(node))) ); if (!affectedPackages.length) { setSelectionContextMenu(null); setStyleSelectionToast("当前节点未被打包"); return; } setNodePackages((currentPackages) => currentPackages .map((nodePackage) => { if (!nodePackage.nodeIds.some((node) => selectedKeys.has(getCanvasSelectionKey(node)))) { return nodePackage; } const remainingNodeIds = nodePackage.nodeIds.filter( (node) => !selectedKeys.has(getCanvasSelectionKey(node)) ); return remainingNodeIds.length >= 2 ? { ...nodePackage, nodeIds: remainingNodeIds } : null; }) .filter((nodePackage): nodePackage is CanvasNodePackage => Boolean(nodePackage)) ); setSelectionContextMenu(null); setStyleSelectionToast(`已取消 ${affectedPackages.length} 个打包关联`); }; const collapseSelectedCanvasPackage = () => { if (!selectedPackageId) return; const activePackage = nodePackages.find((nodePackage) => nodePackage.id === selectedPackageId); if (!activePackage) return; const collapsedBounds = getCanvasNodePackageBounds(activePackage) || activePackage.collapsedBounds; setNodePackages((currentPackages) => currentPackages.map((nodePackage) => nodePackage.id === selectedPackageId ? { ...nodePackage, collapsed: true, collapsedBounds, updatedAt: new Date().toISOString(), } : nodePackage ) ); setSelectedNodes([]); setSelectedNode(null); setSelectionContextMenu(null); setStyleSelectionToast(`已折叠「${activePackage.title}」`); }; const expandCanvasNodePackage = (nodePackage: CanvasNodePackage) => { setNodePackages((currentPackages) => currentPackages.map((currentPackage) => currentPackage.id === nodePackage.id ? { ...currentPackage, collapsed: false, updatedAt: new Date().toISOString(), } : currentPackage ) ); setSelectedNodes([]); setSelectedNode(null); setSelectedPackageId(nodePackage.id); setSelectionContextMenu(null); setStyleSelectionToast(`已展开「${nodePackage.title}」`); }; const addTextNode = (source?: CanvasTextNode, position = { x: 0, y: 0 }) => { const nodeNumber = textNodeIdRef.current; textNodeIdRef.current += 1; pushHistorySnapshot(); const node: CanvasTextNode = { id: `text-node-${nodeNumber}-${Date.now()}`, title: source ? buildCopyTitle(source.title) : `文本节点 ${nodeNumber}`, prompt: source?.prompt ?? "", content: source?.content ?? "", isEditingContent: false, isComposerOpen: source?.isComposerOpen ?? !source?.content, selectedModelId: defaultTextModelId, position, size: source?.size ?? createCanvasNodeSize("text"), }; setTextNodes((currentNodes) => [...currentNodes, node]); setSelectedNode({ kind: "text", id: node.id }); }; const duplicateTextNode = (source: CanvasTextNode, position = { x: source.position.x + 120, y: source.position.y }) => { addTextNode(source, position); }; const updateTextNodePrompt = (nodeId: string, prompt: string) => { setTextNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, prompt, } : node ) ); }; const updateTextNodeContent = (nodeId: string, content: string) => { setTextNodes((currentNodes) => currentNodes.map((node) => (node.id === nodeId ? { ...node, content } : node)) ); }; const setTextNodeContentEditing = (nodeId: string, isEditingContent: boolean) => { setTextNodes((currentNodes) => currentNodes.map((node) => (node.id === nodeId ? { ...node, isEditingContent } : node)) ); }; const finishTextNodeContentEditing = (nodeId: string) => { setTextNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, isEditingContent: false, isComposerOpen: node.content.trim() ? false : node.isComposerOpen, } : node ) ); }; const handleGenerateTextNode = async (nodeId: string) => { const textNode = textNodes.find((node) => node.id === nodeId); if (!textNode || textGenerationInFlightRef.current.has(nodeId)) return; let prompt = getEffectiveNodePrompt("text", nodeId, textNode.prompt); // Resolve @-mentions: @图片1 → 第1张图片 if (MENTION_TOKEN_RE.test(prompt)) { const mentionOptions = buildNodeMentionOptions("text", nodeId, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks); prompt = prompt.replace(MENTION_TOKEN_RE, (match) => { const opt = mentionOptions.find((o) => o.token === match); if (!opt) return match; const num = match.match(/\d+/)![0]; const kindMap: Record = { image: "张图片", video: "段视频", text: "个文本" }; return `第${num}${kindMap[opt.kind] || "个节点"}`; }); } if (!prompt) { setTextGenerationStatus(nodeId, { status: "error", message: "请输入提示词或连接上游节点" }); return; } if (!isAuthenticated) { onOpenLogin(); return; } const controller = new AbortController(); let streamedText = ""; textGenerationInFlightRef.current.add(nodeId); textGenerationAbortControllersRef.current.set(nodeId, controller); setTextGenerationStatus(nodeId, { status: "running", message: "AI 正在生成文本" }); setTextNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, content: "", isEditingContent: false } : node ) ); try { await aiGenerationClient.streamChat( { model: defaultTextModelId, messages: [{ role: "user", content: prompt }], temperature: 0.7, }, (chunk) => { if (controller.signal.aborted) return; streamedText += chunk; setTextNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, content: streamedText, isEditingContent: false } : node ) ); }, controller.signal, ); if (controller.signal.aborted) return; const finalText = streamedText.trim(); if (!finalText) { throw new Error("AI 没有返回文本,请换个提示词再试"); } setTextNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, content: finalText, isEditingContent: false } : node ) ); setTextGenerationStatus(nodeId, { status: "success", message: "文本生成完成" }); } catch (error) { if (controller.signal.aborted) return; setTextGenerationStatus(nodeId, { status: "error", message: error instanceof Error ? error.message : "文本生成失败", }); } finally { textGenerationInFlightRef.current.delete(nodeId); textGenerationAbortControllersRef.current.delete(nodeId); } }; const reversePromptFromLinkedNode = async (source: CanvasTextNode) => { const sourceLinkedImages = imageNodes.filter((img) => img.sourceTextNodeId === source.id && img.imageUrl); const manualLinkedImageIds = new Set( manualLinks .filter( (link) => (link.from.kind === "image" && link.to.kind === "text" && link.to.nodeId === source.id) || (link.from.kind === "text" && link.to.kind === "image" && link.from.nodeId === source.id) ) .map((link) => (link.from.kind === "image" ? link.from.nodeId : link.to.nodeId)) ); const manualLinkedImages = imageNodes.filter( (img) => manualLinkedImageIds.has(img.id) && img.imageUrl ); const allLinkedImages = [...sourceLinkedImages, ...manualLinkedImages]; if (!allLinkedImages.length) { setAssetLibraryNotice("请先将图片链接到文本节点后再反推提示词"); return; } const linkedImage = allLinkedImages[0]; const imageUrl = linkedImage.imageUrl!; setTextNodes((nodes) => nodes.map((n) => n.id === source.id ? { ...n, content: "正在分析图片,生成提示词...", isEditingContent: false } : n) ); try { const result = await aiGenerationClient.chatCompletion({ model: "qwen3.6-plus", messages: [ { role: "user", content: [ { type: "image_url", image_url: { url: imageUrl } }, { type: "text", text: "请详细描述这张图片的内容,用于 AI 绘画提示词。包含:主体描述、构图方式、光影氛围、色彩风格、镜头语言。用英文输出,简洁精准,适合直接作为 Stable Diffusion 或 Midjourney 提示词使用。" }, ], }, ], temperature: 0.3, }); const prompt = result.trim() || "(AI 未返回有效描述)"; setTextNodes((nodes) => nodes.map((n) => n.id === source.id ? { ...n, content: prompt, prompt: "", isEditingContent: false } : n) ); } catch (error) { const msg = error instanceof Error ? error.message : "反推提示词失败"; setTextNodes((nodes) => nodes.map((n) => n.id === source.id ? { ...n, content: `反推失败:${msg}`, isEditingContent: false } : n) ); } }; const addVideoNodeFromText = (source: CanvasTextNode) => { const nodeNumber = videoNodeIdRef.current; videoNodeIdRef.current += 1; const prompt = source.content || source.prompt || ""; const node: CanvasVideoNode = { id: `video-node-${nodeNumber}-${Date.now()}`, title: `视频节点 ${nodeNumber}`, prompt, model: defaultVideoModel, aspectRatio: "16:9", resolution: getDefaultVideoQuality(defaultVideoModel), duration: "4", videoMode: "text2video", sourceTextNodeId: source.id, position: { x: source.position.x + 520, y: source.position.y, }, size: createCanvasNodeSize("video"), }; setVideoNodes((currentNodes) => [...currentNodes, node]); setSelectedNode({ kind: "video", id: node.id }); }; const addVideoNode = (position = { x: 0, y: 0 }) => { const nodeNumber = videoNodeIdRef.current; videoNodeIdRef.current += 1; const node: CanvasVideoNode = { id: `video-node-${nodeNumber}-${Date.now()}`, title: `视频节点 ${nodeNumber}`, prompt: "", model: defaultVideoModel, aspectRatio: "16:9", resolution: getDefaultVideoQuality(defaultVideoModel), duration: "4", videoMode: "text2video", sourceTextNodeId: "", position, size: createCanvasNodeSize("video"), }; setVideoNodes((currentNodes) => [...currentNodes, node]); setSelectedNode({ kind: "video", id: node.id }); }; const duplicateVideoNode = (source: CanvasVideoNode, position = { x: source.position.x + 120, y: source.position.y }) => { const nodeNumber = videoNodeIdRef.current; videoNodeIdRef.current += 1; const node: CanvasVideoNode = { ...source, id: `video-node-${nodeNumber}-${Date.now()}`, title: buildCopyTitle(source.title), position: { ...position }, size: { ...source.size }, }; setVideoNodes((currentNodes) => [...currentNodes, node]); setSelectedNode({ kind: "video", id: node.id }); }; const updateVideoNodePrompt = (nodeId: string, prompt: string) => { setVideoNodes((currentNodes) => currentNodes.map((node) => (node.id === nodeId ? { ...node, prompt } : node)) ); }; const updateVideoNodeSetting = ( nodeId: string, patch: Partial> ) => { setVideoNodes((currentNodes) => currentNodes.map((node) => { if (node.id !== nodeId) return node; const nextModel = patch.model ?? node.model; return { ...node, ...patch, resolution: patch.model && !patch.resolution ? getDefaultVideoQuality(nextModel) : patch.resolution ?? resolveVideoQuality(nextModel, node.resolution), }; }) ); }; const addImageNode = ( imageUrl = "", fileName = "本地图片", position = { x: 0, y: 0 }, options?: { title?: string; sourceImageNodeId?: string } ) => { const nodeNumber = imageNodeIdRef.current; imageNodeIdRef.current += 1; const node: CanvasImageNode = { id: `image-node-${nodeNumber}-${Date.now()}`, title: options?.title ?? `图片节点 ${nodeNumber}`, prompt: "", imageUrl, model: fallbackVisibleImageModel, aspectRatio: "16:9", imageSize: getDefaultImageQuality(fallbackVisibleImageModel), fileName, sourceImageNodeId: options?.sourceImageNodeId, position, size: createCanvasNodeSize("image"), }; setImageNodes((currentNodes) => [...currentNodes, node]); setSelectedNode({ kind: "image", id: node.id }); }; const duplicateImageNode = (source: CanvasImageNode, position = { x: source.position.x + 120, y: source.position.y }) => { const nodeNumber = imageNodeIdRef.current; imageNodeIdRef.current += 1; const node: CanvasImageNode = { ...source, id: `image-node-${nodeNumber}-${Date.now()}`, title: buildCopyTitle(source.title), position: { ...position }, size: { ...source.size }, }; setImageNodes((currentNodes) => [...currentNodes, node]); setSelectedNode({ kind: "image", id: node.id }); }; const duplicateCopiedCanvasNode = (source: CanvasCopiedNode, position: CanvasPoint) => { if (source.kind === "text") { duplicateTextNode(source.node, position); return; } if (source.kind === "image") { duplicateImageNode(source.node, position); return; } duplicateVideoNode(source.node, position); }; const updateImageNodePrompt = (nodeId: string, prompt: string) => { setImageNodes((currentNodes) => currentNodes.map((node) => (node.id === nodeId ? { ...node, prompt } : node)) ); }; const updateImageNodeSetting = ( nodeId: string, patch: Partial> ) => { setImageNodes((currentNodes) => currentNodes.map((node) => { if (node.id !== nodeId) return node; const nextModel = resolveVisibleImageModel(patch.model ?? node.model); return { ...node, ...patch, model: nextModel, imageSize: patch.model && !patch.imageSize ? getDefaultImageQuality(nextModel) : patch.imageSize ?? resolveImageQuality(nextModel, node.imageSize), }; }) ); }; const isHttpUrl = (url: string | undefined) => Boolean(url && /^https?:\/\//i.test(url)); const isDataImageUrl = (url: string | undefined) => Boolean(url && /^data:image\/[^;,]+;base64,/i.test(url)); const isBlobUrl = (url: string | undefined) => Boolean(url && /^blob:/i.test(url)); const isCanvasImageReferenceUrl = (url: string | undefined): url is string => Boolean(url && (isHttpUrl(url) || isDataImageUrl(url) || isBlobUrl(url))); const getCanvasNodePositionX = (port: CanvasNodePort) => { if (port.kind === "text") return textNodes.find((node) => node.id === port.nodeId)?.position.x ?? null; if (port.kind === "image") return imageNodes.find((node) => node.id === port.nodeId)?.position.x ?? null; return videoNodes.find((node) => node.id === port.nodeId)?.position.x ?? null; }; const normalizeCanvasLinkForFlow = (first: CanvasNodePort, second: CanvasNodePort) => { const sideValidatedLink = normalizeCanvasLinkPorts(first, second); if (!sideValidatedLink) return null; const firstX = getCanvasNodePositionX(first); const secondX = getCanvasNodePositionX(second); if (firstX !== null && secondX !== null && firstX !== secondX) { const source = firstX < secondX ? first : second; const target = source === first ? second : first; return { from: { ...source, side: "right" as const }, to: { ...target, side: "left" as const }, }; } return { from: { ...sideValidatedLink.from, side: "right" as const }, to: { ...sideValidatedLink.to, side: "left" as const }, }; }; const getIncomingCanvasPorts = (kind: CanvasNodeKind, nodeId: string): CanvasNodePort[] => { const ports: CanvasNodePort[] = []; const addPort = (port: CanvasNodePort | null | undefined) => { if (!port) return; if (ports.some((item) => getCanvasPortIdentity(item) === getCanvasPortIdentity(port))) return; ports.push(port); }; if (kind === "image") { const node = imageNodes.find((item) => item.id === nodeId); if (node?.sourceImageNodeId) { addPort({ kind: "image", nodeId: node.sourceImageNodeId, side: "right", slot: "center" }); } if (node?.sourceTextNodeId) { addPort({ kind: "text", nodeId: node.sourceTextNodeId, side: "right", slot: "center" }); } } if (kind === "video") { const node = videoNodes.find((item) => item.id === nodeId); if (node?.sourceTextNodeId) { addPort({ kind: "text", nodeId: node.sourceTextNodeId, side: "right", slot: "center" }); } } manualLinks.forEach((link) => { const normalizedLink = normalizeCanvasLinkForFlow(link.from, link.to); if (!normalizedLink) return; if (normalizedLink.to.kind === kind && normalizedLink.to.nodeId === nodeId) { addPort(normalizedLink.from); } }); return ports; }; const getNodePromptContent = ( kind: CanvasNodeKind, nodeId: string, visited = new Set() ): string => { const key = `${kind}:${nodeId}`; if (visited.has(key)) return ""; visited.add(key); if (kind === "text") { const node = textNodes.find((item) => item.id === nodeId); return node?.content.trim() || node?.prompt.trim() || ""; } if (kind === "image") { const node = imageNodes.find((item) => item.id === nodeId); const ownPrompt = node?.prompt.trim(); if (ownPrompt) return ownPrompt; } if (kind === "video") { const node = videoNodes.find((item) => item.id === nodeId); const ownPrompt = node?.prompt.trim(); if (ownPrompt) return ownPrompt; } return getIncomingCanvasPorts(kind, nodeId) .map((port) => getNodePromptContent(port.kind, port.nodeId, visited)) .filter(Boolean) .join("\n"); }; const getEffectiveNodePrompt = (kind: CanvasNodeKind, nodeId: string, explicitPrompt: string) => explicitPrompt.trim() || getIncomingCanvasPorts(kind, nodeId) .map((port) => getNodePromptContent(port.kind, port.nodeId)) .filter(Boolean) .join("\n"); const getConnectedImageReferenceItems = ( kind: CanvasNodeKind, nodeId: string, ownImageNode?: CanvasImageNode ) => { const items: CanvasImageReferenceItem[] = []; const seen = new Set(); const addImageNode = (node: CanvasImageNode | undefined) => { const imageUrl = node?.imageUrl?.trim(); if (!node || !isCanvasImageReferenceUrl(imageUrl)) return; const identity = `${node.id}:${imageUrl}`; if (seen.has(identity)) return; seen.add(identity); items.push({ nodeId: node.id, title: node.title, imageUrl, fileName: node.fileName, }); }; addImageNode(ownImageNode); getIncomingCanvasPorts(kind, nodeId).forEach((port) => { if (port.kind !== "image") return; const imageNode = imageNodes.find((node) => node.id === port.nodeId); addImageNode(imageNode); }); return items; }; const getConnectedImageReferenceUrls = ( kind: CanvasNodeKind, nodeId: string, ownImageNode?: CanvasImageNode ) => { const urls: string[] = []; getConnectedImageReferenceItems(kind, nodeId, ownImageNode).forEach((item) => { if (!isHttpUrl(item.imageUrl) || urls.includes(item.imageUrl)) return; urls.push(item.imageUrl); }); return urls; }; const persistResolvedImageReferenceUrl = (nodeId: string, imageUrl: string) => { setImageNodes((currentNodes) => currentNodes.map((node) => (node.id === nodeId && node.imageUrl !== imageUrl ? { ...node, imageUrl } : node)) ); }; const uploadCanvasImageReference = async (item: CanvasImageReferenceItem) => { const rawUrl = item.imageUrl.trim(); if (isHttpUrl(rawUrl)) return rawUrl; let dataUrl = rawUrl; let mimeType = rawUrl.match(/^data:([^;,]+);base64,/i)?.[1] || "image/png"; if (isBlobUrl(rawUrl)) { const response = await fetch(rawUrl); if (!response.ok) { throw new Error(`图片节点「${item.title}」读取失败,请重新上传后再生成。`); } const blob = await response.blob(); if (!blob.type.startsWith("image/")) { throw new Error(`图片节点「${item.title}」不是可用的图片文件。`); } mimeType = blob.type || mimeType; dataUrl = await blobToDataUrl(blob); } if (!isDataImageUrl(dataUrl)) { throw new Error(`图片节点「${item.title}」不是可提交的图片地址,请重新上传。`); } const uploaded = await aiGenerationClient.uploadAsset({ dataUrl, name: item.fileName || item.title || "canvas-reference.png", mimeType, }); const uploadedUrl = uploaded.url || uploaded.signedUrl; if (!uploadedUrl) { throw new Error(`图片节点「${item.title}」上传失败,请稍后重试。`); } persistResolvedImageReferenceUrl(item.nodeId, uploadedUrl); return uploadedUrl; }; const resolveConnectedImageReferenceUrls = async ( kind: CanvasNodeKind, nodeId: string, ownImageNode?: CanvasImageNode ) => { const items = getConnectedImageReferenceItems(kind, nodeId, ownImageNode); const results = await Promise.all(items.map((item) => uploadCanvasImageReference(item))); const seen = new Set(); return results.filter((url): url is string => { if (!url || seen.has(url)) return false; seen.add(url); return true; }); }; const handleGenerateImageNode = async (nodeId: string) => { const imageNode = imageNodes.find((node) => node.id === nodeId); if (!imageNode) return; if (imageGenerationInFlightRef.current.has(nodeId)) return; const referenceItems = getConnectedImageReferenceItems("image", nodeId, imageNode); const basePrompt = getEffectiveNodePrompt("image", nodeId, imageNode.prompt) || (referenceItems.length ? "根据参考图片生成图片" : ""); const markingSuffix = imageNode.marking ? `\n标记: ${imageNode.marking}` : ""; const prompt = basePrompt + markingSuffix; if (!prompt) { setImageGenerationStatus(nodeId, { status: "error", message: "请输入提示词或连接上游节点" }); return; } if (!isAuthenticated) { onOpenLogin(); return; } if (!onCreateTask) { setImageGenerationStatus(nodeId, { status: "error", message: "生成入口暂不可用" }); return; } const model = resolveVisibleImageModel(imageNode.model || defaultImageModel); const ratio = imageNode.aspectRatio || "16:9"; const quality = resolveImageQuality(model, imageNode.imageSize || ""); imageGenerationInFlightRef.current.add(nodeId); setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 }); setGenerationToast("图片正在生成"); let task: Awaited> | null = null; try { const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode); const taskInput: CreatePreviewTaskInput = { title: imageNode.title || "图片节点生成", type: "image", prompt, params: { projectId: projectId || undefined, model, ratio, quality, gridMode: "single", referenceUrls: referenceUrls.length ? referenceUrls : undefined, }, }; task = await onCreateTask(taskInput); if (task.status === "failed") { throw new Error(translateTaskError(task.errorMessage)); } if (task.status === "completed" && !task.outputUrl) { throw new Error("图片生成任务已完成,但服务器没有返回结果地址,请稍后重试"); } addCanvasGenKeepalive(task.id, nodeId, "image", projectId || ""); setImageGenerationStatus(nodeId, { status: "running", message: "图片生成中", progress: Math.max(18, Number(task.progress || 0)) }); const outputUrl = task.outputUrl || (await waitForImageTaskResult(task.id, (status) => { const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const statusLabel = status.status === "pending" ? "图片排队中" : status.status === "running" ? "图片生成中" : status.status === "completed" ? "图片生成完成" : "图片生成失败"; setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); })); setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 }); removeCanvasGenKeepalive(task.id); const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ url: outputUrl, mediaType: "image/png", resultType: "image", taskId: task.id, originalUrl: outputUrl, }); setImageNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, imageUrl: outputUrl, assetRef: immediateAssetRef, taskRef: { taskId: task!.id, status: "completed", resultUrl: outputUrl, updatedAt: new Date().toISOString(), }, fileName: `${imageNode.title || "图片节点"}-${ratio}-${quality}`, } : node ) ); let durableAssetRef = immediateAssetRef; try { durableAssetRef = await persistCanvasGeneratedResultAsset({ title: imageNode.title || "image-node", url: outputUrl, mediaType: "image/png", resultType: "image", taskId: task.id, originalUrl: outputUrl, }); } catch { durableAssetRef = immediateAssetRef; } if (durableAssetRef.url !== outputUrl || durableAssetRef.ossKey) { setImageNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, imageUrl: durableAssetRef.url, assetRef: durableAssetRef, taskRef: { taskId: task!.id, status: "completed", resultUrl: durableAssetRef.url, updatedAt: new Date().toISOString(), }, fileName: `${imageNode.title || "图片节点"}-${ratio}-${quality}`, } : node ) ); } } catch (error) { setImageGenerationStatus(nodeId, { status: "error", message: error instanceof Error ? error.message : "图片生成失败", }); } finally { imageGenerationInFlightRef.current.delete(nodeId); if (task?.id) removeCanvasGenKeepalive(task.id); } }; const getVideoFrameMode = (videoMode: CanvasVideoNode["videoMode"]) => videoMode === "firstlast" ? "start-end" : "omni"; const handleGenerateVideoNode = async (nodeId: string) => { const videoNode = videoNodes.find((node) => node.id === nodeId); if (!videoNode || videoGenerationInFlightRef.current.has(nodeId)) return; const basePrompt = getEffectiveNodePrompt("video", nodeId, videoNode.prompt); const extraParts: string[] = []; if (videoNode.marking) extraParts.push(`标记: ${videoNode.marking}`); const cameraPrompt = getCameraMotionPrompt(videoNode.cameraMotion || ""); if (cameraPrompt) extraParts.push(`运镜: ${cameraPrompt}`); const prompt = extraParts.length > 0 ? `${basePrompt || ""}\n${extraParts.join("\n")}`.trim() : basePrompt; const referenceItems = getConnectedImageReferenceItems("video", nodeId); if (videoNode.videoMode === "img2video" && referenceItems.length === 0) { setVideoGenerationStatus(nodeId, { status: "error", message: "图生视频需要先连接至少一个有图片的图片节点" }); return; } if (!prompt && referenceItems.length === 0) { setVideoGenerationStatus(nodeId, { status: "error", message: "请输入提示词或连接图片节点" }); return; } if (!isAuthenticated) { onOpenLogin(); return; } if (!onCreateTask) { setVideoGenerationStatus(nodeId, { status: "error", message: "生成入口暂不可用" }); return; } const model = toHappyHorseDisplayModel(videoNode.model || defaultVideoModel); const ratio = videoNode.aspectRatio || "16:9"; const quality = resolveVideoQuality(model, videoNode.resolution || ""); const duration = Number(videoNode.duration) || 4; videoGenerationInFlightRef.current.add(nodeId); setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 }); setGenerationToast("视频正在生成"); try { const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId); if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) { throw new Error("图生视频需要先连接至少一个可用的图片节点"); } let requestModel = resolveVideoRequestModel({ model, referenceUrls }); const task = await onCreateTask({ title: videoNode.title || "视频节点生成", type: "video", prompt: prompt || "根据参考图片生成视频", params: { projectId: projectId || undefined, model: requestModel, ratio, quality, resolution: quality, duration, frameMode: getVideoFrameMode(videoNode.videoMode), referenceUrls: referenceUrls.length ? referenceUrls : undefined, muted: false, hasReferenceVideo: false, }, }); if (task.status === "failed") { throw new Error(translateTaskError(task.errorMessage)); } if (task.status === "completed" && !task.outputUrl) { throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试"); } setVideoGenerationStatus(nodeId, { status: "running", message: "视频生成中", progress: Math.max(18, Number(task.progress || 0)) }); const outputUrl = task.outputUrl || (await waitForImageTaskResult(task.id, (status) => { const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0))); const statusLabel = status.status === "pending" ? "视频排队中" : status.status === "running" ? "视频生成中" : status.status === "completed" ? "视频生成完成" : "视频生成失败"; setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress }); })); setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 }); const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({ url: outputUrl, mediaType: "video/mp4", resultType: "video", taskId: task.id, originalUrl: outputUrl, }); setVideoNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, videoUrl: outputUrl, assetRef: immediateAssetRef, taskRef: { taskId: task.id, status: "completed", resultUrl: outputUrl, updatedAt: new Date().toISOString(), }, } : node ) ); const assetRef = await persistCanvasGeneratedResultAsset({ title: videoNode.title || "video-node", url: outputUrl, mediaType: "video/mp4", resultType: "video", taskId: task.id, originalUrl: outputUrl, }); await delay(420); if (assetRef.url !== outputUrl || assetRef.ossKey) { setVideoNodes((currentNodes) => currentNodes.map((node) => node.id === nodeId ? { ...node, videoUrl: assetRef.url, assetRef, taskRef: { taskId: task.id, status: "completed", resultUrl: assetRef.url, updatedAt: new Date().toISOString(), }, } : node ) ); } } catch (error) { setVideoGenerationStatus(nodeId, { status: "error", message: error instanceof Error ? error.message : "视频生成失败", }); } finally { videoGenerationInFlightRef.current.delete(nodeId); } }; const handleImageFileSelected = ( event: ChangeEvent, position: { x: number; y: number } ) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const imageUrl = URL.createObjectURL(file); if (pendingImageToImageNodeId) { const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId); if (sourceNode) { setImageNodes((currentNodes) => currentNodes.map((node) => node.id === sourceNode.id ? { ...node, imageUrl, fileName: file.name, focusSelection: undefined, assetRef: null, taskRef: null } : node ) ); addImageNode( "", "图生图", { x: sourceNode.position.x + 520, y: sourceNode.position.y }, { title: "图生图", sourceImageNodeId: sourceNode.id } ); } setPendingImageToImageNodeId(null); } else if (pendingImageNodeId) { setImageNodes((currentNodes) => currentNodes.map((node) => node.id === pendingImageNodeId ? { ...node, imageUrl, fileName: file.name, focusSelection: undefined, assetRef: null, taskRef: null } : node ) ); selectCanvasNode("image", pendingImageNodeId); setPendingImageNodeId(null); } else { addImageNode(imageUrl, file.name, position); } setContextMenu(null); setNodeMenu(null); }; const activeTextNode = textNodeMenu ? textNodes.find((node) => node.id === textNodeMenu.nodeId) ?? null : null; const activeImageNode = imageNodeMenu ? imageNodes.find((node) => node.id === imageNodeMenu.nodeId) ?? null : null; const activeVideoNode = videoNodeMenu ? videoNodes.find((node) => node.id === videoNodeMenu.nodeId) ?? null : null; const openImageStylePicker = (nodeId: string) => { selectCanvasNode("image", nodeId); setCanvasSelectMenu(null); setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setStylePickerTab("square"); setStylePickerCategory(canvasStylePickerCategories[0]); setStylePickerSearch(""); setStylePickerImageNodeId(nodeId); }; const handleSelectImageStyle = (styleCase: CanvasStyleCase) => { if (!stylePickerImageNodeId) return; const styleReference = createStyleReferenceFromCase(styleCase); setImageNodes((currentNodes) => currentNodes.map((node) => node.id === stylePickerImageNodeId ? { ...node, styleReference } : node ) ); setRecentStyleCases((currentCases) => [ styleCase, ...currentCases.filter((item) => item.id !== styleCase.id), ].slice(0, 24)); setStyleSelectionToast(`已选择「${styleCase.title}」风格`); setStylePickerImageNodeId(null); }; const openImageFocusMode = (node: CanvasImageNode) => { selectCanvasNode("image", node.id); setCanvasSelectMenu(null); setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setStylePickerImageNodeId(null); setImageFocusNodeId(node.id); setImageFocusDraft(node.focusSelection ?? null); setImageFocusDrag(null); }; const cancelImageFocusMode = () => { setImageFocusNodeId(null); setImageFocusDraft(null); setImageFocusDrag(null); }; const confirmImageFocusMode = () => { if (!imageFocusNodeId || !imageFocusDraft) { cancelImageFocusMode(); return; } setImageNodes((currentNodes) => currentNodes.map((node) => node.id === imageFocusNodeId ? { ...node, focusSelection: imageFocusDraft } : node ) ); setStyleSelectionToast("已确认聚焦区域"); cancelImageFocusMode(); }; const handleImageFocusDragStart = (event: MouseEvent, nodeId: string) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); const startX = clampCanvasPercent(((event.clientX - rect.left) / rect.width) * 100); const startY = clampCanvasPercent(((event.clientY - rect.top) / rect.height) * 100); selectCanvasNode("image", nodeId); setImageFocusNodeId(nodeId); setCanvasSelectMenu(null); setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setImageFocusDrag({ nodeId, startX, startY, rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height, }, }); setImageFocusDraft({ x: startX, y: startY, width: 0, height: 0, ratio: imageFocusDraft?.ratio || "16:9", }); }; const handleImageFocusRatioChange = (ratio: string) => { if (!imageFocusDraft) return; const targetNode = imageFocusNodeId ? imageNodes.find((node) => node.id === imageFocusNodeId) : null; const containerRatio = targetNode ? targetNode.size.width / targetNode.size.height : 16 / 9; setImageFocusDraft(applyImageFocusRatioFromTopLeft(imageFocusDraft, ratio, containerRatio)); }; const stylePickerNode = stylePickerImageNodeId ? imageNodes.find((node) => node.id === stylePickerImageNodeId) ?? null : null; const stylePickerSourceCases = stylePickerTab === "recent" ? recentStyleCases : stylePickerTab === "favorites" ? stylePickerCases.filter((item) => item.isFavorited) : stylePickerCases; const stylePickerQuery = stylePickerSearch.trim().toLowerCase(); const stylePickerVisibleCases = stylePickerSourceCases.filter((item) => { const matchesCategory = stylePickerCategory === canvasStylePickerCategories[0] || item.keywords.includes(stylePickerCategory.toLowerCase()); const matchesQuery = !stylePickerQuery || item.keywords.includes(stylePickerQuery); return matchesCategory && matchesQuery; }); const getCanvasNodeRect = (position: CanvasPoint, size: CanvasNodeSize) => { const rect = canvasRef.current?.getBoundingClientRect(); const canvasWidth = rect?.width ?? 0; const canvasHeight = rect?.height ?? 0; return { left: canvasWidth / 2 + position.x - size.width / 2, top: canvasHeight / 2 + position.y - size.height / 2, width: size.width, height: size.height, }; }; const getCanvasNodeScreenRect = (position: CanvasPoint, size: CanvasNodeSize) => { const worldRect = getCanvasNodeRect(position, size); return { left: canvasViewport.x + worldRect.left * canvasViewport.zoom, top: canvasViewport.y + worldRect.top * canvasViewport.zoom, width: worldRect.width * canvasViewport.zoom, height: worldRect.height * canvasViewport.zoom, }; }; const getNodesInSelectionRect = (selectionRect: { left: number; top: number; width: number; height: number }) => [ ...textNodes.flatMap((node) => doCanvasRectsIntersect(selectionRect, getCanvasNodeScreenRect(node.position, node.size)) ? [{ kind: "text" as const, id: node.id }] : [] ), ...imageNodes.flatMap((node) => doCanvasRectsIntersect(selectionRect, getCanvasNodeScreenRect(node.position, node.size)) ? [{ kind: "image" as const, id: node.id }] : [] ), ...videoNodes.flatMap((node) => doCanvasRectsIntersect(selectionRect, getCanvasNodeScreenRect(node.position, node.size)) ? [{ kind: "video" as const, id: node.id }] : [] ), ]; dragCallbacksRef.current = { pushHistorySnapshot, clearCanvasSelection, selectCanvasNode, applyCanvasSelection, getCanvasPointFromClient, getNodesInSelectionRect, expandCanvasNodePackage, onBeforeResize: () => { setCanvasSelectMenu(null); setImageFocusNodeId(null); setImageFocusDraft(null); setImageFocusDrag(null); }, }; const getCanvasNodeBoundsFromSelection = (node: CanvasSelectedNode): CanvasNodeBounds | null => { if (node.kind === "text") { const textNode = textNodes.find((item) => item.id === node.id); return textNode ? getCanvasNodeScreenRect(textNode.position, textNode.size) : null; } if (node.kind === "image") { const imageNode = imageNodes.find((item) => item.id === node.id); return imageNode ? getCanvasNodeScreenRect(imageNode.position, imageNode.size) : null; } const videoNode = videoNodes.find((item) => item.id === node.id); return videoNode ? getCanvasNodeScreenRect(videoNode.position, videoNode.size) : null; }; const mergeCanvasNodeBounds = (bounds: CanvasNodeBounds[]): CanvasNodeBounds | null => { if (!bounds.length) return null; const left = Math.min(...bounds.map((item) => item.left)); const top = Math.min(...bounds.map((item) => item.top)); const right = Math.max(...bounds.map((item) => item.left + item.width)); const bottom = Math.max(...bounds.map((item) => item.top + item.height)); return { left, top, width: right - left, height: bottom - top, }; }; const getCanvasSelectedNodesBounds = () => selectedNodes.length > 1 && !selectedPackageId ? mergeCanvasNodeBounds( selectedNodes .map((node) => getCanvasNodeBoundsFromSelection(node)) .filter((bounds): bounds is CanvasNodeBounds => Boolean(bounds)) ) : null; const getCanvasNodePackageBounds = (nodePackage: CanvasNodePackage) => mergeCanvasNodeBounds( nodePackage.nodeIds .map((node) => getCanvasNodeBoundsFromSelection(node)) .filter((bounds): bounds is CanvasNodeBounds => Boolean(bounds)) ); const getCanvasNodePackageBoundsWithMeta = () => nodePackages .filter((nodePackage) => !nodePackage.collapsed) .map((nodePackage) => { const bounds = getCanvasNodePackageBounds(nodePackage); return bounds ? { nodePackage, bounds } : null; }) .filter( (item): item is { nodePackage: CanvasNodePackage; bounds: CanvasNodeBounds } => Boolean(item) ); const getCanvasCollapsedPackageCardsWithMeta = () => nodePackages .filter((nodePackage) => nodePackage.collapsed) .map((nodePackage) => { const bounds = nodePackage.collapsedBounds || getCanvasNodePackageBounds(nodePackage); return bounds ? { nodePackage, bounds } : null; }) .filter( (item): item is { nodePackage: CanvasNodePackage; bounds: CanvasNodeBounds } => Boolean(item) ); const isPendingPort = (port: CanvasNodePort) => pendingLinkPort?.kind === port.kind && pendingLinkPort.nodeId === port.nodeId && pendingLinkPort.side === port.side && pendingLinkPort.slot === port.slot; const connectorButtonClassName = ( baseClassName: string, port: CanvasNodePort ) => `${baseClassName} studio-canvas-node-connector--${port.side} studio-canvas-node-connector--${port.slot}${isPendingPort(port) ? " is-linking" : ""}`; const connectorPortKey = (port: CanvasNodePort) => `${port.kind}-${port.nodeId}-${port.side}-${port.slot}`; const canConnectCanvasPorts = (from: CanvasNodePort, to: CanvasNodePort) => Boolean(normalizeCanvasLinkForFlow(from, to)); const resetConnectorFollow = (port: CanvasNodePort) => { const portKey = connectorPortKey(port); setConnectorFollowOffsets((currentOffsets) => { if (!currentOffsets[portKey]) return currentOffsets; const { [portKey]: _removed, ...nextOffsets } = currentOffsets; return nextOffsets; }); }; const updateConnectorFollow = ( port: CanvasNodePort, event: MouseEvent ) => { const rect = event.currentTarget.getBoundingClientRect(); const deltaX = event.clientX - (rect.left + rect.width / 2); const deltaY = event.clientY - (rect.top + rect.height / 2); const distance = Math.hypot(deltaX, deltaY); if (distance > connectorFollowRadius) { resetConnectorFollow(port); return; } const followScale = Math.min(connectorMaxFollowOffset, distance * connectorFollowStrength) / Math.max(distance, 1); const portKey = connectorPortKey(port); setConnectorFollowOffsets((currentOffsets) => ({ ...currentOffsets, [portKey]: { x: deltaX * followScale, y: deltaY * followScale, }, })); }; const renderConnectorButton = (port: CanvasNodePort, baseClassName: string) => { const portKey = connectorPortKey(port); const followOffset = connectorFollowOffsets[portKey] ?? { x: 0, y: 0 }; return ( ); }; const closeNodeContextMenus = () => { setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); }; const renderCanvasNodeContextMenu = ({ left, top, saveAssetSource, assetName, assetCoverUrl = "", copyNode, duplicateNode, deleteNode, }: { left: number; top: number; saveAssetSource: CanvasAssetSaveSource; assetName: string; assetCoverUrl?: string; copyNode: () => void; duplicateNode: () => void; deleteNode: () => void; }) => (
event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} >
); const getNodePortPoint = (port: CanvasNodePort) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return null; const canvasCenterX = rect.width / 2; const canvasCenterY = rect.height / 2; if (port.kind === "text") { const node = textNodes.find((item) => item.id === port.nodeId); if (!node) return null; return { x: canvasCenterX + node.position.x + getCanvasNodeSideDirection(port.side) * (node.size.width / 2 + connectorAnchorOutset), y: canvasCenterY + node.position.y, }; } if (port.kind === "image") { const node = imageNodes.find((item) => item.id === port.nodeId); if (!node) return null; return { x: canvasCenterX + node.position.x + getCanvasNodeSideDirection(port.side) * (node.size.width / 2 + connectorAnchorOutset), y: canvasCenterY + node.position.y, }; } const node = videoNodes.find((item) => item.id === port.nodeId); if (!node) return null; return { x: canvasCenterX + node.position.x + getCanvasNodeSideDirection(port.side) * (node.size.width / 2 + connectorAnchorOutset), y: canvasCenterY + node.position.y, }; }; const buildLinkFromPorts = (id: string, from: CanvasNodePort, to: CanvasNodePort) => { const source = getNodePortPoint(from); const target = getNodePortPoint(to); if (!source || !target) return null; return { id, sourceX: source.x, sourceY: source.y, targetX: target.x, targetY: target.y, sourceSide: from.side, targetSide: to.side, sourceKind: from.kind, sourceNodeId: from.nodeId, targetKind: to.kind, targetNodeId: to.nodeId, }; }; const isSameCanvasPort = (first: CanvasNodePort, second: CanvasNodePort) => first.nodeId === second.nodeId && first.kind === second.kind && first.side === second.side && first.slot === second.slot; const connectCanvasPorts = (from: CanvasNodePort, to: CanvasNodePort) => { if (isSameCanvasPort(from, to)) return; const normalizedLink = normalizeCanvasLinkForFlow(from, to); if (normalizedLink) { pushHistorySnapshot(); const linkIdentity = getCanvasLinkIdentity(normalizedLink.from, normalizedLink.to); setManualLinks((currentLinks) => { const hasExistingLink = currentLinks.some((link) => { const normalizedExistingLink = normalizeCanvasLinkForFlow(link.from, link.to); return normalizedExistingLink ? getCanvasLinkIdentity(normalizedExistingLink.from, normalizedExistingLink.to) === linkIdentity : false; }); return hasExistingLink ? currentLinks : [ ...currentLinks, { id: `manual-link-${Date.now()}`, from: normalizedLink.from, to: normalizedLink.to, }, ]; }); } setPendingLinkPort(null); setPendingLinkPreviewPoint(null); }; const getCanvasPortFromElement = (element: Element | null): CanvasNodePort | null => { const target = element?.closest( "[data-canvas-port-kind][data-canvas-port-node-id][data-canvas-port-side][data-canvas-port-slot]", ); if (!target) return null; const kind = target.dataset.canvasPortKind; const nodeId = target.dataset.canvasPortNodeId; const side = target.dataset.canvasPortSide; const slot = target.dataset.canvasPortSlot; if ((kind !== "text" && kind !== "image" && kind !== "video") || !nodeId) return null; if (side !== "left" && side !== "right") return null; if (slot !== "center") return null; return { kind, nodeId, side, slot }; }; const handleConnectorDragStart = (event: MouseEvent, port: CanvasNodePort) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); setConnectorDrag({ port, startX: event.clientX, startY: event.clientY, hasMoved: false, }); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setCanvasSelectMenu(null); }; const removeCanvasLink = (linkId: string) => { pushHistorySnapshot(); setManualLinks((currentLinks) => currentLinks.filter((link) => link.id !== linkId)); setImageNodes((currentNodes) => currentNodes.map((node) => { if (node.sourceImageNodeId && `${node.sourceImageNodeId}-${node.id}` === linkId) { return { ...node, sourceImageNodeId: undefined }; } if (node.sourceTextNodeId && `${node.sourceTextNodeId}-${node.id}` === linkId) { return { ...node, sourceTextNodeId: undefined }; } return node; }) ); setVideoNodes((currentNodes) => currentNodes.map((node) => node.sourceTextNodeId && `${node.sourceTextNodeId}-${node.id}` === linkId ? { ...node, sourceTextNodeId: "" } : node ) ); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); setConnectorDrag(null); }; const collapsedPackageNodeKeys = new Set( nodePackages.flatMap((nodePackage) => nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : [] ) ); const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) => collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })); const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) => isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) || isNodeCollapsedInPackage(link.targetKind, link.targetNodeId); const nodeLinks = [ ...imageNodes.flatMap((imageNode) => { if (imageNode.sourceImageNodeId) { const link = buildLinkFromPorts( `${imageNode.sourceImageNodeId}-${imageNode.id}`, { kind: "image", nodeId: imageNode.sourceImageNodeId, side: "right", slot: "center" }, { kind: "image", nodeId: imageNode.id, side: "left", slot: "center" } ); return link ? [link] : []; } if (!imageNode.sourceTextNodeId) return []; const link = buildLinkFromPorts( `${imageNode.sourceTextNodeId}-${imageNode.id}`, { kind: "text", nodeId: imageNode.sourceTextNodeId, side: "right", slot: "center" }, { kind: "image", nodeId: imageNode.id, side: "left", slot: "center" } ); return link ? [link] : []; }), ...videoNodes.flatMap((videoNode) => { const link = buildLinkFromPorts( `${videoNode.sourceTextNodeId}-${videoNode.id}`, { kind: "text", nodeId: videoNode.sourceTextNodeId, side: "right", slot: "center" }, { kind: "video", nodeId: videoNode.id, side: "left", slot: "center" } ); return link ? [link] : []; }), ...manualLinks.flatMap((link) => { const normalizedLink = normalizeCanvasLinkForFlow(link.from, link.to); if (!normalizedLink) return []; const positionedLink = buildLinkFromPorts(link.id, normalizedLink.from, normalizedLink.to); return positionedLink ? [positionedLink] : []; }), ].filter((link) => !isLinkCollapsedInPackage(link)); const pendingLinkPreview = pendingLinkPort && pendingLinkPreviewPoint ? (() => { const source = getNodePortPoint(pendingLinkPort); return source ? { id: "pending-link-preview", sourceX: source.x, sourceY: source.y, targetX: pendingLinkPreviewPoint.x, targetY: pendingLinkPreviewPoint.y, sourceSide: pendingLinkPort.side, targetSide: null, } : null; })() : connectionDropMenu ? (() => { const source = getNodePortPoint(connectionDropMenu.sourcePort); const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); return source ? { id: "pending-link-preview", sourceX: source.x, sourceY: source.y, targetX: target.x, targetY: target.y, sourceSide: connectionDropMenu.sourcePort.side, targetSide: null, } : null; })() : null; const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => { const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0); clearCanvasSelection(); setSelectionDrag(null); setContextMenu(null); setSelectionContextMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setCanvasSelectMenu(null); setNodeMenu({ ...menuPosition, originLeft: clientX, originTop: clientY, }); }, []); const handlePaneContextMenu = useCallback((event: MouseEvent | globalThis.MouseEvent) => { event.preventDefault(); openCanvasAddNodeMenu(event.clientX, event.clientY); }, [openCanvasAddNodeMenu]); const handleCanvasContextMenu = (event: MouseEvent) => { if ( event.target instanceof Element && event.target.closest( ".studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box, .studio-canvas-node-package-card, .react-flow__controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-selection-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, textarea, input, button, [role='menu']" ) ) { return; } event.preventDefault(); event.stopPropagation(); const clickPoint = getCanvasPointFromClient(event.clientX, event.clientY); const expandedBounds = getCanvasNodePackageBoundsWithMeta(); for (const { nodePackage, bounds } of expandedBounds) { const boxLeft = bounds.left - 18; const boxTop = bounds.top - 34; const boxRight = boxLeft + bounds.width + 36; const boxBottom = boxTop + bounds.height + 54; if ( clickPoint.x >= boxLeft && clickPoint.x <= boxRight && clickPoint.y >= boxTop && clickPoint.y <= boxBottom ) { openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY); return; } } const collapsedCards = getCanvasCollapsedPackageCardsWithMeta(); for (const { nodePackage, bounds } of collapsedCards) { const cardW = Math.min(360, Math.max(240, bounds.width)); const cardH = Math.max(112, Math.min(150, bounds.height || 132)); if ( clickPoint.x >= bounds.left && clickPoint.x <= bounds.left + cardW && clickPoint.y >= bounds.top && clickPoint.y <= bounds.top + cardH ) { openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY); return; } } const selBounds = getCanvasSelectedNodesBounds(); if (selBounds) { const selLeft = selBounds.left - 10; const selTop = selBounds.top - 30; const selRight = selLeft + selBounds.width + 20; const selBottom = selTop + selBounds.height + 40; if ( clickPoint.x >= selLeft && clickPoint.x <= selRight && clickPoint.y >= selTop && clickPoint.y <= selBottom ) { openCanvasSelectionContextMenu(event.clientX, event.clientY); return; } } openCanvasAddNodeMenu(event.clientX, event.clientY); }; const handlePaneClick = useCallback(() => { if (suppressNextPaneClickRef.current) { suppressNextPaneClickRef.current = false; return; } clearCanvasSelection(); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); setConnectorDrag(null); setCanvasSelectMenu(null); setRecentProjectsOpen(false); }, []); useEffect(() => { if (!contextMenu && !nodeMenu && !textNodeMenu && !imageNodeMenu && !videoNodeMenu && !selectionContextMenu && !canvasSelectMenu && !connectionDropMenu) return; const closeMenu = () => { setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setSelectionContextMenu(null); setCanvasSelectMenu(null); if (connectionDropMenu) { setConnectionDropMenu(null); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); } }; const raf = requestAnimationFrame(() => { window.addEventListener("click", closeMenu); window.addEventListener("keydown", closeMenu); }); return () => { cancelAnimationFrame(raf); window.removeEventListener("click", closeMenu); window.removeEventListener("keydown", closeMenu); }; }, [contextMenu, nodeMenu, textNodeMenu, imageNodeMenu, videoNodeMenu, selectionContextMenu, canvasSelectMenu, connectionDropMenu]); useEffect(() => { if (!connectorDrag) return; const clearPendingConnector = () => { setPendingLinkPort(null); setPendingLinkPreviewPoint(null); }; const handleMove = (event: globalThis.MouseEvent) => { const hasDragged = Math.hypot(event.clientX - connectorDrag.startX, event.clientY - connectorDrag.startY) > canvasNodeClickMoveThreshold; if (!hasDragged && !connectorDrag.hasMoved) return; if (!connectorDrag.hasMoved) { setConnectorDrag((currentDrag) => currentDrag && isSameCanvasPort(currentDrag.port, connectorDrag.port) ? { ...currentDrag, hasMoved: true } : currentDrag ); setPendingLinkPort(connectorDrag.port); clearCanvasSelection(); } setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); }; const handleUp = (event: globalThis.MouseEvent) => { const hasDragged = connectorDrag.hasMoved || Math.hypot(event.clientX - connectorDrag.startX, event.clientY - connectorDrag.startY) > canvasNodeClickMoveThreshold; if (hasDragged) { const targetPort = getCanvasPortFromElement(document.elementFromPoint(event.clientX, event.clientY)); if (targetPort) { connectCanvasPorts(connectorDrag.port, targetPort); } else { const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40); setConnectionDropMenu({ ...menuPosition, originLeft: event.clientX, originTop: event.clientY, sourcePort: connectorDrag.port, }); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); } } else { clearPendingConnector(); } setConnectorDrag(null); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [connectorDrag]); useEffect(() => { const sourcePort = pendingAutoConnectRef.current; if (!sourcePort || !selectedNode) return; pendingAutoConnectRef.current = null; const targetSide = sourcePort.side === "right" ? "left" : "right"; connectCanvasPorts(sourcePort, { kind: selectedNode.kind, nodeId: selectedNode.id, side: targetSide, slot: "center" }); }, [selectedNode]); const handleCanvasMouseMove = (event: MouseEvent) => { if (!pendingLinkPort || connectionDropMenu) return; setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); }; const handleCanvasWheel = (event: WheelEvent) => { if (!canvasRef.current) return; if ( event.target instanceof Element && event.target.closest( "textarea, input, .canvas-select-chip__dropdown, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, .studio-canvas-style-picker, .studio-canvas-recent-drawer" ) ) { return; } const point = getCanvasPointFromClient(event.clientX, event.clientY); const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; setCanvasViewport((viewport) => { const nextZoom = clampCanvasViewportZoom(viewport.zoom * zoomDelta); if (nextZoom === viewport.zoom) return viewport; const worldX = (point.x - viewport.x) / viewport.zoom; const worldY = (point.y - viewport.y) / viewport.zoom; return { zoom: nextZoom, x: point.x - worldX * nextZoom, y: point.y - worldY * nextZoom, }; }); }; const getAllCanvasNodeBounds = (): CanvasNodeBounds | null => { const allBounds: CanvasNodeBounds[] = []; for (const node of textNodes) allBounds.push(getCanvasNodeScreenRect(node.position, node.size)); for (const node of imageNodes) allBounds.push(getCanvasNodeScreenRect(node.position, node.size)); for (const node of videoNodes) allBounds.push(getCanvasNodeScreenRect(node.position, node.size)); return mergeCanvasNodeBounds(allBounds); }; const fitCanvasView = () => { const bounds = getAllCanvasNodeBounds(); if (!bounds || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const padding = 80; const availW = rect.width - padding * 2; const availH = rect.height - padding * 2; const zoom = clampCanvasViewportZoom(Math.min(availW / bounds.width, availH / bounds.height)); const cx = bounds.left + bounds.width / 2; const cy = bounds.top + bounds.height / 2; setCanvasViewport({ zoom, x: rect.width / 2 - cx * zoom, y: rect.height / 2 - cy * zoom, }); }; const zoomCanvasIn = () => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const center = { x: rect.width / 2, y: rect.height / 2 }; setCanvasViewport((vp) => { const next = clampCanvasViewportZoom(vp.zoom * 1.25); if (next === vp.zoom) return vp; const wx = (center.x - vp.x) / vp.zoom; const wy = (center.y - vp.y) / vp.zoom; return { zoom: next, x: center.x - wx * next, y: center.y - wy * next }; }); }; const zoomCanvasOut = () => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const center = { x: rect.width / 2, y: rect.height / 2 }; setCanvasViewport((vp) => { const next = clampCanvasViewportZoom(vp.zoom * 0.8); if (next === vp.zoom) return vp; const wx = (center.x - vp.x) / vp.zoom; const wy = (center.y - vp.y) / vp.zoom; return { zoom: next, x: center.x - wx * next, y: center.y - wy * next }; }); }; const resetCanvasZoom = () => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); setCanvasViewport((vp) => { const wx = (rect.width / 2 - vp.x) / vp.zoom; const wy = (rect.height / 2 - vp.y) / vp.zoom; return { zoom: 1, x: rect.width / 2 - wx, y: rect.height / 2 - wy }; }); }; const handleCanvasDoubleClick = (event: MouseEvent) => { if (event.button !== 0 || spacePanning || imageFocusNodeId) return; const target = event.target instanceof Element ? event.target : null; if (target?.closest("textarea, input, button, [role='menu'], .studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box__label, .react-flow__controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, .studio-canvas-zoom-controls")) return; const worldPoint = getCanvasWorldPointFromClient(event.clientX, event.clientY); addTextNode(undefined, worldPoint); }; const shouldStartSelectionDrag = (target: EventTarget | null) => target instanceof Element && Boolean(target.closest(".react-flow__pane, .studio-canvas, .studio-canvas-world, .studio-canvas-node-links")) && !target.closest( ".studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box, .studio-canvas-node-package-card, .studio-canvas-zoom-controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-selection-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, textarea, input, button, [role='menu']" ); const startProjectNameEditing = () => { setProjectNameDraft(currentProjectTitle); setProjectNameEditing(true); setRecentProjectsOpen(false); }; const cancelProjectNameEditing = () => { setProjectNameDraft(currentProjectTitle); setProjectNameEditing(false); }; const commitProjectNameEditing = () => { const nextTitle = projectNameDraft.trim() || "未命名项目"; setCurrentProjectTitle(nextTitle); setProjectNameDraft(nextTitle); setProjectNameEditing(false); setProjectSaveState((current) => current.status === "saving" ? current : { status: "idle", message: "项目名称已更新,记得保存" }, ); }; const buildCanvasWorkflowSnapshot = (): WebCanvasWorkflow => { const workflowNodes: WebCanvasWorkflow["nodes"] = [ ...textNodes.map((node) => ({ id: node.id, kind: "text" as const, label: node.title, detail: node.content || node.prompt || "", position: { ...node.position }, size: { ...node.size }, params: { prompt: node.prompt, content: node.content, model: node.selectedModelId, }, })), ...imageNodes.map((node) => ({ id: node.id, kind: "image" as const, label: node.title, detail: node.prompt || "", position: { ...node.position }, size: { ...node.size }, previewUrl: node.assetRef?.url || node.imageUrl || undefined, assetRef: node.assetRef || null, taskRef: node.taskRef || null, params: { prompt: node.prompt, fileName: node.fileName, model: node.model, aspectRatio: node.aspectRatio, imageSize: node.imageSize, styleReference: node.styleReference, focusSelection: node.focusSelection, marking: node.marking, }, metadata: { fileName: node.fileName, model: node.model, aspectRatio: node.aspectRatio, imageSize: node.imageSize, styleReference: node.styleReference, focusSelection: node.focusSelection, marking: node.marking, }, })), ...videoNodes.map((node) => ({ id: node.id, kind: "video" as const, label: node.title, detail: node.prompt || "", position: { ...node.position }, size: { ...node.size }, previewUrl: node.assetRef?.url || node.videoUrl || undefined, assetRef: node.assetRef || null, taskRef: node.taskRef || null, params: { model: node.model, aspectRatio: node.aspectRatio, resolution: node.resolution, duration: node.duration, videoMode: node.videoMode, marking: node.marking, cameraMotion: node.cameraMotion, }, metadata: { model: node.model, aspectRatio: node.aspectRatio, resolution: node.resolution, duration: node.duration, videoMode: node.videoMode, marking: node.marking, cameraMotion: node.cameraMotion, }, })), ]; const nodeIds = new Set(workflowNodes.map((node) => node.id)); const edgeMap = new Map(); const addEdge = (id: string, source: string, target: string, label = "连接") => { if (!nodeIds.has(source) || !nodeIds.has(target) || source === target) return; const edgeId = id || `${source}-${target}`; edgeMap.set(edgeId, { id: edgeId, source, target, label, animated: true }); }; imageNodes.forEach((node) => { if (node.sourceImageNodeId) addEdge(`${node.sourceImageNodeId}-${node.id}`, node.sourceImageNodeId, node.id, "图生图"); if (node.sourceTextNodeId) addEdge(`${node.sourceTextNodeId}-${node.id}`, node.sourceTextNodeId, node.id, "生成图片"); }); videoNodes.forEach((node) => { if (node.sourceTextNodeId) addEdge(`${node.sourceTextNodeId}-${node.id}`, node.sourceTextNodeId, node.id, "生成视频"); }); manualLinks.forEach((link) => { const normalizedLink = normalizeCanvasLinkForFlow(link.from, link.to); if (normalizedLink) addEdge(link.id, normalizedLink.from.nodeId, normalizedLink.to.nodeId); }); const firstVideoNode = videoNodes[0]; const firstImageNode = imageNodes[0]; return normalizeCanvasWorkflowSchema({ ...workflow, title: currentProjectTitle.trim() || workflow.title || "未命名项目", settings: { model: firstVideoNode?.model || firstImageNode?.model || workflow.settings.model, ratio: firstVideoNode?.aspectRatio || firstImageNode?.aspectRatio || workflow.settings.ratio, duration: firstVideoNode ? `${firstVideoNode.duration}s` : workflow.settings.duration, resolution: firstVideoNode?.resolution || firstImageNode?.imageSize || workflow.settings.resolution, }, viewport: { ...canvasViewport }, nodes: workflowNodes, edges: Array.from(edgeMap.values()), packages: createWorkflowPackagesFromCanvasPackages(nodePackages), }); }; const runCanvasAutoSave = useCallback(async () => { if (!isAuthenticated || !onSaveWorkflow || shouldShowEmptyProjectState) return; if (canvasAutoSaveInFlightRef.current) { canvasAutoSavePendingRef.current = true; return; } const snapshot = buildCanvasWorkflowSnapshot(); const fingerprint = buildCanvasWorkflowJson(snapshot); if (fingerprint === lastAutoSavedWorkflowFingerprintRef.current) return; canvasAutoSaveInFlightRef.current = true; setAutoSaveStatus("saving"); try { await onSaveWorkflow(snapshot, { silent: true, reason: "autosave" }); lastAutoSavedWorkflowFingerprintRef.current = fingerprint; isDirtyRef.current = false; setAutoSaveStatus("saved"); if (autoSaveStatusTimerRef.current) window.clearTimeout(autoSaveStatusTimerRef.current); autoSaveStatusTimerRef.current = window.setTimeout(() => setAutoSaveStatus("idle"), 3000); } catch (error) { console.warn("[Canvas autosave] failed", error); setAutoSaveStatus("error"); } finally { canvasAutoSaveInFlightRef.current = false; if (canvasAutoSavePendingRef.current) { canvasAutoSavePendingRef.current = false; window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs); } } }, [ isAuthenticated, onSaveWorkflow, projectId, shouldShowEmptyProjectState, textNodes, imageNodes, videoNodes, manualLinks, nodePackages, currentProjectTitle, workflow, ]); // Save immediately when user leaves page or switches tab useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === "hidden" && isDirtyRef.current) { void runCanvasAutoSave(); } }; const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (isDirtyRef.current) { void runCanvasAutoSave(); e.preventDefault(); e.returnValue = ""; } }; document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("beforeunload", handleBeforeUnload); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("beforeunload", handleBeforeUnload); }; }, [runCanvasAutoSave]); useEffect(() => { if (canvasAutoSaveTimerRef.current !== null) { window.clearTimeout(canvasAutoSaveTimerRef.current); canvasAutoSaveTimerRef.current = null; } if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); canvasAutoSaveIdleHandleRef.current = null; } if (!isAuthenticated || !onSaveWorkflow || shouldShowEmptyProjectState) return undefined; if (canvasAutoSaveHydrationRef.current) { canvasAutoSaveHydrationRef.current = false; lastAutoSavedWorkflowFingerprintRef.current = buildCanvasWorkflowJson(buildCanvasWorkflowSnapshot()); return undefined; } canvasAutoSaveTimerRef.current = window.setTimeout(() => { canvasAutoSaveTimerRef.current = null; const requestIdleCallback = (window as unknown as { requestIdleCallback?: typeof window.requestIdleCallback; }).requestIdleCallback?.bind(window); if (requestIdleCallback) { canvasAutoSaveIdleHandleRef.current = requestIdleCallback( () => void runCanvasAutoSave(), { timeout: canvasAutoSaveIdleTimeoutMs } ); return; } window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs); }, canvasAutoSaveDebounceMs); return () => { if (canvasAutoSaveTimerRef.current !== null) { window.clearTimeout(canvasAutoSaveTimerRef.current); canvasAutoSaveTimerRef.current = null; } if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current); canvasAutoSaveIdleHandleRef.current = null; } }; }, [ isAuthenticated, onSaveWorkflow, projectId, shouldShowEmptyProjectState, textNodes, imageNodes, videoNodes, manualLinks, nodePackages, currentProjectTitle, canvasViewport, runCanvasAutoSave, ]); const handleSaveProject = async () => { if (!isAuthenticated) { onOpenLogin(); return; } if (!onSaveWorkflow || projectSaveState.status === "saving") return; setProjectSaveState({ status: "saving", message: "正在保存到服务器..." }); try { await onSaveWorkflow(buildCanvasWorkflowSnapshot(), { reason: "manual" }); isDirtyRef.current = false; setProjectSaveState({ status: "success", message: "已保存到服务器" }); } catch (error) { setProjectSaveState({ status: "error", message: error instanceof Error ? error.message : "保存失败,请稍后重试", }); } }; const handleExportWorkflowJson = () => { const snapshot = buildCanvasWorkflowSnapshot(); const workflowJson = buildCanvasWorkflowJson(snapshot); const fileName = buildWorkflowFileName(snapshot.title); const blob = new Blob([workflowJson], { type: "application/json;charset=utf-8" }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); setCommunityPublishState({ status: "success", message: `已导出 ${fileName}` }); }; const handlePublishWorkflowToCommunity = async () => { if (communityPublishState.status === "saving") return; if (!isAuthenticated) { onOpenLogin(); return; } if (!onSaveWorkflow) { setCommunityPublishState({ status: "error", message: "保存入口暂不可用,无法提交审核" }); return; } const snapshot = buildCanvasWorkflowSnapshot(); if (!snapshot.nodes.length) { setCommunityPublishState({ status: "error", message: "画布为空,请先添加节点后再提交审核" }); return; } const fileName = buildWorkflowFileName(snapshot.title); const workflowJson = buildCanvasWorkflowJson(snapshot); setCommunityPublishState({ status: "saving", message: "正在保存并提交社区审核..." }); setProjectSaveState({ status: "saving", message: "提交前正在保存当前画布..." }); let projectSaved = false; try { const savedProject = await onSaveWorkflow(snapshot, { reason: "publish" }); projectSaved = true; setProjectSaveState({ status: "success", message: "已保存到服务器" }); const uploadedWorkflow = await aiGenerationClient.uploadAsset({ dataUrl: textToDataUrl(workflowJson, "application/json"), name: fileName, mimeType: "application/json", scope: "community-case-workflow", }); await communityClient.publishCase( buildCanvasCommunityCaseInput({ workflow: snapshot, projectId: savedProject?.id || projectId || snapshot.id, uploadedWorkflow: { url: uploadedWorkflow.url, ossKey: uploadedWorkflow.ossKey, fileName, }, }), ); setCommunityPublishState({ status: "success", message: "已提交社区审核,通过后会显示在社区页面" }); } catch (error) { if (!projectSaved) { setProjectSaveState({ status: "error", message: error instanceof Error ? error.message : "保存失败,请稍后重试", }); } setCommunityPublishState({ status: "error", message: error instanceof Error ? error.message : "提交审核失败,请稍后重试", }); } }; const handleCanvasMouseDown = (event: MouseEvent) => { if (event.button === 1) { event.preventDefault(); event.stopPropagation(); setCanvasPanDrag({ startX: event.clientX, startY: event.clientY, originX: canvasViewport.x, originY: canvasViewport.y, }); setSelectionDrag(null); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); setConnectorDrag(null); return; } if (event.button === 0 && imageFocusNodeId) { event.preventDefault(); event.stopPropagation(); cancelImageFocusMode(); return; } if (event.button === 0 && spacePanning) { event.preventDefault(); event.stopPropagation(); setCanvasPanDrag({ startX: event.clientX, startY: event.clientY, originX: canvasViewport.x, originY: canvasViewport.y, }); return; } if (event.button !== 0) return; const target = event.target instanceof HTMLElement ? event.target : null; if ( !target?.closest( ".studio-canvas-text-node, .studio-canvas-image-node, .studio-canvas-video-node, .studio-canvas-node-package-box__label, .react-flow__controls, .studio-canvas-project-bar, .studio-canvas-recent-drawer, .studio-canvas-context-menu, .studio-canvas-node-context-menu, .studio-canvas-selection-context-menu, .studio-canvas-add-node-menu, .studio-canvas-save-asset, textarea, input, button, [role='menu']" ) ) { const clickPoint = getCanvasPointFromClient(event.clientX, event.clientY); const expandedBounds = getCanvasNodePackageBoundsWithMeta(); for (const { nodePackage, bounds } of expandedBounds) { const boxLeft = bounds.left - 18; const boxTop = bounds.top - 34; const boxRight = boxLeft + bounds.width + 36; const boxBottom = boxTop + bounds.height + 54; if ( clickPoint.x >= boxLeft && clickPoint.x <= boxRight && clickPoint.y >= boxTop && clickPoint.y <= boxBottom ) { event.preventDefault(); event.stopPropagation(); startPackageDrag(event, nodePackage, false); return; } } const collapsedCards = getCanvasCollapsedPackageCardsWithMeta(); for (const { nodePackage, bounds } of collapsedCards) { const cardW = Math.min(360, Math.max(240, bounds.width)); const cardH = Math.max(112, Math.min(150, bounds.height || 132)); if ( clickPoint.x >= bounds.left && clickPoint.x <= bounds.left + cardW && clickPoint.y >= bounds.top && clickPoint.y <= bounds.top + cardH ) { event.preventDefault(); event.stopPropagation(); startPackageDrag(event, nodePackage, true); return; } } const selBounds = getCanvasSelectedNodesBounds(); if (selBounds) { const selLeft = selBounds.left - 10; const selTop = selBounds.top - 30; const selRight = selLeft + selBounds.width + 20; const selBottom = selTop + selBounds.height + 40; if ( clickPoint.x >= selLeft && clickPoint.x <= selRight && clickPoint.y >= selTop && clickPoint.y <= selBottom ) { event.preventDefault(); event.stopPropagation(); startSelectedNodesDrag(event); return; } } } if (!shouldStartSelectionDrag(event.target)) return; event.preventDefault(); event.stopPropagation(); const point = getCanvasPointFromClient(event.clientX, event.clientY); setSelectionDrag({ start: point, current: point, hasMoved: false }); clearCanvasSelection(); setPendingLinkPort(null); setPendingLinkPreviewPoint(null); setConnectorDrag(null); setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setCanvasSelectMenu(null); setImageFocusNodeId(null); setImageFocusDraft(null); setImageFocusDrag(null); }; const handleCanvasAuxClick = (event: MouseEvent) => { if (event.button !== 1) return; event.preventDefault(); event.stopPropagation(); }; useCanvasKeyboard({ onDelete: () => { if (!selectedNodes.length && !selectedNode && !selectedPackageId) return; pushHistorySnapshot(); if (selectedPackageId) { setNodePackages((pkgs) => pkgs.filter((p) => p.id !== selectedPackageId)); setSelectedPackageId(null); return; } const toDelete = selectedNode ? [selectedNode] : selectedNodes; const textIds = new Set(toDelete.filter((n) => n.kind === "text").map((n) => n.id)); const imageIds = new Set(toDelete.filter((n) => n.kind === "image").map((n) => n.id)); const videoIds = new Set(toDelete.filter((n) => n.kind === "video").map((n) => n.id)); if (textIds.size) setTextNodes((nodes) => nodes.filter((n) => !textIds.has(n.id))); if (imageIds.size) setImageNodes((nodes) => nodes.filter((n) => !imageIds.has(n.id))); if (videoIds.size) setVideoNodes((nodes) => nodes.filter((n) => !videoIds.has(n.id))); clearCanvasSelection(); }, onUndo: () => { const snapshot = undo(getHistorySnapshot()); if (snapshot) applyHistorySnapshot(snapshot); }, onRedo: () => { const snapshot = redo(getHistorySnapshot()); if (snapshot) applyHistorySnapshot(snapshot); }, onSelectAll: () => { const all: CanvasSelectedNode[] = [ ...textNodes.map((n) => ({ kind: "text" as const, id: n.id })), ...imageNodes.map((n) => ({ kind: "image" as const, id: n.id })), ...videoNodes.map((n) => ({ kind: "video" as const, id: n.id })), ]; setSelectedNodes(all); setSelectedNode(null); }, onCopy: () => { if (!selectedNode) return; if (selectedNode.kind === "text") { const node = textNodes.find((n) => n.id === selectedNode.id); if (node) setCopiedCanvasNode({ kind: "text", node }); } else if (selectedNode.kind === "image") { const node = imageNodes.find((n) => n.id === selectedNode.id); if (node) setCopiedCanvasNode({ kind: "image", node }); } else if (selectedNode.kind === "video") { const node = videoNodes.find((n) => n.id === selectedNode.id); if (node) setCopiedCanvasNode({ kind: "video", node }); } }, onPaste: () => { if (!copiedCanvasNode) return; const offset = 40; const pos = { x: copiedCanvasNode.node.position.x + offset, y: copiedCanvasNode.node.position.y + offset }; duplicateCopiedCanvasNode(copiedCanvasNode, pos); }, onDuplicate: () => { if (!selectedNode) return; const offset = 40; if (selectedNode.kind === "text") { const node = textNodes.find((n) => n.id === selectedNode.id); if (node) duplicateTextNode(node, { x: node.position.x + offset, y: node.position.y + offset }); } else if (selectedNode.kind === "image") { const node = imageNodes.find((n) => n.id === selectedNode.id); if (node) duplicateImageNode(node, { x: node.position.x + offset, y: node.position.y + offset }); } else if (selectedNode.kind === "video") { const node = videoNodes.find((n) => n.id === selectedNode.id); if (node) duplicateVideoNode(node, { x: node.position.x + offset, y: node.position.y + offset }); } }, onEscape: () => { clearCanvasSelection(); setContextMenu(null); setNodeMenu(null); setTextNodeMenu(null); setImageNodeMenu(null); setVideoNodeMenu(null); setSelectionContextMenu(null); setCanvasSelectMenu(null); }, setSpacePanning: setSpacePanning, isInputFocused: () => { const el = document.activeElement; return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement; }, }); const isCanvasNodeMoving = Boolean( textNodeDrag?.hasMoved || imageNodeDrag?.hasMoved || videoNodeDrag?.hasMoved || selectionDrag?.hasMoved || nodeResizeDrag ); const selectionRect = selectionDrag?.hasMoved ? normalizeCanvasSelectionRect(selectionDrag.start, selectionDrag.current) : null; const selectedNodesBounds = getCanvasSelectedNodesBounds(); const nodePackageBounds = getCanvasNodePackageBoundsWithMeta(); const collapsedPackageCards = getCanvasCollapsedPackageCardsWithMeta(); const activePackage = selectedPackageId ? nodePackages.find((nodePackage) => nodePackage.id === selectedPackageId) || null : null; const selectedPackageCount = selectedPackageId ? 1 : getCanvasSelectedPackageCount(); const activeProjectNotice = communityPublishState.message ? communityPublishState : projectSaveState; return (
event.preventDefault() : handleCanvasContextMenu} onMouseDownCapture={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseDown} onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel} style={{ "--canvas-bg-size": `${34 * canvasViewport.zoom}px`, "--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`, "--canvas-bg-x": `${canvasViewport.x}px`, "--canvas-bg-y": `${canvasViewport.y}px`, cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined, } as CSSProperties} > handleImageFileSelected(event, pendingImagePosition)} /> handleImageFileSelected(event, pendingImagePosition)} /> {(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
event.stopPropagation()}>
{projectNameEditing ? (
{ event.preventDefault(); commitProjectNameEditing(); }} > setProjectNameDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); cancelProjectNameEditing(); } }} />
) : (
{currentProjectTitle}
)} {activeProjectNotice.message || (projectId ? "服务器项目" : "未保存项目")}
{autoSaveStatus === "saving" ? "保存中..." : autoSaveStatus === "saved" ? "已保存" : autoSaveStatus === "error" ? "保存失败" : ""}
) : null} {(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? ( ) : null}
e.stopPropagation()}>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
event.stopPropagation()} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); }} > {isWaitingForProjects ? ( <>
正在加载项目数据… ) : ( <> 没有画布项目,是否需要新建画布? )}
) : null} {selectionRect ? (
) : null} {nodePackageBounds.map(({ nodePackage, bounds }) => (
startPackageDrag(event, nodePackage, false)} onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)} >
))} {collapsedPackageCards.map(({ nodePackage, bounds }) => ( ))} {selectedNodesBounds ? (
已选中 {selectedNodes.length} 个节点
) : null}
{alignGuides.length > 0 && (