import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; import { AlertCircle, BookOpenCheck, Clock3, Download, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Video } from "lucide-react"; import { VideoPlayer } from "@/components/video-player"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display"; 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 { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import type { GenerationTask } from "@/services/api/task-center"; import { cacheResourceObjectUrl, getCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache"; import { resolveMediaUrl } from "@/services/file-storage"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts"; import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts"; import { createDefaultSubtitleStyle } from "@/types/timeline"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasAudioPlayer } from "./canvas-audio-player"; import { useCanvasNodeActions } from "./canvas-node-action-context"; import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay"; import { MarkdownNodeContent } from "./nodes/markdown-node"; import { ChartNodeContent } from "./nodes/chart-node"; import { CompareNodeContent } from "./nodes/compare-node"; import { ColorGradeNodeContent } from "./nodes/color-grade-node"; import { HtmlNodeContent } from "./nodes/html-node"; import { PanoramaNodeContent } from "./nodes/panorama-node"; import { SvgNodeContent } from "./nodes/svg-node"; import { PortraitClearanceNodeContent } from "./nodes/portrait-clearance-node"; import { ArtCritiqueNodeContent } from "./nodes/ai-art-critique-node"; export type CanvasNodeContentProps = { node: CanvasNodeData; theme: CanvasTheme; isEditingContent: boolean; textareaRef: RefObject; isBatchRoot: boolean; batchCount: number; batchPreviewNodes?: CanvasNodeData[]; 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; onReloadResource?: (node: CanvasNodeData) => void; onOpenTaskDetails?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; reduceMediaEffects?: boolean; mediaActive?: boolean; onMediaPlayRequest?: (nodeId: string) => void; }; export function CanvasNodeContent(props: CanvasNodeContentProps) { 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.node.type === PORTRAIT_CLEARANCE_NODE_TYPE) return ; if (props.node.type === ART_CRITIQUE_NODE_TYPE) return ; if (props.isBatchRoot) return ; if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; const pluginDefinition = getNodeDefinition(props.node.type)?.plugin; if (pluginDefinition) return ; const Renderer = nodeContentRenderers[props.node.type]; return Renderer ? : ; } function PluginCanvasNodeContent({ node, theme, renderer, schema }: CanvasNodeContentProps & { renderer: "declarative" | "sandbox"; schema: Record }) { if (renderer === "sandbox") { return
插件节点等待隔离运行时
; } const data = node.metadata?.pluginData || {}; const fields = Object.keys(schema.properties && typeof schema.properties === "object" ? schema.properties as Record : schema); return
{fields.length ? fields.map((field) =>
{field} {formatPluginValue(data[field] ?? (field === "content" ? node.metadata?.content : undefined))}
) : {node.metadata?.content || "插件节点"}}
; } function formatPluginValue(value: unknown) { if (value === undefined || value === null || value === "") return "未设置"; return typeof value === "string" ? value : JSON.stringify(value); } const nodeContentRenderers: Partial ReactNode>> = { [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, [CanvasNodeType.Markdown]: MarkdownNodeContent, [CanvasNodeType.Svg]: SvgNodeContent, [CanvasNodeType.Html]: HtmlNodeContent, [CanvasNodeType.Panorama]: PanoramaNodeContent, [CanvasNodeType.Compare]: CompareNodeContent, [CanvasNodeType.Chart]: ChartNodeContent, [CanvasNodeType.ColorGrade]: ColorGradeNodeContent, }; function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) { 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, onOpenTaskDetails }: Pick) { const taskId = node.metadata?.taskId; const displayTask = { provider: node.metadata?.taskProvider, status: (node.metadata?.taskStatus || "running") as GenerationTask["status"], stage: node.metadata?.taskStage, officialStatus: node.metadata?.taskOfficialStatus, errorCode: node.metadata?.taskErrorCode, }; const submissionUncertain = Boolean(taskId) && isGenerationTaskSubmissionUncertain(displayTask); const showsProgress = Boolean(taskId) && generationTaskShowsProgress(displayTask); const progress = showsProgress && typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null; const statusLabel = taskId ? generationTaskStatusLabel(displayTask) : "等待任务状态"; const stageLabel = taskId ? generationTaskStageLabel(displayTask) : "正在创建任务"; const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt); return (
{submissionUncertain ? :
} {stageLabel} {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 shortTaskId(id: string) { if (id.length <= 20) return id; return `${id.slice(0, 14)}...${id.slice(-4)}`; } function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick) { const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); const errorDisplayTask = { provider: node.metadata?.taskProvider, status: (node.metadata?.taskStatus || "failed") as GenerationTask["status"], stage: node.metadata?.taskStage, officialStatus: node.metadata?.taskOfficialStatus, errorCode: node.metadata?.taskErrorCode, }; const submissionUncertain = isGenerationTaskSubmissionUncertain(errorDisplayTask); return (
{submissionUncertain ? generationTaskStatusLabel(errorDisplayTask) : generationErrorMessage(node.metadata?.errorDetails)}
{submissionUncertain ? (
{generationTaskStageLabel(errorDisplayTask)}
) : moderationFailure ? (
修改节点提示词后,可重新点击生成。
) : node.metadata?.resourceReloadAvailable ? (
) : ( )}
); } function UnknownNodeContent({ theme }: Pick) { return
未知节点
; } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) { 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 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 }: CanvasNodeContentProps) { 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 ImageNodeContent(props: CanvasNodeContentProps) { 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, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { 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, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) { const playerBoxRef = useRef(null); const { updateMediaNode } = useCanvasNodeActions(); const { url, loading } = useVideoPlaybackUrl(node, mediaActive); const subtitleEntries = node.metadata?.subtitleEntries || []; const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); const [currentTimeMs, setCurrentTimeMs] = useState(0); const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); useEffect(() => { const box = playerBoxRef.current; const video = box?.querySelector("video"); if (!video) return; const handleLoadedMetadata = () => { if (video.videoWidth <= 0 || video.videoHeight <= 0) return; setVideoSize({ width: video.videoWidth, height: video.videoHeight }); if (node.metadata?.naturalWidth !== video.videoWidth || node.metadata?.naturalHeight !== video.videoHeight) { updateMediaNode?.(node.id, (current) => ({ ...current, metadata: { ...current.metadata, naturalWidth: video.videoWidth, naturalHeight: video.videoHeight } })); } }; video.addEventListener("loadedmetadata", handleLoadedMetadata); handleLoadedMetadata(); if (!subtitleEntries.length) return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata); const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000)); video.addEventListener("timeupdate", handleTimeUpdate); return () => { video.removeEventListener("timeupdate", handleTimeUpdate); video.removeEventListener("loadedmetadata", handleLoadedMetadata); }; }, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]); if (!node.metadata?.content) return } label="空视频节点" color={theme.node.placeholder} />; if (!mediaActive) return onMediaPlayRequest?.(node.id)} />; if (!url) return } label={loading ? "正在加载视频" : "视频资源不可用"} />; const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height); const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio)); const fitWidth = Math.round(fitHeight * sourceRatio); const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs); const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined; return (
scheduleResourceBlobCache(node.metadata?.storageKey || "")} /> {activeEntry && activeEntry.text.trim() ? : null}
); } function inferVideoHasAudio(metadata: CanvasNodeData["metadata"]): boolean | undefined { if (typeof metadata?.hasAudio === "boolean") return metadata.hasAudio; // Generated nodes from older saves may not have `hasAudio` yet. In that // case an explicit generation setting is the only persisted signal we // have; leave all other videos in the unknown state. const value = metadata?.generateAudio?.trim().toLowerCase(); if (["false", "0", "off", "no", "disabled"].includes(value || "")) return false; if (["true", "1", "on", "yes", "enabled"].includes(value || "")) return true; return undefined; } function AudioNodeContent({ node, theme }: CanvasNodeContentProps) { if (!node.metadata?.content && !node.metadata?.storageKey) return } label="空音频节点" color={theme.node.placeholder} />; return ; } function InactiveVideoPreview({ node, theme, onPlay }: Pick & { onPlay: () => void }) { const previewRef = useRef(null); const nearViewport = useNearViewport(previewRef); const previewUrl = canvasNodeVideoPreviewUrl(node); const { updateMetadata } = useCanvasNodeActions(); const updateMetadataRef = useRef(updateMetadata); const [hydrating, setHydrating] = useState(false); useEffect(() => { updateMetadataRef.current = updateMetadata; }, [updateMetadata]); useEffect(() => { if (previewUrl || !nearViewport || !node.metadata?.content || !updateMetadataRef.current) { setHydrating(false); return; } const controller = new AbortController(); setHydrating(true); void hydrateCanvasVideoPreview(node, controller.signal) .then((videoPreview) => { if (!controller.signal.aborted && videoPreview) updateMetadataRef.current?.(node.id, { videoPreview }); }) .catch(() => undefined) .finally(() => { if (!controller.signal.aborted) setHydrating(false); }); return () => controller.abort(); }, [nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]); if (previewUrl) { return
{`${node.title
; } return
} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : nearViewport ? "点击播放视频" : "进入视口后加载首帧"} theme={theme} />
; } function VideoPreviewPlayButton({ title, onPlay }: { title: string; onPlay: () => void }) { return ; } function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) { const rawContent = node.metadata?.content || ""; const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent; const storageKey = node.metadata?.storageKey || ""; const [url, setUrl] = useState(""); const [loading, setLoading] = useState(false); useEffect(() => { let cancelled = false; if (!active) { setUrl(""); setLoading(false); return; } setLoading(true); void resolveMediaUrl(storageKey, fallback) .then((resolved) => { if (!cancelled) setUrl(resolved); }) .catch(() => { if (!cancelled) setUrl(fallback); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [active, fallback, storageKey]); return { url, loading }; } function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) { return
{icon}{title}{hint}
; } function MediaLoadingState({ icon, label }: { icon: ReactNode; label: string }) { return
{icon}{label}
; } function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: string; color: string }) { return
{icon}{label}
; } function ImageContent({ node, theme, isBatchRoot, batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: Pick) { const imageContainerRef = useRef(null); const nearViewport = useNearViewport(imageContainerRef); const { url, loading } = useNodeResourceUrl(node, nearViewport); const importedFromLibTV = node.metadata?.importSource?.provider === "libtv"; const { updateMediaNode } = useCanvasNodeActions(); const measuredSizeRef = useRef<{ width: number; height: number } | null>(null); /** * 让节点跟随图片真实比例。 * * 上传接口的 width/height 是可选的(services/api/resources.ts),拿不到时节点会落到 * 默认的横向比例,竖图就被放进一个宽盒子、两侧留黑。这里在图片解码后量真实尺寸并校正。 * * 判据是「用户有没有手动定过尺寸」,**不是**「有没有量过尺寸」——后者会让所有已经 * 存过 naturalWidth 的旧节点永远得不到修正(第一版就是这么写的,所以没生效)。 * 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。 */ const fitToImage = (element: HTMLImageElement) => { // LibTV 已提供原图尺寸和节点尺寸;960px 缩略图不能反向覆盖这些数据。 if (importedFromLibTV) return; const naturalWidth = element.naturalWidth; const naturalHeight = element.naturalHeight; if (!naturalWidth || !naturalHeight) return; if (measuredSizeRef.current?.width === naturalWidth && measuredSizeRef.current.height === naturalHeight) return; measuredSizeRef.current = { width: naturalWidth, height: naturalHeight }; updateMediaNode?.(node.id, (current) => { const metadata = current.metadata; const needsMetadata = metadata?.naturalWidth !== naturalWidth || metadata?.naturalHeight !== naturalHeight; if (current.metadata?.freeResize || current.metadata?.manualSize) { return needsMetadata ? { ...current, metadata: { ...metadata, naturalWidth, naturalHeight } } : current; } const size = fitNodeSize(naturalWidth, naturalHeight); const needsResize = Math.abs(size.width - current.width) >= 1 || Math.abs(size.height - current.height) >= 1; if (!needsMetadata && !needsResize) return current; return { ...current, ...(needsResize ? size : {}), metadata: needsMetadata ? { ...metadata, naturalWidth, naturalHeight } : metadata, }; }); }; return (
{url ? {node.title} event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> :
{loading ? : }
}
); } function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { const storageKey = node.metadata?.storageKey || ""; const rawContent = node.metadata?.content || ""; const content = node.type === CanvasNodeType.Video && node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent; // `previewContent` is intentionally passive-only. When a media node is // activated, VideoPlayer/Audio must receive the playable asset, never the // LibTV OSS snapshot URL stored for the thumbnail. const fallback = node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio ? content : node.metadata?.previewContent || (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content); const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey)); // Inline data URLs are already local, but decoding thousands of them is // still expensive. Images must wait for the same viewport gate as remote // resources; otherwise DOM virtualization does not reduce image work. const isLazyVisual = node.type === CanvasNodeType.Image; const [url, setUrl] = useState(isRemoteResource || isLazyVisual ? "" : fallback); const [loading, setLoading] = useState(isRemoteResource && eager); useEffect(() => { let cancelled = false; if (!isRemoteResource) { setUrl(isLazyVisual && !eager ? "" : fallback); setLoading(false); return; } setUrl(""); setLoading(eager); // 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。 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, isLazyVisual, isRemoteResource, storageKey]); return { url, loading }; } function useNearViewport(ref: 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; } export function CanvasNodeImageInfo({ 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 BatchPreviewImage({ node }: { node: CanvasNodeData }) { const ref = useRef(null); const nearViewport = useNearViewport(ref); const { url } = useNodeResourceUrl(node, nearViewport); return
{url ? {`子图预览:${node.title}`} : null}
; } function BatchFrame({ batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchPreviewNodes?: CanvasNodeData[]; 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) => (
{batchPreviewNodes?.[index] ? : null}
))}
) : null} {children}
); }