import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { buildNodeContext } from "@/lib/canvas/plugin-node-context"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; const selectionBlue = "#2f80ff"; type CanvasNodeProps = { data: CanvasNodeData; scale: number; isSelected: boolean; isRelated: boolean; isFocusRelated: boolean; isConnectionTarget: boolean; isConnecting: boolean; editRequestNonce?: number; showPanel: boolean; showImageInfo: boolean; resourceLabel?: CanvasResourceReference; mentionReferences?: CanvasResourceReference[]; pluginHost?: CanvasPluginHost; registryVersion?: number; renderPanel?: (node: CanvasNodeData) => ReactNode; renderNodeContent?: (node: CanvasNodeData) => ReactNode; batchCount?: number; groupChildCount?: number; isGroupDropTarget?: boolean; batchExpanded?: boolean; batchClosing?: boolean; batchOpening?: boolean; batchRecovering?: boolean; batchMotion?: { x: number; y: number; index: number }; onMouseDown: (event: React.MouseEvent, nodeId: string) => void; onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void; onHoverStart: (nodeId: string) => void; onHoverEnd: (nodeId: string) => void; onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void; onResize: (nodeId: string, width: number, height: number, position?: Position) => void; onContentChange: (nodeId: string, content: string) => void; onTitleChange: (nodeId: string, title: string) => void; onToggleBatch?: (nodeId: string) => void; onSetBatchPrimary?: (node: CanvasNodeData) => void; onRetry?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void; onViewImage?: (node: CanvasNodeData) => void; onContextMenu: (event: React.MouseEvent, nodeId: string) => void; }; type NodeContentRendererProps = { node: CanvasNodeData; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; isEditingContent: boolean; textareaRef: React.RefObject; isBatchRoot: boolean; batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; renderNodeContent?: (node: CanvasNodeData) => ReactNode; pluginContext?: CanvasNodeContext | null; onContentChange: (nodeId: string, content: string) => void; onStopEditing: () => void; mentionReferences: CanvasResourceReference[]; onRetry?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; onSetBatchPrimary?: () => void; groupChildCount: number; }; export const CanvasNode = React.memo(function CanvasNode({ data, scale, isSelected, isRelated, isFocusRelated, isConnectionTarget, isConnecting, editRequestNonce = 0, showPanel, showImageInfo, resourceLabel, mentionReferences = [], pluginHost, renderPanel, renderNodeContent, batchCount = 0, groupChildCount = 0, isGroupDropTarget = false, batchExpanded = false, batchClosing = false, batchOpening = false, batchRecovering = false, batchMotion, onMouseDown, onSelectCapture, onHoverStart, onHoverEnd, onConnectStart, onResize, onContentChange, onTitleChange, onToggleBatch, onSetBatchPrimary, onRetry, onGenerateImage, onViewImage, onContextMenu, }: CanvasNodeProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [hovered, setHovered] = useState(false); const definition = getNodeDefinition(data.type); const pluginContext = useMemo(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]); 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 isGroup = data.type === CanvasNodeType.Group; const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1; // 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。 // forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。 const supportsInteractionToggle = Boolean(definition?.interactionToggle); const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false; const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive); const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId); // 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位 const transparentBg = Boolean(definition?.transparentBackground); const isActive = isConnectionTarget || isSelected || isFocusRelated; const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent"; const textareaRef = useRef(null); const titleInputRef = useRef(null); const resizeRef = useRef({ isResizing: false, corner: "bottom-right" as ResizeCorner, startX: 0, startY: 0, startLeft: 0, startTop: 0, startWidth: 0, startHeight: 0, keepRatio: false, ratio: 1, }); useEffect(() => { setTitleDraft(data.title || ""); }, [data.title]); useEffect(() => { if (!isEditingTitle) return; titleInputRef.current?.focus(); titleInputRef.current?.select(); }, [isEditingTitle]); const finishTitleEditing = useCallback(() => { const title = titleDraft.trim() || data.title || "未命名节点"; setTitleDraft(title); setIsEditingTitle(false); if (title !== data.title) onTitleChange(data.id, title); }, [data.id, data.title, onTitleChange, titleDraft]); useEffect(() => { if (!isEditingTitle) return; const handleOutsidePointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && titleInputRef.current?.contains(target)) return; finishTitleEditing(); }; window.addEventListener("pointerdown", handleOutsidePointerDown, true); return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true); }, [finishTitleEditing, isEditingTitle]); 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 (!editRequestNonce || data.type !== CanvasNodeType.Text) return; setIsEditingContent(true); }, [data.type, editRequestNonce]); 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 minWidth = 220; const minHeight = 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, onResize, scale], ); 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 || Boolean(definition?.keepAspectRatio?.(data)), 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]); return (
{ setHovered(true); onHoverStart(data.id); }} onMouseLeave={() => { setHovered(false); onHoverEnd(data.id); }} onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)} onContextMenu={(event) => onContextMenu(event, data.id)} >
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> {isEditingTitle ? ( setTitleDraft(event.target.value)} onBlur={finishTitleEditing} onKeyDown={(event) => { if (event.key === "Enter") finishTitleEditing(); if (event.key === "Escape") { setTitleDraft(data.title || ""); setIsEditingTitle(false); } }} /> ) : ( )}
onMouseDown(event, data.id)} onDoubleClick={(event) => { if (isBatchRoot) { event.stopPropagation(); onToggleBatch?.(data.id); return; } if (definition?.onDoubleClick && pluginContext) { if (definition.onDoubleClick(pluginContext)) event.stopPropagation(); return; } if (data.type === CanvasNodeType.Image && hasImageContent) { event.stopPropagation(); onViewImage?.(data); return; } if (data.type !== CanvasNodeType.Text) return; event.stopPropagation(); setIsEditingContent(true); }} >
setIsEditingContent(false)} onRetry={onRetry} onGenerateImage={onGenerateImage} onToggleBatch={() => onToggleBatch?.(data.id)} onSetBatchPrimary={() => onSetBatchPrimary?.(data)} groupChildCount={groupChildCount} />
{showImageInfo && hasImageContent ? : null} {resourceLabel ? : null} {!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ?
: null}
{!isGroup ? onConnectStart(event, data.id, "target")} /> : null} {!isGroup ? onConnectStart(event, data.id, "source")} /> : null} {showPanel && !isGroup && renderPanel ?
{renderPanel(data)}
: null}
); }); function NodeContent(props: NodeContentRendererProps) { if (props.node.type === CanvasNodeType.Config && props.renderNodeContent) return props.renderNodeContent(props.node); if (props.isBatchRoot) return ; if (props.node.metadata?.status === "loading") return ; if (props.node.metadata?.status === "error") return ; const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType]; if (Renderer) return ; // 插件节点:有注册渲染器则渲染,否则展示缺少插件占位 const definition = getNodeDefinition(props.node.type); if (definition?.Content && props.pluginContext) { const PluginContent = definition.Content; return ; } return ; } const nodeContentRenderers = { [CanvasNodeType.Text]: TextContent, [CanvasNodeType.Image]: ImageNodeContent, [CanvasNodeType.Config]: EmptyImageContent, [CanvasNodeType.Video]: VideoNodeContent, [CanvasNodeType.Audio]: AudioNodeContent, [CanvasNodeType.Group]: GroupNodeContent, } satisfies Record ReactNode>; function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) { return (
{groupChildCount} 个节点
); } function LoadingContent({ theme }: Pick) { return (
生成中
); } function ErrorContent({ node, theme, onRetry }: Pick) { return (
{node.metadata?.errorDetails || "生成失败"}
); } function MissingPluginContent({ theme, type }: Pick & { type: string }) { return (
缺少插件 节点类型 “{type}” 的插件未安装或未启用
); } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) { const fontSize = node.metadata?.fontSize || 14; const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties; 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()} /> ) : (
event.stopPropagation()} > {node.metadata?.content || 双击编辑文字}
)}
); } function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) { return ( {reference.label} ); } function ImageNodeContent(props: NodeContentRendererProps) { if (!props.node.metadata?.content && props.isBatchRoot) { const content = props.node.metadata?.status === "loading" ? ( ) : props.node.metadata?.status === "error" ? ( ) : ( ); return ( {content} ); } if (!props.node.metadata?.content) return ; return ( ); } function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) { const content = (
空图片节点
); if (isBatchRoot) return ( {content} ); return content; } function VideoNodeContent({ node, theme }: NodeContentRendererProps) { if (!node.metadata?.content) return (
); return