Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-node.tsx
WindC0X 0e251c3c8f feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强
合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
2026-08-15 15:48:16 +08:00

682 lines
37 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 React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { useThemeStore } from "@/stores/use-theme-store";
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 { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
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;
forceInputVisible?: 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;
};
export const CanvasNode = React.memo(function CanvasNode({
data,
dragOffset,
scale,
isSelected,
isRelated,
isFocusRelated,
isConnectionTarget,
forceInputVisible = false,
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 hasReplaceAction = !readOnly && (data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video);
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 nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle";
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
style={{
transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`,
width: data.width,
height: data.height,
contain: "layout style",
}}
onMouseEnter={() => {
setHovered(true);
onHoverStart(data.id);
}}
onMouseLeave={() => {
setHovered(false);
onHoverEnd(data.id);
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<NodeExternalHeader
node={data}
scale={scale}
active={hovered || isSelected || isFocusRelated}
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
editing={isEditingTitle}
draft={titleDraft}
theme={theme}
onDraftChange={setTitleDraft}
onEdit={() => setIsEditingTitle(true)}
onCommit={commitTitle}
onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }}
/>
<div
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
data-node-state={nodeState}
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
style={{
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
}}
onMouseDown={(event) => 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);
}}
>
<div
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
animation: data.metadata?.batchRootId ? (batchClosing ? `canvas-batch-child-out var(--motion-dur-base-calc) var(--motion-ease-in-out) both` : `canvas-batch-child-in var(--motion-dur-slow-calc) var(--motion-ease-out) both`) : undefined,
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
} as React.CSSProperties
}
>
{/* 节点状态徽章(对应 #97 决策2:左上角 loading/success/error,近距离确认信号)*/}
{data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? (
<NodeStatusBadge status={data.metadata.status} />
) : null}
<CanvasNodeContent
node={data}
theme={theme}
isEditingContent={isEditingContent}
textareaRef={textareaRef}
isBatchRoot={isBatchRoot}
batchCount={batchCount}
batchExpanded={batchExpanded}
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
drawingProjectId={drawingProjectId}
mentionReferences={mentionReferences}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
onRetry={onRetry}
onCancelTask={onCancelTask}
onOpenTaskDetails={onOpenTaskDetails}
onToggleBatch={() => onToggleBatch?.(data.id)}
reduceMediaEffects={reduceMediaEffects}
/>
</div>
{hasReplaceAction ? (
<div
className={`absolute right-3 top-3 z-[var(--node-z-overlay)] motion-safe:transition motion-safe:duration-200 ${!hasMediaContent || isSelected || hovered ? "opacity-100" : "pointer-events-none opacity-0"}`}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="canvas-node-inline-action grid size-9 place-items-center p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ outlineColor: theme.accent.primary }}
onClick={(event) => { event.stopPropagation(); onReplaceMedia?.(data); }}
aria-label={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
title={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
>
{hasMediaContent ? <Replace className="size-3.5" /> : <Upload className="size-3.5" />}
</button>
</div>
) : null}
{data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
<div
className={`absolute bottom-[10%] left-1/2 z-[var(--node-z-overlay)] -translate-x-1/2 motion-safe:transition motion-safe:duration-200 ${isSelected ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"}`}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="canvas-node-inline-action inline-flex h-9 items-center gap-2 px-3 text-xs font-medium backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ outlineColor: theme.accent.primary }}
onClick={(event) => { event.stopPropagation(); onOpenTextEditor?.(data); }}
aria-label="放大编辑文本"
>
<Maximize2 className="size-3.5" />
放大编辑
</button>
</div>
) : null}
{data.metadata?.versionLabel ? (
<button
type="button"
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
style={{
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
outlineColor: theme.accent.primary,
}}
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
aria-label={data.metadata.versionLabel + (data.metadata.versionPrimary ? ",主版本" : "") + ",查看版本对比"}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onOpenVersions?.(data); }}
>
{data.metadata.versionLabel}
</button>
) : null}
{showStatusTrack ? (
<div className={`absolute top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${hasReplaceAction ? "right-14" : "right-3"} ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
{isBatchChild && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}
{data.metadata?.locked ? <NodeLockBadge theme={theme} /> : null}
</div>
) : null}
{assetTags.length || (showImageInfo && hasImageContent) ? (
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
{showImageInfo && hasImageContent ? <CanvasNodeImageInfo node={data} /> : null}
</div>
) : null}
{!readOnly && !data.metadata?.locked ? <>
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered || forceInputVisible} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>
);
});
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
return (
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", color: reference.active ? theme.accent.onPrimary : "#ffffff", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
{reference.label}
</span>
);
}
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 (
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none" style={{ background, color: active && location !== "local" ? theme.accent.onPrimary : "#ffffff", opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
{resourceStorageLabel(storageKey)}
</span>
);
}
function NodeLockBadge({ theme }: { theme: CanvasTheme }) {
return <span className="pointer-events-none grid size-7 shrink-0 place-items-center rounded-md border backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.muted }} title="节点已锁定"><Lock className="size-3.5" /></span>;
}
function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number; expanded: boolean; theme: CanvasTheme; onToggle: () => void }) {
return (
<button type="button" className="canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur-md" style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }} aria-label={expanded ? "图片组已展开" : "图片组已收起"} onClick={(event) => { event.stopPropagation(); onToggle(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<span className="leading-none" style={{ color: theme.accent.primary }}>{count}</span>
<ChevronRight className={`size-3 opacity-55 transition-transform ${expanded ? "rotate-90" : ""}`} />
</button>
);
}
function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: boolean; selected: boolean; theme: CanvasTheme; onSelect: () => void }) {
return (
<button type="button" className={`canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-medium backdrop-blur-md transition-opacity ${visible ? "opacity-100" : "pointer-events-none opacity-0"}`} style={{ background: theme.toolbar.panel, borderColor: selected ? theme.accent.primary : theme.toolbar.border, color: selected ? theme.accent.primary : theme.node.text }} aria-label={selected ? "当前主图" : "设置为主图"} aria-pressed={selected} onClick={(event) => { event.stopPropagation(); onSelect(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Star className={`size-3 ${selected ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />
{selected ? "当前主图" : "主图"}
</button>
);
}
function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1">
{tags.map((tag, index) => (
<span
key={`${tag}-${index}`}
className="max-w-full truncate rounded-md border px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none backdrop-blur-sm"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
>
{tag.trim()}
</span>
))}
</div>
);
}
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 <div className={`absolute z-[var(--node-z-handle)] size-7 ${positionClass}`} onMouseDown={(event) => 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 (
<div
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
style={{ maxWidth: maxHeaderWidth, color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Icon className="size-3 shrink-0" strokeWidth={1.8} />
{editing ? (
<input
autoFocus
value={draft}
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded border bg-transparent px-1.5 text-xs font-medium outline-none"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: "var(--elevation-card)" }}
onChange={(event) => 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 ? (
<button type="button" className="group flex min-w-0 flex-1 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:ring-1" style={{ opacity: active ? 1 : 0.78, "--tw-ring-color": theme.node.activeStroke } as React.CSSProperties} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
<span className="min-w-0 flex-1 truncate" title={node.title}>{node.title}</span>
<Pencil className="size-2.5 shrink-0 opacity-55 transition-opacity group-hover:opacity-100" />
</button>
) : (
<span className="min-w-0 flex-1 truncate text-xs font-medium" title={node.title} style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
)}
</div>
);
}
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 (
<div
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
style={{ background: "color-mix(in oklch, var(--status-loading) 20%, transparent)", color: "var(--status-loading)" }}
aria-label="生成中"
>
<span className="size-1.5 animate-pulse rounded-full" style={{ background: "var(--status-loading)" }} />
<span className="text-[var(--fs-micro)] font-medium leading-none">生成中</span>
</div>
);
}
if (status === "error") {
return (
<div
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
style={{ background: "color-mix(in oklch, var(--status-error) 20%, transparent)", color: "var(--status-error)" }}
aria-label="生成失败"
>
<AlertCircle className="size-3" strokeWidth={2} />
<span className="text-[var(--fs-micro)] font-medium leading-none">失败</span>
</div>
);
}
// success:短暂闪现后淡出(由 CSS animation 控制,2s 后消失)
return (
<div
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
style={{ background: "color-mix(in oklch, var(--status-success) 20%, transparent)", color: "var(--status-success)", animation: "canvas-status-success-fade 2s ease-out forwards" }}
aria-label="生成完成"
>
<CheckCircle2 className="size-3" strokeWidth={2} />
<span className="text-[var(--fs-micro)] font-medium leading-none">完成</span>
</div>
);
}
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<HTMLSpanElement>(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<HTMLButtonElement>) => {
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 (
<button
type="button"
className={`group absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 outline-none transition-opacity duration-150 ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
onPointerEnter={updateAnchor}
onPointerMove={updateAnchor}
onPointerLeave={resetAnchor}
onPointerDown={(event) => onPointerDown(event, anchorRatioRef.current)}
aria-label={`${side === "left" ? "输入" : "输出"}连接点,单击创建节点或拖动连线`}
>
<span
ref={handleRef}
className="absolute grid -translate-y-1/2 place-items-center rounded-full border transition-[background-color,box-shadow] duration-150 group-hover:brightness-125 group-focus-visible:brightness-125"
style={{
top: "50%",
width: 18 * inverseScale,
height: 18 * inverseScale,
...(side === "left" ? { right: 6 * inverseScale } : { left: 6 * inverseScale }),
borderWidth: inverseScale,
background: theme.spatial.elevated,
borderColor: theme.node.activeStroke,
color: theme.node.activeStroke,
boxShadow: "none",
}}
>
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />
</span>
</button>
);
}