114
web/src/components/canvas/canvas-upload-modal.tsx
Normal file
114
web/src/components/canvas/canvas-upload-modal.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Modal, Upload, type UploadFile } from "antd";
|
||||
import { FileImage, Film, Music2, UploadCloud } from "lucide-react";
|
||||
|
||||
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
|
||||
|
||||
const CANVAS_UPLOAD_ACCEPT = "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
|
||||
|
||||
type CanvasUploadModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (files: File[]) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFileList([]);
|
||||
setUploading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const files = fileList.flatMap((item) => item.originFileObj ? [item.originFileObj] : []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
if (await onUpload(files)) onClose();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "文件上传失败,请稍后重试");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={null}
|
||||
footer={null}
|
||||
width="min(720px, calc(100vw - 24px))"
|
||||
destroyOnHidden
|
||||
closable={!uploading}
|
||||
keyboard={!uploading}
|
||||
maskClosable={!uploading}
|
||||
onCancel={onClose}
|
||||
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
|
||||
>
|
||||
<div className="flex min-h-96 flex-col overflow-hidden">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border py-0 pl-5 pr-12">
|
||||
<div className="min-w-0">
|
||||
<div role="heading" aria-level={2} className="text-sm font-semibold leading-5">上传文件</div>
|
||||
<div className="mt-0.5 text-[var(--fs-label)] leading-4 text-foreground/45">批量导入图片、视频和音频到当前画布</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[var(--fs-label)] text-foreground/45">已选 {fileList.length} 项</span>
|
||||
</header>
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<Upload.Dragger
|
||||
accept={CANVAS_UPLOAD_ACCEPT}
|
||||
multiple
|
||||
disabled={uploading}
|
||||
fileList={fileList}
|
||||
beforeUpload={(file) => {
|
||||
if (isCanvasUploadFile(file)) return false;
|
||||
message.warning(`“${file.name}”不是支持的图片、视频或音频文件`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
onRemove={() => !uploading}
|
||||
showUploadList={{ showPreviewIcon: false, showDownloadIcon: false, showRemoveIcon: !uploading }}
|
||||
classNames={{ list: "thin-scrollbar" }}
|
||||
styles={{
|
||||
root: { display: "block", width: "100%" },
|
||||
trigger: { borderColor: "var(--border)", borderRadius: "var(--r-lg)", background: "var(--workspace-surface)" },
|
||||
list: { maxHeight: 184, overflowY: "auto" },
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-48 flex-col items-center justify-center px-6 py-8 text-center">
|
||||
<span className="grid size-12 place-items-center rounded-lg bg-foreground/[.06] text-foreground/70">
|
||||
<UploadCloud className="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="mt-4 text-sm font-medium">拖动文件到这里,或点击选择</p>
|
||||
<p className="mt-1 text-xs text-foreground/45">支持同时选择多个文件</p>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-[var(--fs-label)] text-foreground/45" aria-label="支持的文件类型">
|
||||
<span className="inline-flex items-center gap-1"><FileImage className="size-3.5" aria-hidden="true" />图片</span>
|
||||
<span className="inline-flex items-center gap-1"><Film className="size-3.5" aria-hidden="true" />视频</span>
|
||||
<span className="inline-flex items-center gap-1"><Music2 className="size-3.5" aria-hidden="true" />MP3 / WAV</span>
|
||||
</div>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
</section>
|
||||
|
||||
<footer className="flex h-14 shrink-0 items-center justify-between border-t border-border px-4">
|
||||
<span className="hidden text-[var(--fs-label)] text-foreground/45 sm:inline">文件将在确认后按顺序添加到画布</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button disabled={uploading} onClick={onClose}>取消</Button>
|
||||
<Button type="primary" icon={<UploadCloud className="size-4" />} disabled={!fileList.length} loading={uploading} onClick={() => void submit()}>
|
||||
添加到画布{fileList.length ? `(${fileList.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasUploadFile(file: File) {
|
||||
return file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-mo
|
||||
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
|
||||
import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
|
||||
import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
|
||||
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
|
||||
import { Minimap } from "@/components/canvas/canvas-mini-map";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
@@ -431,6 +432,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const {
|
||||
assetPickerOpen,
|
||||
closeUploadModal,
|
||||
closeAssetPicker,
|
||||
createImageAssetNode,
|
||||
fileDropActive,
|
||||
@@ -442,12 +444,14 @@ function InfiniteCanvasPage() {
|
||||
handleImageInputChange,
|
||||
handleProjectAssetsInsert,
|
||||
handleProjectChapterInsert,
|
||||
handleUploadFiles,
|
||||
handleUploadRequest,
|
||||
imageInputRef,
|
||||
openAssetsAtPosition,
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
startUploadStatus,
|
||||
uploadModalOpen,
|
||||
uploadStatus,
|
||||
} = useCanvasUpload({
|
||||
canvasId: projectId,
|
||||
@@ -1813,6 +1817,8 @@ function InfiniteCanvasPage() {
|
||||
onToggleFrame={(node) => toggleFrameCollapsed(node.id)}
|
||||
/>
|
||||
|
||||
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />
|
||||
|
||||
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
|
||||
|
||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
|
||||
|
||||
@@ -40,6 +40,9 @@ export type StartCanvasUploadStatus = (title: string, detail: string, total?: nu
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -63,6 +66,7 @@ export function useCanvasUpload({
|
||||
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);
|
||||
|
||||
@@ -133,12 +137,12 @@ export function useCanvasUpload({
|
||||
if (domainProjectId) progress.update("写入项目资产", 4);
|
||||
const persisted = await persistMediaNode(node);
|
||||
progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
|
||||
return true;
|
||||
return id;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "图片上传失败";
|
||||
progress.fail(details);
|
||||
message.error(details);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
|
||||
|
||||
@@ -193,10 +197,12 @@ export function useCanvasUpload({
|
||||
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]);
|
||||
|
||||
@@ -214,10 +220,12 @@ export function useCanvasUpload({
|
||||
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]);
|
||||
|
||||
@@ -267,6 +275,10 @@ export function useCanvasUpload({
|
||||
|
||||
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
|
||||
@@ -280,6 +292,44 @@ export function useCanvasUpload({
|
||||
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 replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
|
||||
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
|
||||
if (!currentNode) return false;
|
||||
@@ -626,12 +676,15 @@ export function useCanvasUpload({
|
||||
handleImageInputChange,
|
||||
handleProjectAssetsInsert,
|
||||
handleProjectChapterInsert,
|
||||
handleUploadFiles,
|
||||
handleUploadRequest,
|
||||
imageInputRef,
|
||||
closeUploadModal,
|
||||
openAssetsAtPosition,
|
||||
pasteAssistantImage,
|
||||
pasteSystemClipboard,
|
||||
startUploadStatus,
|
||||
uploadModalOpen,
|
||||
uploadStatus,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user