279 lines
9.7 KiB
TypeScript
279 lines
9.7 KiB
TypeScript
"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";
|
||
|
||
// 性能优化:判断节点是否在视口内
|
||
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<HTMLDivElement>(null);
|
||
const [isPanning, setIsPanning] = useState(false);
|
||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
|
||
|
||
// Store state
|
||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||
const tool = useCanvasStore(canvasSelectors.tool);
|
||
|
||
// 性能优化:监听容器大小变化
|
||
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 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 moveNode = useCanvasStore((state) => state.moveNode);
|
||
const resizeNode = useCanvasStore((state) => state.resizeNode);
|
||
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
||
|
||
// 处理画布点击(取消选择)
|
||
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 handleWheel = useCallback((e: React.WheelEvent) => {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
e.preventDefault();
|
||
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 });
|
||
}
|
||
} else {
|
||
// 普通滚动平移
|
||
setViewport({
|
||
x: viewport.x - e.deltaX,
|
||
y: viewport.y - e.deltaY,
|
||
});
|
||
}
|
||
}, [viewport, setViewport]);
|
||
|
||
// 渲染节点
|
||
const renderNode = (node: CanvasNode) => {
|
||
const isSelected = selectedNodeIds.includes(node.id);
|
||
|
||
return (
|
||
<Rnd
|
||
key={node.id}
|
||
position={{
|
||
x: node.position.x * viewport.zoom + viewport.x,
|
||
y: node.position.y * viewport.zoom + viewport.y,
|
||
}}
|
||
size={{
|
||
width: node.size.width * viewport.zoom,
|
||
height: node.size.height * viewport.zoom,
|
||
}}
|
||
scale={1}
|
||
onDragStart={() => {
|
||
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);
|
||
}}
|
||
>
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="w-full h-full"
|
||
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
|
||
>
|
||
{node.type === 'image' && (
|
||
<ImageNode node={node} isSelected={isSelected} />
|
||
)}
|
||
{node.type === 'text' && (
|
||
<TextNode node={node} isSelected={isSelected} />
|
||
)}
|
||
{node.type === 'video' && (
|
||
<VideoNode node={node} isSelected={isSelected} />
|
||
)}
|
||
{node.type === 'message' && (
|
||
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
||
<span className="text-sm">{(node.data as any).content}</span>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
</Rnd>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className={cn(
|
||
"relative w-full h-full overflow-hidden bg-background",
|
||
tool === 'pan' && "cursor-grab",
|
||
isPanning && "cursor-grabbing"
|
||
)}
|
||
onClick={handleCanvasClick}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
onWheel={handleWheel}
|
||
>
|
||
{/* 点阵背景 - 浅色模式 */}
|
||
<div
|
||
className="absolute inset-0 pointer-events-none dark:hidden"
|
||
style={{
|
||
backgroundImage: `radial-gradient(rgba(0,0,0,0.1) 1px, transparent 1px)`,
|
||
backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`,
|
||
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
|
||
}}
|
||
/>
|
||
{/* 深色模式点阵 */}
|
||
<div
|
||
className="absolute inset-0 pointer-events-none hidden dark:block"
|
||
style={{
|
||
backgroundImage: `radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px)`,
|
||
backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`,
|
||
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
|
||
}}
|
||
/>
|
||
|
||
{/* 节点层 - 使用虚拟化渲染 */}
|
||
<div className="absolute inset-0">
|
||
{visibleNodes.map(renderNode)}
|
||
</div>
|
||
|
||
{/* 性能指示器(节点数量大于 50 时显示) */}
|
||
{nodes.length >= 50 && (
|
||
<div className="absolute top-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground">
|
||
渲染: {visibleNodes.length}/{nodes.length}
|
||
</div>
|
||
)}
|
||
|
||
{/* 空状态提示 */}
|
||
{nodes.length === 0 && (
|
||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<div className="text-center text-muted-foreground">
|
||
<p className="text-lg mb-2">画布是空的</p>
|
||
<p className="text-sm">点击顶部"添加"按钮开始创作</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 缩放指示器 */}
|
||
<div className="absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-3 py-1.5 text-xs text-muted-foreground">
|
||
{Math.round(viewport.zoom * 100)}%
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|