Files
omniai-web/src/features/canvas/CanvasPage.tsx
T
stringadmin f5a75074a4 feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】
- 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword)
- register-email 现在需要验证码
- 服务端新增 email_verification_codes 表 + patch-email-verification.js
- App.tsx 登录后 emailVerified 检查提醒
- keyServerClient token 显式传递修复 401 错误

【电商模块】
- 自动推进: 策划完成后自动生成分镜图/视频
- 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词
- 任务持久化指纹修复 (图片数量替代 blob URL)
- 新增「视频换装」入口 (happyhorse-1.0-video-edit)

【剧本评分】
- 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取)
- 历史记录支持点击查看/恢复评测结果

【画布】
- ReactFlow 节点禁止内置拖拽避免冲突
- 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标)

【页面修复】
- 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题
- 资产库新增悬停删除按钮
- scriptEvalClient 改用服务端 /api/ai/chat 端点
- TokenUsagePage 未登录跳过 API 调用
2026-06-03 20:19:07 +08:00

5726 lines
239 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
Background,
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 { 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<string>();
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<CanvasFloatingMenuPosition | null>(null);
const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
const [textNodes, setTextNodes] = useState<CanvasTextNode[]>([]);
const [canvasSelectMenu, setCanvasSelectMenu] = useState<string | null>(null);
const [copiedCanvasNode, setCopiedCanvasNode] = useState<CanvasCopiedNode | null>(null);
const [imageNodeMenu, setImageNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
const [imageNodes, setImageNodes] = useState<CanvasImageNode[]>([]);
const [imageLoadErrors, setImageLoadErrors] = useState<Record<string, string>>({});
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
const [stylePickerLoading, setStylePickerLoading] = useState(false);
const [stylePickerError, setStylePickerError] = useState<string | null>(null);
const [stylePickerReloadToken, setStylePickerReloadToken] = useState(0);
const [stylePickerTab, setStylePickerTab] = useState<CanvasStylePickerTab>("square");
const [stylePickerCategory, setStylePickerCategory] = useState(canvasStylePickerCategories[0]);
const [stylePickerSearch, setStylePickerSearch] = useState("");
const [recentStyleCases, setRecentStyleCases] = useState<CanvasStyleCase[]>([]);
const [styleSelectionToast, setStyleSelectionToast] = useState<string | null>(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<CanvasVideoNode[]>([]);
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [nodePackages, setNodePackages] = useState<CanvasNodePackage[]>([]);
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null);
const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({ x: 0, y: 0, zoom: 1 });
const [manualLinks, setManualLinks] = useState<CanvasManualLink[]>([]);
const [pendingLinkPort, setPendingLinkPort] = useState<CanvasNodePort | null>(null);
const [pendingLinkPreviewPoint, setPendingLinkPreviewPoint] = useState<CanvasPoint | null>(null);
const [connectorFollowOffsets, setConnectorFollowOffsets] = useState<Record<string, CanvasConnectorFollowOffset>>({});
const [connectorDrag, setConnectorDrag] = useState<CanvasConnectorDrag | null>(null);
const [connectionDropMenu, setConnectionDropMenu] = useState<{ left: number; top: number; originLeft: number; originTop: number; sourcePort: CanvasNodePort } | null>(null);
const pendingAutoConnectRef = useRef<CanvasNodePort | null>(null);
const [pendingImagePosition, setPendingImagePosition] = useState({ x: 0, y: 0 });
const [pendingImageNodeId, setPendingImageNodeId] = useState<string | null>(null);
const [pendingImageToImageNodeId, setPendingImageToImageNodeId] = useState<string | null>(null);
const [markingPopoverNodeId, setMarkingPopoverNodeId] = useState<string | null>(null);
const [cameraMotionDropdownNodeId, setCameraMotionDropdownNodeId] = useState<string | null>(null);
const [saveAssetSource, setSaveAssetSource] = useState<CanvasAssetSaveSource | null>(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<ServerAssetItem[]>([]);
const [assetLibraryNotice, setAssetLibraryNotice] = useState<string | null>(null);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [selectedExistingCategory, setSelectedExistingCategory] = useState("");
const coverFileInputRef = useRef<HTMLInputElement>(null);
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
const imageNodeInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLElement>(null);
const videoGenerationInFlightRef = useRef(new Set<string>());
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
const suppressNextPaneClickRef = useRef(false);
const canvasAutoSaveTimerRef = useRef<number | null>(null);
const canvasAutoSaveIdleHandleRef = useRef<number | null>(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<import("./useCanvasNodeDrag").CanvasNodeDragCallbacks>({
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<Record<string, CanvasPromptMentionState>>({});
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<number | null>(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<CanvasProjectSaveState>({
status: "idle",
message: "",
});
const [communityPublishState, setCommunityPublishState] = useState<CanvasProjectSaveState>({
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<string>();
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<HTMLElement>,
nodePackage: CanvasNodePackage
) => {
event.preventDefault();
event.stopPropagation();
openCanvasNodePackageContextMenu(nodePackage, event.clientX, event.clientY);
};
const startPackageDrag = (
event: React.MouseEvent<HTMLElement>,
nodePackage: CanvasNodePackage,
collapsed: boolean
) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const textOrigins: Record<string, CanvasPoint> = {};
const imageOrigins: Record<string, CanvasPoint> = {};
const videoOrigins: Record<string, CanvasPoint> = {};
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<HTMLElement>) => {
if (event.button !== 0 || selectedNodes.length < 2) return;
event.preventDefault();
event.stopPropagation();
const textOrigins: Record<string, CanvasPoint> = {};
const imageOrigins: Record<string, CanvasPoint> = {};
const videoOrigins: Record<string, CanvasPoint> = {};
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<string, string> = { 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<Pick<CanvasVideoNode, "model" | "aspectRatio" | "resolution" | "duration" | "videoMode">>
) => {
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<Pick<CanvasImageNode, "model" | "aspectRatio" | "imageSize">>
) => {
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>()
): 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<string>();
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<string>();
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<ReturnType<typeof onCreateTask>> | 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<HTMLInputElement>,
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<HTMLDivElement>, 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<HTMLButtonElement>
) => {
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 (
<button
type="button"
className={connectorButtonClassName(baseClassName, port)}
data-canvas-port-kind={port.kind}
data-canvas-port-node-id={port.nodeId}
data-canvas-port-side={port.side}
data-canvas-port-slot={port.slot}
onMouseDown={(event) => handleConnectorDragStart(event, port)}
onMouseMove={(event) => {
if (!connectorDrag) updateConnectorFollow(port, event);
}}
onMouseLeave={() => resetConnectorFollow(port)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
style={{
"--connector-follow-x": `${followOffset.x}px`,
"--connector-follow-y": `${followOffset.y}px`,
} as CSSProperties}
>
<span>+</span>
</button>
);
};
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;
}) => (
<div
className="studio-canvas-node-context-menu"
style={{ left, top }}
role="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
setSaveAssetSource(saveAssetSource);
setAssetName(assetName);
setAssetCoverUrl(assetCoverUrl);
setAssetSaveMode(saveAssetSource.kind === "text" ? "create" : "existing");
setSelectedExistingCategory("");
closeNodeContextMenus();
setSaveAssetOpen(true);
}}
>
</button>
<button type="button" role="menuitem" disabled></button>
<span className="studio-canvas-node-context-menu__divider" />
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
copyNode();
closeNodeContextMenus();
}}
>
<span> <span className="studio-canvas-node-context-menu__hint">?</span></span>
<kbd>C</kbd>
</button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
duplicateNode();
closeNodeContextMenus();
}}
>
<span> <span className="studio-canvas-node-context-menu__hint">?</span></span>
</button>
<button type="button" role="menuitem" disabled>
<span></span>
<kbd>V</kbd>
</button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
deleteNode();
closeNodeContextMenus();
}}
>
<span></span>
<kbd></kbd>
</button>
<span className="studio-canvas-node-context-menu__divider" />
<button
type="button"
role="menuitem"
className="studio-canvas-node-context-menu__primary"
onClick={(event) => {
event.stopPropagation();
copyNode();
closeNodeContextMenus();
}}
>
</button>
</div>
);
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<HTMLElement>(
"[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<HTMLButtonElement>, 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;
})()
: 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<HTMLElement>) => {
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, 0);
setConnectionDropMenu({
...menuPosition,
originLeft: event.clientX,
originTop: event.clientY,
sourcePort: connectorDrag.port,
});
}
} 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<HTMLElement>) => {
if (!pendingLinkPort) return;
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
};
const handleCanvasWheel = (event: WheelEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<string, WebCanvasWorkflow["edges"][number]>();
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<HTMLElement>) => {
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<HTMLElement>) => {
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 (
<WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion">
<div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${(shouldShowEmptyProjectState || isWaitingForProjects) ? " studio-tool-layout--canvas-empty" : ""}`}>
<section
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
ref={canvasRef}
onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => 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": `${24 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`,
"--canvas-bg-y": `${canvasViewport.y}px`,
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
} as CSSProperties}
>
<input
ref={canvasUploadInputRef}
type="file"
accept="image/*"
className="studio-canvas-hidden-input"
onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
/>
<input
ref={imageNodeInputRef}
type="file"
accept="image/*"
className="studio-canvas-hidden-input"
onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
/>
{(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}>
<div className="studio-canvas-project-bar__identity">
{projectNameEditing ? (
<form
className="studio-canvas-project-bar__name-form"
onSubmit={(event) => {
event.preventDefault();
commitProjectNameEditing();
}}
>
<input
value={projectNameDraft}
autoFocus
aria-label="编辑项目名称"
onChange={(event) => setProjectNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelProjectNameEditing();
}
}}
/>
<button type="submit" aria-label="确认项目名称">
<CheckOutlined />
</button>
<button type="button" aria-label="取消编辑项目名称" onClick={cancelProjectNameEditing}>
<CloseOutlined />
</button>
</form>
) : (
<div className="studio-canvas-project-bar__name" title={currentProjectTitle}>
<span>{currentProjectTitle}</span>
</div>
)}
<span className={`studio-canvas-project-bar__status is-${activeProjectNotice.status}`}>
{activeProjectNotice.message || (projectId ? "服务器项目" : "未保存项目")}
</span>
</div>
<button
type="button"
className="studio-canvas-project-bar__rename"
aria-label="编辑项目名称"
title="编辑项目名称"
onClick={startProjectNameEditing}
>
<EditOutlined />
</button>
<button
type="button"
className={`studio-canvas-project-bar__recent${recentProjectsOpen ? " is-active" : ""}`}
aria-controls="studio-canvas-recent-drawer"
aria-expanded={recentProjectsOpen}
onClick={(event) => {
event.stopPropagation();
setRecentProjectsOpen((current) => !current);
}}
>
<ClockCircleOutlined />
<span></span>
{projects.length ? <em>{projects.length}</em> : null}
</button>
<button
type="button"
className="studio-canvas-project-bar__export"
aria-label="导出工作流 JSON"
title="导出工作流 JSON"
onClick={handleExportWorkflowJson}
>
<DownloadOutlined />
<span> JSON</span>
</button>
<button
type="button"
className="studio-canvas-project-bar__save"
disabled={projectSaveState.status === "saving"}
onClick={() => void handleSaveProject()}
>
<SaveOutlined />
{projectSaveState.status === "saving" ? "保存中" : "保存"}
</button>
<span className={`studio-canvas-project-bar__autosave-status studio-canvas-project-bar__autosave-status--${autoSaveStatus}`}>
{autoSaveStatus === "saving" ? "保存中..." : autoSaveStatus === "saved" ? "已保存" : autoSaveStatus === "error" ? "保存失败" : ""}
</span>
<button
type="button"
className="studio-canvas-project-bar__publish"
disabled={communityPublishState.status === "saving"}
onClick={() => void handlePublishWorkflowToCommunity()}
>
<UploadOutlined />
<span>{communityPublishState.status === "saving" ? "提交中" : "提交审核"}</span>
</button>
</div>
) : null}
{(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (
<aside
id="studio-canvas-recent-drawer"
className="studio-canvas-recent-drawer"
aria-label="最近项目"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="studio-canvas-recent-drawer__list">
{projects.length ? (
projects.map((project, index) => (
<button
key={project.id}
type="button"
className="studio-canvas-recent-project"
disabled={!onOpenProject}
onClick={() => {
setRecentProjectsOpen(false);
onOpenProject?.(project);
}}
>
{project.thumbnailUrl ? (
<img src={project.thumbnailUrl} alt="" />
) : (
<span className="studio-canvas-recent-project__thumb">
<FolderOpenOutlined />
</span>
)}
<span className="studio-canvas-recent-project__body">
<strong>{project.source === "server" ? project.name : `预览项目 ${index + 1}`}</strong>
<small>{project.description || formatCanvasProjectUpdatedAt(project.updatedAt)}</small>
<em>
{project.storyboardCount} · {project.imageCount} · {project.videoCount}
</em>
</span>
</button>
))
) : (
<div className="studio-canvas-recent-drawer__empty">
<FileImageOutlined />
<strong></strong>
<span>{isAuthenticated ? "社区最近项目为空" : "登录后可查看最近项目"}</span>
</div>
)}
</div>
<button
type="button"
className="studio-canvas-recent-drawer__community"
onClick={() => {
setRecentProjectsOpen(false);
onOpenCommunity();
}}
>
</button>
</aside>
) : null}
<ReactFlow
nodes={[]}
edges={[]}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
minZoom={0.3}
maxZoom={1.6}
panOnDrag={false}
panOnScroll={false}
zoomOnDoubleClick={false}
zoomOnPinch={false}
zoomOnScroll={false}
proOptions={{ hideAttribution: true }}
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
>
<Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}%
</button>
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div
className="studio-canvas-empty-projects"
role="status"
aria-live="polite"
onMouseDown={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{isWaitingForProjects ? (
<>
<div className="studio-canvas-loading-spinner" />
<strong></strong>
</>
) : (
<>
<strong></strong>
<button
type="button"
className="studio-canvas-empty-projects__button"
onClick={(event) => {
event.stopPropagation();
if (onStartCreate) {
onStartCreate();
return;
}
onOpenLogin();
}}
>
</button>
</>
)}
</div>
) : null}
{selectionRect ? (
<div
className="studio-canvas-selection-box"
style={{
left: selectionRect.left,
top: selectionRect.top,
width: selectionRect.width,
height: selectionRect.height,
}}
/>
) : null}
{nodePackageBounds.map(({ nodePackage, bounds }) => (
<div
key={nodePackage.id}
className={`studio-canvas-node-package-box${selectedPackageId === nodePackage.id ? " is-selected" : ""}${packageDrag?.packageId === nodePackage.id ? " is-dragging" : ""}`}
style={{
left: bounds.left - 18,
top: bounds.top - 34,
width: bounds.width + 36,
height: bounds.height + 54,
}}
onMouseDown={(event) => startPackageDrag(event, nodePackage, false)}
onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
>
<button
type="button"
className="studio-canvas-node-package-box__label"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
>
<strong>{nodePackage.title}</strong>
<em>{nodePackage.nodeIds.length} </em>
</button>
</div>
))}
{collapsedPackageCards.map(({ nodePackage, bounds }) => (
<button
key={nodePackage.id}
type="button"
className={`studio-canvas-node-package-card${packageDrag?.packageId === nodePackage.id ? " is-dragging" : ""}`}
style={{
left: bounds.left,
top: bounds.top,
width: Math.min(360, Math.max(240, bounds.width)),
height: Math.max(112, Math.min(150, bounds.height || 132)),
}}
onMouseDown={(event) => startPackageDrag(event, nodePackage, true)}
onContextMenu={(event) => handleCanvasNodePackagePointer(event, nodePackage)}
>
<span className="studio-canvas-node-package-card__label">{nodePackage.title}</span>
<span className="studio-canvas-node-package-card__meta">{nodePackage.nodeIds.length} </span>
</button>
))}
{selectedNodesBounds ? (
<div
className="studio-canvas-selection-summary"
style={{
left: selectedNodesBounds.left - 10,
top: selectedNodesBounds.top - 30,
width: selectedNodesBounds.width + 20,
height: selectedNodesBounds.height + 40,
}}
>
<span className="studio-canvas-selection-summary__label">
{selectedNodes.length}
</span>
</div>
) : null}
<div
className="studio-canvas-world"
style={{
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px) scale(${canvasViewport.zoom})`,
}}
>
{alignGuides.length > 0 && (
<div className="studio-canvas-align-guides" aria-hidden="true">
{alignGuides.map((guide, i) => (
<div
key={`${guide.axis}-${guide.position}-${i}`}
className={`studio-canvas-align-guide studio-canvas-align-guide--${guide.axis}`}
style={guide.axis === "x" ? { left: guide.position } : { top: guide.position }}
/>
))}
</div>
)}
{nodeLinks.length || pendingLinkPreview ? (
<svg className="studio-canvas-node-links" aria-hidden="true">
{nodeLinks.map((link) => {
const controlOffset = Math.max(120, Math.abs(link.targetX - link.sourceX) * 0.42);
const sourceControlX =
link.sourceX + getCanvasNodeSideDirection(link.sourceSide) * controlOffset;
const targetControlX =
link.targetX + getCanvasNodeSideDirection(link.targetSide) * controlOffset;
return (
<g key={link.id}>
<path
className="studio-canvas-node-link-hit"
d={`M ${link.sourceX} ${link.sourceY} C ${sourceControlX} ${link.sourceY}, ${targetControlX} ${link.targetY}, ${link.targetX} ${link.targetY}`}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
removeCanvasLink(link.id);
}}
/>
<circle cx={link.sourceX} cy={link.sourceY} r="8" />
<circle cx={link.targetX} cy={link.targetY} r="8" />
</g>
);
})}
{pendingLinkPreview ? (
<g className="studio-canvas-node-link-preview">
{(() => {
const controlOffset = Math.max(
120,
Math.abs(pendingLinkPreview.targetX - pendingLinkPreview.sourceX) * 0.42
);
const sourceDirection = getCanvasNodeSideDirection(pendingLinkPreview.sourceSide);
const sourceControlX = pendingLinkPreview.sourceX + sourceDirection * controlOffset;
const targetControlX = pendingLinkPreview.targetX - sourceDirection * controlOffset;
return (
<>
<path
d={`M ${pendingLinkPreview.sourceX} ${pendingLinkPreview.sourceY} C ${sourceControlX} ${pendingLinkPreview.sourceY}, ${targetControlX} ${pendingLinkPreview.targetY}, ${pendingLinkPreview.targetX} ${pendingLinkPreview.targetY}`}
/>
<circle cx={pendingLinkPreview.sourceX} cy={pendingLinkPreview.sourceY} r="8" />
<circle cx={pendingLinkPreview.targetX} cy={pendingLinkPreview.targetY} r="6" />
</>
);
})()}
</g>
) : null}
</svg>
) : null}
{textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => {
const textNodeSelected = isSelectedNode("text", textNode.id);
const textNodeActive = isActiveSelectedNode("text", textNode.id);
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
const textTaskState = textGenerationState[textNode.id];
const textNodeGenerating = textTaskState?.status === "running";
const textNodeCanGenerate = Boolean(getEffectiveNodePrompt("text", textNode.id, textNode.prompt));
const textNodeDisplayContent =
textNode.content ||
(textTaskState?.status === "running"
? "AI 正在写入文本..."
: textTaskState?.status === "error"
? textTaskState.message
: "");
return (
<div
className={`studio-canvas-text-node${textNodeDrag?.nodeId === textNode.id ? " is-dragging" : ""}${textNodeSelected ? " is-selected" : ""}${textNodeResizing ? " is-resizing" : ""}`}
key={textNode.id}
style={{
"--text-node-x": `${textNode.position.x}px`,
"--text-node-y": `${textNode.position.y}px`,
"--canvas-node-width": `${textNode.size.width}px`,
"--canvas-node-height": `${textNode.size.height}px`,
} as CSSProperties}
>
<div className="studio-canvas-text-node__scaled">
<div className="studio-canvas-text-node__title">
<FileTextOutlined />
<span>{textNode.title}</span>
</div>
<div
className="studio-canvas-text-node__card"
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
const pkg = findNodePackage("text", textNode.id);
if (pkg) {
openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
return;
}
if (isMultiSelectedNode("text", textNode.id)) {
openCanvasSelectionContextMenu(event.clientX, event.clientY);
return;
}
selectCanvasNode("text", textNode.id);
setContextMenu(null);
setNodeMenu(null);
setSelectionContextMenu(null);
setTextNodeMenu({ left: event.clientX, top: event.clientY, nodeId: textNode.id });
setImageNodeMenu(null);
setVideoNodeMenu(null);
}}
onMouseDown={(event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
if (!textNodeSelected) clearCanvasSelection();
setContextMenu(null);
setNodeMenu(null);
setTextNodeMenu(null);
setImageNodeMenu(null);
setVideoNodeMenu(null);
setCanvasSelectMenu(null);
setTextNodeDrag({
nodeId: textNode.id,
startX: event.clientX,
startY: event.clientY,
originX: textNode.position.x,
originY: textNode.position.y,
hasMoved: false,
});
}}
>
{textNode.isEditingContent ? (
<textarea
className="studio-canvas-text-node__inline-input"
value={textNode.content}
autoFocus
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onChange={(event) => updateTextNodeContent(textNode.id, event.target.value)}
onBlur={() => finishTextNodeContentEditing(textNode.id)}
placeholder="请编写您的内容"
/>
) : textNodeDisplayContent ? (
<button
type="button"
className="studio-canvas-text-node__content"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
selectCanvasNode("text", textNode.id);
setTextNodeContentEditing(textNode.id, true);
}}
>
{textNodeDisplayContent}
</button>
) : (
<>
<div className="studio-canvas-text-node__glyph" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
<div className="studio-canvas-text-node__suggestions">
<span>:</span>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setTextNodeContentEditing(textNode.id, true);
}}
>
<FileTextOutlined />
</button>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
addVideoNodeFromText(textNode);
}}
>
<VideoCameraOutlined />
</button>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
reversePromptFromLinkedNode(textNode);
}}
>
<FileImageOutlined />
</button>
</div>
</>
)}
{renderConnectorButton({ kind: "text", nodeId: textNode.id, side: "left", slot: "center" }, "studio-canvas-text-node__connector")}
{renderConnectorButton({ kind: "text", nodeId: textNode.id, side: "right", slot: "center" }, "studio-canvas-text-node__connector")}
<button
type="button"
className="studio-canvas-node-resize-handle"
aria-label="Resize text node"
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
/>
</div>
{textNodeActive && !isCanvasNodeMoving ? (() => {
const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const filteredMentions = mentionState.open
? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateTextNodePrompt(textNode.id, value);
// Detect @-mention trigger
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(textNode.id);
};
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const opt = filteredMentions[mentionState.activeIndex];
if (opt) {
const ta = e.currentTarget;
insertTextNodeMention(textNode.id, opt, ta);
}
} else if (e.key === "Escape") {
e.preventDefault();
closeTextNodeMention(textNode.id);
}
};
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const caret = ta.selectionStart || 0;
setTextNodeMentionStates((prev) => {
const cur = prev[textNode.id];
if (!cur?.open) return prev;
return { ...prev, [textNode.id]: { ...cur, caret } };
});
};
return (
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={textNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
>
<span className="studio-canvas-mention-thumb">
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
title={textNodeGenerating ? "生成中" : "生成"}
disabled={textNodeGenerating || !textNodeCanGenerate}
aria-busy={textNodeGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!textNodeGenerating && textNodeCanGenerate) {
void handleGenerateTextNode(textNode.id);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
})() : null}
</div>
</div>
);
})}
{imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => {
const imageNodeSelected = isSelectedNode("image", imageNode.id);
const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
const imageTaskState = imageGenerationState[imageNode.id];
const imageNodeGenerating =
imageTaskState?.status === "submitting" || imageTaskState?.status === "running";
const imageNodeProgress = normalizeCanvasGenerationProgress(imageTaskState);
const imageNodeProgressVisible =
imageNodeGenerating || (imageTaskState?.status === "success" && !imageNode.imageUrl);
const imageNodeCanGenerate = Boolean(
getEffectiveNodePrompt("image", imageNode.id, imageNode.prompt) ||
getConnectedImageReferenceItems("image", imageNode.id, imageNode).length
);
const imageNodeFocusActive = imageFocusNodeId === imageNode.id;
const imageFocusToolActive = Boolean(imageFocusNodeId);
const imageNodeLoadFailed = Boolean(
imageNode.imageUrl && imageLoadErrors[imageNode.id] === imageNode.imageUrl
);
const imageFocusSelection = imageNodeFocusActive ? imageFocusDraft ?? imageNode.focusSelection ?? null : null;
const imageFocusSelectionReady = Boolean(
imageFocusSelection && imageFocusSelection.width >= 2 && imageFocusSelection.height >= 2
);
const imageFocusSelectionStyle = imageFocusSelection
? {
"--focus-x": `${imageFocusSelection.x}%`,
"--focus-y": `${imageFocusSelection.y}%`,
"--focus-w": `${imageFocusSelection.width}%`,
"--focus-h": `${imageFocusSelection.height}%`,
} as CSSProperties
: undefined;
return (
<div
className={`studio-canvas-image-node${imageNodeDrag?.nodeId === imageNode.id ? " is-dragging" : ""}${imageNodeSelected ? " is-selected" : ""}${imageNodeResizing ? " is-resizing" : ""}${imageFocusToolActive ? " is-focus-tool-active" : ""}${imageNodeFocusActive ? " is-focus-selecting" : ""}`}
key={imageNode.id}
style={{
"--image-node-x": `${imageNode.position.x}px`,
"--image-node-y": `${imageNode.position.y}px`,
"--canvas-node-width": `${imageNode.size.width}px`,
"--canvas-node-height": `${imageNode.size.height}px`,
} as CSSProperties}
>
<div className="studio-canvas-image-node__scaled">
{imageNodeSelected && imageNode.imageUrl && (
<CanvasNodeToolbar
actions={[
{ key: "regenerate", label: "重绘", icon: <ReloadOutlined />, loading: imageNodeGenerating },
{ key: "upscale", label: "超分", icon: <ThunderboltOutlined />, disabled: imageNodeGenerating },
{ key: "save", label: "保存", icon: <SaveOutlined />, disabled: imageNodeGenerating },
]}
onAction={(key) => {
if (key === "regenerate") void handleGenerateImageNode(imageNode.id);
if (key === "save") {
setSaveAssetSource({
kind: "image",
name: imageNode.fileName || imageNode.title,
description: imageNode.prompt || "从画布图片节点保存的素材。",
imageUrl: imageNode.imageUrl,
});
setAssetName(imageNode.fileName || imageNode.title);
setAssetCoverUrl(imageNode.imageUrl || "");
setAssetSaveMode("existing");
setSelectedExistingCategory("");
setSaveAssetOpen(true);
}
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
}}
moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
{ key: "download", label: "下载", icon: <DownloadOutlined />, disabled: !imageNode.imageUrl },
{ key: "duplicate", label: "创建副本", icon: <PictureOutlined /> },
{ key: "delete", label: "删除", icon: <DeleteOutlined /> },
]}
onMoreAction={(key) => {
if (key === "copy" && imageNode.imageUrl) void navigator.clipboard.writeText(imageNode.imageUrl);
if (key === "download" && imageNode.imageUrl) {
const a = document.createElement("a");
a.href = imageNode.imageUrl;
a.download = imageNode.fileName || `${imageNode.title}.png`;
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
if (key === "duplicate") duplicateImageNode(imageNode);
if (key === "delete") setImageNodes((currentNodes) => currentNodes.filter((n) => n.id !== imageNode.id));
}}
/>
)}
<div className="studio-canvas-image-node__title">
<FileImageOutlined />
<span>{imageNode.title}</span>
</div>
<button
type="button"
className="studio-canvas-image-node__upload"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
selectCanvasNode("image", imageNode.id);
setPendingImageNodeId(imageNode.id);
setPendingImagePosition(imageNode.position);
imageNodeInputRef.current?.click();
}}
>
<UploadOutlined />
</button>
<div
className="studio-canvas-image-node__card"
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
const pkg = findNodePackage("image", imageNode.id);
if (pkg) {
openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
return;
}
if (isMultiSelectedNode("image", imageNode.id)) {
openCanvasSelectionContextMenu(event.clientX, event.clientY);
return;
}
selectCanvasNode("image", imageNode.id);
setContextMenu(null);
setNodeMenu(null);
setSelectionContextMenu(null);
setTextNodeMenu(null);
setVideoNodeMenu(null);
setImageNodeMenu({ left: event.clientX, top: event.clientY, nodeId: imageNode.id });
}}
onMouseDown={(event) => {
if (event.button !== 0) return;
if (imageFocusNodeId) {
handleImageFocusDragStart(event, imageNode.id);
return;
}
event.preventDefault();
event.stopPropagation();
if (!imageNodeSelected) clearCanvasSelection();
setContextMenu(null);
setNodeMenu(null);
setTextNodeMenu(null);
setImageNodeMenu(null);
setVideoNodeMenu(null);
setCanvasSelectMenu(null);
setImageNodeDrag({
nodeId: imageNode.id,
startX: event.clientX,
startY: event.clientY,
originX: imageNode.position.x,
originY: imageNode.position.y,
hasMoved: false,
});
}}
>
{imageNode.imageUrl && !imageNodeLoadFailed ? (
<img
src={imageNode.imageUrl}
alt={imageNode.fileName || imageNode.title}
onLoad={(e) => {
setImageLoadErrors((current) => {
if (current[imageNode.id] !== imageNode.imageUrl) return current;
const next = { ...current };
delete next[imageNode.id];
return next;
});
const img = e.currentTarget;
const nw = img.naturalWidth;
const nh = img.naturalHeight;
if (nw > 0 && nh > 0) {
const titleHeight = 36;
const nodeWidth = imageNode.size.width;
const newHeight = Math.round((nodeWidth * nh) / nw) + titleHeight;
const clamped = clampCanvasNodeSize("image", nodeWidth, newHeight);
if (clamped.height !== imageNode.size.height) {
setImageNodes((cur) => cur.map((n) =>
n.id === imageNode.id ? { ...n, size: clamped } : n
));
}
}
}}
onError={() => {
setImageLoadErrors((current) => ({ ...current, [imageNode.id]: imageNode.imageUrl }));
}}
/>
) : (
<div
className={`studio-canvas-image-node__placeholder${imageNodeLoadFailed ? " is-error" : ""}`}
role={imageNodeLoadFailed ? "status" : undefined}
aria-hidden={imageNodeLoadFailed ? undefined : true}
>
<FileImageOutlined className="studio-canvas-image-node__placeholder-icon" />
{imageNodeLoadFailed ? (
<div className="studio-canvas-image-node__placeholder-copy">
<strong></strong>
<small></small>
</div>
) : null}
</div>
)}
{imageNodeProgressVisible ? (
<CanvasSmoothedProgressRing
progress={imageNodeProgress}
status={imageTaskState?.status || "running"}
message={imageTaskState?.message || "图片生成中"}
/>
) : null}
{imageNodeFocusActive && imageFocusSelectionReady ? (
<div
className={`studio-canvas-image-focus-layer${imageFocusDrag?.nodeId === imageNode.id ? " is-dragging" : ""}`}
style={imageFocusSelectionStyle}
onMouseDown={(event) => handleImageFocusDragStart(event, imageNode.id)}
>
<span className="studio-canvas-image-focus-layer__shade is-top" />
<span className="studio-canvas-image-focus-layer__shade is-bottom" />
<span className="studio-canvas-image-focus-layer__shade is-left" />
<span className="studio-canvas-image-focus-layer__shade is-right" />
<div className="studio-canvas-image-focus-layer__box" aria-hidden="true">
{["nw", "n", "ne", "e", "se", "s", "sw", "w"].map((handle) => (
<span key={handle} className={`studio-canvas-image-focus-layer__handle is-${handle}`} />
))}
</div>
</div>
) : null}
{renderConnectorButton({ kind: "image", nodeId: imageNode.id, side: "left", slot: "center" }, "studio-canvas-image-node__connector")}
{renderConnectorButton({ kind: "image", nodeId: imageNode.id, side: "right", slot: "center" }, "studio-canvas-image-node__connector")}
<button
type="button"
className="studio-canvas-node-resize-handle"
aria-label="Resize image node"
onMouseDown={(event) => handleNodeResizeStart(event, "image", imageNode.id, imageNode.size)}
/>
</div>
{imageNodeFocusActive && imageFocusSelectionReady ? (
<div
className="studio-canvas-image-focus-toolbar"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="studio-canvas-image-focus-toolbar__ratios">
{imageFocusRatioOptions.map((option) => (
<button
key={option.value}
type="button"
className={imageFocusSelection?.ratio === option.value ? "is-active" : ""}
onClick={() => handleImageFocusRatioChange(option.value)}
>
{option.label}
</button>
))}
</div>
<button type="button" className="studio-canvas-image-focus-toolbar__cancel" onClick={cancelImageFocusMode}>
</button>
<button type="button" className="studio-canvas-image-focus-toolbar__confirm" onClick={confirmImageFocusMode}>
</button>
</div>
) : null}
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const imgFilteredMentions = imgMentionState.open
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
: [];
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateImageNodePrompt(imageNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(imageNode.id);
};
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
};
return (
<div className="studio-canvas-image-composer">
<div className="studio-canvas-image-composer__tools">
<button
type="button"
className={`studio-canvas-image-composer__style-button${imageNode.styleReference ? " has-style" : ""}`}
title={imageNode.styleReference ? `已选风格:${imageNode.styleReference.title}` : "选择社区风格"}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openImageStylePicker(imageNode.id);
}}
>
{imageNode.styleReference ? (
<span className="studio-canvas-image-composer__style-thumb">
<img src={imageNode.styleReference.imageUrl} alt={imageNode.styleReference.title} />
</span>
) : (
<BgColorsOutlined />
)}
<span className="studio-canvas-image-composer__style-label">
{imageNode.styleReference?.title || "风格"}
</span>
</button>
<button
type="button"
className={imageNode.marking ? "is-active" : ""}
title={imageNode.marking ? `标记: ${imageNode.marking}` : "添加标记描述"}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setMarkingPopoverNodeId(markingPopoverNodeId === imageNode.id ? null : imageNode.id);
setCameraMotionDropdownNodeId(null);
}}
>
<FileImageOutlined /><span></span>
</button>
{markingPopoverNodeId === imageNode.id && (
<div
className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
value={imageNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
);
}}
/>
<div className="studio-canvas-marking-actions">
{imageNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
<button
type="button"
className={imageNodeFocusActive ? "is-active" : ""}
title="框选聚焦区域"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openImageFocusMode(imageNode);
}}
>
<BarsOutlined /><span></span>
</button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div>
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={imageNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleImagePromptChange}
onKeyDown={handleImagePromptKeyDown}
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
/>
{imgMentionState.open && (
<div className="studio-canvas-mention-panel">
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label"></span></div>
)}
</div>
)}
</div>
<div className="studio-canvas-image-composer__footer">
<CanvasSelectChip
ariaLabel="选择生图模型"
className="canvas-select-chip--model studio-canvas-composer-chip"
value={resolveVisibleImageModel(imageNode.model || defaultImageModel)}
options={visibleImageModelOptions}
open={canvasSelectMenu === `${imageNode.id}:image-model`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${imageNode.id}:image-model` ? null : `${imageNode.id}:image-model`
)
}
onChange={(value) => {
updateImageNodeSetting(imageNode.id, { model: value });
setCanvasSelectMenu(null);
}}
/>
<CanvasSelectChip
ariaLabel="选择图片比例"
className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
value={imageNode.aspectRatio || "16:9"}
options={imageRatioOptions}
open={canvasSelectMenu === `${imageNode.id}:image-ratio`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${imageNode.id}:image-ratio` ? null : `${imageNode.id}:image-ratio`
)
}
onChange={(value) => {
updateImageNodeSetting(imageNode.id, { aspectRatio: value });
setCanvasSelectMenu(null);
}}
/>
<CanvasSelectChip
ariaLabel="选择图片清晰度"
className="studio-canvas-composer-chip studio-canvas-composer-chip--mini"
value={resolveImageQuality(resolveVisibleImageModel(imageNode.model || defaultImageModel), imageNode.imageSize || "")}
options={getImageQualityOptions(resolveVisibleImageModel(imageNode.model || defaultImageModel))}
open={canvasSelectMenu === `${imageNode.id}:image-size`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${imageNode.id}:image-size` ? null : `${imageNode.id}:image-size`
)
}
onChange={(value) => {
updateImageNodeSetting(imageNode.id, { imageSize: value });
setCanvasSelectMenu(null);
}}
/>
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${imageNodeCanGenerate && !imageNodeGenerating ? " is-ready" : ""}`}
title={imageNodeGenerating ? "生成中" : "生成"}
disabled={imageNodeGenerating || !imageNodeCanGenerate}
aria-busy={imageNodeGenerating}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
if (!imageNodeGenerating && imageNodeCanGenerate) {
void handleGenerateImageNode(imageNode.id);
}
}}
>
<SendOutlined />
</button>
</div>
</div>
); })() : null}
</div>
</div>
);
})}
{videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
const videoNodeSelected = isSelectedNode("video", videoNode.id);
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
const videoTaskState = videoGenerationState[videoNode.id];
const videoNodeGenerating =
videoTaskState?.status === "submitting" || videoTaskState?.status === "running";
const videoNodeProgress = normalizeCanvasGenerationProgress(videoTaskState);
const videoNodeProgressVisible =
videoNodeGenerating || (videoTaskState?.status === "success" && !videoNode.videoUrl);
const videoNodeCanGenerate = Boolean(
getEffectiveNodePrompt("video", videoNode.id, videoNode.prompt) ||
getConnectedImageReferenceItems("video", videoNode.id).length
);
return (
<div
className={`studio-canvas-video-node${videoNodeDrag?.nodeId === videoNode.id ? " is-dragging" : ""}${videoNodeSelected ? " is-selected" : ""}${videoNodeResizing ? " is-resizing" : ""}`}
key={videoNode.id}
style={{
"--video-node-x": `${videoNode.position.x}px`,
"--video-node-y": `${videoNode.position.y}px`,
"--canvas-node-width": `${videoNode.size.width}px`,
"--canvas-node-height": `${videoNode.size.height}px`,
} as CSSProperties}
>
<div className="studio-canvas-video-node__scaled">
{videoNodeSelected && videoNode.videoUrl && (
<CanvasNodeToolbar
actions={[
{ key: "regenerate", label: "重新生成", icon: <ReloadOutlined />, loading: videoNodeGenerating },
{ key: "save", label: "保存", icon: <SaveOutlined />, disabled: videoNodeGenerating },
]}
onAction={(key) => {
if (key === "regenerate") void handleGenerateVideoNode(videoNode.id);
if (key === "save") {
setSaveAssetSource({
kind: "video",
name: videoNode.title,
description: videoNode.prompt || "从画布视频节点保存的素材。",
url: videoNode.videoUrl,
});
setAssetName(videoNode.title);
setAssetCoverUrl("");
setAssetSaveMode("existing");
setSelectedExistingCategory("");
setSaveAssetOpen(true);
}
}}
moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !videoNode.videoUrl },
{ key: "download", label: "下载", icon: <DownloadOutlined />, disabled: !videoNode.videoUrl },
{ key: "duplicate", label: "创建副本", icon: <PictureOutlined /> },
{ key: "delete", label: "删除", icon: <DeleteOutlined /> },
]}
onMoreAction={(key) => {
if (key === "copy" && videoNode.videoUrl) void navigator.clipboard.writeText(videoNode.videoUrl);
if (key === "download" && videoNode.videoUrl) {
const a = document.createElement("a");
a.href = videoNode.videoUrl;
a.download = `${videoNode.title}.mp4`;
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
if (key === "duplicate") duplicateVideoNode(videoNode);
if (key === "delete") setVideoNodes((currentNodes) => currentNodes.filter((n) => n.id !== videoNode.id));
}}
/>
)}
<div className="studio-canvas-video-node__title">
<VideoCameraOutlined />
<span>{videoNode.title}</span>
</div>
<div
className="studio-canvas-video-node__preview"
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
const pkg = findNodePackage("video", videoNode.id);
if (pkg) {
openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
return;
}
if (isMultiSelectedNode("video", videoNode.id)) {
openCanvasSelectionContextMenu(event.clientX, event.clientY);
return;
}
selectCanvasNode("video", videoNode.id);
setContextMenu(null);
setNodeMenu(null);
setSelectionContextMenu(null);
setTextNodeMenu(null);
setImageNodeMenu(null);
setVideoNodeMenu({ left: event.clientX, top: event.clientY, nodeId: videoNode.id });
}}
onMouseDown={(event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
if (!videoNodeSelected) clearCanvasSelection();
setContextMenu(null);
setNodeMenu(null);
setTextNodeMenu(null);
setImageNodeMenu(null);
setVideoNodeMenu(null);
setCanvasSelectMenu(null);
setVideoNodeDrag({
nodeId: videoNode.id,
startX: event.clientX,
startY: event.clientY,
originX: videoNode.position.x,
originY: videoNode.position.y,
hasMoved: false,
});
}}
>
{videoNode.videoUrl ? (
<CanvasNodeVideoPlayer src={videoNode.videoUrl} title={videoNode.title} onVideoMeta={(vw, vh) => {
if (vw > 0 && vh > 0) {
const titleHeight = 36;
const nodeWidth = videoNode.size.width;
const newHeight = Math.round((nodeWidth * vh) / vw) + titleHeight;
const clamped = clampCanvasNodeSize("video", nodeWidth, newHeight);
if (clamped.height !== videoNode.size.height) {
setVideoNodes((cur) => cur.map((n) =>
n.id === videoNode.id ? { ...n, size: clamped } : n
));
}
}
}} />
) : (
<span className="studio-canvas-video-node__play"><VideoCameraOutlined /></span>
)}
{videoNodeProgressVisible ? (
<CanvasSmoothedProgressRing
progress={videoNodeProgress}
status={videoTaskState?.status || "running"}
message={videoTaskState?.message || "视频生成中"}
/>
) : null}
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "right", slot: "center" }, "studio-canvas-video-node__connector")}
<button
type="button"
className="studio-canvas-node-resize-handle"
aria-label="Resize video node"
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
/>
</div>
{videoNodeActive && !isCanvasNodeMoving ? (() => {
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const vidFilteredMentions = vidMentionState.open
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
: [];
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateVideoNodePrompt(videoNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(videoNode.id);
};
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
};
return (
<div className="studio-canvas-video-composer">
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
<button
type="button"
className={videoNode.videoMode === "text2video" ? "is-active" : ""}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "text2video" })}
>
</button>
<button
type="button"
className={videoNode.videoMode === "img2video" ? "is-active" : ""}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "img2video" })}
>
</button>
<button
type="button"
className={videoNode.videoMode === "firstlast" ? "is-active" : ""}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "firstlast" })}
>
</button>
</div>
<div className="studio-canvas-video-composer__tools studio-canvas-video-composer__feature-tools">
<button
type="button"
className={videoNode.marking ? "is-active" : ""}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setMarkingPopoverNodeId(markingPopoverNodeId === videoNode.id ? null : videoNode.id);
setCameraMotionDropdownNodeId(null);
}}
>
{videoNode.marking ? " ✓" : ""}
</button>
<button
type="button"
className={videoNode.cameraMotion ? "is-active" : ""}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCameraMotionDropdownNodeId(cameraMotionDropdownNodeId === videoNode.id ? null : videoNode.id);
setMarkingPopoverNodeId(null);
}}
>
{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
</button>
{markingPopoverNodeId === videoNode.id && (
<div
className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角在城市街头行走"
value={videoNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
);
}}
/>
<div className="studio-canvas-marking-actions">
{videoNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
{cameraMotionDropdownNodeId === videoNode.id && (
<div
className="studio-canvas-camera-dropdown"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{CAMERA_MOTION_PRESETS.map((preset) => (
<button
key={preset.value}
type="button"
className={`studio-canvas-camera-dropdown__item${videoNode.cameraMotion === preset.value ? " is-active" : ""}`}
onClick={() => {
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, cameraMotion: preset.value } : n)),
);
setCameraMotionDropdownNodeId(null);
}}
>
{preset.label}
</button>
))}
</div>
)}
<button type="button"></button>
<button type="button" className="is-active"></button>
</div>
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={videoNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleVideoPromptChange}
onKeyDown={handleVideoPromptKeyDown}
placeholder="根据文字描述生成视频。"
/>
{vidMentionState.open ? (
<div className="studio-canvas-mention-panel">
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
>
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
<CanvasSelectChip
ariaLabel="选择视频模型"
className="canvas-select-chip--model studio-canvas-composer-chip"
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
options={canvasEnterpriseVideoModelOptions}
open={canvasSelectMenu === `${videoNode.id}:video-model`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${videoNode.id}:video-model` ? null : `${videoNode.id}:video-model`
)
}
onChange={(value) => {
updateVideoNodeSetting(videoNode.id, { model: value });
setCanvasSelectMenu(null);
}}
/>
<CanvasSelectChip
ariaLabel="选择视频比例"
className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
value={videoNode.aspectRatio || "16:9"}
options={videoRatioOptions}
open={canvasSelectMenu === `${videoNode.id}:video-ratio`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${videoNode.id}:video-ratio` ? null : `${videoNode.id}:video-ratio`
)
}
onChange={(value) => {
updateVideoNodeSetting(videoNode.id, { aspectRatio: value });
setCanvasSelectMenu(null);
}}
/>
<CanvasSelectChip
ariaLabel="选择视频清晰度"
className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
value={resolveVideoQuality(videoNode.model || defaultVideoModel, videoNode.resolution || "")}
options={getVideoQualityOptions(videoNode.model || defaultVideoModel)}
open={canvasSelectMenu === `${videoNode.id}:video-resolution`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${videoNode.id}:video-resolution` ? null : `${videoNode.id}:video-resolution`
)
}
onChange={(value) => {
updateVideoNodeSetting(videoNode.id, { resolution: value });
setCanvasSelectMenu(null);
}}
/>
<CanvasSelectChip
ariaLabel="选择视频时长"
className="studio-canvas-composer-chip studio-canvas-composer-chip--mini"
value={videoNode.duration || "4"}
options={videoDurationOptions}
open={canvasSelectMenu === `${videoNode.id}:video-duration`}
onToggle={() =>
setCanvasSelectMenu((current) =>
current === `${videoNode.id}:video-duration` ? null : `${videoNode.id}:video-duration`
)
}
onChange={(value) => {
updateVideoNodeSetting(videoNode.id, { duration: value });
setCanvasSelectMenu(null);
}}
/>
<button
type="button"
className={`studio-canvas-generate-button${videoNodeCanGenerate && !videoNodeGenerating ? " is-ready" : ""}`}
title={videoNodeGenerating ? "生成中" : "生成"}
disabled={videoNodeGenerating || !videoNodeCanGenerate}
aria-busy={videoNodeGenerating}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
if (!videoNodeGenerating && videoNodeCanGenerate) {
void handleGenerateVideoNode(videoNode.id);
}
}}
>
<SendOutlined />
</button>
</div>
</div>
); })() : null}
</div>
</div>
);
})}
</div>
{textNodeMenu && activeTextNode
? renderCanvasNodeContextMenu({
left: textNodeMenu.left,
top: textNodeMenu.top,
saveAssetSource: {
kind: "text",
name: activeTextNode.title,
description: activeTextNode.prompt || "从画布文本节点保存的素材。",
},
assetName: activeTextNode.title,
copyNode: () => setCopiedCanvasNode({ kind: "text", node: activeTextNode }),
duplicateNode: () => duplicateTextNode(activeTextNode),
deleteNode: () => setTextNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeTextNode.id)),
})
: null}
{imageNodeMenu && activeImageNode
? renderCanvasNodeContextMenu({
left: imageNodeMenu.left,
top: imageNodeMenu.top,
saveAssetSource: {
kind: "image",
name: activeImageNode.fileName || activeImageNode.title,
description: activeImageNode.prompt || "从画布图片节点保存的素材。",
imageUrl: activeImageNode.imageUrl,
},
assetName: activeImageNode.fileName || activeImageNode.title,
assetCoverUrl: activeImageNode.imageUrl,
copyNode: () => setCopiedCanvasNode({ kind: "image", node: activeImageNode }),
duplicateNode: () => duplicateImageNode(activeImageNode),
deleteNode: () => setImageNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeImageNode.id)),
})
: null}
{videoNodeMenu && activeVideoNode
? renderCanvasNodeContextMenu({
left: videoNodeMenu.left,
top: videoNodeMenu.top,
saveAssetSource: {
kind: "video",
name: activeVideoNode.title,
description: activeVideoNode.prompt || "从画布视频节点保存的素材。",
url: activeVideoNode.videoUrl,
},
assetName: activeVideoNode.title,
copyNode: () => setCopiedCanvasNode({ kind: "video", node: activeVideoNode }),
duplicateNode: () => duplicateVideoNode(activeVideoNode),
deleteNode: () => setVideoNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeVideoNode.id)),
})
: null}
{selectionContextMenu ? (
<div
className="studio-canvas-selection-context-menu"
style={{ left: selectionContextMenu.left, top: selectionContextMenu.top }}
role="menu"
aria-label={activePackage ? "打包节点操作" : "已选节点操作"}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
{activePackage ? (
<>
<div className="studio-canvas-selection-context-menu__title">
<strong>{activePackage.title}</strong>
<span>{activePackage.nodeIds.length} </span>
</div>
<button
type="button"
role="menuitem"
className="studio-canvas-selection-context-menu__primary"
onClick={(event) => {
event.stopPropagation();
collapseSelectedCanvasPackage();
}}
>
<span></span>
<kbd>Fold</kbd>
</button>
{selectedPackageCount > 0 ? (
<button
type="button"
role="menuitem"
className="studio-canvas-selection-context-menu__unpackage"
onClick={(event) => {
event.stopPropagation();
unpackageSelectedCanvasNodes();
}}
>
<span></span>
<kbd>Ungroup</kbd>
</button>
) : null}
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
clearCanvasSelection();
}}
>
<span></span>
<kbd>Esc</kbd>
</button>
</>
) : (
<>
<div className="studio-canvas-selection-context-menu__title">
<strong></strong>
<span>{selectedNodes.length} </span>
</div>
<button
type="button"
role="menuitem"
className="studio-canvas-selection-context-menu__primary"
disabled={selectedNodes.length < 2}
onClick={(event) => {
event.stopPropagation();
packageSelectedCanvasNodes();
}}
>
<span></span>
<kbd>Group</kbd>
</button>
{selectedPackageCount > 0 ? (
<button
type="button"
role="menuitem"
className="studio-canvas-selection-context-menu__unpackage"
onClick={(event) => {
event.stopPropagation();
unpackageSelectedCanvasNodes();
}}
>
<span></span>
<kbd>Ungroup</kbd>
</button>
) : null}
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
clearCanvasSelection();
}}
>
<span></span>
<kbd>Esc</kbd>
</button>
</>
)}
</div>
) : null}
{saveAssetOpen ? (
<div className="studio-canvas-save-asset" role="dialog" aria-modal="true" aria-label="创建素材文件夹">
<div className="studio-canvas-save-asset__modal">
<header className="studio-canvas-save-asset__head">
<button
type="button"
className={assetSaveMode === "create" ? "is-active" : ""}
onClick={() => {
setAssetSaveMode("create");
setCoverLibraryOpen(false);
}}
>
</button>
<button
type="button"
className={assetSaveMode === "existing" ? "is-active" : ""}
onClick={() => {
setAssetSaveMode("existing");
setCoverSourceOpen(false);
setCoverLibraryOpen(false);
}}
>
</button>
<button type="button" className="studio-canvas-save-asset__close" aria-label="关闭" onClick={() => setSaveAssetOpen(false)}>
<CloseOutlined />
</button>
</header>
{assetSaveMode === "create" ? (
<>
<div className="studio-canvas-save-asset__body">
<section className="studio-canvas-save-asset__cover">
<div className="studio-canvas-save-asset__label"></div>
<div className={`studio-canvas-save-asset__placeholder${assetCoverUrl ? " has-cover" : ""}`}>
{assetCoverUrl ? <img src={assetCoverUrl} alt="素材封面" /> : <span></span>}
<button type="button" onClick={() => setCoverSourceOpen((open) => !open)}><UploadOutlined /> </button>
{coverSourceOpen ? (
<div className="studio-canvas-save-asset__cover-menu">
<button type="button" onClick={() => coverFileInputRef.current?.click()}></button>
<button
type="button"
onClick={() => {
setCoverSourceOpen(false);
setCoverLibraryOpen(true);
}}
>
</button>
</div>
) : null}
{coverLibraryOpen ? (
<div className="studio-canvas-save-asset__cover-library">
{canvasAssets.length ? canvasAssets.slice(0, 6).map((asset) => (
<button
type="button"
key={asset.id}
onClick={() => {
setAssetCoverUrl(asset.imageUrl);
setCoverLibraryOpen(false);
}}
>
<img src={asset.imageUrl} alt={asset.name} />
<span>{asset.name}</span>
</button>
)) : <span>{assetLibraryNotice || "服务器资产库暂无封面"}</span>}
</div>
) : null}
<input
ref={coverFileInputRef}
type="file"
accept="image/*"
className="studio-canvas-save-asset__file-input"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setAssetCoverUrl(URL.createObjectURL(file));
setCoverSourceOpen(false);
}}
/>
</div>
</section>
<section className="studio-canvas-save-asset__form">
<label>
<span> <b>*</b></span>
<input value={assetName} onChange={(event) => setAssetName(event.target.value)} />
</label>
<label>
<span> <b>*</b></span>
<button
type="button"
className={`studio-canvas-save-asset__select${assetCategoryOpen ? " is-open" : ""}`}
onClick={() => setAssetCategoryOpen((open) => !open)}
>
<span>{assetCategory || "请选择"}</span>
<DownOutlined />
</button>
{assetCategoryOpen ? (
<div className="studio-canvas-save-asset__select-menu">
{["其它", "人物", "场景", "物品", "风格", "音效"].map((category) => (
<button
type="button"
key={category}
className={assetCategory === category ? "is-selected" : ""}
onClick={() => {
setAssetCategory(category);
setAssetCategoryOpen(false);
}}
>
{category}
</button>
))}
</div>
) : null}
</label>
</section>
</div>
<button
type="button"
className="studio-canvas-save-asset__create"
disabled={isSavingAsset}
onClick={() => void saveCanvasAssetToServer(resolveAssetCategory(assetCategory))}
>
{isSavingAsset ? "保存中" : "确认"}
</button>
</>
) : (
<>
<div className="studio-canvas-save-asset__existing">
<div className="studio-canvas-save-asset__existing-title"></div>
<div className="studio-canvas-save-asset__existing-grid">
{assetLibraryCategories.map((category) => (
<button
type="button"
key={category.key}
className={selectedExistingCategory === category.key ? "is-selected" : ""}
onClick={() => setSelectedExistingCategory(category.key)}
>
{category.label}
<span>{serverAssets.filter((asset) => asset.type === category.key).length} </span>
</button>
))}
</div>
</div>
<button
type="button"
className="studio-canvas-save-asset__create"
disabled={!selectedExistingCategory || isSavingAsset}
onClick={() => {
if (!selectedExistingCategory) return;
void saveCanvasAssetToServer(selectedExistingCategory as AssetLibraryCategory);
}}
>
{isSavingAsset ? "保存中" : "确认"}
</button>
</>
)}
</div>
</div>
) : null}
{contextMenu ? (
<div
className="studio-canvas-context-menu"
style={{ left: contextMenu.left, top: contextMenu.top }}
role="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!contextMenu) return;
setPendingImagePosition(getTextNodePositionFromClient(contextMenu.originLeft, contextMenu.originTop));
canvasUploadInputRef.current?.click();
}}
>
</button>
<button type="button" role="menuitem" disabled></button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!contextMenu) return;
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 260, 390, 0);
setContextMenu(null);
setNodeMenu({
...menuPosition,
originLeft: contextMenu.originLeft,
originTop: contextMenu.originTop,
});
}}
>
</button>
<span className="studio-canvas-context-menu__divider" />
<button type="button" role="menuitem">
<span></span>
<kbd>Z</kbd>
</button>
<button type="button" role="menuitem" disabled>
<span></span>
<kbd>Z</kbd>
</button>
<span className="studio-canvas-context-menu__divider" />
<button
type="button"
role="menuitem"
disabled={!copiedCanvasNode}
onClick={(event) => {
event.stopPropagation();
if (!copiedCanvasNode || !contextMenu) return;
duplicateCopiedCanvasNode(
copiedCanvasNode,
getTextNodePositionFromClient(contextMenu.originLeft, contextMenu.originTop)
);
setContextMenu(null);
}}
>
<span></span>
<kbd>V</kbd>
</button>
</div>
) : null}
{nodeMenu ? (
<div
className="studio-canvas-add-node-menu"
style={{ left: nodeMenu.left, top: nodeMenu.top }}
role="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<div className="studio-canvas-add-node-menu__title"></div>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!nodeMenu) return;
setNodeMenu(null);
setContextMenu(null);
addTextNode(undefined, getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
}}
>
<span className="studio-canvas-add-node-menu__icon"><BarsOutlined /></span>
<span></span>
</button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!nodeMenu) return;
addImageNode("", "图片节点", getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
setNodeMenu(null);
setContextMenu(null);
}}
>
<span className="studio-canvas-add-node-menu__icon"><FileImageOutlined /></span>
<span></span>
</button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!nodeMenu) return;
addVideoNode(getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
setNodeMenu(null);
setContextMenu(null);
}}
>
<span className="studio-canvas-add-node-menu__icon"><VideoCameraOutlined /></span>
<span></span>
</button>
<div className="studio-canvas-add-node-menu__title studio-canvas-add-node-menu__title--section"></div>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!nodeMenu) return;
setPendingImagePosition(getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
imageNodeInputRef.current?.click();
}}
>
<span className="studio-canvas-add-node-menu__icon"><UploadOutlined /></span>
<span></span>
</button>
</div>
) : null}
{connectionDropMenu ? (
<div
className="studio-canvas-add-node-menu"
style={{ left: connectionDropMenu.left, top: connectionDropMenu.top }}
role="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => {
if (pendingLinkPort) {
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
}
}}
>
<div className="studio-canvas-add-node-menu__title"></div>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!connectionDropMenu) return;
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addTextNode(undefined, pos);
setConnectionDropMenu(null);
}}
>
<span className="studio-canvas-add-node-menu__icon"><BarsOutlined /></span>
<span></span>
</button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!connectionDropMenu) return;
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addImageNode("", "图片节点", pos);
setConnectionDropMenu(null);
}}
>
<span className="studio-canvas-add-node-menu__icon"><FileImageOutlined /></span>
<span></span>
</button>
<button
type="button"
role="menuitem"
onClick={(event) => {
event.stopPropagation();
if (!connectionDropMenu) return;
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addVideoNode(pos);
setConnectionDropMenu(null);
}}
>
<span className="studio-canvas-add-node-menu__icon"><VideoCameraOutlined /></span>
<span></span>
</button>
</div>
) : null}
{stylePickerImageNodeId ? (
<div
className="studio-canvas-style-picker"
role="dialog"
aria-modal="true"
aria-label="选择社区风格"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="studio-canvas-style-picker__backdrop"
aria-label="关闭风格选择"
onClick={() => setStylePickerImageNodeId(null)}
/>
<div className="studio-canvas-style-picker__panel">
<header className="studio-canvas-style-picker__header">
<nav className="studio-canvas-style-picker__tabs" aria-label="风格来源">
{canvasStylePickerTabs.map((tab) => (
<button
key={tab.key}
type="button"
className={stylePickerTab === tab.key ? "is-active" : ""}
onClick={() => setStylePickerTab(tab.key)}
>
{tab.label}
</button>
))}
</nav>
<label className="studio-canvas-style-picker__search">
<SearchOutlined />
<input
value={stylePickerSearch}
placeholder="搜索作品名称、作者、标签"
onChange={(event) => setStylePickerSearch(event.target.value)}
/>
</label>
<button
type="button"
className="studio-canvas-style-picker__community-link"
onClick={() => {
setStylePickerImageNodeId(null);
onOpenCommunity();
}}
>
</button>
<button
type="button"
className="studio-canvas-style-picker__close"
aria-label="关闭"
onClick={() => setStylePickerImageNodeId(null)}
>
<CloseOutlined />
</button>
</header>
<div className="studio-canvas-style-picker__subbar">
<div className="studio-canvas-style-picker__categories" role="tablist" aria-label="风格分类">
{canvasStylePickerCategories.map((category) => (
<button
key={category}
type="button"
className={stylePickerCategory === category ? "is-active" : ""}
onClick={() => setStylePickerCategory(category)}
>
{category}
</button>
))}
</div>
<div className="studio-canvas-style-picker__filters" aria-hidden="true">
<span></span>
</div>
</div>
<main className="studio-canvas-style-picker__body">
{stylePickerLoading ? (
<div className="studio-canvas-style-picker__grid" aria-busy="true">
{Array.from({ length: 18 }).map((_, index) => (
<div className="studio-canvas-style-picker__skeleton" key={index} />
))}
</div>
) : stylePickerError ? (
<div className="studio-canvas-style-picker__empty">
<strong></strong>
<span>{stylePickerError}</span>
<button type="button" onClick={() => setStylePickerReloadToken((token) => token + 1)}>
</button>
</div>
) : stylePickerVisibleCases.length ? (
<div className="studio-canvas-style-picker__grid">
{stylePickerVisibleCases.map((item) => (
<button
type="button"
key={item.id}
className={`studio-canvas-style-picker__card${stylePickerNode?.styleReference?.id === item.id ? " is-selected" : ""}`}
onClick={() => handleSelectImageStyle(item)}
>
<span className="studio-canvas-style-picker__image">
<img src={item.imageUrl} alt={item.title} loading="lazy" />
<em>{Math.max(item.favoriteCount, item.likeCount, 0)}</em>
</span>
<strong>{item.title}</strong>
<small>{item.author}</small>
</button>
))}
</div>
) : (
<div className="studio-canvas-style-picker__empty">
<strong></strong>
<span></span>
</div>
)}
</main>
</div>
</div>
) : null}
{styleSelectionToast ? (
<div className="studio-canvas-style-toast" role="status">
{styleSelectionToast}
</div>
) : null}
{generationToast ? (
<div className="studio-canvas-generation-toast" role="status">
{generationToast}
</div>
) : null}
</section>
</div>
</WorkspacePageShell>
);
}
export default CanvasPage;