Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-node-hover-toolbar.tsx

555 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Segmented, Tag } from "antd";
import { Download, Ellipsis, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas";
import { ImageToolSettingsModal } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, isImageQuickToolId, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
type CanvasNodeHoverToolbarProps = {
node: CanvasNodeData | null;
viewport: ViewportTransform;
containerRef: RefObject<HTMLDivElement | null>;
onKeep: (nodeId: string) => void;
onLeave: () => void;
onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void;
onAnnotate: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
onPortraitTexture: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
onSplit: (node: CanvasNodeData) => void;
onUpscale: (node: CanvasNodeData) => void;
onSuperResolve: (node: CanvasNodeData) => void;
onAngle: (node: CanvasNodeData) => void;
onViewImage: (node: CanvasNodeData) => void;
onExtractVideoLastFrame: (node: CanvasNodeData) => void;
extractingVideoFrame: boolean;
onReversePrompt: (node: CanvasNodeData) => void;
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onToggleLocked: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
workspaceMode?: CanvasWorkspaceMode;
};
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [
{ value: "character", label: "角色" },
{ value: "environment", label: "场景" },
{ value: "wardrobe", label: "服饰" },
{ value: "prop", label: "道具" },
{ value: "weapon", label: "武器" },
{ value: "style", label: "画风" },
{ value: "other", label: "其他" },
];
type ToolbarTool = {
id: string;
title: string;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
danger?: boolean;
disabled?: boolean;
};
const MAX_IMAGE_QUICK_TOOLS = 7;
const NODE_DOCK_LABELS_STORAGE_KEY = "canvas-node-dock-show-labels-v1";
export function CanvasNodeHoverToolbar({
node,
viewport,
containerRef,
onKeep,
onLeave,
onInfo,
onEditText,
onDecreaseFont,
onIncreaseFont,
onToggleDialog,
onAnnotate,
onGenerateImage,
onUpload,
onDownload,
onSaveAsset,
onMaskEdit,
onEmotion,
onPortraitTexture,
onCrop,
onSplit,
onUpscale,
onSuperResolve,
onAngle,
onViewImage,
onExtractVideoLastFrame,
extractingVideoFrame,
onReversePrompt,
onRetry,
onToggleFreeResize,
onToggleLocked,
onDelete,
workspaceMode = "professional",
}: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showDockLabels, setShowDockLabels] = useState(true);
const [draftShowDockLabels, setDraftShowDockLabels] = useState(true);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const [imageToolMenuOpen, setImageToolMenuOpen] = useState(false);
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const { message } = App.useApp();
const copyText = useCopyText();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const simpleMode = workspaceMode === "simple";
useEffect(() => {
try {
const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored) as unknown;
setQuickImageToolIds(readImageQuickToolsConfig(parsed));
} catch {
window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
}
}, []);
useEffect(() => {
setShowDockLabels(window.localStorage.getItem(NODE_DOCK_LABELS_STORAGE_KEY) !== "0");
}, []);
useEffect(() => {
setImageToolSettingsOpen(false);
setImageToolMenuOpen(false);
}, [node?.id]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!node || !container) {
setAnchor(null);
return;
}
const element = container.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(node.id)}"]`);
if (!element) {
setAnchor(null);
return;
}
const update = () => {
const nodeRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2;
const toolbarWidth = toolbarRef.current?.offsetWidth || 0;
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
// 外置节点标题占用节点上方一行,工具 Dock 再向上错开,避免遮挡名称编辑入口。
const top = nodeRect.top - containerRect.top - 42;
if (toolbarRef.current) {
toolbarRef.current.style.left = `${left}px`;
toolbarRef.current.style.top = `${top}px`;
return;
}
setAnchor((current) => current?.left === left && current.top === top ? current : { left, top });
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
resizeObserver.observe(container);
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
const viewportLayer = element.parentElement;
const mutationObserver = new MutationObserver(update);
if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] });
const unsubscribeViewport = subscribeCanvasViewportPreview(container, update);
window.addEventListener("resize", update);
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
unsubscribeViewport();
window.removeEventListener("resize", update);
};
}, [anchor === null, containerRef, node, showDockLabels, viewport.k, viewport.x, viewport.y]);
if (!node || !anchor) return null;
const activeNode = node;
const isImage = node.type === CanvasNodeType.Image;
const isVideo = node.type === CanvasNodeType.Video;
const isAudio = node.type === CanvasNodeType.Audio;
const hasImage = isImage && Boolean(node.metadata?.content);
const hasVideo = isVideo && Boolean(node.metadata?.content);
const hasAudio = isAudio && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId);
const isEditableText = isText && !isCharacterReference;
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isEditableText || isImage || isVideo;
const requiresPromptChange = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
const canRetry = node.metadata?.status === "error" && !requiresPromptChange;
const quickImageToolIdSet = new Set(quickImageToolIds);
const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim();
if (!prompt) {
message.warning("暂无可复制的提示词");
return;
}
copyText(prompt, "提示词已复制");
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
function openImageToolSettings() {
onKeep(activeNode.id);
setImageToolMenuOpen(false);
setDraftImageToolIds(quickImageToolIds);
setDraftShowDockLabels(showDockLabels);
setImageToolSettingsOpen(true);
}
const baseToolbarTools: ToolbarTool[] = [
{ id: "info", title: isCharacterReference ? "查看角色详情" : "查看节点信息", label: isCharacterReference ? "角色详情" : "信息", icon: isCharacterReference ? <UserRound className="size-3.5" /> : <Info className="size-3.5" />, onClick: () => onInfo(node) },
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-3.5" />, onClick: () => onDelete(node), danger: true },
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-3.5" />, onClick: () => onRetry(node) }] : []),
...(hasVideo && !simpleMode ? [{ id: "extractLastFrame", title: extractingVideoFrame ? "正在截取尾帧" : "截取尾帧", label: extractingVideoFrame ? "截取中" : "尾帧", icon: extractingVideoFrame ? <LoaderCircle className="size-3.5 animate-spin" /> : <GalleryHorizontalEnd className="size-3.5" />, onClick: () => onExtractVideoLastFrame(node), disabled: extractingVideoFrame }] : []),
...(hasImage || hasVideo || isEditableText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-3.5" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-3.5" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: isEditableText ? "调用文本模型生成内容" : "编辑", label: isEditableText ? "文本生成" : "编辑", icon: <MessageSquare className="size-3.5" />, onClick: () => onToggleDialog(node) }] : []),
...(isEditableText ? [{ id: "editText", title: "放大编辑文本", label: "放大编辑", icon: <Maximize2 className="size-3.5" />, onClick: () => onEditText(node) }] : []),
...(isEditableText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-3.5" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig && !simpleMode ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-3.5" />, onClick: () => onToggleDialog(node) }] : []),
...(isEditableText && !simpleMode ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-3.5" />, onClick: () => onDecreaseFont(node) }] : []),
...(isEditableText && !simpleMode ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-3.5" />, onClick: () => onIncreaseFont(node) }] : []),
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-3.5" />, onClick: () => onUpload(node) }] : []),
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-3.5" />, onClick: () => onUpload(node) }] : []),
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-3.5" />, onClick: () => onUpload(node) }] : []),
...(hasImage && !simpleMode ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool): tool is ToolbarTool & { id: ImageQuickToolId } => isImageQuickToolId(tool.id));
const temporaryImageToolbarTools = selectableImageToolbarTools.filter((tool) => !quickImageToolIdSet.has(tool.id));
const dockItems: FloatingDockEntry[] = [
...toolbarTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger, disabled: tool.disabled, onClick: () => tool.onClick() })),
{ kind: "separator", id: "node-state-separator" },
{ id: "node-lock", label: node.metadata?.locked ? "解锁节点" : "锁定位置和尺寸", displayLabel: node.metadata?.locked ? "解锁" : "锁定", icon: node.metadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />, active: Boolean(node.metadata?.locked), onClick: () => onToggleLocked(node) },
];
const closeImageToolSettings = () => {
setImageToolSettingsOpen(false);
onLeave();
};
const runTemporaryImageTool = (tool: ToolbarTool) => {
setImageToolMenuOpen(false);
tool.onClick();
};
const handleImageToolMenuOpenChange = (open: boolean) => {
setImageToolMenuOpen(open);
if (open) onKeep(activeNode.id);
else if (!imageToolSettingsOpen) onLeave();
};
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
setDraftImageToolIds((current) => {
const selected = new Set(current);
if (visible && selected.size >= MAX_IMAGE_QUICK_TOOLS) {
message.warning(`最多固定 ${MAX_IMAGE_QUICK_TOOLS} 个快捷工具`);
return current;
}
if (visible) selected.add(id);
else selected.delete(id);
return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id);
});
};
const saveImageToolSettings = () => {
setQuickImageToolIds(draftImageToolIds);
setShowDockLabels(draftShowDockLabels);
window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(draftImageToolIds));
window.localStorage.setItem(NODE_DOCK_LABELS_STORAGE_KEY, draftShowDockLabels ? "1" : "0");
closeImageToolSettings();
};
const dockShellStyle = canvasDockStyle(theme, theme.node.text);
const embeddedDockStyle = { ...dockShellStyle, background: "transparent", borderColor: "transparent", boxShadow: "none" };
return (
<>
<div
ref={toolbarRef}
className="canvas-node-toolbar absolute z-[70] flex -translate-x-1/2 -translate-y-full items-end justify-center overflow-visible"
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: `min(calc(100% - 20px), ${showDockLabels ? 840 : 560}px)`, color: theme.node.text }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => {
if (!imageToolSettingsOpen && !imageToolMenuOpen) onLeave();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className={`aceternity-floating-dock thin-scrollbar relative flex max-w-full overflow-x-auto rounded-[14px] border backdrop-blur-2xl ${showDockLabels ? "h-11 items-center px-2 py-1" : "h-10 items-end gap-1 px-1.5 pb-1"}`} style={showDockLabels ? { ...dockShellStyle, boxShadow: `0 18px 52px ${theme.spatial.shadow}` } : dockShellStyle}>
{dockItems.length ? <FloatingDock embedded items={dockItems} size="compact" showLabels={showDockLabels} ariaLabel="节点快捷工具" className={`pointer-events-auto shrink-0 ${showDockLabels ? "" : "max-w-[min(calc(100vw-20px),400px)]"}`} style={embeddedDockStyle} /> : null}
{hasImage && !simpleMode ? (
<Dropdown
open={imageToolMenuOpen}
trigger={["click"]}
placement="topRight"
onOpenChange={handleImageToolMenuOpenChange}
menu={{
items: [
...temporaryImageToolbarTools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, danger: tool.danger, onClick: () => runTemporaryImageTool(tool) })),
...(temporaryImageToolbarTools.length ? [{ type: "divider" as const }] : []),
{ key: "manage-image-quick-tools", icon: <Settings2 className="size-3.5" />, label: "管理快捷工具", onClick: openImageToolSettings },
],
}}
>
<button
type="button"
className={`aceternity-dock-command pointer-events-auto shrink-0 outline-none focus-visible:ring-2 ${showDockLabels ? "inline-flex h-8 items-center justify-center gap-1.5 rounded-[9px] px-2.5" : "grid size-8 place-items-center rounded-full"}`}
style={{ color: theme.node.text, "--tw-ring-color": theme.accent.primary } as CSSProperties}
aria-label="更多图片工具"
title="更多图片工具"
>
<Ellipsis className="size-3.5" />
{showDockLabels ? <span className="inline-flex h-4 items-center text-[11px] font-medium leading-none">更多</span> : null}
</button>
</Dropdown>
) : null}
</div>
</div>
{hasImage ? (
<ImageToolSettingsModal
open={imageToolSettingsOpen}
tools={selectableImageToolbarTools}
selectedIds={draftImageToolIds}
showLabels={draftShowDockLabels}
onToggle={setDraftImageToolVisible}
onShowLabelsChange={setDraftShowDockLabels}
onCancel={closeImageToolSettings}
onSave={saveImageToolSettings}
/>
) : null}
</>
);
}
export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, readOnly = false, onUnauthorized }: { node: CanvasNodeData | null; open: boolean; onClose: () => void; onMetadataChange?: (nodeId: string, metadata: Partial<CanvasNodeMetadata>) => void; readOnly?: boolean; onUnauthorized?: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [view, setView] = useState<"info" | "json">("info");
const [assetTags, setAssetTags] = useState<string[]>([]);
const [assetTagInput, setAssetTagInput] = useState("");
const [assetCategory, setAssetCategory] = useState<CanvasAssetCategory>("other");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
const nodeTypeLabel = node?.type === CanvasNodeType.Text ? "文本" : node?.type === CanvasNodeType.Script ? "分镜脚本" : node?.type === CanvasNodeType.Skill ? "技能" : node?.type === CanvasNodeType.Image ? "图片" : node?.type === CanvasNodeType.Video ? "视频" : node?.type === CanvasNodeType.Audio ? "音频" : node?.type === CanvasNodeType.Drawing ? "绘图" : node?.type === CanvasNodeType.Frame ? "背板" : "生成配置";
const json = useMemo(() => {
if (!node) return "";
return JSON.stringify(
node,
(key, value) => {
if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
return value;
},
2,
);
}, [node]);
useEffect(() => {
if (open) setView("info");
}, [node?.id, open]);
useEffect(() => {
setAssetTags(node?.metadata?.assetTags || []);
setAssetTagInput("");
setAssetCategory(node ? canvasNodeAssetCategory(node) : "other");
}, [node?.id, node?.metadata?.assetCategory, node?.metadata?.assetTags]);
const saveAssetCategory = (category: CanvasAssetCategory) => {
if (!node || node.type !== CanvasNodeType.Image) return;
setAssetCategory(category);
onMetadataChange?.(node.id, { assetCategory: category });
};
const saveAssetTags = (nextTags: string[]) => {
if (!node || node.type !== CanvasNodeType.Image) return;
const tags = Array.from(new Set(nextTags.map((item) => item.trim()).filter(Boolean)));
setAssetTags(tags);
onMetadataChange?.(node.id, { assetTags: tags });
};
const addAssetTag = () => {
const tags = assetTagInput
.split(/\n|,|,/)
.map((item) => item.trim())
.filter(Boolean);
if (!tags.length) return;
saveAssetTags([...assetTags, ...tags]);
setAssetTagInput("");
};
const removeAssetTag = (tag: string) => {
saveAssetTags(assetTags.filter((item) => item !== tag));
};
const title = (
<div className="flex items-center justify-between gap-4 pr-10">
<div className="min-w-0">
<div className="text-[17px] font-semibold tracking-[-0.02em]">节点信息</div>
{node ? <div className="mt-0.5 truncate text-xs opacity-45">{node.id}</div> : null}
</div>
<Segmented
size="small"
value={view}
onChange={(value) => setView(value as "info" | "json")}
options={[
{ label: "信息", value: "info" },
{ label: "JSON", value: "json" },
]}
/>
</div>
);
return (
<Modal
className="canvas-node-info-modal"
title={title}
open={open && Boolean(node)}
centered
footer={null}
width={720}
onCancel={onClose}
styles={{ body: { paddingTop: 8 } }}
>
{node ? (
<div className="h-[min(68vh,640px)] min-h-[420px] text-sm" style={{ color: theme.node.text }}>
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-4 overflow-auto pr-1">
<div className="grid gap-2 rounded-2xl border p-3" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
<InfoRow label="类型" value={nodeTypeLabel} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
</div>
{node.type === CanvasNodeType.Image ? (
<div className="border-t pt-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 text-xs font-medium opacity-45">项目资产分类</div>
<div className="flex flex-wrap gap-1.5">
{assetCategoryOptions.map((option) => {
const active = assetCategory === option.value;
return <button key={option.value} type="button" disabled={readOnly} onClick={() => saveAssetCategory(option.value)} className="h-7 rounded-md border px-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60" style={{ borderColor: active ? theme.accent.primary : theme.toolbar.border, background: active ? theme.accent.primarySoft : theme.toolbar.panel, color: active ? theme.accent.primary : theme.node.muted }}>{option.label}</button>;
})}
</div>
<div className="mt-2 text-[11px] leading-5 opacity-45">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</div>
</div>
) : null}
{node.metadata?.prompt ? (
<div className="rounded-xl border px-3 py-2" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="mb-1 text-xs font-medium opacity-45">提示词</div>
<div className="whitespace-pre-wrap break-words leading-6">{node.metadata.prompt}</div>
</div>
) : null}
{node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot ? (
<div className="rounded-xl border px-3 py-2" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<div className="mb-1 text-xs font-medium opacity-45">技能模板</div>
<div className="whitespace-pre-wrap break-words leading-6">{node.metadata.skillSnapshot.template}</div>
{node.metadata.skillSnapshot.outputContract ? (
<>
<div className="mb-1 mt-3 text-xs font-medium opacity-45">输出约束</div>
<div className="whitespace-pre-wrap break-words leading-6">{node.metadata.skillSnapshot.outputContract}</div>
</>
) : null}
</div>
) : null}
</div>
{node.type === CanvasNodeType.Image ? (
<div className="rounded-2xl border p-3" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">资产标签</div>
<div className="mt-0.5 text-xs opacity-45">一条标签描述一个角色、环境、道具或镜头用途。</div>
</div>
<span className="shrink-0 text-xs opacity-45">{assetTags.length} 条</span>
</div>
{readOnly ? (
<div className="mb-2 rounded-lg border px-3 py-2 text-xs opacity-55" style={{ borderColor: theme.toolbar.border }}>
分享画布为只读,标签无法编辑。
</div>
) : (
<div className="flex gap-2">
<Input
value={assetTagInput}
placeholder="例如:角色: 张三"
onChange={(event) => setAssetTagInput(event.target.value)}
onPressEnter={addAssetTag}
/>
<Button type="primary" icon={<Plus className="size-4" />} disabled={!assetTagInput.trim()} onClick={addAssetTag}>
加入
</Button>
</div>
)}
<div className="mt-3 flex min-h-10 flex-wrap gap-2 rounded-xl border px-2 py-2" style={{ borderColor: theme.toolbar.border, background: theme.node.fill }}>
{assetTags.length ? (
assetTags.map((tag) => (
<Tag key={tag} closable={!readOnly} onClose={() => (readOnly ? onUnauthorized?.() : removeAssetTag(tag))} className="!m-0 !rounded-lg !px-2 !py-1 !text-sm">
{tag}
</Tag>
))
) : (
<span className="px-1 py-1 text-xs opacity-40">{readOnly ? "暂无标签" : "还没有标签,输入后点击“加入”或按 Enter。"}</span>
)}
</div>
</div>
) : null}
{node.metadata?.errorDetails ? (
<div className="rounded-2xl border p-3 text-red-500" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails}
</div>
) : null}
</div>
) : (
<pre className="thin-scrollbar h-full overflow-auto rounded-2xl border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
{json}
</pre>
)}
</div>
) : null}
</Modal>
);
}
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="rounded-xl border px-3 py-2" style={{ borderColor: "rgba(148,163,184,.22)" }}>
<div className="mb-1 text-xs font-medium opacity-45">{label}</div>
<div className="min-w-0 whitespace-pre-wrap break-words text-sm font-medium leading-5">{value}</div>
</div>
);
}