Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-upload.ts

805 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type DragEvent, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { CANVAS_IMAGE_ASSET_DND_TYPE } from "@/components/canvas/canvas-asset-tray";
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { CANVAS_PROJECT_CHAPTER_DND_TYPE, type CanvasProjectChapterPayload } from "@/components/canvas/canvas-project-sidebar";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { getProjectUnit } from "@/services/api/projects";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useAssetStore, type ImageAsset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
import type { TimelineDirectMedia } from "@/types/timeline";
import type { CanvasUploadStatus } from "./canvas-project-feedback";
type UseCanvasUploadOptions = {
canvasId: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set<string> };
getCanvasCenter: () => Position;
screenToCanvas: (clientX: number, clientY: number) => Position;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
};
export type StartCanvasUploadStatus = (title: string, detail: string, total?: number) => {
update: (detail: string, step: number) => void;
done: (detail?: string) => void;
fail: (detail?: string) => void;
};
const NODE_STATUS_SUCCESS = "success" as const;
const BATCH_UPLOAD_COLUMNS = 3;
const BATCH_UPLOAD_COLUMN_GAP = 380;
const BATCH_UPLOAD_ROW_GAP = 300;
export function useCanvasUpload({
canvasId,
domainProjectId,
nodesRef,
selectedNodeIdsRef,
getCanvasCenter,
screenToCanvas,
setNodes,
setSelectedNodeIds,
setSelectedConnectionId,
setContextMenu,
setDialogNodeId,
}: UseCanvasUploadOptions) {
const { message } = App.useApp();
const queryClient = useQueryClient();
const imageInputRef = useRef<HTMLInputElement>(null);
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
const assetInsertPositionRef = useRef<Position | null>(null);
const uploadStatusIdRef = useRef(0);
const statusTimersRef = useRef<Set<number>>(new Set());
const fileDragDepthRef = useRef(0);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadStatus, setUploadStatus] = useState<CanvasUploadStatus | null>(null);
const [fileDropActive, setFileDropActive] = useState(false);
useEffect(() => () => {
statusTimersRef.current.forEach((timer) => window.clearTimeout(timer));
}, []);
const startUploadStatus = useCallback<StartCanvasUploadStatus>((title, detail, total = 3) => {
const id = (uploadStatusIdRef.current += 1);
setUploadStatus({ id, title, detail, step: 1, total });
const dismiss = (delay: number) => {
const timer = window.setTimeout(() => {
statusTimersRef.current.delete(timer);
setUploadStatus((current) => (current?.id === id ? null : current));
}, delay);
statusTimersRef.current.add(timer);
};
return {
update: (nextDetail: string, step: number) => setUploadStatus((current) => (current?.id === id ? { ...current, detail: nextDetail, step: Math.min(Math.max(step, 1), total) } : current)),
done: (nextDetail = "处理完成") => {
setUploadStatus((current) => (current?.id === id ? { ...current, detail: nextDetail, step: total, done: true } : current));
dismiss(850);
},
fail: (nextDetail = "处理失败") => {
setUploadStatus((current) => (current?.id === id ? { ...current, detail: nextDetail, error: true } : current));
dismiss(1800);
},
};
}, []);
const selectInsertedNode = useCallback((nodeId: string, dialog: "open" | "close" | "preserve") => {
setSelectedNodeIds(new Set([nodeId]));
setSelectedConnectionId(null);
if (dialog !== "preserve") setDialogNodeId(dialog === "open" ? nodeId : null);
}, [setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
const persistMediaNode = useCallback(async (node: CanvasNodeData) => {
try {
const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-upload" });
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
return true;
} catch (error) {
message.warning(error instanceof Error ? `媒体已添加到画布,但素材同步失败:${error.message}` : "媒体已添加到画布,但素材同步失败");
return false;
}
}, [canvasId, domainProjectId, message, queryClient, setNodes]);
const createImageFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
const size = fitNodeSize(image.width, image.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: file.name,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: imageMetadata(image),
};
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "图片上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => {
try {
const content = asset.data.storageKey ? await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl || asset.coverUrl) : asset.data.dataUrl || asset.coverUrl;
if (!content) {
message.error("素材图片不可用");
return;
}
const size = fitNodeSize(asset.data.width || NODE_DEFAULT_SIZE[CanvasNodeType.Image].width, asset.data.height || NODE_DEFAULT_SIZE[CanvasNodeType.Image].height);
const center = position || getCanvasCenter();
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: asset.title || "素材图片",
position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: {
content,
storageKey: asset.data.storageKey,
status: NODE_STATUS_SUCCESS,
naturalWidth: asset.data.width,
naturalHeight: asset.data.height,
bytes: asset.data.bytes || getDataUrlByteSize(content.startsWith("data:") ? content : ""),
mimeType: asset.data.mimeType || "image/png",
prompt: typeof asset.metadata?.prompt === "string" ? asset.metadata.prompt : asset.title,
assetId: asset.id,
assetTags: asset.tags || [],
},
};
setNodes((current) => [...current, node]);
selectInsertedNode(id, "close");
} catch (error) {
message.error(error instanceof Error ? error.message : "素材图片读取失败");
}
}, [getCanvasCenter, message, selectInsertedNode, setNodes]);
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "视频上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "preserve");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "音频上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => {
const trimmed = text.trim();
if (!trimmed) return false;
const node = {
...createCanvasNode(CanvasNodeType.Text, position || getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }),
title: trimmed.slice(0, 32) || "剪切板文本",
};
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, "open");
setContextMenu(null);
return true;
}, [getCanvasCenter, selectInsertedNode, setContextMenu, setNodes]);
const handleProjectChapterInsert = useCallback(async (chapter: CanvasProjectChapterPayload, position?: Position) => {
let sourceText = chapter.sourceText;
if (sourceText === undefined) {
try {
sourceText = (await getProjectUnit(chapter.projectId, chapter.id)).unit.sourceText;
} catch (error) {
message.error(error instanceof Error ? `章节正文读取失败:${error.message}` : "章节正文读取失败");
return;
}
}
const content = htmlToPlainText(sourceText);
const node = createCanvasNode(CanvasNodeType.Text, position || getCanvasCenter(), {
content,
prompt: content,
status: NODE_STATUS_SUCCESS,
workflowKind: "free",
workflowTitle: "项目章节",
workflowDescription: `第 ${chapter.position + 1} 章`,
chapterId: chapter.id,
chapterTitle: chapter.title,
fontSize: 14,
});
node.title = `章节 · ${chapter.title}`;
node.width = 460;
node.height = 280;
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, "preserve");
setContextMenu(null);
message.success(`已添加“${chapter.title}”`);
}, [getCanvasCenter, message, selectInsertedNode, setContextMenu, setNodes]);
const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => {
uploadTargetRef.current = { nodeId, position };
if (!nodeId) {
setUploadModalOpen(true);
return;
}
const target = nodeId ? nodesRef.current.find((node) => node.id === nodeId) : null;
if (imageInputRef.current) {
imageInputRef.current.accept = target?.type === CanvasNodeType.Image
? "image/*"
: target?.type === CanvasNodeType.Video
? "video/*"
: target?.type === CanvasNodeType.Audio
? "audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
: "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
}
imageInputRef.current?.click();
}, [nodesRef]);
const closeUploadModal = useCallback(() => {
uploadTargetRef.current = null;
setUploadModalOpen(false);
}, []);
const handleUploadFiles = useCallback(async (files: File[]) => {
const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file));
if (!supportedFiles.length) {
message.warning("请选择图片、视频、MP3 或 WAV 文件");
return false;
}
const center = uploadTargetRef.current?.position || getCanvasCenter();
const columns = Math.min(BATCH_UPLOAD_COLUMNS, supportedFiles.length);
const originX = center.x - ((columns - 1) * BATCH_UPLOAD_COLUMN_GAP) / 2;
const createdIds: string[] = [];
for (let index = 0; index < supportedFiles.length; index += 1) {
const file = supportedFiles[index];
const position = {
x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP,
y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP,
};
const createdId = await (isAudioFile(file)
? createAudioFileNode(file, position)
: file.type.startsWith("video/")
? createVideoFileNode(file, position)
: createImageFileNode(file, position));
if (createdId) createdIds.push(createdId);
}
if (!createdIds.length) return false;
setSelectedNodeIds(new Set(createdIds));
setSelectedConnectionId(null);
setDialogNodeId(null);
const failedCount = supportedFiles.length - createdIds.length;
if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`);
else message.success(`已添加 ${createdIds.length} 个文件到画布`);
return true;
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
// 时间线专用:把本地音视频文件上传为直连媒体(仅时间线作用域,不创建画布节点),返回媒体描述数组。
const uploadTimelineMedia = useCallback(async (files: File[]): Promise<TimelineDirectMedia[]> => {
const supportedFiles = files.filter((file) => file.type.startsWith("video/") || isAudioFile(file));
if (!supportedFiles.length) {
message.warning("请选择视频、MP3 或 WAV 文件");
return [];
}
const created: TimelineDirectMedia[] = [];
for (const file of supportedFiles) {
try {
if (isAudioFile(file)) {
const audio = await uploadMediaFile(file, "audio");
created.push({
id: `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
kind: "audio",
title: file.name,
storageKey: audio.storageKey,
url: audio.url,
durationMs: audio.durationMs,
bytes: audio.bytes,
mimeType: audio.mimeType,
});
} else {
const video = await uploadMediaFile(file, "video");
created.push({
id: `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
kind: "video",
title: file.name,
storageKey: video.storageKey,
url: video.url,
width: video.width,
height: video.height,
durationMs: video.durationMs,
bytes: video.bytes,
mimeType: video.mimeType,
});
}
} catch (error) {
message.error(error instanceof Error ? `素材上传失败:${error.message}` : "素材上传失败");
}
}
if (created.length) message.success(`已上传 ${created.length} 个素材到时间线`);
return created;
}, [message]);
// 组装能力闭环:把时间线合成结果(MP4 Blob)上传并创建为新的视频节点放回画布,
// 复用上传/持久化/选中逻辑,新节点可继续编辑字幕与样式。
const createVideoNodeFromBlob = useCallback(async (blob: Blob, title: string): Promise<CanvasNodeData | null> => {
const progress = startUploadStatus("合成视频片段", "上传合成结果", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(blob, "video");
progress.update("更新画布节点", 3);
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const center = getCanvasCenter();
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = {
id,
type: CanvasNodeType.Video,
title,
position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: { ...videoMetadata(video), status: NODE_STATUS_SUCCESS },
} satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "preserve");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "已生成新视频片段并加入项目资产" : "已生成新视频片段,项目资产待重试");
return node;
} catch (error) {
const details = error instanceof Error ? error.message : "合成视频片段失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, getCanvasCenter, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (!currentNode) return false;
if (isAudioFile(file)) {
const progress = startUploadStatus("替换音频", "读取音频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "preserve");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "音频替换失败";
progress.fail(details);
message.error(details);
return false;
}
}
if (file.type.startsWith("video/")) {
const progress = startUploadStatus("替换视频", "读取视频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "视频替换失败";
progress.fail(details);
message.error(details);
return false;
}
}
const progress = startUploadStatus("替换图片", "读取图片文件");
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
const node = {
...currentNode,
type: CanvasNodeType.Image,
title: file.name,
metadata: {
...currentNode.metadata,
...imageMetadata(image),
assetId: undefined,
taskId: undefined,
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchFailedCount: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
transparentBackground: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
} satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "图片替换失败";
progress.fail(details);
message.error(details);
return false;
}
}, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => {
const isNodeMarker = (value: string) => {
const trimmed = value.trim();
return trimmed.startsWith("open-ai-canvas-nodes:") || trimmed.startsWith("open-ai-canvas-nodes-json:");
};
const pasteImageFile = async (file: File) => {
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id));
if (selected.length === 1 && selected[0].type === CanvasNodeType.Image) {
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
return true;
}
const inserted = await createImageFileNode(file, position || getCanvasCenter());
if (inserted) message.success("已从剪切板添加图片");
return Boolean(inserted);
};
// 1) paste 事件里的图片文件(截图/资源管理器复制)优先。
const filesFromEvent = clipboardEvent
? Array.from(clipboardEvent.clipboardData?.items || [])
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file))
: [];
if (filesFromEvent.length) return pasteImageFile(filesFromEvent[0]);
// 2) 若系统文本是节点标记,说明最近一次复制是画布节点,不要再读旧图片。
const eventText = clipboardEvent?.clipboardData?.getData("text/plain") || "";
if (isNodeMarker(eventText)) return false;
// 3) 异步读系统剪贴板:有图片才导入;若文本是节点标记则让位给节点粘贴。
if (navigator.clipboard?.read) {
try {
const items = await navigator.clipboard.read();
const textItem = items.find((item) => item.types.includes("text/plain"));
if (textItem) {
const textBlob = await textItem.getType("text/plain");
const text = await textBlob.text();
if (isNodeMarker(text)) return false;
}
const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/")));
if (imageItem) {
const imageType = imageItem.types.find((type) => type.startsWith("image/"));
if (!imageType) return false;
const blob = await imageItem.getType(imageType);
return pasteImageFile(new File([blob], "clipboard-image.png", { type: imageType }));
}
} catch {
// 无权限时继续文本分支。
}
}
try {
const text = eventText || (navigator.clipboard?.readText ? await navigator.clipboard.readText() : "");
if (isNodeMarker(text)) return false;
if (createTextNodeFromClipboard(text, position)) {
message.success("已从剪切板添加文本");
return true;
}
} catch {
// ignore
}
return false;
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
const handleImageInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
try {
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
if (target?.nodeId) {
const targetNode = nodesRef.current.find((node) => node.id === target.nodeId);
const compatible = !targetNode
|| (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|| (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|| (targetNode.type === CanvasNodeType.Audio && isAudioFile(file))
|| (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio);
if (!compatible) {
message.warning("请选择与当前节点相同类型的媒体文件");
return;
}
await replaceNodeMedia(target.nodeId, file);
return;
}
const position = target?.position || getCanvasCenter();
await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
} finally {
uploadTargetRef.current = null;
event.target.value = "";
}
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
fileDragDepthRef.current = 0;
setFileDropActive(false);
const chapterPayload = parseProjectChapterPayload(event.dataTransfer.getData(CANVAS_PROJECT_CHAPTER_DND_TYPE));
if (chapterPayload) {
void handleProjectChapterInsert(chapterPayload, screenToCanvas(event.clientX, event.clientY));
return;
}
const imageAssetId = event.dataTransfer.getData(CANVAS_IMAGE_ASSET_DND_TYPE);
if (imageAssetId) {
const asset = useAssetStore.getState().assets.find((item): item is ImageAsset => item.kind === "image" && item.id === imageAssetId);
if (!asset) {
message.warning("素材不存在");
return;
}
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
return;
}
const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
if (!files.length) return;
if (files.length > 1) {
void handleUploadFiles(files);
return;
}
const file = files[0];
const position = screenToCanvas(event.clientX, event.clientY);
const target = [...nodesRef.current].reverse().find((node) => {
const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|| (node.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|| (node.type === CanvasNodeType.Audio && isAudioFile(file));
return compatible && position.x >= node.position.x && position.x <= node.position.x + node.width && position.y >= node.position.y && position.y <= node.position.y + node.height;
});
if (target) {
void replaceNodeMedia(target.id, file).then((replaced) => {
if (replaced) message.success("媒体已替换,可撤销恢复");
});
return;
}
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;
event.preventDefault();
fileDragDepthRef.current += 1;
setFileDropActive(true);
}, []);
const handleFileDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event) && !Array.from(event.dataTransfer.types).includes(CANVAS_PROJECT_CHAPTER_DND_TYPE)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
const handleFileDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;
fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1);
if (fileDragDepthRef.current === 0) setFileDropActive(false);
}, []);
const pasteAssistantImage = useCallback((file: File) => {
void createImageFileNode(file, getCanvasCenter()).then((inserted) => {
if (inserted) message.success("已从剪切板添加图片");
});
}, [createImageFileNode, getCanvasCenter, message]);
const openAssetsAtPosition = useCallback((position?: Position) => {
assetInsertPositionRef.current = position || null;
setAssetPickerOpen(true);
}, []);
const closeAssetPicker = useCallback(() => {
assetInsertPositionRef.current = null;
setAssetPickerOpen(false);
}, []);
const createAssetPayloadNode = useCallback(async (payload: InsertAssetPayload, center: Position) => {
if (payload.kind === "character") {
const width = 320;
const height = 260;
return {
id: `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: CanvasNodeType.Text,
title: payload.title,
position: { x: center.x - width / 2, y: center.y - height / 2 },
width,
height,
metadata: {
workflowKind: "character",
characterAssetId: payload.assetId,
characterVersionId: payload.versionId,
characterVersionPolicy: "current",
characterName: payload.title,
characterPrompt: payload.prompt,
characterAliases: payload.aliases,
characterDefinition: payload.definition,
characterCoverUrl: payload.coverUrl,
characterVisualStatus: payload.visualStatus,
characterVoiceStatus: payload.voiceStatus,
characterVoiceName: payload.voiceName,
characterVoiceProfile: payload.voiceProfile,
characterVoiceInstructions: payload.voiceInstructions,
assetId: payload.assetId,
status: NODE_STATUS_SUCCESS,
fontSize: 14,
},
} satisfies CanvasNodeData;
}
if (payload.kind === "text") {
const node = { ...createCanvasNode(CanvasNodeType.Text, center, { content: payload.content, status: NODE_STATUS_SUCCESS, assetId: payload.assetId }), title: payload.content.slice(0, 32) || "Assistant Text" };
return node;
}
if (payload.kind === "audio") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Audio, title: payload.title, position: { x: center.x - spec.width / 2, y: center.y - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { content: payload.url, storageKey: payload.storageKey, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "audio/mpeg", assetId: payload.assetId, status: NODE_STATUS_SUCCESS } } satisfies CanvasNodeData;
}
if (payload.kind === "video") {
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const size = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData;
}
const storedImage = payload.url
? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: payload.storageKey
? { url: payload.dataUrl, storageKey: payload.storageKey, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: await uploadImage(payload.dataUrl);
const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage;
const size = fitNodeSize(meta.width, meta.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const metadata = storedImage.storageKey
? imageMetadata({ ...storedImage, storageKey: storedImage.storageKey, width: meta.width, height: meta.height })
: { content: storedImage.url, status: NODE_STATUS_SUCCESS, naturalWidth: meta.width, naturalHeight: meta.height, bytes: storedImage.bytes, mimeType: storedImage.mimeType };
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
}, []);
const insertAssetPayloads = useCallback(async (payloads: InsertAssetPayload[], origin: Position, successMessage: string, failureMessage: string): Promise<CanvasNodeData[]> => {
try {
const created = await Promise.all(payloads.map((payload, index) => createAssetPayloadNode(payload, {
x: origin.x + (index % BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_COLUMN_GAP,
y: origin.y + Math.floor(index / BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_ROW_GAP,
})));
setNodes((current) => [...current, ...created]);
setSelectedNodeIds(new Set(created.map((node) => node.id)));
setSelectedConnectionId(null);
setDialogNodeId(null);
message.success(successMessage);
return created;
} catch (error) {
message.error(error instanceof Error ? error.message : failureMessage);
throw error;
}
}, [createAssetPayloadNode, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const handleAssetsInsert = useCallback(async (payloads: InsertAssetPayload[]): Promise<CanvasNodeData[]> => {
const origin = assetInsertPositionRef.current || getCanvasCenter();
return insertAssetPayloads(payloads, origin, `已插入 ${payloads.length} 项素材`, "素材插入失败");
}, [getCanvasCenter, insertAssetPayloads]);
const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise<CanvasNodeData[]> => {
const origin = position || getCanvasCenter();
return insertAssetPayloads(payloads, origin, `已引入 ${payloads.length} 项项目资产`, "项目资产引入失败");
}, [getCanvasCenter, insertAssetPayloads]);
return {
assetPickerOpen,
closeAssetPicker,
createVideoNodeFromBlob,
createAssetPayloadNode,
createImageAssetNode,
fileDropActive,
handleAssetsInsert,
handleDrop,
handleFileDragEnter,
handleFileDragLeave,
handleFileDragOver,
handleImageInputChange,
handleProjectAssetsInsert,
handleProjectChapterInsert,
handleUploadFiles,
handleUploadRequest,
imageInputRef,
closeUploadModal,
openAssetsAtPosition,
pasteAssistantImage,
pasteSystemClipboard,
startUploadStatus,
uploadModalOpen,
uploadStatus,
uploadTimelineMedia,
};
}
function hasDraggedFiles(event: DragEvent<HTMLElement>) {
return Array.from(event.dataTransfer.types).includes("Files");
}
function parseProjectChapterPayload(value: string): CanvasProjectChapterPayload | null {
if (!value) return null;
try {
const payload = JSON.parse(value) as Partial<CanvasProjectChapterPayload>;
const validSource = payload.sourceText === undefined || typeof payload.sourceText === "string";
return typeof payload.id === "string" && typeof payload.projectId === "string" && typeof payload.title === "string" && validSource && typeof payload.position === "number" ? payload as CanvasProjectChapterPayload : null;
} catch {
return null;
}
}
function htmlToPlainText(value: string) {
if (!value) return "";
const document = new DOMParser().parseFromString(value, "text/html");
return document.body.textContent?.trim() || "";
}