f5a75074a4
【认证系统】 - 新增邮箱验证码注册/登录流程 (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 调用
5726 lines
239 KiB
TypeScript
5726 lines
239 KiB
TypeScript
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;
|