350 lines
21 KiB
TypeScript
350 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
|
|
import { BookOpenCheck, FileText, Image as ImageIcon, Music2, Pencil, Video } from "lucide-react";
|
|
|
|
import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
|
|
import { CachedResourceImage } from "@/components/cached-resource-image";
|
|
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
|
import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale";
|
|
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
|
import { producedModelLabel } from "@/lib/canvas/produced-model";
|
|
import { useConfigStore } from "@/stores/use-config-store";
|
|
import { useUserStore } from "@/stores/use-user-store";
|
|
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
|
import type { CanvasTheme } from "@/lib/canvas-theme";
|
|
import { formatBytes } from "@/lib/image-utils";
|
|
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
|
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
|
|
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
|
import { CanvasVideoPreviewImage } from "./canvas-video-preview-image";
|
|
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";
|
|
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 { ArtCritiqueNodeContent } from "./nodes/ai-art-critique-node";
|
|
import { MediaConversionNodeContent } from "./nodes/media-conversion-node";
|
|
import { MEDIA_CONVERSION_NODE_TYPE } from "@/lib/media-conversion/contracts";
|
|
import { AudioNodeContent, EmptyImageContent, ImageNodeContent, VideoNodeContent } from "./canvas-node-media-content";
|
|
import { ErrorContent, LoadingContent, UnknownNodeContent } from "./canvas-node-status-content";
|
|
|
|
export type CanvasNodeContentProps = {
|
|
node: CanvasNodeData;
|
|
theme: CanvasTheme;
|
|
renderLOD?: CanvasNodeRenderLOD;
|
|
isEditingContent: boolean;
|
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
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) {
|
|
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
|
|
// Keep the image renderer mounted while node LOD changes. Visibility still gates first load.
|
|
if (props.node.type === CanvasNodeType.Image && props.renderLOD !== "full" && (props.node.metadata?.content || props.node.metadata?.storageKey)) {
|
|
return <ImageNodeContent {...props} />;
|
|
}
|
|
if (props.renderLOD === "shell") return <CanvasNodeShellContent node={props.node} theme={props.theme} />;
|
|
if (props.renderLOD === "preview") return <CanvasNodePreviewContent node={props.node} theme={props.theme} />;
|
|
const hasCustomContent =
|
|
props.node.type === CanvasNodeType.Config ||
|
|
props.node.type === CanvasNodeType.Script ||
|
|
props.node.type === CanvasNodeType.BatchTable ||
|
|
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 === ART_CRITIQUE_NODE_TYPE) return <ArtCritiqueNodeContent node={props.node} />;
|
|
if (props.node.type === MEDIA_CONVERSION_NODE_TYPE) return <MediaConversionNodeContent node={props.node} theme={props.theme} />;
|
|
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
|
|
if (props.node.metadata?.status === "loading") return <LoadingContent node={props.node} theme={props.theme} onOpenTaskDetails={props.onOpenTaskDetails} />;
|
|
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} onReloadResource={props.onReloadResource} />;
|
|
|
|
const pluginDefinition = getNodeDefinition(props.node.type)?.plugin;
|
|
if (pluginDefinition) return <PluginCanvasNodeContent {...props} renderer={pluginDefinition.renderer} schema={pluginDefinition.schema} />;
|
|
const Renderer = nodeContentRenderers[props.node.type];
|
|
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
|
}
|
|
|
|
function CanvasNodeShellContent({ node, theme }: { node: CanvasNodeData; theme: CanvasTheme }) {
|
|
const Icon = node.type === CanvasNodeType.Video ? Video : node.type === CanvasNodeType.Audio ? Music2 : node.type === CanvasNodeType.Image ? ImageIcon : FileText;
|
|
return (
|
|
<div className="flex size-full flex-col items-center justify-center gap-2 rounded-[var(--node-radius)] px-3 text-center" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
|
<Icon className="size-6 opacity-40" />
|
|
<span className="max-w-full truncate text-xs font-medium" title={node.title}>{node.title || "画布节点"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CanvasNodePreviewContent({ node, theme }: { node: CanvasNodeData; theme: CanvasTheme }) {
|
|
if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) {
|
|
return <CachedResourceImage storageKey={node.metadata?.storageKey} src={node.metadata?.previewContent || node.metadata?.content} alt={node.title} loading="lazy" decoding="async" draggable={false} className="pointer-events-none block size-full select-none object-contain" fallback={<CanvasNodeShellContent node={node} theme={theme} />} />;
|
|
}
|
|
if (node.type === CanvasNodeType.Video && (node.metadata?.content || node.metadata?.storageKey)) {
|
|
return <CanvasVideoPreviewImage node={node} alt={node.title} loading="lazy" decoding="async" draggable={false} className="pointer-events-none block size-full select-none object-contain" fallback={<CanvasNodeShellContent node={node} theme={theme} />} />;
|
|
}
|
|
return <CanvasNodeShellContent node={node} theme={theme} />;
|
|
}
|
|
|
|
function PluginCanvasNodeContent({ node, theme, renderer, schema }: CanvasNodeContentProps & { renderer: "declarative" | "sandbox"; schema: Record<string, unknown> }) {
|
|
if (renderer === "sandbox") {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center p-4 text-center text-xs" style={{ color: theme.node.placeholder }}>
|
|
插件节点等待隔离运行时
|
|
</div>
|
|
);
|
|
}
|
|
const data = node.metadata?.pluginData || {};
|
|
const fields = Object.keys(schema.properties && typeof schema.properties === "object" ? (schema.properties as Record<string, unknown>) : schema);
|
|
return (
|
|
<div className="flex h-full w-full flex-col gap-3 overflow-auto p-4 pt-10 text-xs" style={{ color: theme.node.text }}>
|
|
{fields.length ? (
|
|
fields.map((field) => (
|
|
<div key={field} className="flex flex-col gap-1">
|
|
<span className="font-medium opacity-60">{field}</span>
|
|
<span className="whitespace-pre-wrap break-words opacity-90">{formatPluginValue(data[field] ?? (field === "content" ? node.metadata?.content : undefined))}</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<span className="whitespace-pre-wrap break-words">{node.metadata?.content || "插件节点"}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatPluginValue(value: unknown) {
|
|
if (value === undefined || value === null || value === "") return "未设置";
|
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
}
|
|
|
|
const nodeContentRenderers: Partial<Record<string, (props: CanvasNodeContentProps) => 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 (
|
|
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.panel, color: theme.node.text }}>
|
|
{previewUrl ? (
|
|
<img src={previewUrl} alt="绘图预览" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
|
|
) : (
|
|
<div className="absolute inset-0 grid place-items-center" style={{ background: theme.node.panel, backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
|
|
<div className="flex flex-col items-center gap-2 rounded-[var(--r-lg)] px-4 py-3" style={{ border: `1px solid ${theme.node.edge}`, background: theme.node.fill, color: theme.node.muted }}>
|
|
<span className="grid size-10 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.edge}`, color: theme.node.text }}>
|
|
<Pencil className="size-5" />
|
|
</span>
|
|
<span className="text-[var(--fs-tiny)] font-medium">打开绘图</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-xs font-semibold" title={node.title || "绘图"}>
|
|
{node.title || "绘图"}
|
|
</div>
|
|
<div className="mt-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
|
{shapeCount} 个图形 · {pageCount} 个页面
|
|
</div>
|
|
</div>
|
|
<Pencil className="size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) {
|
|
const fontSize = canvasTextFontSize(node.width, node.height, node.metadata?.fontSize);
|
|
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 (
|
|
<div className="flex h-full w-full flex-col overflow-hidden pt-10">
|
|
{isEditingContent ? (
|
|
<CanvasResourceMentionTextarea
|
|
ref={textareaRef}
|
|
className="thin-scrollbar m-0 block h-full w-full resize-none appearance-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent px-4 pb-4 pt-0 font-mono outline-none select-text"
|
|
style={textStyle}
|
|
value={node.metadata?.content || ""}
|
|
references={mentionReferences}
|
|
highlightLabels={false}
|
|
onChange={(value) => 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 ? (
|
|
<div
|
|
className="thin-scrollbar block h-full w-full select-text overflow-y-auto break-words bg-transparent px-4 pb-4 font-mono [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-70 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 dark:[&_code]:bg-white/8 [&_h1]:my-2 [&_h1]:text-[1.55em] [&_h1]:font-semibold [&_h2]:my-2 [&_h2]:text-[1.3em] [&_h2]:font-semibold [&_h3]:my-1.5 [&_h3]:text-[1.12em] [&_h3]:font-semibold [&_hr]:my-3 [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-black/90 [&_pre]:p-2 [&_pre]:text-white [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5"
|
|
style={textStyle}
|
|
onWheel={(event) => event.stopPropagation()}
|
|
dangerouslySetInnerHTML={{ __html: richTextHTML }}
|
|
/>
|
|
) : (
|
|
<div className="thin-scrollbar block h-full w-full select-text overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pb-4 pt-0 font-mono" style={textStyle} onWheel={(event) => event.stopPropagation()}>
|
|
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex h-full w-full flex-col overflow-hidden p-4" style={{ color: theme.node.text }}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: `${theme.node.activeStroke}18`, color: theme.node.activeStroke }}>
|
|
<BookOpenCheck className="size-4" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold" title={skill?.name || node.title || "技能"}>
|
|
{skill?.name || node.title || "技能"}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
|
<span>{skillCategoryLabel(skill?.category)}</span>
|
|
<span>·</span>
|
|
<span>{skillOutputModeLabel(skill?.outputMode)}</span>
|
|
{skill?.version ? (
|
|
<>
|
|
<span>·</span>
|
|
<span>v{skill.version}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{skill?.description ? (
|
|
<div className="mt-3 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>
|
|
{skill.description}
|
|
</div>
|
|
) : null}
|
|
<div className="thin-scrollbar mt-3 min-h-0 flex-1 overflow-hidden rounded-[var(--r-md)] px-3 py-2 text-xs leading-5" style={{ background: theme.node.panel, color: theme.node.text }}>
|
|
<div className="mb-1 font-semibold opacity-55">模板</div>
|
|
<div className="line-clamp-4 whitespace-pre-wrap break-words">{template || "未配置技能模板"}</div>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
{tags.length ? (
|
|
tags.map((tag) => (
|
|
<span key={tag} className="rounded-[var(--r-sm)] bg-black/5 px-1.5 py-0.5 text-[var(--fs-tiny)] dark:bg-white/6" style={{ color: theme.node.muted }}>
|
|
{tag}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
|
连接到图片、视频、音频或文本节点后生效
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 "文本";
|
|
}
|
|
|
|
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 (
|
|
<span className="ml-auto max-w-full shrink-0 truncate rounded-[var(--r-sm)] bg-black/55 px-2 py-1 text-[var(--fs-label)] font-medium leading-none text-white backdrop-blur-sm">
|
|
{width} x {height}
|
|
{size ? ` · ${size}` : ""}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function CanvasNodeProducedModel({ stored }: { stored: string }) {
|
|
// Keep catalog subscriptions off the CanvasNode shell and avoid normalizing config per node.
|
|
const channels = useConfigStore((state) => state.config.channels);
|
|
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
|
|
const visibleChannels = useMemo(() => customChannelsEnabled ? channels : channels.filter((channel) => channel.scope === "system"), [channels, customChannelsEnabled]);
|
|
const label = producedModelLabel({ channels: visibleChannels }, stored);
|
|
return <span className="max-w-full min-w-0 truncate rounded-[var(--r-sm)] bg-black/55 px-2 py-1 text-[var(--fs-label)] font-medium leading-none text-white backdrop-blur-sm">{label}</span>;
|
|
}
|