import React, { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, ScanSearch, Settings2, Star, Trash2, Type, Video, WandSparkles } from "lucide-react"; import { useCanvasNodeActions } from "./canvas-node-action-context"; import { canvasThemes } from "@/lib/canvas-theme"; import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod"; import { canvasConnectionTilt } from "@/lib/canvas/canvas-connection-tilt"; import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Position } from "@/types/canvas"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts"; import { getNodeDefinition, getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry"; import { CanvasNodeContent, CanvasNodeImageInfo, CanvasNodeProducedModel } 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; renderLOD?: CanvasNodeRenderLOD; dragOffset?: Position; scale: number; isSelected: boolean; mediaActive?: boolean; isRelated: boolean; isFocusRelated: boolean; isConnectionTarget: boolean; connectionApproach?: Position; forceInputVisible?: boolean; showImageInfo: boolean; reduceMediaEffects?: boolean; readOnly?: boolean; resourceLabel?: CanvasResourceReference; mentionReferences?: CanvasResourceReference[]; renderNodeContent?: (node: CanvasNodeData) => ReactNode; drawingProjectId?: string; batchCount?: number; batchPreviewNodes?: CanvasNodeData[]; 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; onReloadResource?: (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; onMediaPlayRequest?: (nodeId: string) => void; onContextMenu: (event: React.MouseEvent, nodeId: string) => void; }; export const CanvasNode = React.memo(function CanvasNode({ data, renderLOD = "full", dragOffset, scale, isSelected, mediaActive = false, isRelated, isFocusRelated, isConnectionTarget, connectionApproach, forceInputVisible = false, showImageInfo, reduceMediaEffects = false, readOnly = false, resourceLabel, mentionReferences = [], renderNodeContent, drawingProjectId, batchCount = 0, batchPreviewNodes, batchExpanded = false, batchClosing = false, batchOpening = false, batchRecovering = false, batchPrimary = false, batchMotion, onMouseDown, onHoverStart, onHoverEnd, onConnectStart, onResize, onTitleChange, onContentChange, onToggleBatch, onSetBatchPrimary, onRetry, onReloadResource, onOpenTaskDetails, onOpenVersions, onViewImage, onOpenTextEditor, onOpenDirector, onOpenDrawing, onMediaPlayRequest, onContextMenu, }: CanvasNodeProps) { const theme = canvasThemes[useActiveTheme()]; const [hovered, setHovered] = useState(false); const [isEditingContent, setIsEditingContent] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(data.title); const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions(); 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 || data.metadata?.storageKey); const mediaDimensionLabel = formatMediaDimensionLabel(data, hasImageContent || hasVideoContent); const isComposerNode = data.type === CanvasNodeType.Config; const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent; const producedModelStored = data.metadata?.producedModel; const showProducedModel = showImageInfo && hasMediaContent && Boolean(producedModelStored); 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 effectiveRenderLOD: CanvasNodeRenderLOD = renderLOD === "full" || hovered || isEditingContent || isEditingTitle || mediaActive ? "full" : renderLOD; const showChrome = effectiveRenderLOD === "full"; const nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle"; const showOutputConnection = getNodeDefinition(data.type)?.showOutputConnection !== false; const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || []; const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null; const textareaRef = 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(() => { 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 minSize = getNodeMinSize(data.type); const minWidth = minSize.width; // 分镜脚本的高度由表格内容动态撑开,覆盖注册表里的静态下限。 const minHeight = scriptMinHeight || minSize.height; 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: shouldKeepAspectRatio(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]); const commitTitle = () => { const next = titleDraft.trim(); setIsEditingTitle(false); if (!next) { setTitleDraft(data.title); return; } if (next !== data.title) onTitleChange?.(data.id, next); }; const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset ? canvasConnectionTilt(data, connectionApproach) : undefined; return (
{ setHovered(true); onHoverStart(data.id); }} onMouseLeave={() => { setHovered(false); onHoverEnd(data.id); }} onContextMenu={(event) => onContextMenu(event, data.id)} > {showChrome ? setIsEditingTitle(true)} onCommit={commitTitle} onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }} /> : null}
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); }} >
{/* 节点状态徽章(对应 #97 决策2:左上角 loading/success/error,近距离确认信号)*/} {showChrome && data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? ( ) : null} setIsEditingContent(false)} onRetry={onRetry} onReloadResource={onReloadResource} onOpenTaskDetails={onOpenTaskDetails} onToggleBatch={() => onToggleBatch?.(data.id)} reduceMediaEffects={reduceMediaEffects} mediaActive={mediaActive} onMediaPlayRequest={onMediaPlayRequest} />
{showChrome && data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
) : null} {showChrome && data.metadata?.versionLabel ? ( ) : null} {showChrome && showStatusTrack ? (
{resourceLabel && data.type !== CanvasNodeType.Image ? : null} {hasMediaContent && !readOnly ? : null} {isBatchRoot ? onToggleBatch?.(data.id)} /> : null} {isBatchChild && !readOnly ? onSetBatchPrimary?.(data)} /> : null} {data.metadata?.locked ? : null}
) : null} {/* 批次子图操作条:成功子项提供下载/副本/设为主图,失败子项提供重试/删除 */} {showChrome && isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
{hasImageContent ? } onClick={() => downloadNode?.(data)} /> : null} {hasImageContent ? } onClick={() => duplicateNode?.(data)} /> : null} {hasImageContent ? ( } onClick={() => onSetBatchPrimary?.(data)} /> ) : null} {data.metadata?.status === "error" && data.metadata.resourceReloadAvailable ? } onClick={() => onReloadResource?.(data)} /> : null} {data.metadata?.status === "error" ? } onClick={() => onRetry?.(data)} /> : null} {data.metadata?.status === "error" ? } danger onClick={() => deleteNode?.(data)} /> : null}
) : null} {/* 批次主图位(折叠根节点封面)常驻下载按钮 */} {showChrome && isBatchRoot && hasImageContent && !readOnly ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> } onClick={() => downloadNode?.(data)} />
) : null} {showChrome && (assetTags.length || showProducedModel || (showImageInfo && hasImageContent)) ? (
{assetTags.length ? : null} {showProducedModel ? : null}
{showImageInfo && hasImageContent ? : null}
) : null} {showChrome && !readOnly && !data.metadata?.locked && (isSelected || hovered) ? <> : null}
{(showChrome || forceInputVisible) && !readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.BatchTable ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} {showChrome && !readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
); }, areCanvasNodePropsEqual); function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProps) { return ( previous.data === next.data && previous.renderLOD === next.renderLOD && previous.dragOffset?.x === next.dragOffset?.x && previous.dragOffset?.y === next.dragOffset?.y && previous.scale === next.scale && previous.isSelected === next.isSelected && previous.mediaActive === next.mediaActive && previous.isRelated === next.isRelated && previous.isFocusRelated === next.isFocusRelated && previous.isConnectionTarget === next.isConnectionTarget && previous.connectionApproach?.x === next.connectionApproach?.x && previous.connectionApproach?.y === next.connectionApproach?.y && previous.forceInputVisible === next.forceInputVisible && previous.showImageInfo === next.showImageInfo && previous.reduceMediaEffects === next.reduceMediaEffects && previous.readOnly === next.readOnly && previous.resourceLabel === next.resourceLabel && previous.mentionReferences === next.mentionReferences && previous.renderNodeContent === next.renderNodeContent && previous.drawingProjectId === next.drawingProjectId && previous.batchCount === next.batchCount && previous.batchPreviewNodes === next.batchPreviewNodes && previous.batchExpanded === next.batchExpanded && previous.batchClosing === next.batchClosing && previous.batchOpening === next.batchOpening && previous.batchRecovering === next.batchRecovering && previous.batchPrimary === next.batchPrimary && previous.batchMotion?.x === next.batchMotion?.x && previous.batchMotion?.y === next.batchMotion?.y && previous.batchMotion?.index === next.batchMotion?.index && previous.onMouseDown === next.onMouseDown && previous.onHoverStart === next.onHoverStart && previous.onHoverEnd === next.onHoverEnd && previous.onConnectStart === next.onConnectStart && previous.onResize === next.onResize && previous.onTitleChange === next.onTitleChange && previous.onContentChange === next.onContentChange && previous.onToggleBatch === next.onToggleBatch && previous.onSetBatchPrimary === next.onSetBatchPrimary && previous.onRetry === next.onRetry && previous.onReloadResource === next.onReloadResource && previous.onOpenTaskDetails === next.onOpenTaskDetails && previous.onOpenVersions === next.onOpenVersions && previous.onViewImage === next.onViewImage && previous.onReplaceMedia === next.onReplaceMedia && previous.onOpenTextEditor === next.onOpenTextEditor && previous.onOpenDirector === next.onOpenDirector && previous.onOpenDrawing === next.onOpenDrawing && previous.onContextMenu === next.onContextMenu ); } function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) { return ( {reference.label} ); } 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 ( {resourceStorageLabel(storageKey)} ); } function NodeLockBadge({ theme }: { theme: CanvasTheme }) { return ; } function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number; expanded: boolean; theme: CanvasTheme; onToggle: () => void }) { return ( ); } function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: boolean; selected: boolean; theme: CanvasTheme; onSelect: () => void }) { return ( ); } function BatchChildActionButton({ theme, label, icon, onClick, danger = false }: { theme: CanvasTheme; label: string; icon: ReactNode; onClick: () => void; danger?: boolean }) { return ( ); } function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
{tags.map((tag, index) => ( {tag.trim()} ))}
); } 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
onMouseDown(event, corner)} />; } const NODE_EXTERNAL_HEADER_MIN_SCALE = 0.35; function formatMediaDimensionLabel(node: CanvasNodeData, hasVisualMediaContent: boolean) { const width = node.metadata?.naturalWidth; const height = node.metadata?.naturalHeight; if (!hasVisualMediaContent || !Number.isFinite(width) || !Number.isFinite(height) || !width || !height || width <= 0 || height <= 0) return null; return `${Math.round(width)}*${Math.round(height)}`; } function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, editing, draft, theme, onDraftChange, onEdit, onCommit, onCancel }: { node: CanvasNodeData; scale: number; dimensionLabel: string | null; 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 (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
{editing ? ( 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 ? ( ) : ( {node.title} )}
{dimensionLabel ? {dimensionLabel} : null}
); } function nodeTypeIcon(type: CanvasNodeTypeId) { 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; if (type === ART_CRITIQUE_NODE_TYPE) return ScanSearch; return Type; } // 节点状态徽章(对应 #97 决策2:左上角状态指示,loading/success/error) function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }) { if (status === "loading") { return (
生成中
); } if (status === "error") { return (
失败
); } // success:短暂闪现后淡出(由 CSS animation 控制,2s 后消失) return (
完成
); } function ConnectionSideRail({ side, scale, theme, visible = false, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; visible?: boolean; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) { const handleRef = useRef(null); const [railHovered, setRailHovered] = useState(false); const inverseScale = 1 / Math.max(scale, 0.05); const railSize = 80; // Keep the control responsive to zoom, but avoid sub-pixel circles and // strokes at far zoom levels where the plus sign appears visually off-center. const handleSize = Math.max(20, 8 * inverseScale); // LibTV centers the visual quick-add icon in an approximately 80px // circular hit zone, then offsets it toward the node edge. Keep that // visual layer separate from the real centered connection anchor. const sideOffset = side === "left" ? 25 : -25; const resetHandle = useCallback(() => { if (!handleRef.current) return; handleRef.current.style.transform = `translate(${sideOffset}px, 0) scale(1)`; }, [sideOffset]); const updateHandle = (event: React.PointerEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); const width = Math.max(bounds.width, 1); const height = Math.max(bounds.height, 1); const followLimit = 30; const deltaX = event.clientX - (bounds.left + width / 2); const deltaY = event.clientY - (bounds.top + height / 2); const offsetScreenX = Math.max(-followLimit, Math.min(followLimit, deltaX)); const offsetScreenY = Math.max(-followLimit, Math.min(followLimit, deltaY)); const focus = 1 + Math.max(0, 1 - Math.hypot(offsetScreenX, offsetScreenY) / followLimit) * 0.1; // While the pointer is inside the rail LibTV uses the rail center as // the origin. The side-specific +/-25px offset is only the resting // position used after leaving the rail. const offsetX = offsetScreenX * inverseScale; const offsetY = offsetScreenY * inverseScale; if (handleRef.current) handleRef.current.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${focus})`; }; return ( ); }