import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, Plus, RefreshCw, Replace, Settings2, Square, Star, Type, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { CometCard } from "@/components/ui/aceternity/comet-card"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text"; import { formatBytes } from "@/lib/image-utils"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { useThemeStore } from "@/stores/use-theme-store"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { storyboardMinNodeHeight } from "./canvas-script-node"; import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage"; import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size"; import { VideoPlayer } from "@/components/video-player"; type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; type CanvasNodeProps = { data: CanvasNodeData; dragOffset?: Position; scale: number; isSelected: boolean; isRelated: boolean; isFocusRelated: boolean; isConnectionTarget: boolean; isConnecting: boolean; showImageInfo: boolean; reduceMediaEffects?: boolean; readOnly?: boolean; resourceLabel?: CanvasResourceReference; mentionReferences?: CanvasResourceReference[]; renderNodeContent?: (node: CanvasNodeData) => ReactNode; drawingProjectId?: string; batchCount?: number; batchExpanded?: boolean; batchClosing?: boolean; batchOpening?: boolean; batchRecovering?: boolean; batchPrimary?: boolean; batchMotion?: { x: number; y: number; index: number }; onMouseDown: (event: React.MouseEvent, nodeId: string) => void; onHoverStart: (nodeId: string) => void; onHoverEnd: (nodeId: string) => void; onConnectStart: (event: React.PointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => void; onResize: (nodeId: string, width: number, height: number, position?: Position) => void; onTitleChange?: (nodeId: string, title: string) => void; onContentChange: (nodeId: string, content: string) => void; onToggleBatch?: (nodeId: string) => void; onSetBatchPrimary?: (node: CanvasNodeData) => void; onRetry?: (node: CanvasNodeData) => void; onCancelTask?: (node: CanvasNodeData) => void; onOpenTaskDetails?: (node: CanvasNodeData) => void; onOpenVersions?: (node: CanvasNodeData) => void; onViewImage?: (node: CanvasNodeData) => void; onReplaceMedia?: (node: CanvasNodeData) => void; onOpenTextEditor?: (node: CanvasNodeData) => void; onOpenDirector?: (node: CanvasNodeData) => void; onOpenDrawing?: (node: CanvasNodeData) => void; onContextMenu: (event: React.MouseEvent, nodeId: string) => void; }; type NodeContentRendererProps = { node: CanvasNodeData; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; isEditingContent: boolean; textareaRef: React.RefObject; isBatchRoot: boolean; batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; renderNodeContent?: (node: CanvasNodeData) => ReactNode; drawingProjectId?: string; onContentChange: (nodeId: string, content: string) => void; onStopEditing: () => void; mentionReferences: CanvasResourceReference[]; onRetry?: (node: CanvasNodeData) => void; onCancelTask?: (node: CanvasNodeData) => void; onOpenTaskDetails?: (node: CanvasNodeData) => void; onOpenDrawing?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; reduceMediaEffects?: boolean; }; export const CanvasNode = React.memo(function CanvasNode({ data, dragOffset, scale, isSelected, isRelated, isFocusRelated, isConnectionTarget, showImageInfo, reduceMediaEffects = false, readOnly = false, resourceLabel, mentionReferences = [], renderNodeContent, drawingProjectId, batchCount = 0, batchExpanded = false, batchClosing = false, batchOpening = false, batchRecovering = false, batchPrimary = false, batchMotion, onMouseDown, onHoverStart, onHoverEnd, onConnectStart, onResize, onTitleChange, onContentChange, onToggleBatch, onSetBatchPrimary, onRetry, onCancelTask, onOpenTaskDetails, onOpenVersions, onViewImage, onReplaceMedia, onOpenTextEditor, onOpenDirector, onOpenDrawing, onContextMenu, }: CanvasNodeProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [hovered, setHovered] = useState(false); const [isEditingContent, setIsEditingContent] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(data.title); const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content); const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content); const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content); const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent; const isGeneratingNode = data.type !== CanvasNodeType.Frame && data.metadata?.status === "loading"; const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1; const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId); const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly)); const isActive = isConnectionTarget || isSelected || isFocusRelated; const flushMediaContent = hasImageContent || hasVideoContent; const mediaBorderColor = isActive ? theme.accent.primary : isRelated && !isBatchChild ? theme.accent.primary : "transparent"; const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || []; const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null; const cometDepth = hasMediaContent ? 6.8 : data.type === CanvasNodeType.Script ? 2.8 : 4.6; const cometTranslate = hasMediaContent ? 6 : data.type === CanvasNodeType.Script ? 2.5 : 4; const cometDisabled = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isEditingTitle || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening; const textareaRef = useRef(null); const resizeRef = useRef({ isResizing: false, corner: "bottom-right" as ResizeCorner, startX: 0, startY: 0, startLeft: 0, startTop: 0, startWidth: 0, startHeight: 0, keepRatio: false, ratio: 1, }); useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; const handleWheel = (event: WheelEvent) => event.stopPropagation(); textarea.addEventListener("wheel", handleWheel, { passive: false }); return () => textarea.removeEventListener("wheel", handleWheel); }, [data.type, isEditingContent]); useEffect(() => { if (!isEditingContent) return; const textarea = textareaRef.current; textarea?.focus(); textarea?.setSelectionRange(textarea.value.length, textarea.value.length); }, [isEditingContent]); useEffect(() => { if (!isEditingTitle) setTitleDraft(data.title); }, [data.title, isEditingTitle]); useEffect(() => { if (!isEditingContent) return; const handleOutsidePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; if (isEditingContent && textareaRef.current?.contains(target)) return; setIsEditingContent(false); }; window.addEventListener("pointerdown", handleOutsidePointerDown, true); return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true); }, [isEditingContent]); const handleResizeMove = useCallback( (event: MouseEvent) => { if (!resizeRef.current.isResizing) return; const dx = (event.clientX - resizeRef.current.startX) / scale; const dy = (event.clientY - resizeRef.current.startY) / scale; const isMediaNode = data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video; const minWidth = data.type === CanvasNodeType.Script ? 800 : isMediaNode ? MEDIA_NODE_MIN_SIZE.width : 220; const minHeight = scriptMinHeight || (isMediaNode ? MEDIA_NODE_MIN_SIZE.height : 160); const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth; const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight; const fromLeft = resizeRef.current.corner.includes("left"); const fromTop = resizeRef.current.corner.includes("top"); const rawWidth = Math.max(minWidth, resizeRef.current.startWidth + (fromLeft ? -dx : dx)); const rawHeight = Math.max(minHeight, resizeRef.current.startHeight + (fromTop ? -dy : dy)); let width = rawWidth; let height = rawHeight; if (resizeRef.current.keepRatio) { const ratio = resizeRef.current.ratio; if (Math.abs(dx) >= Math.abs(dy)) { height = width / ratio; } else { width = height * ratio; } if (height < minHeight) { height = minHeight; width = height * ratio; } if (width < minWidth) { width = minWidth; height = width / ratio; } } onResize(data.id, width, height, { x: fromLeft ? startRight - width : resizeRef.current.startLeft, y: fromTop ? startBottom - height : resizeRef.current.startTop, }); }, [data.id, data.type, onResize, scale, scriptMinHeight], ); const handleResizeUp = useCallback(() => { resizeRef.current.isResizing = false; window.removeEventListener("mousemove", handleResizeMove); window.removeEventListener("mouseup", handleResizeUp); }, [handleResizeMove]); const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => { event.stopPropagation(); event.preventDefault(); resizeRef.current = { isResizing: true, corner, startX: event.clientX, startY: event.clientY, startLeft: data.position.x, startTop: data.position.y, startWidth: data.width, startHeight: data.height, keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video, ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1), }; window.addEventListener("mousemove", handleResizeMove); window.addEventListener("mouseup", handleResizeUp); }; useEffect(() => { return () => { window.removeEventListener("mousemove", handleResizeMove); window.removeEventListener("mouseup", handleResizeUp); }; }, [handleResizeMove, handleResizeUp]); const commitTitle = () => { const next = titleDraft.trim(); setIsEditingTitle(false); if (!next) { setTitleDraft(data.title); return; } if (next !== data.title) onTitleChange?.(data.id, next); }; return (
{ setHovered(true); onHoverStart(data.id); }} onMouseLeave={() => { setHovered(false); onHoverEnd(data.id); }} onContextMenu={(event) => onContextMenu(event, data.id)} > setIsEditingTitle(true)} onCommit={commitTitle} onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }} /> onMouseDown(event, data.id)} onDoubleClick={(event) => { if (isBatchRoot) { event.stopPropagation(); onToggleBatch?.(data.id); return; } if (data.type === CanvasNodeType.Image && hasImageContent) { event.stopPropagation(); onViewImage?.(data); return; } if (data.metadata?.directorSceneId) { event.stopPropagation(); onOpenDirector?.(data); return; } if (data.type === CanvasNodeType.Drawing) { event.stopPropagation(); onOpenDrawing?.(data); return; } if (!readOnly && data.type === CanvasNodeType.Text && data.metadata?.workflowKind === "character" && data.metadata.characterAssetId) { event.stopPropagation(); onOpenTextEditor?.(data); return; } if (readOnly || data.type !== CanvasNodeType.Text) return; event.stopPropagation(); setIsEditingContent(true); }} >
{/* 节点状态徽章(对应 #97 决策2:左上角 loading/success/error,近距离确认信号)*/} {data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? ( ) : null} setIsEditingContent(false)} onRetry={onRetry} onCancelTask={onCancelTask} onOpenTaskDetails={onOpenTaskDetails} onOpenDrawing={onOpenDrawing} onToggleBatch={() => onToggleBatch?.(data.id)} reduceMediaEffects={reduceMediaEffects} />
{flushMediaContent ? (
) : null} {(hasImageContent || hasVideoContent) && !readOnly ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
) : null} {data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
) : null} {data.metadata?.versionLabel ? ( ) : null} {showStatusTrack ? (
{resourceLabel ? : null} {hasMediaContent && !readOnly ? : null} {isBatchRoot ? onToggleBatch?.(data.id)} /> : null} {isBatchChild && !readOnly ? onSetBatchPrimary?.(data)} /> : null} {data.metadata?.locked ? : null}
) : null} {assetTags.length || (showImageInfo && hasImageContent) ? (
{assetTags.length ? : null} {showImageInfo && hasImageContent ? : null}
) : null} {!hasImageContent && !hasVideoContent && !hasAudioContent && data.type !== CanvasNodeType.Drawing ?
: null} {!readOnly && !data.metadata?.locked ? <> : null} {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
); }); function NodeContent(props: NodeContentRendererProps) { const hasCustomContent = props.node.type === CanvasNodeType.Config || props.node.type === CanvasNodeType.Script || Boolean(props.node.metadata?.directorSceneId) || (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId)) || (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent) || (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content); if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node); if (props.isBatchRoot) return ; if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; const Renderer = nodeContentRenderers[props.node.type]; return Renderer ? : ; } const nodeContentRenderers = { [CanvasNodeType.Text]: TextContent, [CanvasNodeType.Script]: UnknownNodeContent, [CanvasNodeType.Skill]: SkillContent, [CanvasNodeType.Image]: ImageNodeContent, [CanvasNodeType.Config]: EmptyImageContent, [CanvasNodeType.Video]: VideoNodeContent, [CanvasNodeType.Audio]: AudioNodeContent, [CanvasNodeType.Drawing]: DrawingContent, [CanvasNodeType.Frame]: UnknownNodeContent, } satisfies Record ReactNode>; function DrawingContent({ node, theme, drawingProjectId }: NodeContentRendererProps) { const shapeCount = node.metadata?.drawingShapeCount || 0; const pageCount = node.metadata?.drawingPageCount || 1; const [previewUrl, setPreviewUrl] = useState(node.metadata?.drawingPreviewUrl || ""); useEffect(() => { const drawingId = node.metadata?.drawingId; const fallbackPreview = node.metadata?.drawingPreviewUrl || ""; setPreviewUrl(fallbackPreview); if (!drawingProjectId || !drawingId) return; let active = true; let objectUrl = ""; void loadCanvasDrawingPreview(drawingProjectId, drawingId).then((preview) => { if (!active) return; if (!preview) { setPreviewUrl(fallbackPreview); return; } objectUrl = URL.createObjectURL(preview); setPreviewUrl(objectUrl); }).catch((error) => console.warn("读取绘图节点预览失败", error)); return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [drawingProjectId, node.metadata?.drawingId, node.metadata?.drawingPreviewUrl, node.metadata?.drawingRevision]); return (
{previewUrl ? ( 绘图预览 ) : (
)}
{node.title || "绘图"}
{shapeCount} 个图形 · {pageCount} 个页面
); } function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick) { const taskId = node.metadata?.taskId; const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null; const statusLabel = taskStatusLabel(node.metadata?.taskStatus); const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt); return (
{node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")} {taskId ? (
{statusLabel} {progress !== null ? ` · ${progress}%` : ""}
{progress !== null ? (
) : null}
{elapsed} · {shortTaskId(taskId)}
) : null}
); } function useTaskElapsed(createdAt?: string) { const [, setTick] = useState(0); useEffect(() => { if (!createdAt) return; const timer = window.setInterval(() => setTick((value) => value + 1), 1000); return () => window.clearInterval(timer); }, [createdAt]); if (!createdAt) return "刚刚"; const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000)); if (seconds < 60) return `${seconds}秒`; const minutes = Math.floor(seconds / 60); return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`; } function taskStatusLabel(status?: string) { if (status === "queued") return "排队中"; if (status === "running") return "生成中"; if (status === "succeeded") return "任务已完成"; if (status === "failed") return "任务失败"; if (status === "cancelled") return "任务已取消"; return status ? "未知任务状态" : "等待任务状态"; } function shortTaskId(id: string) { if (id.length <= 20) return id; return `${id.slice(0, 14)}...${id.slice(-4)}`; } function ErrorContent({ node, theme, onRetry }: Pick) { const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); return (
{generationErrorMessage(node.metadata?.errorDetails)}
{moderationFailure ? (
修改节点提示词后,可重新点击生成。
) : ( )}
); } function UnknownNodeContent({ theme }: Pick) { return (
未知节点
); } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: NodeContentRendererProps) { const fontSize = node.metadata?.fontSize || 14; const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties; const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]); return (
{isEditingContent ? ( onContentChange(node.id, value)} onBlur={onStopEditing} onKeyDown={(event) => { if (event.key === "Escape") onStopEditing(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} /> ) : richTextHTML ? (
event.stopPropagation()} dangerouslySetInnerHTML={{ __html: richTextHTML }} /> ) : (
event.stopPropagation()} > {node.metadata?.content || 双击编辑文字}
)}
); } function SkillContent({ node, theme }: NodeContentRendererProps) { const skill = node.metadata?.skillSnapshot; const tags = skill?.tags?.slice(0, 4) || []; const template = skill?.template || node.metadata?.content || ""; return (
{skill?.name || node.title || "技能"}
{skillCategoryLabel(skill?.category)} · {skillOutputModeLabel(skill?.outputMode)} {skill?.version ? ( <> · v{skill.version} ) : null}
{skill?.description ?
{skill.description}
: null}
模板
{template || "未配置技能模板"}
{tags.length ? tags.map((tag) => ( {tag} )) : 连接到图片、视频、音频或文本节点后生效}
); } function skillCategoryLabel(category?: string) { if (category === "writing") return "剧情"; if (category === "storyboard") return "分镜"; if (category === "image") return "生图"; if (category === "video") return "视频"; return "通用"; } function skillOutputModeLabel(mode?: string) { if (mode === "json") return "JSON"; if (mode === "image_prompt") return "生图提示词"; if (mode === "workflow") return "工作流"; return "文本"; } function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) { return ( {reference.label} ); } function ResourceStorageBadge({ storageKey, active, theme }: { storageKey?: string; active: boolean; theme: CanvasTheme }) { const location = resourceStorageLocation(storageKey); const background = active ? (location === "local" ? "rgba(245,158,11,.9)" : theme.accent.primary) : "rgba(0,0,0,.35)"; return ( {resourceStorageLabel(storageKey)} ); } function NodeLockBadge({ theme }: { theme: CanvasTheme }) { return ; } function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number; expanded: boolean; theme: CanvasTheme; onToggle: () => void }) { return ( ); } function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: boolean; selected: boolean; theme: CanvasTheme; onSelect: () => void }) { return ( ); } function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
{tags.map((tag, index) => ( {tag.trim()} ))}
); } function ImageNodeContent(props: NodeContentRendererProps) { if (!props.node.metadata?.content && props.isBatchRoot) { const content = props.node.metadata?.status === "loading" ? ( ) : props.node.metadata?.status === "error" ? ( ) : ( ); return ( {content} ); } if (!props.node.metadata?.content) return ; return ( ); } function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) { const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; const content = (
{isCharacterReference ? (
{node.metadata?.characterName || node.title}
多视角参考 · 待生成
) : 空图片节点}
); if (isBatchRoot) return ( {content} ); return content; } function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRendererProps) { const playWhenReadyRef = useRef(false); const { url, loading, load } = useNodeResourceUrl(node, false); if (!node.metadata?.content) return (
); if (!url) { return : } label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; } return { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls />; } function AudioNodeContent({ node, theme }: NodeContentRendererProps) { const audioRef = useRef(null); const playWhenReadyRef = useRef(false); const { url, loading, load } = useNodeResourceUrl(node, false); useEffect(() => { if (!url || !playWhenReadyRef.current) return; playWhenReadyRef.current = false; void audioRef.current?.play().catch(() => undefined); }, [url]); if (!node.metadata?.content) return (
空音频节点
); if (!url) { return : } label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; } return (
{node.title || "音频"}
); } function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, }: { node: CanvasNodeData; theme: CanvasTheme; isBatchRoot: boolean; batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; }) { const imageContainerRef = useRef(null); const nearViewport = useNearViewport(imageContainerRef); const { url, loading } = useNodeResourceUrl(node, nearViewport); return (
{url ? ( {node.title} event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> ) :
{loading ? : }
}
); } function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) { return ( ); } function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey)); const [url, setUrl] = useState(isRemoteResource ? "" : fallback); const [loading, setLoading] = useState(isRemoteResource && eager); useEffect(() => { let cancelled = false; if (!isRemoteResource) { setUrl(fallback); setLoading(false); return; } setUrl(""); setLoading(eager); const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); void resolve .then((cached) => { if (!cancelled) setUrl(cached || (eager ? fallback : "")); }) .catch(() => { if (!cancelled && eager) setUrl(fallback); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [eager, fallback, isRemoteResource, storageKey]); const load = useCallback(async () => { if (url) return url; if (!isRemoteResource) return fallback; setLoading(true); try { const next = (await cacheResourceObjectUrl(storageKey)) || fallback; setUrl(next); return next; } catch { setUrl(fallback); return fallback; } finally { setLoading(false); } }, [fallback, isRemoteResource, storageKey, url]); return { url, loading, load }; } function useNearViewport(ref: React.RefObject) { const [nearViewport, setNearViewport] = useState(false); useEffect(() => { const element = ref.current; if (!element || typeof IntersectionObserver === "undefined") { setNearViewport(true); return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNearViewport(true); observer.disconnect(); } }, { rootMargin: "600px" }, ); observer.observe(element); return () => observer.disconnect(); }, [ref]); return nearViewport; } function ImageInfoBar({ node }: { node: CanvasNodeData }) { const width = Math.round(node.metadata?.naturalWidth || node.width); const height = Math.round(node.metadata?.naturalHeight || node.height); const size = formatBytes(node.metadata?.bytes || 0); return ( {width} x {height} {size ? ` · ${size}` : ""} ); } function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) { const isBatchRoot = batchCount > 1; return (
{ event.stopPropagation(); onToggleBatch?.(); } : undefined } > {isBatchRoot ? (
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
))}
) : null} {children}
); } function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) { const positionClass = { "top-left": "-left-[14px] -top-[14px] cursor-nwse-resize", "top-right": "-right-[14px] -top-[14px] cursor-nesw-resize", "bottom-left": "-bottom-[14px] -left-[14px] cursor-nesw-resize", "bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize", }[corner]; return
onMouseDown(event, corner)} />; } const NODE_EXTERNAL_HEADER_MIN_SCALE = 0.35; function NodeExternalHeader({ node, scale, active, editable, editing, draft, theme, onDraftChange, onEdit, onCommit, onCancel }: { node: CanvasNodeData; scale: number; active: boolean; editable: boolean; editing: boolean; draft: string; theme: CanvasTheme; onDraftChange: (value: string) => void; onEdit: () => void; onCommit: () => void; onCancel: () => void; }) { // 标题保持屏幕尺寸只适用于近景;远景继续反向缩放会遮住节点和连线。 if (scale < NODE_EXTERNAL_HEADER_MIN_SCALE && !editing) return null; const inverseScale = 1 / Math.max(scale, 0.05); const Icon = nodeTypeIcon(node.type); const maxHeaderWidth = Math.min(240, node.width * scale); return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > {editing ? ( onDraftChange(event.target.value)} onFocus={(event) => event.currentTarget.select()} onBlur={onCommit} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") onCancel(); }} aria-label="节点名称" /> ) : editable ? ( ) : ( {node.title} )}
); } function nodeTypeIcon(type: CanvasNodeType) { if (type === CanvasNodeType.Image) return ImageIcon; if (type === CanvasNodeType.Video) return Video; if (type === CanvasNodeType.Audio) return Music2; if (type === CanvasNodeType.Drawing) return Pencil; if (type === CanvasNodeType.Script) return Clapperboard; if (type === CanvasNodeType.Config) return Settings2; if (type === CanvasNodeType.Skill) return BookOpenCheck; return Type; } // 节点状态徽章(对应 #97 决策2:左上角状态指示,loading/success/error) function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }) { if (status === "loading") { return (
生成中
); } if (status === "error") { return (
失败
); } // success:短暂闪现后淡出(由 CSS animation 控制,2s 后消失) return (
完成
); } function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) { const handleRef = useRef(null); const anchorRatioRef = useRef(0.5); const inverseScale = 1 / Math.max(scale, 0.05); const resetAnchor = useCallback(() => { anchorRatioRef.current = 0.5; if (handleRef.current) handleRef.current.style.top = "50%"; }, []); useEffect(() => { if (!visible) resetAnchor(); }, [resetAnchor, visible]); const updateAnchor = (event: React.PointerEvent) => { const railBounds = event.currentTarget.getBoundingClientRect(); const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds; const screenPadding = Math.min(railBounds.height * 0.35, 12); const railRatio = Math.min(1 - screenPadding / Math.max(railBounds.height, 1), Math.max(screenPadding / Math.max(railBounds.height, 1), (event.clientY - railBounds.top) / Math.max(railBounds.height, 1))); const anchorY = railBounds.top + railBounds.height * railRatio; anchorRatioRef.current = Math.min(1, Math.max(0, (anchorY - nodeBounds.top) / Math.max(nodeBounds.height, 1))); if (handleRef.current) handleRef.current.style.top = `${railRatio * 100}%`; }; return ( ); }