"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 [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); const [marqueeStart, setMarqueeStart] = useState(null); const [marqueeEnd, setMarqueeEnd] = useState(null); const [isFileDragActive, setIsFileDragActive] = useState(false); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const dragCounterRef = useRef(0); const dragPointerRef = useRef({ x: 0, y: 0 }); const groupDragRef = useRef<{ nodeId: string; startX: number; startY: number; originPositions: Record; } | null>(null); // 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 selectNodes = useCanvasStore((state) => state.selectNodes); 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 isMacPlatform = React.useMemo(() => { if (typeof navigator === "undefined") return false; return /Mac|iPhone|iPad|iPod/i.test(navigator.platform); }, []); const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; return { x: clientX - rect.left, y: clientY - rect.top, }; }, []); const isPointOnNodeElement = useCallback((target: EventTarget | null) => { if (!(target instanceof HTMLElement)) return false; return !!target.closest("[data-canvas-node-id]"); }, []); // 处理画布点击(取消选择) const handleCanvasClick = useCallback((e: React.MouseEvent) => { if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { clearSelection(); } }, [clearSelection, isMarqueeSelecting, isPointOnNodeElement]); // 处理画布平移开始 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(); return; } if (tool === "select" && e.button === 0 && !isPointOnNodeElement(e.target)) { const start = toCanvasPosition(e.clientX, e.clientY); setIsMarqueeSelecting(true); setMarqueeStart(start); setMarqueeEnd(start); } }, [isPointOnNodeElement, toCanvasPosition, tool, viewport]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); if (isPanning) { setViewport({ x: e.clientX - panStart.x, y: e.clientY - panStart.y, }); return; } if (isMarqueeSelecting) { setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY)); } }, [isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]); // 处理画布平移结束 const handleMouseUp = useCallback(() => { setIsPanning(false); if (isMarqueeSelecting && marqueeStart && marqueeEnd) { const left = Math.min(marqueeStart.x, marqueeEnd.x); const top = Math.min(marqueeStart.y, marqueeEnd.y); const right = Math.max(marqueeStart.x, marqueeEnd.x); const bottom = Math.max(marqueeStart.y, marqueeEnd.y); const minSize = 4; if (right - left < minSize && bottom - top < minSize) { clearSelection(); } else { const selectedIds = nodes .filter((node) => { const x = node.position.x * viewport.zoom + viewport.x; const y = node.position.y * viewport.zoom + viewport.y; const width = node.size.width * viewport.zoom; const height = node.size.height * viewport.zoom; const nodeRight = x + width; const nodeBottom = y + height; return !(nodeRight < left || x > right || nodeBottom < top || y > bottom); }) .map((node) => node.id); if (selectedIds.length > 0) { selectNodes(selectedIds); } else { clearSelection(); } } } setIsMarqueeSelecting(false); setMarqueeStart(null); setMarqueeEnd(null); }, [clearSelection, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]); // 新增图片后自动居中展示 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(); e.stopPropagation(); // mac: 双指滚动默认平移,Cmd/Ctrl+滚轮才缩放(保留触控板体验) // windows/linux: 鼠标滚轮默认缩放画布,不触发整页缩放 const shouldZoom = isMacPlatform ? (e.ctrlKey || e.metaKey) : !e.shiftKey; if (!shouldZoom) { setViewport({ x: viewport.x - e.deltaX, y: viewport.y - e.deltaY, }); return; } const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05; const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * zoomFactor)); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; 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 }); }, [isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number; originalWidth: number; originalHeight: number; }> => { return new Promise((resolve) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const maxSize = 500; const originalWidth = img.naturalWidth; const originalHeight = img.naturalHeight; let width = originalWidth; let height = originalHeight; 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, originalWidth, originalHeight }); }; img.onerror = () => { URL.revokeObjectURL(url); resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 }); }; img.src = url; }); }, []); const processDroppedFiles = useCallback(async (files: File[], dropClientX: number, dropClientY: number) => { if (files.length === 0) return; const rect = containerRef.current?.getBoundingClientRect(); const baseX = rect ? (dropClientX - rect.left - viewport.x) / viewport.zoom : 100; const baseY = rect ? (dropClientY - rect.top - viewport.y) / viewport.zoom : 100; const spacing = 20; for (let index = 0; index < files.length; index += 1) { const file = files[index]; if (!file.type.startsWith("image/")) continue; if (file.size > 50 * 1024 * 1024) { toast.error("图片大小不能超过 50MB"); continue; } const dimensions = await getImageDimensionsFromFile(file); const cleanName = file.name.replace(/\.[^.]+$/, ""); 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: cleanName || "拖拽上传", title: cleanName || "拖拽上传", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, }, }, connections: [], }); try { const result = await imageGenerationService.uploadImage(file); if (result.success && result.data?.url) { updateNode(placeholderId, { data: { src: result.data.url, status: "completed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, }, }, }); toast.success("图片已添加到画布"); } else { updateNode(placeholderId, { data: { src: "", status: "failed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", }, }); toast.error(result.error || "图片上传失败"); } } catch (_error) { updateNode(placeholderId, { data: { src: "", status: "failed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", }, }); toast.error("图片上传失败"); } } }, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); React.useEffect(() => { const handleWindowDrop = async (e: DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = e.clientX || dragPointerRef.current.x + rect.left; const y = e.clientY || dragPointerRef.current.y + rect.top; const inContainer = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; if (!inContainer) return; const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); if (files.length === 0) return; e.preventDefault(); dragCounterRef.current = 0; setIsFileDragActive(false); await processDroppedFiles(files, x, y); }; window.addEventListener("drop", handleWindowDrop); return () => window.removeEventListener("drop", handleWindowDrop); }, [isFileDrag, processDroppedFiles]); const handleDragEnter = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragCounterRef.current += 1; setIsFileDragActive(true); dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); }, [isFileDrag, toCanvasPosition]); const handleDragLeave = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) { setIsFileDragActive(false); } }, [isFileDrag]); const handleDragOver = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); }, [isFileDrag, toCanvasPosition]); const handleDrop = useCallback(async (e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; setIsFileDragActive(false); const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); if (files.length > 0) { await processDroppedFiles(files, e.clientX, e.clientY); } }, [isFileDrag, processDroppedFiles]); // 渲染节点 const renderNode = (node: CanvasNode) => { const isSelected = selectedNodeIds.includes(node.id); return ( { if (!isSelected) { selectNode(node.id); groupDragRef.current = null; return; } if (selectedNodeIds.length > 1) { const originPositions: Record = {}; nodes.forEach((item) => { if (selectedNodeIds.includes(item.id)) { originPositions[item.id] = { ...item.position }; } }); groupDragRef.current = { nodeId: node.id, startX: node.position.x * viewport.zoom + viewport.x, startY: node.position.y * viewport.zoom + viewport.y, originPositions, }; } else { groupDragRef.current = null; } }} onDrag={(e, d) => { const group = groupDragRef.current; if (!group || group.nodeId !== node.id || selectedNodeIds.length <= 1) return; const deltaCanvasX = (d.x - group.startX) / viewport.zoom; const deltaCanvasY = (d.y - group.startY) / viewport.zoom; selectedNodeIds.forEach((id) => { const origin = group.originPositions[id]; if (!origin) return; moveNode(id, { x: origin.x + deltaCanvasX, y: origin.y + deltaCanvasY, }); }); }} onDragStop={(e, d) => { const group = groupDragRef.current; if (group && group.nodeId === node.id && selectedNodeIds.length > 1) { const deltaCanvasX = (d.x - group.startX) / viewport.zoom; const deltaCanvasY = (d.y - group.startY) / viewport.zoom; selectedNodeIds.forEach((id) => { const origin = group.originPositions[id]; if (!origin) return; moveNode(id, { x: origin.x + deltaCanvasX, y: origin.y + deltaCanvasY, }); }); groupDragRef.current = null; pushHistory(); return; } const newX = (d.x - viewport.x) / viewport.zoom; const newY = (d.y - viewport.y) / viewport.zoom; moveNode(node.id, { x: newX, y: newY }); groupDragRef.current = null; 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" )} data-canvas-node-id={node.id} 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 (
{/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
{/* 深色模式点阵(固定大小,不随缩放变化) */}
{isFileDragActive && (
松开鼠标即可添加图片到画布
)} {isMarqueeSelecting && marqueeStart && marqueeEnd && (
)} {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
{visibleNodes.map(renderNode)}
{/* 性能指示器(节点数量大于 50 时显示) */} {nodes.length >= 50 && (
渲染: {visibleNodes.length}/{nodes.length}
)} {/* 空状态提示 */} {nodes.length === 0 && (

画布是空的

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

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