"use client"; import React, { useRef, useState, useCallback, useMemo } from "react"; import { Rnd } from "react-rnd"; import { motion } from "framer-motion"; import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import type { CanvasNode, Position } from "@/types/canvas"; import { ImageNode } from "./nodes/ImageNode"; import { TextNode } from "./nodes/TextNode"; import { VideoNode } from "./nodes/VideoNode"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { imageGenerationService } from "@/services/canvas"; import { Minus, Plus } from "lucide-react"; // 性能优化:判断节点是否在视口内 function isNodeInViewport( node: CanvasNode, viewport: { x: number; y: number; zoom: number }, containerSize: { width: number; height: number } ): boolean { const nodeScreenX = node.position.x * viewport.zoom + viewport.x; const nodeScreenY = node.position.y * viewport.zoom + viewport.y; const nodeScreenWidth = node.size.width * viewport.zoom; const nodeScreenHeight = node.size.height * viewport.zoom; // 添加缓冲区(200px),使即将进入视口的节点也被渲染 const buffer = 200; return ( nodeScreenX + nodeScreenWidth > -buffer && nodeScreenX < containerSize.width + buffer && nodeScreenY + nodeScreenHeight > -buffer && nodeScreenY < containerSize.height + buffer ); } export function CanvasWorkspace() { const containerRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const dragCounterRef = useRef(0); // Store state const nodes = useCanvasStore(canvasSelectors.nodes); const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const viewport = useCanvasStore(canvasSelectors.viewport); const tool = useCanvasStore(canvasSelectors.tool); const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId); // 性能优化:监听容器大小变化 React.useEffect(() => { const updateSize = () => { if (containerRef.current) { setContainerSize({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight, }); } }; updateSize(); window.addEventListener('resize', updateSize); return () => window.removeEventListener('resize', updateSize); }, []); const isFileDrag = useCallback((dataTransfer?: DataTransfer | null) => { if (!dataTransfer) return false; const types = Array.from(dataTransfer.types || []); if ( types.includes("Files") || types.includes("application/x-moz-file") || types.includes("public.file-url") ) { return true; } const items = dataTransfer.items; if (items && Array.from(items).some((item) => item.kind === "file")) { return true; } return false; }, []); // 防止浏览器默认打开文件 React.useEffect(() => { const handleWindowDragOver = (e: DragEvent) => { if (isFileDrag(e.dataTransfer)) { e.preventDefault(); } }; const handleWindowDrop = (e: DragEvent) => { if (isFileDrag(e.dataTransfer)) { e.preventDefault(); } }; window.addEventListener("dragover", handleWindowDragOver); window.addEventListener("drop", handleWindowDrop); return () => { window.removeEventListener("dragover", handleWindowDragOver); window.removeEventListener("drop", handleWindowDrop); }; }, [isFileDrag]); // 性能优化:只渲染视口内的节点(虚拟化) const visibleNodes = useMemo(() => { // 如果节点数量少于 50,直接渲染所有节点 if (nodes.length < 50) return nodes; return nodes.filter(node => isNodeInViewport(node, viewport, containerSize) || selectedNodeIds.includes(node.id) // 选中的节点总是渲染 ); }, [nodes, viewport, containerSize, selectedNodeIds]); // Store actions const setViewport = useCanvasStore((state) => state.setViewport); const selectNode = useCanvasStore((state) => state.selectNode); const clearSelection = useCanvasStore((state) => state.clearSelection); const addNode = useCanvasStore((state) => state.addNode); const moveNode = useCanvasStore((state) => state.moveNode); const resizeNode = useCanvasStore((state) => state.resizeNode); const pushHistory = useCanvasStore((state) => state.pushHistory); const updateNode = useCanvasStore((state) => state.updateNode); const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId); const centerTimeoutRef = useRef(null); // 处理画布点击(取消选择) const handleCanvasClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { clearSelection(); } }, [clearSelection]); // 处理画布平移开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) { setIsPanning(true); setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y }); e.preventDefault(); } }, [tool, viewport]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { if (isPanning) { setViewport({ x: e.clientX - panStart.x, y: e.clientY - panStart.y, }); } }, [isPanning, panStart, setViewport]); // 处理画布平移结束 const handleMouseUp = useCallback(() => { setIsPanning(false); }, []); // 新增图片后自动居中展示 const centerNodeInView = useCallback((nodeId: string) => { const node = nodes.find(n => n.id === nodeId); if (!node) return; const width = containerSize.width || 0; const height = containerSize.height || 0; if (width === 0 || height === 0) return; const zoom = viewport.zoom; const centerX = node.position.x + node.size.width / 2; const centerY = node.position.y + node.size.height / 2; const newX = width / 2 - centerX * zoom; const newY = height / 2 - centerY * zoom; setViewport({ x: newX, y: newY }); }, [containerSize.width, containerSize.height, nodes, setViewport, viewport.zoom]); React.useEffect(() => { if (!lastAddedNodeId) return; if (containerSize.width === 0 || containerSize.height === 0) return; if (centerTimeoutRef.current) { window.clearTimeout(centerTimeoutRef.current); } centerTimeoutRef.current = window.setTimeout(() => { centerNodeInView(lastAddedNodeId); clearLastAddedNodeId(); }, 80); return () => { if (centerTimeoutRef.current) { window.clearTimeout(centerTimeoutRef.current); centerTimeoutRef.current = null; } }; }, [lastAddedNodeId, centerNodeInView, clearLastAddedNodeId, containerSize.width, containerSize.height]); // 处理滚轮缩放 const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); // Shift + 滚轮 = 平移 if (e.shiftKey) { setViewport({ x: viewport.x - e.deltaY, y: viewport.y - e.deltaX, }); return; } // 默认滚轮 = 缩放(以鼠标位置为中心) const delta = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * delta)); const rect = containerRef.current?.getBoundingClientRect(); if (rect) { const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const scaleChange = newZoom / viewport.zoom; const newX = mouseX - (mouseX - viewport.x) * scaleChange; const newY = mouseY - (mouseY - viewport.y) * scaleChange; setViewport({ x: newX, y: newY, zoom: newZoom }); } }, [viewport, setViewport]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number }> => { return new Promise((resolve) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const maxSize = 500; let width = img.naturalWidth; let height = img.naturalHeight; if (width > maxSize || height > maxSize) { const scale = Math.min(maxSize / width, maxSize / height); width = Math.round(width * scale); height = Math.round(height * scale); } const minSize = 150; if (width < minSize && height < minSize) { const scale = minSize / Math.min(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } URL.revokeObjectURL(url); resolve({ width, height }); }; img.onerror = () => { URL.revokeObjectURL(url); resolve({ width: 300, height: 300 }); }; img.src = url; }); }, []); const handleDragEnter = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragCounterRef.current += 1; }, [isFileDrag]); const handleDragLeave = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); }, [isFileDrag]); const handleDragOver = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }, [isFileDrag]); const handleDrop = useCallback(async (e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); if (files.length === 0) return; const rect = containerRef.current?.getBoundingClientRect(); const baseX = rect ? (e.clientX - rect.left - viewport.x) / viewport.zoom : 100; const baseY = rect ? (e.clientY - rect.top - viewport.y) / viewport.zoom : 100; const spacing = 20; for (let index = 0; index < files.length; index++) { const file = files[index]; if (file.size > 20 * 1024 * 1024) { toast.error("图片大小不能超过 20MB"); continue; } const dimensions = await getImageDimensionsFromFile(file); const placeholderId = addNode({ type: "image", position: { x: baseX + index * spacing, y: baseY + index * spacing }, size: { width: dimensions.width, height: dimensions.height }, data: { src: "", status: "generating", prompt: file.name || "拖拽上传", }, connections: [], }); try { const result = await imageGenerationService.uploadImage(file); if (result.success && result.data?.url) { updateNode(placeholderId, { data: { src: result.data.url, status: "completed", prompt: file.name || "拖拽上传", }, }); toast.success("图片已添加到画布"); } else { updateNode(placeholderId, { data: { src: "", status: "failed", prompt: file.name || "拖拽上传", }, }); toast.error(result.error || "图片上传失败"); } } catch (error) { updateNode(placeholderId, { data: { src: "", status: "failed", prompt: file.name || "拖拽上传", }, }); toast.error("图片上传失败"); } } }, [addNode, getImageDimensionsFromFile, isFileDrag, updateNode, viewport.x, viewport.y, viewport.zoom]); // 渲染节点 const renderNode = (node: CanvasNode) => { const isSelected = selectedNodeIds.includes(node.id); return ( { if (!isSelected) { selectNode(node.id); } }} onDragStop={(e, d) => { const newX = (d.x - viewport.x) / viewport.zoom; const newY = (d.y - viewport.y) / viewport.zoom; moveNode(node.id, { x: newX, y: newY }); pushHistory(); }} onResizeStop={(e, direction, ref, delta, position) => { const newWidth = parseInt(ref.style.width) / viewport.zoom; const newHeight = parseInt(ref.style.height) / viewport.zoom; const newX = (position.x - viewport.x) / viewport.zoom; const newY = (position.y - viewport.y) / viewport.zoom; resizeNode(node.id, { width: newWidth, height: newHeight }); moveNode(node.id, { x: newX, y: newY }); pushHistory(); }} bounds="parent" enableResizing={isSelected && tool === 'select'} disableDragging={tool !== 'select'} className={cn( "absolute", isSelected && "ring-2 ring-primary ring-offset-2" )} style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 onClick={(e: React.MouseEvent) => { e.stopPropagation(); selectNode(node.id, e.shiftKey); }} > {node.type === 'image' && ( )} {node.type === 'text' && ( )} {node.type === 'video' && ( )} {node.type === 'message' && (
{(node.data as any).content}
)}
); }; return (
{/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
{/* 深色模式点阵(固定大小,不随缩放变化) */}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
{visibleNodes.map(renderNode)}
{/* 性能指示器(节点数量大于 50 时显示) */} {nodes.length >= 50 && (
渲染: {visibleNodes.length}/{nodes.length}
)} {/* 空状态提示 */} {nodes.length === 0 && (

画布是空的

点击顶部"添加"按钮开始创作

)} {/* 缩放控制器 */}
); }