"use client"; import React, { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { ChevronRight, Image as ImageIcon, RefreshCw, Star } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasNodeData, type Position } from "../types"; 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; renderPanel?: (node: CanvasNodeData) => ReactNode; renderNodeContent?: (node: CanvasNodeData) => ReactNode; batchCount?: number; batchExpanded?: boolean; batchClosing?: boolean; batchOpening?: boolean; batchRecovering?: 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.MouseEvent, nodeId: string, handleType: "source" | "target") => void; onResize: (nodeId: string, width: number, height: number, position?: Position) => void; onContentChange: (nodeId: string, content: string) => void; onToggleBatch?: (nodeId: string) => void; onSetBatchPrimary?: (node: CanvasNodeData) => void; onRetry?: (node: CanvasNodeData) => void; onGenerateImage?: (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; onContentChange: (nodeId: string, content: string) => void; onStopEditing: () => void; onRetry?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; onSetBatchPrimary?: () => void; }; export const CanvasNode = React.memo(function CanvasNode({ data, scale, isSelected, isRelated, isFocusRelated, isConnectionTarget, isConnecting, editRequestNonce = 0, showPanel, renderPanel, renderNodeContent, batchCount = 0, batchExpanded = false, batchClosing = false, batchOpening = false, batchRecovering = false, batchMotion, onMouseDown, onHoverStart, onHoverEnd, onConnectStart, onResize, onContentChange, onToggleBatch, onSetBatchPrimary, onRetry, onGenerateImage, onContextMenu, }: CanvasNodeProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [hovered, setHovered] = useState(false); const [isEditingContent, setIsEditingContent] = useState(false); const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content); const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1; const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId); const isActive = isConnectionTarget || isSelected || isFocusRelated; const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent"; 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 (!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, 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); }} onContextMenu={(event) => onContextMenu(event, data.id)} >
onMouseDown(event, data.id)} onDoubleClick={(event) => { if (isBatchRoot) { event.stopPropagation(); onToggleBatch?.(data.id); return; } if (data.type !== CanvasNodeType.Text) return; event.stopPropagation(); setIsEditingContent(true); }} >
setIsEditingContent(false)} onRetry={onRetry} onGenerateImage={onGenerateImage} onToggleBatch={() => onToggleBatch?.(data.id)} onSetBatchPrimary={() => onSetBatchPrimary?.(data)} />
{!hasImageContent ? (
) : null}
onConnectStart(event, data.id, "target")} /> onConnectStart(event, data.id, "source")} /> {showPanel && renderPanel && data.type !== CanvasNodeType.Config ?
{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]; return ; } const nodeContentRenderers = { [CanvasNodeType.Text]: TextContent, [CanvasNodeType.Image]: ImageNodeContent, [CanvasNodeType.Config]: EmptyImageContent, } satisfies Record ReactNode>; function LoadingContent({ theme }: Pick) { return (
生成中
); } function ErrorContent({ node, theme, onRetry, }: Pick) { return (
{node.metadata?.errorDetails || "生成失败"}
); } function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage, }: NodeContentRendererProps) { return (
{isEditingContent ? (