feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选
This commit is contained in:
27
web/src/components/canvas/canvas-file-upload-content.tsx
Normal file
27
web/src/components/canvas/canvas-file-upload-content.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { AlertCircle, ImageUp, FileText, Film, Music2 } from "lucide-react";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { uploadVeilOpacity } from "@/lib/canvas/canvas-file-upload";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
|
||||
export function CanvasFileUploadContent({ node, theme, reduceMotion = false }: { node: CanvasNodeData; theme: CanvasTheme; reduceMotion?: boolean }) {
|
||||
const { upload } = useCanvasNodeActions();
|
||||
const failed = node.metadata?.fileUpload === "error";
|
||||
const percent = node.metadata?.fileUploadProgress;
|
||||
const label = node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "文本";
|
||||
const Icon = failed ? AlertCircle : node.type === CanvasNodeType.Video ? Film : node.type === CanvasNodeType.Audio ? Music2 : node.type === CanvasNodeType.Text ? FileText : ImageUp;
|
||||
return (
|
||||
<div className="canvas-file-upload" data-static={reduceMotion || failed || undefined} style={{ "--upload-surface": theme.node.fill, "--upload-text": theme.node.text, "--upload-muted": theme.node.muted } as CSSProperties}>
|
||||
<div className="canvas-file-upload-glow" aria-hidden="true" style={{ opacity: uploadVeilOpacity(percent) }} />
|
||||
<div className="canvas-file-upload-frost" aria-hidden="true" style={{ opacity: uploadVeilOpacity(percent) }} />
|
||||
<div className="canvas-file-upload-copy" role="status" aria-live="polite" aria-busy={!failed}>
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
<span className="canvas-file-upload-label">{failed ? `${label}上传未完成` : `正在上传${label}`}</span>
|
||||
<span className="canvas-file-upload-detail">{failed ? node.metadata?.errorDetails || "请重新选择文件上传" : percent === 100 ? "文件已传输,正在保存与处理" : percent !== undefined ? `已上传 ${percent}%` : "正在准备文件"}</span>
|
||||
{!failed && <span role="progressbar" aria-label={`${label}上传进度`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="sr-only" />}
|
||||
{failed && upload ? <button type="button" data-canvas-no-zoom className="canvas-file-upload-retry" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); upload(node); }}>重新选择文件</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
|
||||
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
|
||||
// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。
|
||||
export type CanvasNodeActionContextValue = {
|
||||
upload?: (node: CanvasNodeData) => void;
|
||||
download?: (node: CanvasNodeData) => void;
|
||||
duplicate?: (node: CanvasNodeData) => void;
|
||||
deleteNode?: (node: CanvasNodeData) => void;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
|
||||
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
|
||||
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
@@ -26,6 +27,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
|
||||
import { CanvasAudioPlayer } from "./canvas-audio-player";
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
|
||||
import { CanvasFileUploadContent } from "./canvas-file-upload-content";
|
||||
import { MarkdownNodeContent } from "./nodes/markdown-node";
|
||||
import { ChartNodeContent } from "./nodes/chart-node";
|
||||
import { CompareNodeContent } from "./nodes/compare-node";
|
||||
@@ -62,6 +64,7 @@ export type CanvasNodeContentProps = {
|
||||
};
|
||||
|
||||
export function CanvasNodeContent(props: CanvasNodeContentProps) {
|
||||
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
|
||||
const hasCustomContent = props.node.type === CanvasNodeType.Config
|
||||
|| props.node.type === CanvasNodeType.Script
|
||||
|| Boolean(props.node.metadata?.directorSceneId)
|
||||
@@ -496,6 +499,14 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentPro
|
||||
const updateMetadataRef = useRef(updateMetadata);
|
||||
const [hydrating, setHydrating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = previewRef.current;
|
||||
if (!element) return;
|
||||
const content = node.metadata?.content || "";
|
||||
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
|
||||
return bindCanvasVideoHoverPreview(element, () => resolveMediaUrl(node.metadata?.storageKey, fallback));
|
||||
}, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMetadataRef.current = updateMetadata;
|
||||
}, [updateMetadata]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, C
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { canvasConnectionTilt } from "@/lib/canvas/canvas-connection-tilt";
|
||||
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
|
||||
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -28,6 +29,7 @@ type CanvasNodeProps = {
|
||||
isRelated: boolean;
|
||||
isFocusRelated: boolean;
|
||||
isConnectionTarget: boolean;
|
||||
connectionApproach?: Position;
|
||||
forceInputVisible?: boolean;
|
||||
showImageInfo: boolean;
|
||||
reduceMediaEffects?: boolean;
|
||||
@@ -75,6 +77,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
isRelated,
|
||||
isFocusRelated,
|
||||
isConnectionTarget,
|
||||
connectionApproach,
|
||||
forceInputVisible = false,
|
||||
showImageInfo,
|
||||
reduceMediaEffects = false,
|
||||
@@ -265,6 +268,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
if (next !== data.title) onTitleChange?.(data.id, next);
|
||||
};
|
||||
|
||||
const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset
|
||||
? canvasConnectionTilt(data, connectionApproach) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-node-id={data.id}
|
||||
@@ -302,13 +308,17 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<div
|
||||
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
|
||||
data-node-state={nodeState}
|
||||
data-connection-tilt={connectionTilt ? "true" : undefined}
|
||||
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
|
||||
style={{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
|
||||
border: isComposerNode ? "0" : "1px solid transparent",
|
||||
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
|
||||
}}
|
||||
"--connection-tilt-x": `${connectionTilt?.rotateX || 0}deg`,
|
||||
"--connection-tilt-y": `${connectionTilt?.rotateY || 0}deg`,
|
||||
transformOrigin: connectionTilt?.origin,
|
||||
} as React.CSSProperties}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
if (isBatchRoot) {
|
||||
@@ -488,6 +498,8 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
|
||||
previous.isRelated === next.isRelated &&
|
||||
previous.isFocusRelated === next.isFocusRelated &&
|
||||
previous.isConnectionTarget === next.isConnectionTarget &&
|
||||
previous.connectionApproach?.x === next.connectionApproach?.x &&
|
||||
previous.connectionApproach?.y === next.connectionApproach?.y &&
|
||||
previous.forceInputVisible === next.forceInputVisible &&
|
||||
previous.showImageInfo === next.showImageInfo &&
|
||||
previous.reduceMediaEffects === next.reduceMediaEffects &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Palette, Info } from "lucide-react";
|
||||
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
|
||||
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||||
import { CanvasAppearanceControls } from "@/components/canvas/canvas-appearance-controls";
|
||||
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
|
||||
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
|
||||
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
|
||||
import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
|
||||
@@ -89,6 +90,7 @@ export function CanvasToolbar({
|
||||
onOpenProjectCharacters: () => void;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const { bringToFront, zIndex } = useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)");
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
@@ -98,6 +100,10 @@ export function CanvasToolbar({
|
||||
const [panelX, setPanelX] = useState(0);
|
||||
const [prefs, setPrefs] = useState<ToolbarPrefs | null>(() => readToolbarPrefs("main"));
|
||||
|
||||
useEffect(() => {
|
||||
if (addOpen || appearanceOpen) bringToFront();
|
||||
}, [addOpen, appearanceOpen, bringToFront]);
|
||||
|
||||
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
|
||||
@@ -186,7 +192,7 @@ export function CanvasToolbar({
|
||||
const createCommands = useCanvasCreateCommands(ctx, runAddAction);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] z-[var(--z-toolbar)] flex justify-center">
|
||||
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] flex justify-center" style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}>
|
||||
<AnimatePresence>
|
||||
{addOpen ? (
|
||||
<AddNodeMenu
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Modal, Upload, type UploadFile } from "antd";
|
||||
import { FileImage, Film, Music2, UploadCloud, X } from "lucide-react";
|
||||
import { FileImage, FileText, Film, Music2, UploadCloud, X } 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";
|
||||
import { CANVAS_UPLOAD_ACCEPT, isTextUploadFile, uploadNodeType } from "@/lib/canvas/canvas-file-upload";
|
||||
|
||||
type CanvasUploadModalProps = {
|
||||
open: boolean;
|
||||
@@ -29,7 +29,9 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
if (await onUpload(files)) onClose();
|
||||
const pendingUpload = onUpload(files);
|
||||
onClose();
|
||||
await pendingUpload;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "文件上传失败,请稍后重试");
|
||||
} finally {
|
||||
@@ -54,7 +56,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
<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 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>
|
||||
@@ -67,7 +69,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
fileList={fileList}
|
||||
beforeUpload={(file) => {
|
||||
if (isCanvasUploadFile(file)) return false;
|
||||
message.warning(`“${file.name}”不是支持的图片、视频或音频文件`);
|
||||
message.warning(`“${file.name}”不是支持的图片、视频、音频或 TXT / Markdown 文件`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}}
|
||||
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
|
||||
@@ -86,7 +88,8 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
|
||||
<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>
|
||||
<span className="inline-flex items-center gap-1"><Music2 className="size-3.5" aria-hidden="true" />音频</span>
|
||||
<span className="inline-flex items-center gap-1"><FileText className="size-3.5" aria-hidden="true" />TXT / Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -148,11 +151,11 @@ function CanvasUploadFilePreview({ file }: { file: UploadFile }) {
|
||||
}
|
||||
return (
|
||||
<div className="grid size-full place-items-center text-foreground/45">
|
||||
{source && isAudioFile(source) ? <Music2 className="size-7" aria-hidden="true" /> : <FileImage className="size-7" aria-hidden="true" />}
|
||||
{source && isTextUploadFile(source) ? <FileText className="size-7" aria-hidden="true" /> : source && (source.type.startsWith("audio/") || isAudioFile(source)) ? <Music2 className="size-7" aria-hidden="true" /> : <FileImage className="size-7" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasUploadFile(file: File) {
|
||||
return file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file);
|
||||
return Boolean(uploadNodeType(file));
|
||||
}
|
||||
|
||||
@@ -397,6 +397,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-canvas-viewport
|
||||
data-canvas-pan-state={isPanning ? "grabbing" : isSpacePressed || !boxSelectEnabled ? "grab" : undefined}
|
||||
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : isSpacePressed || !boxSelectEnabled ? "cursor-grab" : "canvas-cursor-select"}`}
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user