1253 lines
44 KiB
TypeScript
1253 lines
44 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 { Model3DNode } from "./nodes/Model3DNode";
|
||
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
|
||
);
|
||
}
|
||
|
||
interface CanvasWorkspaceProps {
|
||
occlusionRight?: number;
|
||
}
|
||
|
||
export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
|
||
const SNAP_THRESHOLD = 10;
|
||
const SNAP_GAP = 12;
|
||
const SELECTION_COLOR = "#27a8ff";
|
||
const SELECTION_STROKE_WIDTH = 2;
|
||
const IMAGE_HANDLE_SIZE = 12;
|
||
const IMAGE_HANDLE_OFFSET = IMAGE_HANDLE_SIZE / 2;
|
||
const SNAP_GUIDE_COLOR = "#ff8a3d";
|
||
|
||
type SnapGuideLines = {
|
||
vertical?: { x: number; top: number; bottom: number };
|
||
horizontal?: { y: number; left: number; right: number };
|
||
};
|
||
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 [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
|
||
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 lastBlankCanvasClickAtRef = useRef(0);
|
||
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 getSnapPreview = useCallback(
|
||
(
|
||
node: CanvasNode,
|
||
target: Position,
|
||
ignoreNodeIds: Set<string> = new Set(),
|
||
): { position: Position; guides: SnapGuideLines | null } => {
|
||
if (node.type !== "image") {
|
||
return { position: target, guides: null };
|
||
}
|
||
|
||
const candidates = nodes.filter(
|
||
(item) =>
|
||
item.type === "image" &&
|
||
item.id !== node.id &&
|
||
!ignoreNodeIds.has(item.id),
|
||
);
|
||
|
||
if (candidates.length === 0) {
|
||
return { position: target, guides: null };
|
||
}
|
||
|
||
let bestMatchX: {
|
||
offset: number;
|
||
delta: number;
|
||
guide?: SnapGuideLines["vertical"];
|
||
} = {
|
||
offset: 0,
|
||
delta: Number.POSITIVE_INFINITY,
|
||
};
|
||
let bestMatchY: {
|
||
offset: number;
|
||
delta: number;
|
||
guide?: SnapGuideLines["horizontal"];
|
||
} = {
|
||
offset: 0,
|
||
delta: 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, guide?: SnapGuideLines["vertical"]) => {
|
||
const abs = Math.abs(delta);
|
||
if (abs <= SNAP_THRESHOLD && abs < bestMatchX.delta) {
|
||
bestMatchX = {
|
||
offset: delta,
|
||
delta: abs,
|
||
guide,
|
||
};
|
||
}
|
||
};
|
||
|
||
const pickBestY = (delta: number, guide?: SnapGuideLines["horizontal"]) => {
|
||
const abs = Math.abs(delta);
|
||
if (abs <= SNAP_THRESHOLD && abs < bestMatchY.delta) {
|
||
bestMatchY = {
|
||
offset: delta,
|
||
delta: abs,
|
||
guide,
|
||
};
|
||
}
|
||
};
|
||
|
||
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, {
|
||
x: targetLeft,
|
||
top: Math.min(selfTop, targetTop),
|
||
bottom: Math.max(selfBottom, targetBottom),
|
||
});
|
||
pickBestX(targetCenterX - selfCenterX, {
|
||
x: targetCenterX,
|
||
top: Math.min(selfTop, targetTop),
|
||
bottom: Math.max(selfBottom, targetBottom),
|
||
});
|
||
pickBestX(targetRight - selfRight, {
|
||
x: targetRight,
|
||
top: Math.min(selfTop, targetTop),
|
||
bottom: Math.max(selfBottom, targetBottom),
|
||
});
|
||
pickBestX(targetRight + SNAP_GAP - selfLeft);
|
||
pickBestX(targetLeft - SNAP_GAP - selfRight);
|
||
|
||
pickBestY(targetTop - selfTop, {
|
||
y: targetTop,
|
||
left: Math.min(selfLeft, targetLeft),
|
||
right: Math.max(selfRight, targetRight),
|
||
});
|
||
pickBestY(targetCenterY - selfCenterY, {
|
||
y: targetCenterY,
|
||
left: Math.min(selfLeft, targetLeft),
|
||
right: Math.max(selfRight, targetRight),
|
||
});
|
||
pickBestY(targetBottom - selfBottom, {
|
||
y: targetBottom,
|
||
left: Math.min(selfLeft, targetLeft),
|
||
right: Math.max(selfRight, targetRight),
|
||
});
|
||
pickBestY(targetBottom + SNAP_GAP - selfTop);
|
||
pickBestY(targetTop - SNAP_GAP - selfBottom);
|
||
}
|
||
|
||
const snappedPosition = {
|
||
x: target.x + (Number.isFinite(bestMatchX.delta) ? bestMatchX.offset : 0),
|
||
y: target.y + (Number.isFinite(bestMatchY.delta) ? bestMatchY.offset : 0),
|
||
};
|
||
|
||
return {
|
||
position: snappedPosition,
|
||
guides: {
|
||
vertical: bestMatchX.guide,
|
||
horizontal: bestMatchY.guide,
|
||
},
|
||
};
|
||
},
|
||
[nodes, SNAP_GAP],
|
||
);
|
||
|
||
const getSnappedCanvasPosition = useCallback(
|
||
(
|
||
node: CanvasNode,
|
||
target: Position,
|
||
ignoreNodeIds: Set<string> = new Set(),
|
||
): Position => {
|
||
return getSnapPreview(node, target, ignoreNodeIds).position;
|
||
},
|
||
[getSnapPreview],
|
||
);
|
||
|
||
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 clearBrowserTextSelection = useCallback(() => {
|
||
if (typeof window === "undefined") return;
|
||
const selection = window.getSelection();
|
||
if (!selection || selection.rangeCount === 0) return;
|
||
selection.removeAllRanges();
|
||
}, []);
|
||
|
||
const fitAllImageNodesIntoViewport = useCallback(() => {
|
||
const imageNodes = nodes.filter(
|
||
(node) => node.type === "image" && Boolean((node.data as any)?.src)
|
||
);
|
||
if (imageNodes.length === 0) return;
|
||
if (containerSize.width <= 0 || containerSize.height <= 0) return;
|
||
|
||
const padding = 80;
|
||
const minX = Math.min(...imageNodes.map((node) => node.position.x));
|
||
const minY = Math.min(...imageNodes.map((node) => node.position.y));
|
||
const maxX = Math.max(
|
||
...imageNodes.map((node) => node.position.x + node.size.width)
|
||
);
|
||
const maxY = Math.max(
|
||
...imageNodes.map((node) => node.position.y + node.size.height)
|
||
);
|
||
|
||
const contentWidth = Math.max(1, maxX - minX + padding * 2);
|
||
const contentHeight = Math.max(1, maxY - minY + padding * 2);
|
||
|
||
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
|
||
const fitZoomX = visibleWidth / contentWidth;
|
||
const fitZoomY = containerSize.height / contentHeight;
|
||
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
|
||
|
||
const centerX = (minX + maxX) / 2;
|
||
const centerY = (minY + maxY) / 2;
|
||
const visibleCenterX = visibleWidth / 2;
|
||
const nextViewportX = visibleCenterX - centerX * targetZoom;
|
||
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
|
||
|
||
setViewport({
|
||
x: nextViewportX,
|
||
y: nextViewportY,
|
||
zoom: targetZoom,
|
||
});
|
||
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
|
||
|
||
// 处理画布点击(取消选择)
|
||
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) {
|
||
const now = Date.now();
|
||
const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320;
|
||
lastBlankCanvasClickAtRef.current = now;
|
||
clearSelection();
|
||
if (isDoubleBlankClick) {
|
||
fitAllImageNodesIntoViewport();
|
||
}
|
||
}
|
||
}, [clearSelection, fitAllImageNodesIntoViewport, 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)) {
|
||
e.preventDefault();
|
||
clearBrowserTextSelection();
|
||
const start = toCanvasPosition(e.clientX, e.clientY);
|
||
setIsMarqueeSelecting(true);
|
||
setMarqueeStart(start);
|
||
setMarqueeEnd(start);
|
||
}
|
||
}, [clearBrowserTextSelection, 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) {
|
||
e.preventDefault();
|
||
setViewport({
|
||
x: e.clientX - panStart.x,
|
||
y: e.clientY - panStart.y,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (isMarqueeSelecting) {
|
||
e.preventDefault();
|
||
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);
|
||
setSnapGuides(null);
|
||
clearBrowserTextSelection();
|
||
|
||
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);
|
||
}, [clearBrowserTextSelection, 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,
|
||
assetId: result.data.assetId,
|
||
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: IMAGE_HANDLE_SIZE,
|
||
height: IMAGE_HANDLE_SIZE,
|
||
right: -IMAGE_HANDLE_OFFSET,
|
||
top: -IMAGE_HANDLE_OFFSET,
|
||
borderRadius: 0,
|
||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||
backgroundColor: "#ffffff",
|
||
},
|
||
bottomRight: {
|
||
width: IMAGE_HANDLE_SIZE,
|
||
height: IMAGE_HANDLE_SIZE,
|
||
right: -IMAGE_HANDLE_OFFSET,
|
||
bottom: -IMAGE_HANDLE_OFFSET,
|
||
borderRadius: 0,
|
||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||
backgroundColor: "#ffffff",
|
||
},
|
||
bottomLeft: {
|
||
width: IMAGE_HANDLE_SIZE,
|
||
height: IMAGE_HANDLE_SIZE,
|
||
left: -IMAGE_HANDLE_OFFSET,
|
||
bottom: -IMAGE_HANDLE_OFFSET,
|
||
borderRadius: 0,
|
||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||
backgroundColor: "#ffffff",
|
||
},
|
||
topLeft: {
|
||
width: IMAGE_HANDLE_SIZE,
|
||
height: IMAGE_HANDLE_SIZE,
|
||
left: -IMAGE_HANDLE_OFFSET,
|
||
top: -IMAGE_HANDLE_OFFSET,
|
||
borderRadius: 0,
|
||
border: `${SELECTION_STROKE_WIDTH}px 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={() => {
|
||
setSnapGuides(null);
|
||
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) {
|
||
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 preview = getSnapPreview(
|
||
node,
|
||
rawAnchor,
|
||
new Set(selectedNodeIds),
|
||
);
|
||
setSnapGuides(preview.guides);
|
||
selectedNodeIds.forEach((id) => {
|
||
const itemOrigin = group.originPositions[id];
|
||
if (!itemOrigin) return;
|
||
moveNode(id, {
|
||
x: itemOrigin.x + deltaCanvasX,
|
||
y: itemOrigin.y + deltaCanvasY,
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (selectedNodeIds.length > 1) return;
|
||
|
||
const rawPosition = {
|
||
x: (d.x - viewport.x) / viewport.zoom,
|
||
y: (d.y - viewport.y) / viewport.zoom,
|
||
};
|
||
const preview = getSnapPreview(node, rawPosition);
|
||
setSnapGuides(preview.guides);
|
||
}}
|
||
onDragStop={(e, d) => {
|
||
setSnapGuides(null);
|
||
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 === 'model3d' && (
|
||
<Model3DNode 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}
|
||
>
|
||
{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),
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{snapGuides?.vertical && (
|
||
<>
|
||
<div
|
||
className="absolute z-20 pointer-events-none"
|
||
style={{
|
||
left: snapGuides.vertical.x * viewport.zoom + viewport.x,
|
||
top: snapGuides.vertical.top * viewport.zoom + viewport.y,
|
||
width: 1,
|
||
height: Math.max(
|
||
1,
|
||
(snapGuides.vertical.bottom - snapGuides.vertical.top) * viewport.zoom,
|
||
),
|
||
backgroundColor: SNAP_GUIDE_COLOR,
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute z-20 pointer-events-none"
|
||
style={{
|
||
left: snapGuides.vertical.x * viewport.zoom + viewport.x - 4,
|
||
top: snapGuides.vertical.top * viewport.zoom + viewport.y - 4,
|
||
color: SNAP_GUIDE_COLOR,
|
||
fontSize: 11,
|
||
lineHeight: "11px",
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
<div
|
||
className="absolute z-20 pointer-events-none"
|
||
style={{
|
||
left: snapGuides.vertical.x * viewport.zoom + viewport.x - 4,
|
||
top:
|
||
snapGuides.vertical.bottom * viewport.zoom + viewport.y - 4,
|
||
color: SNAP_GUIDE_COLOR,
|
||
fontSize: 11,
|
||
lineHeight: "11px",
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{snapGuides?.horizontal && (
|
||
<>
|
||
<div
|
||
className="absolute z-20 pointer-events-none"
|
||
style={{
|
||
left: snapGuides.horizontal.left * viewport.zoom + viewport.x,
|
||
top: snapGuides.horizontal.y * viewport.zoom + viewport.y,
|
||
width: Math.max(
|
||
1,
|
||
(snapGuides.horizontal.right - snapGuides.horizontal.left) * viewport.zoom,
|
||
),
|
||
height: 1,
|
||
backgroundColor: SNAP_GUIDE_COLOR,
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute z-20 pointer-events-none"
|
||
style={{
|
||
left: snapGuides.horizontal.left * viewport.zoom + viewport.x - 4,
|
||
top: snapGuides.horizontal.y * viewport.zoom + viewport.y - 4,
|
||
color: SNAP_GUIDE_COLOR,
|
||
fontSize: 11,
|
||
lineHeight: "11px",
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
<div
|
||
className="absolute z-20 pointer-events-none"
|
||
style={{
|
||
left: snapGuides.horizontal.right * viewport.zoom + viewport.x - 4,
|
||
top: snapGuides.horizontal.y * viewport.zoom + viewport.y - 4,
|
||
color: SNAP_GUIDE_COLOR,
|
||
fontSize: 11,
|
||
lineHeight: "11px",
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
|
||
<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-16 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>
|
||
);
|
||
}
|