Files
omniai-web/src/features/canvas/CanvasPage.tsx
T

5786 lines
243 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
BarsOutlined,
BgColorsOutlined,
CheckOutlined,
ClockCircleOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined,
EditOutlined,
FileImageOutlined,
FileTextOutlined,
FolderOpenOutlined,
MutedOutlined,
PauseCircleOutlined,
PictureOutlined,
PlayCircleOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
SendOutlined,
SoundOutlined,
ThunderboltOutlined,
UploadOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import {
ReactFlow,
} from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type {
WebCanvasWorkflow,
WebCanvasWorkflowNodePackage,
} from "../../types";
import type { AssetLibraryCategory } from "../assets/localAssetStore";
import {
buildCanvasCommunityCaseInput,
buildCanvasWorkflowJson,
buildWorkflowFileName,
textToDataUrl,
} from "./canvasCommunityPublish";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema";
import { createBlankWorkflow } from "../../data/workflows";
2026-06-02 12:38:01 +08:00
import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory";
import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
import {
toHappyHorseDisplayModel,
} from "../../utils/happyHorseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { translateTaskError } from "../../utils/translateTaskError";
import type {
CanvasAlignGuide,
CanvasAssetSaveSource,
CanvasCopiedNode,
CanvasConnectorDrag,
CanvasConnectorFollowOffset,
CanvasFloatingMenuPosition,
CanvasImageFocusDrag,
CanvasImageFocusSelection,
CanvasImageGenerationState,
CanvasImageNode,
CanvasImageNodeDrag,
CanvasImageReferenceItem,
CanvasManualLink,
CanvasNodeBounds,
CanvasNodeKind,
CanvasNodePackage,
CanvasNodePackageDrag,
CanvasNodePort,
CanvasNodeResizeDrag,
CanvasNodeSize,
CanvasOption,
CanvasPageProps,
CanvasPanDrag,
CanvasPoint,
CanvasProjectSaveState,
CanvasSelectedNode,
CanvasSelectionDrag,
CanvasStyleCase,
CanvasStylePickerTab,
CanvasStyleReference,
CanvasTextGenerationState,
CanvasPromptMentionOption,
CanvasPromptMentionState,
CanvasTextNode,
CanvasTextNodeDrag,
CanvasVideoGenerationState,
CanvasVideoMode,
CanvasVideoNode,
CanvasVideoNodeDrag,
CanvasViewport,
} from "./canvasTypes";
import {
assetLibraryCategories,
canvasAutoSaveDebounceMs,
canvasAutoSaveIdleTimeoutMs,
canvasNodeClickMoveThreshold,
canvasNodeDefaultSizes,
canvasStylePickerCategories,
canvasStylePickerTabs,
connectorAnchorOutset,
connectorFollowRadius,
connectorFollowStrength,
connectorMaxFollowOffset,
defaultImageModel,
defaultTextModelId,
defaultVideoModel,
image4kCapableModels,
imageFocusRatioOptions,
imageModelOptions,
imageRatioOptions,
textModelOptions,
videoDurationOptions,
videoRatioOptions,
} from "./canvasConstants";
import {
applyImageFocusRatioFromTopLeft,
blobToDataUrl,
buildCanvasStyleKeywords,
buildCopyTitle,
clampCanvasPercent,
buildReversePromptFromAsset,
canvasGenerationProgressStyle,
clampCanvasNodeSize,
clampCanvasViewportZoom,
communityCaseToCanvasStyleCase,
createCanvasNodeSize,
createStyleReferenceFromCase,
delay,
doCanvasRectsIntersect,
getCanvasLinkIdentity,
getCanvasNodeSideDirection,
getCanvasPortIdentity,
getCanvasSelectionKey,
getDefaultImageQuality,
getDefaultVideoQuality,
getImageQualityOptions,
getOptionLabel,
getVideoQualityOptions,
getWorkflowImageNodeFileName,
getWorkflowImageNodePrompt,
getWorkflowNodeFocusSelection,
getWorkflowNodeMetadataString,
getWorkflowNodeStyleReference,
hasCanvasOptionValue,
moveCanvasNodesForPackageDrag,
normalizeCanvasGenerationProgress,
normalizeCanvasLinkPorts,
normalizeCanvasSelectionRect,
normalizeImageFocusSelectionFromAnchor,
positionFloatingMenu,
resolveImageQuality,
resolveVideoQuality,
resolveWorkflowImageModel,
resolveWorkflowRatio,
resolveWorkflowVideoMode,
resolveWorkflowVideoModel,
waitForImageTaskResult,
waitForVideoTaskResult,
} from "./canvasUtils";
import {
createImageNodesFromWorkflow,
createManualLinksFromWorkflow,
createNodePackagesFromWorkflow,
createTextNodesFromWorkflow,
createVideoNodesFromWorkflow,
createWorkflowPackagesFromCanvasPackages,
formatCanvasProjectUpdatedAt,
formatCanvasVideoTime,
resolveAssetCategory,
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
2026-06-02 12:38:01 +08:00
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,
2026-06-02 12:38:01 +08:00
projectId,
projects = [],
projectsLoaded = true,
onOpenCommunity,
onOpenProject,
onStartCreate,
isAuthenticated,
session,
onOpenLogin,
onSaveWorkflow,
onCreateTask,
}: CanvasPageProps) {
const workflow = rawWorkflow || createBlankWorkflow();
2026-06-02 12:38:01 +08:00
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 [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
2026-06-02 12:38:01 +08:00
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,
2026-06-02 12:38:01 +08:00
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;
2026-06-02 12:38:01 +08:00
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;
2026-06-02 12:38:01 +08:00
if (projectId && isAuthenticated) {
restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflow.id, workflow.nodes, projectId]);
2026-06-02 12:38:01 +08:00
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;
})()
: connectionDropMenu
? (() => {
const source = getNodePortPoint(connectionDropMenu.sourcePort);
const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
return source
? {
id: "pending-link-preview",
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
sourceSide: connectionDropMenu.sourcePort.side,
targetSide: null,
}
: null;
})()
: null;
2026-06-02 12:38:01 +08:00
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, -40);
2026-06-02 12:38:01 +08:00
setConnectionDropMenu({
...menuPosition,
originLeft: event.clientX,
originTop: event.clientY,
sourcePort: connectorDrag.port,
});
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
2026-06-02 12:38:01 +08:00
}
} 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 || connectionDropMenu) return;
2026-06-02 12:38:01 +08:00
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" : ""}`}>
2026-06-02 12:38:01 +08:00
<section
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
2026-06-02 12:38:01 +08:00
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}
2026-06-02 12:38:01 +08:00
style={{
"--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
2026-06-02 12:38:01 +08:00
"--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) ? (
2026-06-02 12:38:01 +08:00
<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 ? (
2026-06-02 12:38:01 +08:00
<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}
2026-06-02 12:38:01 +08:00
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}
/>
2026-06-02 12:38:01 +08:00
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
2026-06-02 12:38:01 +08:00
{Math.round(canvasViewport.zoom * 100)}%
</button>
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button>
2026-06-02 12:38:01 +08:00
</div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
2026-06-02 12:38:01 +08:00
<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>
</>
)}
2026-06-02 12:38:01 +08:00
</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") setCanvasToolModal({ tool: "upscale", imageNode });
2026-06-02 12:38:01 +08:00
}}
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"
title="多宫格生成"
disabled={!imageNode.imageUrl}
2026-06-02 12:38:01 +08:00
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "multiGrid", imageNode });
2026-06-02 12:38:01 +08:00
}}
>
<BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
2026-06-02 12:38:01 +08:00
</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()}
>
<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>
{canvasToolModal && (
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
<header className="studio-canvas-tool-modal__header">
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
</header>
<div className="studio-canvas-tool-modal__body">
{canvasToolModal.tool === "multiGrid" && (
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "upscale" && (
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "inpaint" && (
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
</div>
</div>
</div>
)}
2026-06-02 12:38:01 +08:00
</WorkspacePageShell>
);
}
export default CanvasPage;