ai-chatbot/components/canvas/CanvasWorkspace.tsx
Your Name 2f33598e57
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: match canvas selected image border and corner handles
2026-02-21 00:22:56 +08:00

1019 lines
36 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 { Download, 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 SNAP_THRESHOLD = 10;
const SNAP_GAP = 12;
const SELECTION_COLOR = "#27a8ff";
const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
const [marqueeStart, setMarqueeStart] = useState<Position | null>(null);
const [marqueeEnd, setMarqueeEnd] = useState<Position | null>(null);
const [isFileDragActive, setIsFileDragActive] = useState(false);
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
const dragCounterRef = useRef(0);
const dragPointerRef = useRef<Position>({ x: 0, y: 0 });
const suppressCanvasClickRef = useRef(false);
const groupDragRef = useRef<{
nodeId: string;
startX: number;
startY: number;
originPositions: Record<string, Position>;
} | 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);
}, []);
// macOS 触控板横向双指滑动会触发浏览器前进/后退,必须使用非 passive 监听阻止默认行为
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventTrackpadHistorySwipe = (event: WheelEvent) => {
if (event.cancelable) {
event.preventDefault();
}
};
container.addEventListener("wheel", preventTrackpadHistorySwipe, { passive: false });
return () => {
container.removeEventListener("wheel", preventTrackpadHistorySwipe);
};
}, []);
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]);
const selectedImageNodes = useMemo(
() =>
nodes.filter(
(node) =>
selectedNodeIds.includes(node.id) &&
node.type === "image" &&
Boolean((node.data as any)?.src)
),
[nodes, 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<number | null>(null);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === "undefined") return false;
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
}, []);
const hasBlockingDialogOpen = useCallback(() => {
if (typeof document === "undefined") return false;
return Boolean(document.querySelector('[role="dialog"][data-state="open"]'));
}, []);
const getSnappedCanvasPosition = useCallback(
(
node: CanvasNode,
target: Position,
ignoreNodeIds: Set<string> = new Set(),
): Position => {
if (node.type !== "image") {
return target;
}
const candidates = nodes.filter(
(item) =>
item.type === "image" &&
item.id !== node.id &&
!ignoreNodeIds.has(item.id),
);
if (candidates.length === 0) {
return target;
}
let bestOffsetX = 0;
let bestDeltaX = Number.POSITIVE_INFINITY;
let bestOffsetY = 0;
let bestDeltaY = Number.POSITIVE_INFINITY;
const selfLeft = target.x;
const selfCenterX = target.x + node.size.width / 2;
const selfRight = target.x + node.size.width;
const selfTop = target.y;
const selfCenterY = target.y + node.size.height / 2;
const selfBottom = target.y + node.size.height;
const pickBestX = (delta: number) => {
const abs = Math.abs(delta);
if (abs <= SNAP_THRESHOLD && abs < bestDeltaX) {
bestDeltaX = abs;
bestOffsetX = delta;
}
};
const pickBestY = (delta: number) => {
const abs = Math.abs(delta);
if (abs <= SNAP_THRESHOLD && abs < bestDeltaY) {
bestDeltaY = abs;
bestOffsetY = delta;
}
};
for (const candidate of candidates) {
const targetLeft = candidate.position.x;
const targetCenterX = candidate.position.x + candidate.size.width / 2;
const targetRight = candidate.position.x + candidate.size.width;
const targetTop = candidate.position.y;
const targetCenterY = candidate.position.y + candidate.size.height / 2;
const targetBottom = candidate.position.y + candidate.size.height;
pickBestX(targetLeft - selfLeft);
pickBestX(targetCenterX - selfCenterX);
pickBestX(targetRight - selfRight);
pickBestX(targetRight + SNAP_GAP - selfLeft);
pickBestX(targetLeft - SNAP_GAP - selfRight);
pickBestY(targetTop - selfTop);
pickBestY(targetCenterY - selfCenterY);
pickBestY(targetBottom - selfBottom);
pickBestY(targetBottom + SNAP_GAP - selfTop);
pickBestY(targetTop - SNAP_GAP - selfBottom);
}
return {
x: target.x + (Number.isFinite(bestDeltaX) ? bestOffsetX : 0),
y: target.y + (Number.isFinite(bestDeltaY) ? bestOffsetY : 0),
};
},
[nodes, SNAP_GAP],
);
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], [data-canvas-overlay]");
}, []);
const isEventFromCanvasContainer = useCallback((target: EventTarget | null) => {
if (!(target instanceof Node) || !containerRef.current) return false;
return containerRef.current.contains(target);
}, []);
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
return;
}
if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
clearSelection();
}
}, [clearSelection, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
// 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
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);
}
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) {
if (isPanning) setIsPanning(false);
return;
}
if (!isEventFromCanvasContainer(e.target)) return;
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));
}
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]);
// 处理画布平移结束
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
if (e && !isEventFromCanvasContainer(e.target)) return;
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);
suppressCanvasClickRef.current = true;
} else {
clearSelection();
}
}
}
setIsMarqueeSelecting(false);
setMarqueeStart(null);
setMarqueeEnd(null);
}, [clearSelection, isEventFromCanvasContainer, 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) => {
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (e.cancelable) {
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 });
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
toast.error("请先框选要下载的图片");
return;
}
if (isDownloadSubmitting) {
return;
}
if (selectedImageNodes.length > 50) {
toast.warning("当前选择图片较多,正在打包,请稍候");
}
const now = new Date();
const folderName = `canvas-images-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
const browserOrigin = typeof window !== "undefined" ? window.location.origin : "";
const files = selectedImageNodes
.map((node, index) => {
const nodeData = node.data as any;
const rawImageUrl = String(nodeData?.src || "").trim();
if (!rawImageUrl) return null;
let imageUrl = rawImageUrl;
if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`;
}
const name =
String(nodeData?.title || "").trim() ||
String(nodeData?.prompt || "").trim() ||
`canvas-image-${index + 1}`;
return { url: imageUrl, name };
})
.filter((item): item is { url: string; name: string } => Boolean(item));
if (files.length === 0) {
toast.error("当前没有可下载的图片");
return;
}
setIsDownloadSubmitting(true);
try {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/canvas/download-zip";
form.style.display = "none";
const payloadInput = document.createElement("input");
payloadInput.type = "hidden";
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName,
files,
});
form.appendChild(payloadInput);
document.body.appendChild(form);
form.submit();
form.remove();
toast.success("已开始打包下载,请留意浏览器下载栏");
} catch (_error) {
toast.error("下载失败,请稍后重试");
} finally {
window.setTimeout(() => {
setIsDownloadSubmitting(false);
}, 800);
}
}, [isDownloadSubmitting, selectedImageNodes]);
// 渲染节点
const renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id);
const isImageNode = node.type === "image";
const enableImageResizeHandles =
isSelected && tool === "select" && isImageNode
? {
top: false,
right: false,
bottom: false,
left: false,
topRight: true,
bottomRight: true,
bottomLeft: true,
topLeft: true,
}
: isSelected && tool === "select";
const imageResizeHandleStyles = isImageNode
? {
topRight: {
width: 16,
height: 16,
right: -8,
top: -8,
borderRadius: 999,
border: `3px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
bottomRight: {
width: 16,
height: 16,
right: -8,
bottom: -8,
borderRadius: 999,
border: `3px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
bottomLeft: {
width: 16,
height: 16,
left: -8,
bottom: -8,
borderRadius: 999,
border: `3px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
topLeft: {
width: 16,
height: 16,
left: -8,
top: -8,
borderRadius: 999,
border: `3px solid ${SELECTION_COLOR}`,
backgroundColor: "#ffffff",
},
}
: undefined;
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);
groupDragRef.current = null;
return;
}
if (selectedNodeIds.length > 1) {
const originPositions: Record<string, Position> = {};
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;
const origin = group.originPositions[node.id];
const rawAnchor = origin
? {
x: origin.x + deltaCanvasX,
y: origin.y + deltaCanvasY,
}
: {
x: node.position.x + deltaCanvasX,
y: node.position.y + deltaCanvasY,
};
const snappedAnchor = getSnappedCanvasPosition(
node,
rawAnchor,
new Set(selectedNodeIds),
);
const offsetX = origin ? snappedAnchor.x - origin.x : deltaCanvasX;
const offsetY = origin ? snappedAnchor.y - origin.y : deltaCanvasY;
selectedNodeIds.forEach((id) => {
const itemOrigin = group.originPositions[id];
if (!itemOrigin) return;
moveNode(id, {
x: itemOrigin.x + offsetX,
y: itemOrigin.y + offsetY,
});
});
groupDragRef.current = null;
pushHistory();
return;
}
const newX = (d.x - viewport.x) / viewport.zoom;
const newY = (d.y - viewport.y) / viewport.zoom;
const snapped = getSnappedCanvasPosition(node, { x: newX, y: newY });
moveNode(node.id, snapped);
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={enableImageResizeHandles}
resizeHandleStyles={imageResizeHandleStyles}
disableDragging={tool !== 'select'}
cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'}
className={cn(
"absolute",
tool === "select" && "cursor-pointer"
)}
data-canvas-node-id={node.id}
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 overscroll-none touch-none bg-background",
tool === 'pan' && "cursor-grab",
isPanning && "cursor-grabbing"
)}
onClick={handleCanvasClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
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`,
}}
/>
{isFileDragActive && (
<div className="absolute inset-0 pointer-events-none z-30 flex items-center justify-center bg-primary/5 border-2 border-dashed border-primary/40 rounded-lg">
<div className="px-4 py-2 rounded-md bg-background/90 text-sm text-primary shadow-sm">
松开鼠标即可添加图片到画布
</div>
</div>
)}
{isMarqueeSelecting && marqueeStart && marqueeEnd && (
<div
className="absolute z-20 pointer-events-none border border-primary/50 bg-primary/10"
style={{
left: Math.min(marqueeStart.x, marqueeEnd.x),
top: Math.min(marqueeStart.y, marqueeEnd.y),
width: Math.abs(marqueeEnd.x - marqueeStart.x),
height: Math.abs(marqueeEnd.y - marqueeStart.y),
}}
/>
)}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<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>
)}
{/* 缩放控制器 */}
{selectedImageNodes.length > 0 && (
<div className="absolute bottom-4 left-4 z-30" data-canvas-overlay>
<Button
variant="secondary"
size="sm"
className="h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60"
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
title="下载为文件夹压缩包"
>
<Download className="h-3.5 w-3.5" />
{isDownloadSubmitting ? "打包中..." : `下载文件夹(${selectedImageNodes.length})`}
</Button>
</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"
data-canvas-overlay
>
<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>
);
}