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";
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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 { 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({
|
2026-06-03 01:39:06 +08:00
|
|
|
|
workflow: rawWorkflow,
|
2026-06-02 12:38:01 +08:00
|
|
|
|
projectId,
|
|
|
|
|
|
projects = [],
|
|
|
|
|
|
projectsLoaded = true,
|
|
|
|
|
|
onOpenCommunity,
|
|
|
|
|
|
onOpenProject,
|
|
|
|
|
|
onStartCreate,
|
|
|
|
|
|
isAuthenticated,
|
|
|
|
|
|
session,
|
|
|
|
|
|
onOpenLogin,
|
|
|
|
|
|
onSaveWorkflow,
|
|
|
|
|
|
onCreateTask,
|
|
|
|
|
|
}: CanvasPageProps) {
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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 [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,
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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;
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-03 01:39:06 +08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-03 01:39:06 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
})()
|
2026-06-04 01:12:51 +08:00
|
|
|
|
: 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 {
|
2026-06-04 17:03:49 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-06-04 01:12:51 +08:00
|
|
|
|
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>) => {
|
2026-06-04 01:12:51 +08:00
|
|
|
|
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">
|
2026-06-03 01:39:06 +08:00
|
|
|
|
<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
|
2026-06-03 01:39:06 +08:00
|
|
|
|
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
ref={canvasRef}
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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={{
|
2026-06-04 13:16:38 +08:00
|
|
|
|
"--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)}
|
|
|
|
|
|
/>
|
2026-06-03 01:39:06 +08:00
|
|
|
|
{(!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}
|
2026-06-03 01:39:06 +08:00
|
|
|
|
{(!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={[]}
|
2026-06-03 20:19:07 +08:00
|
|
|
|
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 }}
|
2026-06-03 01:39:06 +08:00
|
|
|
|
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
|
|
|
|
|
|
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
|
2026-06-04 13:16:38 +08:00
|
|
|
|
/>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
|
2026-06-05 01:00:33 +08:00
|
|
|
|
<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>
|
2026-06-05 01:00:33 +08:00
|
|
|
|
<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>
|
2026-06-03 01:39:06 +08:00
|
|
|
|
{(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();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-06-03 01:39:06 +08:00
|
|
|
|
{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") void handleGenerateImageNode(imageNode.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
moreActions={[
|
|
|
|
|
|
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
|
|
|
|
|
|
{ key: "download", label: "下载", icon: <DownloadOutlined />, disabled: !imageNode.imageUrl },
|
|
|
|
|
|
{ key: "duplicate", label: "创建副本", icon: <PictureOutlined /> },
|
|
|
|
|
|
{ key: "delete", label: "删除", icon: <DeleteOutlined /> },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onMoreAction={(key) => {
|
|
|
|
|
|
if (key === "copy" && imageNode.imageUrl) void navigator.clipboard.writeText(imageNode.imageUrl);
|
|
|
|
|
|
if (key === "download" && imageNode.imageUrl) {
|
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
|
a.href = imageNode.imageUrl;
|
|
|
|
|
|
a.download = imageNode.fileName || `${imageNode.title}.png`;
|
|
|
|
|
|
a.target = "_blank";
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === "duplicate") duplicateImageNode(imageNode);
|
|
|
|
|
|
if (key === "delete") setImageNodes((currentNodes) => currentNodes.filter((n) => n.id !== imageNode.id));
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="studio-canvas-image-node__title">
|
|
|
|
|
|
<FileImageOutlined />
|
|
|
|
|
|
<span>{imageNode.title}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-image-node__upload"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
selectCanvasNode("image", imageNode.id);
|
|
|
|
|
|
setPendingImageNodeId(imageNode.id);
|
|
|
|
|
|
setPendingImagePosition(imageNode.position);
|
|
|
|
|
|
imageNodeInputRef.current?.click();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<UploadOutlined /> 上传
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-image-node__card"
|
|
|
|
|
|
onContextMenu={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const pkg = findNodePackage("image", imageNode.id);
|
|
|
|
|
|
if (pkg) {
|
|
|
|
|
|
openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isMultiSelectedNode("image", imageNode.id)) {
|
|
|
|
|
|
openCanvasSelectionContextMenu(event.clientX, event.clientY);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
selectCanvasNode("image", imageNode.id);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setSelectionContextMenu(null);
|
|
|
|
|
|
setTextNodeMenu(null);
|
|
|
|
|
|
setVideoNodeMenu(null);
|
|
|
|
|
|
setImageNodeMenu({ left: event.clientX, top: event.clientY, nodeId: imageNode.id });
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseDown={(event) => {
|
|
|
|
|
|
if (event.button !== 0) return;
|
|
|
|
|
|
if (imageFocusNodeId) {
|
|
|
|
|
|
handleImageFocusDragStart(event, imageNode.id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!imageNodeSelected) clearCanvasSelection();
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setTextNodeMenu(null);
|
|
|
|
|
|
setImageNodeMenu(null);
|
|
|
|
|
|
setVideoNodeMenu(null);
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
setImageNodeDrag({
|
|
|
|
|
|
nodeId: imageNode.id,
|
|
|
|
|
|
startX: event.clientX,
|
|
|
|
|
|
startY: event.clientY,
|
|
|
|
|
|
originX: imageNode.position.x,
|
|
|
|
|
|
originY: imageNode.position.y,
|
|
|
|
|
|
hasMoved: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{imageNode.imageUrl && !imageNodeLoadFailed ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={imageNode.imageUrl}
|
|
|
|
|
|
alt={imageNode.fileName || imageNode.title}
|
|
|
|
|
|
onLoad={(e) => {
|
|
|
|
|
|
setImageLoadErrors((current) => {
|
|
|
|
|
|
if (current[imageNode.id] !== imageNode.imageUrl) return current;
|
|
|
|
|
|
const next = { ...current };
|
|
|
|
|
|
delete next[imageNode.id];
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
const img = e.currentTarget;
|
|
|
|
|
|
const nw = img.naturalWidth;
|
|
|
|
|
|
const nh = img.naturalHeight;
|
|
|
|
|
|
if (nw > 0 && nh > 0) {
|
|
|
|
|
|
const titleHeight = 36;
|
|
|
|
|
|
const nodeWidth = imageNode.size.width;
|
|
|
|
|
|
const newHeight = Math.round((nodeWidth * nh) / nw) + titleHeight;
|
|
|
|
|
|
const clamped = clampCanvasNodeSize("image", nodeWidth, newHeight);
|
|
|
|
|
|
if (clamped.height !== imageNode.size.height) {
|
|
|
|
|
|
setImageNodes((cur) => cur.map((n) =>
|
|
|
|
|
|
n.id === imageNode.id ? { ...n, size: clamped } : n
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onError={() => {
|
|
|
|
|
|
setImageLoadErrors((current) => ({ ...current, [imageNode.id]: imageNode.imageUrl }));
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`studio-canvas-image-node__placeholder${imageNodeLoadFailed ? " is-error" : ""}`}
|
|
|
|
|
|
role={imageNodeLoadFailed ? "status" : undefined}
|
|
|
|
|
|
aria-hidden={imageNodeLoadFailed ? undefined : true}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FileImageOutlined className="studio-canvas-image-node__placeholder-icon" />
|
|
|
|
|
|
{imageNodeLoadFailed ? (
|
|
|
|
|
|
<div className="studio-canvas-image-node__placeholder-copy">
|
|
|
|
|
|
<strong>图片链接已失效</strong>
|
|
|
|
|
|
<small>原始临时地址已过期,请重新上传或重新生成。</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{imageNodeProgressVisible ? (
|
|
|
|
|
|
<CanvasSmoothedProgressRing
|
|
|
|
|
|
progress={imageNodeProgress}
|
|
|
|
|
|
status={imageTaskState?.status || "running"}
|
|
|
|
|
|
message={imageTaskState?.message || "图片生成中"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`studio-canvas-image-focus-layer${imageFocusDrag?.nodeId === imageNode.id ? " is-dragging" : ""}`}
|
|
|
|
|
|
style={imageFocusSelectionStyle}
|
|
|
|
|
|
onMouseDown={(event) => handleImageFocusDragStart(event, imageNode.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-image-focus-layer__shade is-top" />
|
|
|
|
|
|
<span className="studio-canvas-image-focus-layer__shade is-bottom" />
|
|
|
|
|
|
<span className="studio-canvas-image-focus-layer__shade is-left" />
|
|
|
|
|
|
<span className="studio-canvas-image-focus-layer__shade is-right" />
|
|
|
|
|
|
<div className="studio-canvas-image-focus-layer__box" aria-hidden="true">
|
|
|
|
|
|
{["nw", "n", "ne", "e", "se", "s", "sw", "w"].map((handle) => (
|
|
|
|
|
|
<span key={handle} className={`studio-canvas-image-focus-layer__handle is-${handle}`} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{renderConnectorButton({ kind: "image", nodeId: imageNode.id, side: "left", slot: "center" }, "studio-canvas-image-node__connector")}
|
|
|
|
|
|
{renderConnectorButton({ kind: "image", nodeId: imageNode.id, side: "right", slot: "center" }, "studio-canvas-image-node__connector")}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-node-resize-handle"
|
|
|
|
|
|
aria-label="Resize image node"
|
|
|
|
|
|
onMouseDown={(event) => handleNodeResizeStart(event, "image", imageNode.id, imageNode.size)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-image-focus-toolbar"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="studio-canvas-image-focus-toolbar__ratios">
|
|
|
|
|
|
{imageFocusRatioOptions.map((option) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={option.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={imageFocusSelection?.ratio === option.value ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => handleImageFocusRatioChange(option.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" className="studio-canvas-image-focus-toolbar__cancel" onClick={cancelImageFocusMode}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="studio-canvas-image-focus-toolbar__confirm" onClick={confirmImageFocusMode}>
|
|
|
|
|
|
确认
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
|
|
|
|
|
|
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
|
|
|
|
|
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
|
|
|
|
|
const imgFilteredMentions = imgMentionState.open
|
|
|
|
|
|
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
|
const caret = e.target.selectionStart || 0;
|
|
|
|
|
|
updateImageNodePrompt(imageNode.id, value);
|
|
|
|
|
|
const beforeCaret = value.slice(0, caret);
|
|
|
|
|
|
const atIdx = beforeCaret.lastIndexOf("@");
|
|
|
|
|
|
if (atIdx >= 0) {
|
|
|
|
|
|
const query = beforeCaret.slice(atIdx + 1);
|
|
|
|
|
|
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
|
|
|
|
|
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
closeTextNodeMention(imageNode.id);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
|
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
|
|
|
|
|
|
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
|
|
|
|
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
|
|
|
|
|
|
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
|
|
|
|
|
|
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="studio-canvas-image-composer">
|
|
|
|
|
|
<div className="studio-canvas-image-composer__tools">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-canvas-image-composer__style-button${imageNode.styleReference ? " has-style" : ""}`}
|
|
|
|
|
|
title={imageNode.styleReference ? `已选风格:${imageNode.styleReference.title}` : "选择社区风格"}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
openImageStylePicker(imageNode.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{imageNode.styleReference ? (
|
|
|
|
|
|
<span className="studio-canvas-image-composer__style-thumb">
|
|
|
|
|
|
<img src={imageNode.styleReference.imageUrl} alt={imageNode.styleReference.title} />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<BgColorsOutlined />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="studio-canvas-image-composer__style-label">
|
|
|
|
|
|
{imageNode.styleReference?.title || "风格"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={imageNode.marking ? "is-active" : ""}
|
|
|
|
|
|
title={imageNode.marking ? `标记: ${imageNode.marking}` : "添加标记描述"}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setMarkingPopoverNodeId(markingPopoverNodeId === imageNode.id ? null : imageNode.id);
|
|
|
|
|
|
setCameraMotionDropdownNodeId(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FileImageOutlined /><span>标记</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{markingPopoverNodeId === imageNode.id && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-marking-popover"
|
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="studio-canvas-marking-input"
|
|
|
|
|
|
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
|
|
|
|
|
|
value={imageNode.marking || ""}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const val = e.target.value;
|
|
|
|
|
|
setImageNodes((nodes) =>
|
|
|
|
|
|
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="studio-canvas-marking-actions">
|
|
|
|
|
|
{imageNode.marking && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-marking-clear"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setImageNodes((nodes) =>
|
|
|
|
|
|
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
清除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-marking-done"
|
|
|
|
|
|
onClick={() => setMarkingPopoverNodeId(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
完成
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={imageNodeFocusActive ? "is-active" : ""}
|
|
|
|
|
|
title="框选聚焦区域"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
openImageFocusMode(imageNode);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<BarsOutlined /><span>聚焦</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-text-composer__input-wrap">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={imageNode.prompt}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onChange={handleImagePromptChange}
|
|
|
|
|
|
onKeyDown={handleImagePromptKeyDown}
|
|
|
|
|
|
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{imgMentionState.open && (
|
|
|
|
|
|
<div className="studio-canvas-mention-panel">
|
|
|
|
|
|
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
|
|
|
|
|
|
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
|
|
|
|
|
|
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
|
|
|
|
|
|
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
|
|
|
|
|
<span className="studio-canvas-mention-token">{opt.token}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)) : (
|
|
|
|
|
|
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label">没有可引用的连接节点</span></div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-image-composer__footer">
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择生图模型"
|
|
|
|
|
|
className="canvas-select-chip--model studio-canvas-composer-chip"
|
|
|
|
|
|
value={resolveVisibleImageModel(imageNode.model || defaultImageModel)}
|
|
|
|
|
|
options={visibleImageModelOptions}
|
|
|
|
|
|
open={canvasSelectMenu === `${imageNode.id}:image-model`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${imageNode.id}:image-model` ? null : `${imageNode.id}:image-model`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateImageNodeSetting(imageNode.id, { model: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择图片比例"
|
|
|
|
|
|
className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
|
|
|
|
|
|
value={imageNode.aspectRatio || "16:9"}
|
|
|
|
|
|
options={imageRatioOptions}
|
|
|
|
|
|
open={canvasSelectMenu === `${imageNode.id}:image-ratio`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${imageNode.id}:image-ratio` ? null : `${imageNode.id}:image-ratio`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateImageNodeSetting(imageNode.id, { aspectRatio: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择图片清晰度"
|
|
|
|
|
|
className="studio-canvas-composer-chip studio-canvas-composer-chip--mini"
|
|
|
|
|
|
value={resolveImageQuality(resolveVisibleImageModel(imageNode.model || defaultImageModel), imageNode.imageSize || "")}
|
|
|
|
|
|
options={getImageQualityOptions(resolveVisibleImageModel(imageNode.model || defaultImageModel))}
|
|
|
|
|
|
open={canvasSelectMenu === `${imageNode.id}:image-size`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${imageNode.id}:image-size` ? null : `${imageNode.id}:image-size`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateImageNodeSetting(imageNode.id, { imageSize: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-canvas-text-composer__send studio-canvas-generate-button${imageNodeCanGenerate && !imageNodeGenerating ? " is-ready" : ""}`}
|
|
|
|
|
|
title={imageNodeGenerating ? "生成中" : "生成"}
|
|
|
|
|
|
disabled={imageNodeGenerating || !imageNodeCanGenerate}
|
|
|
|
|
|
aria-busy={imageNodeGenerating}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!imageNodeGenerating && imageNodeCanGenerate) {
|
|
|
|
|
|
void handleGenerateImageNode(imageNode.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SendOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
); })() : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
|
|
|
|
|
|
const videoNodeSelected = isSelectedNode("video", videoNode.id);
|
|
|
|
|
|
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
|
|
|
|
|
|
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
|
|
|
|
|
|
const videoTaskState = videoGenerationState[videoNode.id];
|
|
|
|
|
|
const videoNodeGenerating =
|
|
|
|
|
|
videoTaskState?.status === "submitting" || videoTaskState?.status === "running";
|
|
|
|
|
|
const videoNodeProgress = normalizeCanvasGenerationProgress(videoTaskState);
|
|
|
|
|
|
const videoNodeProgressVisible =
|
|
|
|
|
|
videoNodeGenerating || (videoTaskState?.status === "success" && !videoNode.videoUrl);
|
|
|
|
|
|
const videoNodeCanGenerate = Boolean(
|
|
|
|
|
|
getEffectiveNodePrompt("video", videoNode.id, videoNode.prompt) ||
|
|
|
|
|
|
getConnectedImageReferenceItems("video", videoNode.id).length
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`studio-canvas-video-node${videoNodeDrag?.nodeId === videoNode.id ? " is-dragging" : ""}${videoNodeSelected ? " is-selected" : ""}${videoNodeResizing ? " is-resizing" : ""}`}
|
|
|
|
|
|
key={videoNode.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
"--video-node-x": `${videoNode.position.x}px`,
|
|
|
|
|
|
"--video-node-y": `${videoNode.position.y}px`,
|
|
|
|
|
|
"--canvas-node-width": `${videoNode.size.width}px`,
|
|
|
|
|
|
"--canvas-node-height": `${videoNode.size.height}px`,
|
|
|
|
|
|
} as CSSProperties}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="studio-canvas-video-node__scaled">
|
|
|
|
|
|
{videoNodeSelected && videoNode.videoUrl && (
|
|
|
|
|
|
<CanvasNodeToolbar
|
|
|
|
|
|
actions={[
|
|
|
|
|
|
{ key: "regenerate", label: "重新生成", icon: <ReloadOutlined />, loading: videoNodeGenerating },
|
|
|
|
|
|
{ key: "save", label: "保存", icon: <SaveOutlined />, disabled: videoNodeGenerating },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onAction={(key) => {
|
|
|
|
|
|
if (key === "regenerate") void handleGenerateVideoNode(videoNode.id);
|
|
|
|
|
|
if (key === "save") {
|
|
|
|
|
|
setSaveAssetSource({
|
|
|
|
|
|
kind: "video",
|
|
|
|
|
|
name: videoNode.title,
|
|
|
|
|
|
description: videoNode.prompt || "从画布视频节点保存的素材。",
|
|
|
|
|
|
url: videoNode.videoUrl,
|
|
|
|
|
|
});
|
|
|
|
|
|
setAssetName(videoNode.title);
|
|
|
|
|
|
setAssetCoverUrl("");
|
|
|
|
|
|
setAssetSaveMode("existing");
|
|
|
|
|
|
setSelectedExistingCategory("");
|
|
|
|
|
|
setSaveAssetOpen(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
moreActions={[
|
|
|
|
|
|
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !videoNode.videoUrl },
|
|
|
|
|
|
{ key: "download", label: "下载", icon: <DownloadOutlined />, disabled: !videoNode.videoUrl },
|
|
|
|
|
|
{ key: "duplicate", label: "创建副本", icon: <PictureOutlined /> },
|
|
|
|
|
|
{ key: "delete", label: "删除", icon: <DeleteOutlined /> },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onMoreAction={(key) => {
|
|
|
|
|
|
if (key === "copy" && videoNode.videoUrl) void navigator.clipboard.writeText(videoNode.videoUrl);
|
|
|
|
|
|
if (key === "download" && videoNode.videoUrl) {
|
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
|
a.href = videoNode.videoUrl;
|
|
|
|
|
|
a.download = `${videoNode.title}.mp4`;
|
|
|
|
|
|
a.target = "_blank";
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === "duplicate") duplicateVideoNode(videoNode);
|
|
|
|
|
|
if (key === "delete") setVideoNodes((currentNodes) => currentNodes.filter((n) => n.id !== videoNode.id));
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="studio-canvas-video-node__title">
|
|
|
|
|
|
<VideoCameraOutlined />
|
|
|
|
|
|
<span>{videoNode.title}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-video-node__preview"
|
|
|
|
|
|
onContextMenu={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const pkg = findNodePackage("video", videoNode.id);
|
|
|
|
|
|
if (pkg) {
|
|
|
|
|
|
openCanvasNodePackageContextMenu(pkg, event.clientX, event.clientY);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isMultiSelectedNode("video", videoNode.id)) {
|
|
|
|
|
|
openCanvasSelectionContextMenu(event.clientX, event.clientY);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
selectCanvasNode("video", videoNode.id);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setSelectionContextMenu(null);
|
|
|
|
|
|
setTextNodeMenu(null);
|
|
|
|
|
|
setImageNodeMenu(null);
|
|
|
|
|
|
setVideoNodeMenu({ left: event.clientX, top: event.clientY, nodeId: videoNode.id });
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseDown={(event) => {
|
|
|
|
|
|
if (event.button !== 0) return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!videoNodeSelected) clearCanvasSelection();
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setTextNodeMenu(null);
|
|
|
|
|
|
setImageNodeMenu(null);
|
|
|
|
|
|
setVideoNodeMenu(null);
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
setVideoNodeDrag({
|
|
|
|
|
|
nodeId: videoNode.id,
|
|
|
|
|
|
startX: event.clientX,
|
|
|
|
|
|
startY: event.clientY,
|
|
|
|
|
|
originX: videoNode.position.x,
|
|
|
|
|
|
originY: videoNode.position.y,
|
|
|
|
|
|
hasMoved: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{videoNode.videoUrl ? (
|
|
|
|
|
|
<CanvasNodeVideoPlayer src={videoNode.videoUrl} title={videoNode.title} onVideoMeta={(vw, vh) => {
|
|
|
|
|
|
if (vw > 0 && vh > 0) {
|
|
|
|
|
|
const titleHeight = 36;
|
|
|
|
|
|
const nodeWidth = videoNode.size.width;
|
|
|
|
|
|
const newHeight = Math.round((nodeWidth * vh) / vw) + titleHeight;
|
|
|
|
|
|
const clamped = clampCanvasNodeSize("video", nodeWidth, newHeight);
|
|
|
|
|
|
if (clamped.height !== videoNode.size.height) {
|
|
|
|
|
|
setVideoNodes((cur) => cur.map((n) =>
|
|
|
|
|
|
n.id === videoNode.id ? { ...n, size: clamped } : n
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="studio-canvas-video-node__play"><VideoCameraOutlined /></span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{videoNodeProgressVisible ? (
|
|
|
|
|
|
<CanvasSmoothedProgressRing
|
|
|
|
|
|
progress={videoNodeProgress}
|
|
|
|
|
|
status={videoTaskState?.status || "running"}
|
|
|
|
|
|
message={videoTaskState?.message || "视频生成中"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
|
|
|
|
|
|
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "right", slot: "center" }, "studio-canvas-video-node__connector")}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-node-resize-handle"
|
|
|
|
|
|
aria-label="Resize video node"
|
|
|
|
|
|
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{videoNodeActive && !isCanvasNodeMoving ? (() => {
|
|
|
|
|
|
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
|
|
|
|
|
|
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
|
|
|
|
|
|
const vidFilteredMentions = vidMentionState.open
|
|
|
|
|
|
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
|
const caret = e.target.selectionStart || 0;
|
|
|
|
|
|
updateVideoNodePrompt(videoNode.id, value);
|
|
|
|
|
|
const beforeCaret = value.slice(0, caret);
|
|
|
|
|
|
const atIdx = beforeCaret.lastIndexOf("@");
|
|
|
|
|
|
if (atIdx >= 0) {
|
|
|
|
|
|
const query = beforeCaret.slice(atIdx + 1);
|
|
|
|
|
|
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
|
|
|
|
|
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
closeTextNodeMention(videoNode.id);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
|
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
|
|
|
|
|
|
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
|
|
|
|
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
|
|
|
|
|
|
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
|
|
|
|
|
|
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="studio-canvas-video-composer">
|
|
|
|
|
|
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoNode.videoMode === "text2video" ? "is-active" : ""}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "text2video" })}
|
|
|
|
|
|
>
|
|
|
|
|
|
文生视频
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoNode.videoMode === "img2video" ? "is-active" : ""}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "img2video" })}
|
|
|
|
|
|
>
|
|
|
|
|
|
图生视频
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoNode.videoMode === "firstlast" ? "is-active" : ""}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={() => updateVideoNodeSetting(videoNode.id, { videoMode: "firstlast" })}
|
|
|
|
|
|
>
|
|
|
|
|
|
首尾帧
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-video-composer__tools studio-canvas-video-composer__feature-tools">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoNode.marking ? "is-active" : ""}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setMarkingPopoverNodeId(markingPopoverNodeId === videoNode.id ? null : videoNode.id);
|
|
|
|
|
|
setCameraMotionDropdownNodeId(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
标记{videoNode.marking ? " ✓" : ""}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoNode.cameraMotion ? "is-active" : ""}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
setCameraMotionDropdownNodeId(cameraMotionDropdownNodeId === videoNode.id ? null : videoNode.id);
|
|
|
|
|
|
setMarkingPopoverNodeId(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
运镜{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{markingPopoverNodeId === videoNode.id && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-marking-popover"
|
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="studio-canvas-marking-input"
|
|
|
|
|
|
placeholder="描述标记内容,如:主角在城市街头行走"
|
|
|
|
|
|
value={videoNode.marking || ""}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const val = e.target.value;
|
|
|
|
|
|
setVideoNodes((nodes) =>
|
|
|
|
|
|
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="studio-canvas-marking-actions">
|
|
|
|
|
|
{videoNode.marking && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-marking-clear"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setVideoNodes((nodes) =>
|
|
|
|
|
|
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
清除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-marking-done"
|
|
|
|
|
|
onClick={() => setMarkingPopoverNodeId(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
完成
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{cameraMotionDropdownNodeId === videoNode.id && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-camera-dropdown"
|
|
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{CAMERA_MOTION_PRESETS.map((preset) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={preset.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-canvas-camera-dropdown__item${videoNode.cameraMotion === preset.value ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setVideoNodes((nodes) =>
|
|
|
|
|
|
nodes.map((n) => (n.id === videoNode.id ? { ...n, cameraMotion: preset.value } : n)),
|
|
|
|
|
|
);
|
|
|
|
|
|
setCameraMotionDropdownNodeId(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{preset.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button type="button">角色库</button>
|
|
|
|
|
|
<button type="button" className="is-active">文本</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-text-composer__input-wrap">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={videoNode.prompt}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onChange={handleVideoPromptChange}
|
|
|
|
|
|
onKeyDown={handleVideoPromptKeyDown}
|
|
|
|
|
|
placeholder="根据文字描述生成视频。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{vidMentionState.open ? (
|
|
|
|
|
|
<div className="studio-canvas-mention-panel">
|
|
|
|
|
|
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={opt.token}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
|
|
|
|
|
|
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
|
|
|
|
|
|
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
|
|
|
|
|
|
<span className="studio-canvas-mention-token">{opt.token}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)) : (
|
|
|
|
|
|
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
|
|
|
|
|
|
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择视频模型"
|
|
|
|
|
|
className="canvas-select-chip--model studio-canvas-composer-chip"
|
|
|
|
|
|
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
|
|
|
|
|
|
options={canvasEnterpriseVideoModelOptions}
|
|
|
|
|
|
open={canvasSelectMenu === `${videoNode.id}:video-model`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${videoNode.id}:video-model` ? null : `${videoNode.id}:video-model`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateVideoNodeSetting(videoNode.id, { model: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择视频比例"
|
|
|
|
|
|
className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
|
|
|
|
|
|
value={videoNode.aspectRatio || "16:9"}
|
|
|
|
|
|
options={videoRatioOptions}
|
|
|
|
|
|
open={canvasSelectMenu === `${videoNode.id}:video-ratio`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${videoNode.id}:video-ratio` ? null : `${videoNode.id}:video-ratio`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateVideoNodeSetting(videoNode.id, { aspectRatio: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择视频清晰度"
|
|
|
|
|
|
className="studio-canvas-composer-chip studio-canvas-composer-chip--compact"
|
|
|
|
|
|
value={resolveVideoQuality(videoNode.model || defaultVideoModel, videoNode.resolution || "")}
|
|
|
|
|
|
options={getVideoQualityOptions(videoNode.model || defaultVideoModel)}
|
|
|
|
|
|
open={canvasSelectMenu === `${videoNode.id}:video-resolution`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${videoNode.id}:video-resolution` ? null : `${videoNode.id}:video-resolution`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateVideoNodeSetting(videoNode.id, { resolution: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CanvasSelectChip
|
|
|
|
|
|
ariaLabel="选择视频时长"
|
|
|
|
|
|
className="studio-canvas-composer-chip studio-canvas-composer-chip--mini"
|
|
|
|
|
|
value={videoNode.duration || "4"}
|
|
|
|
|
|
options={videoDurationOptions}
|
|
|
|
|
|
open={canvasSelectMenu === `${videoNode.id}:video-duration`}
|
|
|
|
|
|
onToggle={() =>
|
|
|
|
|
|
setCanvasSelectMenu((current) =>
|
|
|
|
|
|
current === `${videoNode.id}:video-duration` ? null : `${videoNode.id}:video-duration`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
updateVideoNodeSetting(videoNode.id, { duration: value });
|
|
|
|
|
|
setCanvasSelectMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-canvas-generate-button${videoNodeCanGenerate && !videoNodeGenerating ? " is-ready" : ""}`}
|
|
|
|
|
|
title={videoNodeGenerating ? "生成中" : "生成"}
|
|
|
|
|
|
disabled={videoNodeGenerating || !videoNodeCanGenerate}
|
|
|
|
|
|
aria-busy={videoNodeGenerating}
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!videoNodeGenerating && videoNodeCanGenerate) {
|
|
|
|
|
|
void handleGenerateVideoNode(videoNode.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SendOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
); })() : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{textNodeMenu && activeTextNode
|
|
|
|
|
|
? renderCanvasNodeContextMenu({
|
|
|
|
|
|
left: textNodeMenu.left,
|
|
|
|
|
|
top: textNodeMenu.top,
|
|
|
|
|
|
saveAssetSource: {
|
|
|
|
|
|
kind: "text",
|
|
|
|
|
|
name: activeTextNode.title,
|
|
|
|
|
|
description: activeTextNode.prompt || "从画布文本节点保存的素材。",
|
|
|
|
|
|
},
|
|
|
|
|
|
assetName: activeTextNode.title,
|
|
|
|
|
|
copyNode: () => setCopiedCanvasNode({ kind: "text", node: activeTextNode }),
|
|
|
|
|
|
duplicateNode: () => duplicateTextNode(activeTextNode),
|
|
|
|
|
|
deleteNode: () => setTextNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeTextNode.id)),
|
|
|
|
|
|
})
|
|
|
|
|
|
: null}
|
|
|
|
|
|
{imageNodeMenu && activeImageNode
|
|
|
|
|
|
? renderCanvasNodeContextMenu({
|
|
|
|
|
|
left: imageNodeMenu.left,
|
|
|
|
|
|
top: imageNodeMenu.top,
|
|
|
|
|
|
saveAssetSource: {
|
|
|
|
|
|
kind: "image",
|
|
|
|
|
|
name: activeImageNode.fileName || activeImageNode.title,
|
|
|
|
|
|
description: activeImageNode.prompt || "从画布图片节点保存的素材。",
|
|
|
|
|
|
imageUrl: activeImageNode.imageUrl,
|
|
|
|
|
|
},
|
|
|
|
|
|
assetName: activeImageNode.fileName || activeImageNode.title,
|
|
|
|
|
|
assetCoverUrl: activeImageNode.imageUrl,
|
|
|
|
|
|
copyNode: () => setCopiedCanvasNode({ kind: "image", node: activeImageNode }),
|
|
|
|
|
|
duplicateNode: () => duplicateImageNode(activeImageNode),
|
|
|
|
|
|
deleteNode: () => setImageNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeImageNode.id)),
|
|
|
|
|
|
})
|
|
|
|
|
|
: null}
|
|
|
|
|
|
{videoNodeMenu && activeVideoNode
|
|
|
|
|
|
? renderCanvasNodeContextMenu({
|
|
|
|
|
|
left: videoNodeMenu.left,
|
|
|
|
|
|
top: videoNodeMenu.top,
|
|
|
|
|
|
saveAssetSource: {
|
|
|
|
|
|
kind: "video",
|
|
|
|
|
|
name: activeVideoNode.title,
|
|
|
|
|
|
description: activeVideoNode.prompt || "从画布视频节点保存的素材。",
|
|
|
|
|
|
url: activeVideoNode.videoUrl,
|
|
|
|
|
|
},
|
|
|
|
|
|
assetName: activeVideoNode.title,
|
|
|
|
|
|
copyNode: () => setCopiedCanvasNode({ kind: "video", node: activeVideoNode }),
|
|
|
|
|
|
duplicateNode: () => duplicateVideoNode(activeVideoNode),
|
|
|
|
|
|
deleteNode: () => setVideoNodes((currentNodes) => currentNodes.filter((node) => node.id !== activeVideoNode.id)),
|
|
|
|
|
|
})
|
|
|
|
|
|
: null}
|
|
|
|
|
|
{selectionContextMenu ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-selection-context-menu"
|
|
|
|
|
|
style={{ left: selectionContextMenu.left, top: selectionContextMenu.top }}
|
|
|
|
|
|
role="menu"
|
|
|
|
|
|
aria-label={activePackage ? "打包节点操作" : "已选节点操作"}
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{activePackage ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="studio-canvas-selection-context-menu__title">
|
|
|
|
|
|
<strong>{activePackage.title}</strong>
|
|
|
|
|
|
<span>{activePackage.nodeIds.length} 个节点</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
className="studio-canvas-selection-context-menu__primary"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
collapseSelectedCanvasPackage();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>折叠打包</span>
|
|
|
|
|
|
<kbd>Fold</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{selectedPackageCount > 0 ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
className="studio-canvas-selection-context-menu__unpackage"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
unpackageSelectedCanvasNodes();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>取消打包</span>
|
|
|
|
|
|
<kbd>Ungroup</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
clearCanvasSelection();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>取消选择</span>
|
|
|
|
|
|
<kbd>Esc</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="studio-canvas-selection-context-menu__title">
|
|
|
|
|
|
<strong>已选节点</strong>
|
|
|
|
|
|
<span>{selectedNodes.length} 个</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
className="studio-canvas-selection-context-menu__primary"
|
|
|
|
|
|
disabled={selectedNodes.length < 2}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
packageSelectedCanvasNodes();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>打包节点</span>
|
|
|
|
|
|
<kbd>Group</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{selectedPackageCount > 0 ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
className="studio-canvas-selection-context-menu__unpackage"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
unpackageSelectedCanvasNodes();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>取消打包</span>
|
|
|
|
|
|
<kbd>Ungroup</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
clearCanvasSelection();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>取消选择</span>
|
|
|
|
|
|
<kbd>Esc</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{saveAssetOpen ? (
|
|
|
|
|
|
<div className="studio-canvas-save-asset" role="dialog" aria-modal="true" aria-label="创建素材文件夹">
|
|
|
|
|
|
<div className="studio-canvas-save-asset__modal">
|
|
|
|
|
|
<header className="studio-canvas-save-asset__head">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={assetSaveMode === "create" ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setAssetSaveMode("create");
|
|
|
|
|
|
setCoverLibraryOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
创建素材文件夹
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={assetSaveMode === "existing" ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setAssetSaveMode("existing");
|
|
|
|
|
|
setCoverSourceOpen(false);
|
|
|
|
|
|
setCoverLibraryOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
添加到现有素材文件
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="studio-canvas-save-asset__close" aria-label="关闭" onClick={() => setSaveAssetOpen(false)}>
|
|
|
|
|
|
<CloseOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
{assetSaveMode === "create" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="studio-canvas-save-asset__body">
|
|
|
|
|
|
<section className="studio-canvas-save-asset__cover">
|
|
|
|
|
|
<div className="studio-canvas-save-asset__label">封面</div>
|
|
|
|
|
|
<div className={`studio-canvas-save-asset__placeholder${assetCoverUrl ? " has-cover" : ""}`}>
|
|
|
|
|
|
{assetCoverUrl ? <img src={assetCoverUrl} alt="素材封面" /> : <span>暂无封面</span>}
|
|
|
|
|
|
<button type="button" onClick={() => setCoverSourceOpen((open) => !open)}><UploadOutlined /> 更换封面</button>
|
|
|
|
|
|
{coverSourceOpen ? (
|
|
|
|
|
|
<div className="studio-canvas-save-asset__cover-menu">
|
|
|
|
|
|
<button type="button" onClick={() => coverFileInputRef.current?.click()}>本地上传</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setCoverSourceOpen(false);
|
|
|
|
|
|
setCoverLibraryOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
历史图库
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{coverLibraryOpen ? (
|
|
|
|
|
|
<div className="studio-canvas-save-asset__cover-library">
|
|
|
|
|
|
{canvasAssets.length ? canvasAssets.slice(0, 6).map((asset) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
key={asset.id}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setAssetCoverUrl(asset.imageUrl);
|
|
|
|
|
|
setCoverLibraryOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<img src={asset.imageUrl} alt={asset.name} />
|
|
|
|
|
|
<span>{asset.name}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)) : <span>{assetLibraryNotice || "服务器资产库暂无封面"}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={coverFileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
className="studio-canvas-save-asset__file-input"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
setAssetCoverUrl(URL.createObjectURL(file));
|
|
|
|
|
|
setCoverSourceOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section className="studio-canvas-save-asset__form">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>名称 <b>*</b></span>
|
|
|
|
|
|
<input value={assetName} onChange={(event) => setAssetName(event.target.value)} />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>分类 <b>*</b></span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-canvas-save-asset__select${assetCategoryOpen ? " is-open" : ""}`}
|
|
|
|
|
|
onClick={() => setAssetCategoryOpen((open) => !open)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{assetCategory || "请选择"}</span>
|
|
|
|
|
|
<DownOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{assetCategoryOpen ? (
|
|
|
|
|
|
<div className="studio-canvas-save-asset__select-menu">
|
|
|
|
|
|
{["其它", "人物", "场景", "物品", "风格", "音效"].map((category) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
key={category}
|
|
|
|
|
|
className={assetCategory === category ? "is-selected" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setAssetCategory(category);
|
|
|
|
|
|
setAssetCategoryOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{category}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-save-asset__create"
|
|
|
|
|
|
disabled={isSavingAsset}
|
|
|
|
|
|
onClick={() => void saveCanvasAssetToServer(resolveAssetCategory(assetCategory))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isSavingAsset ? "保存中" : "确认"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="studio-canvas-save-asset__existing">
|
|
|
|
|
|
<div className="studio-canvas-save-asset__existing-title">选择要加入的资产库分类</div>
|
|
|
|
|
|
<div className="studio-canvas-save-asset__existing-grid">
|
|
|
|
|
|
{assetLibraryCategories.map((category) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
key={category.key}
|
|
|
|
|
|
className={selectedExistingCategory === category.key ? "is-selected" : ""}
|
|
|
|
|
|
onClick={() => setSelectedExistingCategory(category.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{category.label}
|
|
|
|
|
|
<span>{serverAssets.filter((asset) => asset.type === category.key).length} 个素材</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-save-asset__create"
|
|
|
|
|
|
disabled={!selectedExistingCategory || isSavingAsset}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (!selectedExistingCategory) return;
|
|
|
|
|
|
void saveCanvasAssetToServer(selectedExistingCategory as AssetLibraryCategory);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isSavingAsset ? "保存中" : "确认"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{contextMenu ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-context-menu"
|
|
|
|
|
|
style={{ left: contextMenu.left, top: contextMenu.top }}
|
|
|
|
|
|
role="menu"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!contextMenu) return;
|
|
|
|
|
|
setPendingImagePosition(getTextNodePositionFromClient(contextMenu.originLeft, contextMenu.originTop));
|
|
|
|
|
|
canvasUploadInputRef.current?.click();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
上传
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" role="menuitem" disabled>保存到我的素材</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!contextMenu) return;
|
|
|
|
|
|
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 260, 390, 0);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
setNodeMenu({
|
|
|
|
|
|
...menuPosition,
|
|
|
|
|
|
originLeft: contextMenu.originLeft,
|
|
|
|
|
|
originTop: contextMenu.originTop,
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
添加节点
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="studio-canvas-context-menu__divider" />
|
|
|
|
|
|
<button type="button" role="menuitem">
|
|
|
|
|
|
<span>撤销</span>
|
|
|
|
|
|
<kbd>⌘Z</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" role="menuitem" disabled>
|
|
|
|
|
|
<span>重做</span>
|
|
|
|
|
|
<kbd>⇧⌘Z</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="studio-canvas-context-menu__divider" />
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
disabled={!copiedCanvasNode}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!copiedCanvasNode || !contextMenu) return;
|
|
|
|
|
|
duplicateCopiedCanvasNode(
|
|
|
|
|
|
copiedCanvasNode,
|
|
|
|
|
|
getTextNodePositionFromClient(contextMenu.originLeft, contextMenu.originTop)
|
|
|
|
|
|
);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>粘贴</span>
|
|
|
|
|
|
<kbd>⌘V</kbd>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{nodeMenu ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-add-node-menu"
|
|
|
|
|
|
style={{ left: nodeMenu.left, top: nodeMenu.top }}
|
|
|
|
|
|
role="menu"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="studio-canvas-add-node-menu__title">添加节点</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!nodeMenu) return;
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
addTextNode(undefined, getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><BarsOutlined /></span>
|
|
|
|
|
|
<span>文本</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!nodeMenu) return;
|
|
|
|
|
|
addImageNode("", "图片节点", getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><FileImageOutlined /></span>
|
|
|
|
|
|
<span>图片</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!nodeMenu) return;
|
|
|
|
|
|
addVideoNode(getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
|
|
|
|
|
|
setNodeMenu(null);
|
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><VideoCameraOutlined /></span>
|
|
|
|
|
|
<span>视频</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="studio-canvas-add-node-menu__title studio-canvas-add-node-menu__title--section">添加资源</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!nodeMenu) return;
|
|
|
|
|
|
setPendingImagePosition(getTextNodePositionFromClient(nodeMenu.originLeft, nodeMenu.originTop));
|
|
|
|
|
|
imageNodeInputRef.current?.click();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><UploadOutlined /></span>
|
|
|
|
|
|
<span>上传</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{connectionDropMenu ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-add-node-menu"
|
|
|
|
|
|
style={{ left: connectionDropMenu.left, top: connectionDropMenu.top }}
|
|
|
|
|
|
role="menu"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!connectionDropMenu) return;
|
|
|
|
|
|
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
|
|
|
|
|
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
|
|
|
|
|
addTextNode(undefined, pos);
|
|
|
|
|
|
setConnectionDropMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><BarsOutlined /></span>
|
|
|
|
|
|
<span>文本</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!connectionDropMenu) return;
|
|
|
|
|
|
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
|
|
|
|
|
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
|
|
|
|
|
addImageNode("", "图片节点", pos);
|
|
|
|
|
|
setConnectionDropMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><FileImageOutlined /></span>
|
|
|
|
|
|
<span>图片</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
if (!connectionDropMenu) return;
|
|
|
|
|
|
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
|
|
|
|
|
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
|
|
|
|
|
addVideoNode(pos);
|
|
|
|
|
|
setConnectionDropMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-add-node-menu__icon"><VideoCameraOutlined /></span>
|
|
|
|
|
|
<span>视频</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{stylePickerImageNodeId ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="studio-canvas-style-picker"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label="选择社区风格"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-style-picker__backdrop"
|
|
|
|
|
|
aria-label="关闭风格选择"
|
|
|
|
|
|
onClick={() => setStylePickerImageNodeId(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="studio-canvas-style-picker__panel">
|
|
|
|
|
|
<header className="studio-canvas-style-picker__header">
|
|
|
|
|
|
<nav className="studio-canvas-style-picker__tabs" aria-label="风格来源">
|
|
|
|
|
|
{canvasStylePickerTabs.map((tab) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={tab.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={stylePickerTab === tab.key ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setStylePickerTab(tab.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
<label className="studio-canvas-style-picker__search">
|
|
|
|
|
|
<SearchOutlined />
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={stylePickerSearch}
|
|
|
|
|
|
placeholder="搜索作品名称、作者、标签"
|
|
|
|
|
|
onChange={(event) => setStylePickerSearch(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-style-picker__community-link"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setStylePickerImageNodeId(null);
|
|
|
|
|
|
onOpenCommunity();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
打开社区
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="studio-canvas-style-picker__close"
|
|
|
|
|
|
aria-label="关闭"
|
|
|
|
|
|
onClick={() => setStylePickerImageNodeId(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CloseOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div className="studio-canvas-style-picker__subbar">
|
|
|
|
|
|
<div className="studio-canvas-style-picker__categories" role="tablist" aria-label="风格分类">
|
|
|
|
|
|
{canvasStylePickerCategories.map((category) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={category}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={stylePickerCategory === category ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setStylePickerCategory(category)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{category}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-style-picker__filters" aria-hidden="true">
|
|
|
|
|
|
<span>推荐</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<main className="studio-canvas-style-picker__body">
|
|
|
|
|
|
{stylePickerLoading ? (
|
|
|
|
|
|
<div className="studio-canvas-style-picker__grid" aria-busy="true">
|
|
|
|
|
|
{Array.from({ length: 18 }).map((_, index) => (
|
|
|
|
|
|
<div className="studio-canvas-style-picker__skeleton" key={index} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : stylePickerError ? (
|
|
|
|
|
|
<div className="studio-canvas-style-picker__empty">
|
|
|
|
|
|
<strong>社区风格加载失败</strong>
|
|
|
|
|
|
<span>{stylePickerError}</span>
|
|
|
|
|
|
<button type="button" onClick={() => setStylePickerReloadToken((token) => token + 1)}>
|
|
|
|
|
|
重试
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : stylePickerVisibleCases.length ? (
|
|
|
|
|
|
<div className="studio-canvas-style-picker__grid">
|
|
|
|
|
|
{stylePickerVisibleCases.map((item) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
className={`studio-canvas-style-picker__card${stylePickerNode?.styleReference?.id === item.id ? " is-selected" : ""}`}
|
|
|
|
|
|
onClick={() => handleSelectImageStyle(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="studio-canvas-style-picker__image">
|
|
|
|
|
|
<img src={item.imageUrl} alt={item.title} loading="lazy" />
|
|
|
|
|
|
<em>{Math.max(item.favoriteCount, item.likeCount, 0)}</em>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<strong>{item.title}</strong>
|
|
|
|
|
|
<small>{item.author}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="studio-canvas-style-picker__empty">
|
|
|
|
|
|
<strong>没有匹配的社区作品</strong>
|
|
|
|
|
|
<span>换个分类或关键词再试试。</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{styleSelectionToast ? (
|
|
|
|
|
|
<div className="studio-canvas-style-toast" role="status">
|
|
|
|
|
|
{styleSelectionToast}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{generationToast ? (
|
|
|
|
|
|
<div className="studio-canvas-generation-toast" role="status">
|
|
|
|
|
|
{generationToast}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</WorkspacePageShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default CanvasPage;
|