lovart3/components/canvas/CanvasWorkspace.tsx
Your Name c7ffcda87b feat: add infinite canvas feature with AI image generation
- Add canvas project and node database tables
- Create canvas workspace with drag, resize, zoom and pan
- Add ImageNode with background removal, enhance, extend, angle edit, scene fusion
- Add VideoNode with playback controls
- Add TextNode for text editing
- Integrate AI image generation API (Gemini/Alibaba)
- Implement viewport culling for performance optimization
- Add canvas entry in top navigation bar
2026-01-24 19:48:13 +08:00

271 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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"
)}
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"
>
{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-[#1a1a2e]",
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"
style={{
backgroundImage: `
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 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>
);
}