lovart3/components/canvas/CanvasWorkspace.tsx
Your Name bb4f51d198 feat: improve canvas workflow and auto title
Hide internal prompts from users, auto-focus new images, avoid overlap, enable drag-and-drop upload, and fix canvas navigation reloads.
2026-02-03 16:33:51 +08:00

457 lines
16 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";
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<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);
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 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<number | null>(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 handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer?.types?.includes?.("Files")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
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, updateNode, viewport.x, viewport.y, viewport.zoom]);
// 渲染节点
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}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
<div
className="absolute inset-0 pointer-events-none dark:hidden"
style={{
backgroundImage: `radial-gradient(rgba(0,0,0,0.08) 1px, transparent 1px)`,
backgroundSize: `20px 20px`,
}}
/>
{/* 深色模式点阵(固定大小,不随缩放变化) */}
<div
className="absolute inset-0 pointer-events-none hidden dark:block"
style={{
backgroundImage: `radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)`,
backgroundSize: `20px 20px`,
}}
/>
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<div className="absolute inset-0" style={{ overflow: 'visible' }}>
{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-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newZoom = Math.max(0.1, viewport.zoom - 0.1);
setViewport({ zoom: newZoom });
}}
title="缩小"
>
<Minus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 min-w-[50px] text-xs font-medium"
onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })}
title="重置缩放"
>
{Math.round(viewport.zoom * 100)}%
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newZoom = Math.min(4, viewport.zoom + 0.1);
setViewport({ zoom: newZoom });
}}
title="放大"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}