diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1da62ecf2..bcbe69272 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -5,10 +5,9 @@ import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; import useSWR from "swr"; -import { Send, Loader2, ImagePlus, Paperclip, X, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain } from "lucide-react"; +import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, @@ -144,6 +143,47 @@ function stripMediaMarkup(text: string): string { .trim(); } +function getMessageTimestamp(message: ChatMessage): number | null { + const rawValue = + (message as any).createdAt ?? + (message as any).created_at ?? + (message as any).timestamp ?? + null; + + if (rawValue instanceof Date) { + return rawValue.getTime(); + } + + if (typeof rawValue === "number" && Number.isFinite(rawValue)) { + return rawValue; + } + + if (typeof rawValue === "string") { + const parsed = Date.parse(rawValue); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +} + +function getMessageDayKey(timestamp: number): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function formatMessageDateLabel(timestamp: number): string { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(timestamp)); +} + function normalizeVideoParamsForProvider( params: VideoGenerationParams | null | undefined, providerType?: string @@ -3844,6 +3884,206 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF handleDetailGeneration, ]); + const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => { + if (!firstImageUrl || hasSetCanvasTitle.current) { + return; + } + + hasSetCanvasTitle.current = true; + console.log('[CanvasChat] 开始调用 auto-title API...'); + + try { + console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl); + const response = await fetch('/api/canvas/auto-title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ imageUrl: firstImageUrl }), + }); + + console.log('[CanvasChat] auto-title 响应状态:', response.status); + + if (response.ok) { + const data = await response.json(); + const title = data.title || '未命名画布'; + console.log('[CanvasChat] 识别到的标题:', title); + + const rawId = chatId.startsWith('canvas-') + ? chatId.replace('canvas-', '') + : chatId; + + console.log('[CanvasChat] 更新画布:', rawId); + + const updateResponse = await fetch(`/api/canvas/${rawId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + thumbnail: firstImageUrl, + }), + }); + + console.log('[CanvasChat] 画布更新响应:', updateResponse.status); + } else { + console.error('[CanvasChat] auto-title API 请求失败:', response.status); + } + } catch (error) { + console.error('[CanvasChat] 自动命名失败:', error); + } + }, [chatId]); + + const uploadImageFileAndAddToCanvas = useCallback(async ( + file: File, + nodeIndex: number + ): Promise<{ imageUrl: string; name: string; contentType: string } | null> => { + if (!file.type.startsWith('image/')) { + toast.error('请选择图片文件'); + return null; + } + + if (file.size > 50 * 1024 * 1024) { + toast.error('图片大小不能超过 50MB'); + return null; + } + + const ext = file.type.split('/')[1] || 'png'; + const fileName = (file.name || '').trim() || `粘贴图片-${Date.now()}-${nodeIndex + 1}.${ext}`; + + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('上传失败'); + } + + const data = await response.json(); + const imageUrl = data.url as string; + const dimensions = await getImageDimensions(imageUrl); + const spacing = 20; + const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing); + const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing); + + addNode({ + type: 'image', + position: { x, y }, + size: { width: dimensions.width, height: dimensions.height }, + data: { + src: imageUrl, + status: 'completed', + title: fileName.replace(/\.[^.]+$/, ""), + prompt: fileName.replace(/\.[^.]+$/, ""), + metadata: { + originalWidth: dimensions.originalWidth, + originalHeight: dimensions.originalHeight, + }, + }, + connections: [], + }); + + return { + imageUrl, + name: fileName, + contentType: file.type || 'image/jpeg', + }; + } catch (_error) { + toast.error('图片上传失败'); + return null; + } + }, [addNode]); + + const handlePastedImages = useCallback(async (files: File[]) => { + if (files.length === 0) return; + + let firstImageUrl: string | null = null; + const baseNodeCount = nodes.length; + let uploadIndex = 0; + + if (isVideoModel && videoFrameSlots.length > 0) { + const slotCount = videoFrameSlots.length; + const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); + let handledCount = 0; + + for (const file of files) { + const targetSlotIndex = currentSlots.findIndex((slot) => !slot); + if (targetSlotIndex < 0) break; + + const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); + if (!uploaded) continue; + + const attachment: PickedObjectAttachment = { + url: uploaded.imageUrl, + name: uploaded.name, + contentType: uploaded.contentType, + }; + updateAttachmentAtVideoSlot(targetSlotIndex, attachment); + currentSlots[targetSlotIndex] = attachment; + handledCount += 1; + uploadIndex += 1; + if (!firstImageUrl) { + firstImageUrl = uploaded.imageUrl; + } + } + + if (handledCount > 0) { + toast.success(handledCount === 1 ? "已粘贴图片到视频槽位" : `已粘贴 ${handledCount} 张图片到视频槽位`); + } + if (handledCount < files.length) { + toast.info("视频模式图片槽位已满,剩余图片未添加"); + } + void maybeAutoSetCanvasTitle(firstImageUrl); + return; + } + + let handledCount = 0; + for (const file of files) { + const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); + if (!uploaded) continue; + + handledCount += 1; + uploadIndex += 1; + if (!firstImageUrl) { + firstImageUrl = uploaded.imageUrl; + } + + setAttachments((prev) => { + const newIndex = prev.length; + setTimeout(() => { + insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex); + (textareaRef.current as HTMLDivElement | null)?.focus(); + }, 0); + + return [ + ...prev, + { + url: uploaded.imageUrl, + name: uploaded.name, + contentType: uploaded.contentType, + }, + ]; + }); + } + + if (handledCount > 0) { + toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`); + } + + void maybeAutoSetCanvasTitle(firstImageUrl); + }, [ + attachments, + insertImageTagInEditor, + isVideoModel, + maybeAutoSetCanvasTitle, + nodes.length, + updateAttachmentAtVideoSlot, + uploadImageFileAndAddToCanvas, + videoFrameSlots.length, + ]); + // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; @@ -3935,66 +4175,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } } - // 如果是第一次上传图片,自动识别并设置画布标题和缩略图 - console.log('[CanvasChat] 检查是否需要自动命名:', { - firstImageUrl: !!firstImageUrl, - hasSetCanvasTitle: hasSetCanvasTitle.current + console.log('[CanvasChat] 检查是否需要自动命名:', { + firstImageUrl: !!firstImageUrl, + hasSetCanvasTitle: hasSetCanvasTitle.current }); - - if (firstImageUrl && !hasSetCanvasTitle.current) { - hasSetCanvasTitle.current = true; - console.log('[CanvasChat] 开始调用 auto-title API...'); - - // 异步调用 AI 识别图片主体并更新画布 - (async () => { - try { - // 调用 auto-title API 识别图片主体 - console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl); - const response = await fetch('/api/canvas/auto-title', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ imageUrl: firstImageUrl }), - }); - - console.log('[CanvasChat] auto-title 响应状态:', response.status); - - if (response.ok) { - const data = await response.json(); - const title = data.title || '未命名画布'; - console.log('[CanvasChat] 识别到的标题:', title); - - // 提取纯 UUID(去掉 canvas- 前缀) - const rawId = chatId.startsWith('canvas-') - ? chatId.replace('canvas-', '') - : chatId; - - console.log('[CanvasChat] 更新画布:', rawId); - - // 更新画布标题和缩略图 - const updateResponse = await fetch(`/api/canvas/${rawId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: title, - thumbnail: firstImageUrl, - }), - }); - - console.log('[CanvasChat] 画布更新响应:', updateResponse.status); - - if (updateResponse.ok) { - console.log('[CanvasChat] 画布已自动命名为:', title); - } else { - console.error('[CanvasChat] 画布更新失败'); - } - } else { - console.error('[CanvasChat] auto-title API 请求失败:', response.status); - } - } catch (error) { - console.error('[CanvasChat] 自动命名失败:', error); - } - })(); - } + void maybeAutoSetCanvasTitle(firstImageUrl); e.target.value = ''; }; @@ -4367,7 +4552,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF )} - {renderableVisibleMessages.map((message) => { + {renderableVisibleMessages.map((message, index) => { const assistantTextParts = message.role === "assistant" ? (message.parts || []).filter((part: any) => part.type === "text" && part.text) : []; @@ -4384,211 +4569,205 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF message.role === "assistant" && (hasAssistantImages || hasAssistantVideos) && !hasAssistantText; + + const messageTimestamp = getMessageTimestamp(message); + const prevMessageTimestamp = + index > 0 ? getMessageTimestamp(renderableVisibleMessages[index - 1] as ChatMessage) : null; + const showDateSeparator = Boolean( + messageTimestamp && + (!prevMessageTimestamp || + getMessageDayKey(prevMessageTimestamp) !== getMessageDayKey(messageTimestamp)) + ); return ( -
- {/* 头像 */} - - {message.role === 'user' ? ( - <> - - - - - - ) : ( - <> - - - - - - )} - - - {/* 消息气泡 */} -
- {message.role === 'user' ? ( - // 🆕 用户消息:使用图片标签样式(类似竞品,按 parts 原始顺序渲染) -
-
- {(() => { - const renderedUserParts: React.ReactNode[] = []; - - message.parts?.forEach((part: any, idx: number) => { - const fileUrl = - typeof part?.url === "string" && part.url.trim() - ? part.url - : typeof part?.data === "string" && part.data.trim() - ? part.data - : ""; - - if (part.type === 'file' && fileUrl) { - renderedUserParts.push( - - - {part.name || 'Image'} - - ); - return; - } - - if (part.type === 'text' && part.text) { - const text = String(part.text) - .replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '') - .trim(); - if (!text) return; - renderedUserParts.push( - {text} - ); - } - }); - - if ( - !message.parts?.some((p: any) => p.type === 'file') && - (message as any).attachments?.length > 0 - ) { - (message as any).attachments.forEach((att: any, idx: number) => { - if (!att?.url) return; - renderedUserParts.push( - - - {att.name || 'Image'} - - ); - }); - } - - if (renderedUserParts.length === 0) { - const fallbackText = - extractUserTextPrompt(message as any) || - (isVideoModel ? "已发送视频生成请求" : "已发送请求"); - renderedUserParts.push( - - {fallbackText} - - ); - } - - return renderedUserParts; - })()} -
+
+ {showDateSeparator && messageTimestamp ? ( +
+ {formatMessageDateLabel(messageTimestamp)}
- ) : ( - // AI 消息:保持原样式 -
- {/* 消息内容 */} -
- {message.parts?.map((part: any, i: number) => { - if (part.type === 'text') { - // 提取并显示图片/视频 - const rawText = part.text || ''; - const imageUrls = extractImageUrls(rawText); - const videoUrls = extractVideoUrls(rawText); - const textWithoutImages = stripMediaMarkup(rawText); - - return ( -
- {textWithoutImages && ( - - {textWithoutImages} + ) : null} +
+ {/* 消息区域 */} +
+ {message.role === 'user' ? ( +
+
+ {(() => { + const renderedUserParts: React.ReactNode[] = []; + + message.parts?.forEach((part: any, idx: number) => { + const fileUrl = + typeof part?.url === "string" && part.url.trim() + ? part.url + : typeof part?.data === "string" && part.data.trim() + ? part.data + : ""; + + if (part.type === 'file' && fileUrl) { + renderedUserParts.push( + + + {part.name || 'Image'} + + ); + return; + } + + if (part.type === 'text' && part.text) { + const text = String(part.text) + .replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '') + .trim(); + if (!text) return; + renderedUserParts.push( + {text} + ); + } + }); + + if ( + !message.parts?.some((p: any) => p.type === 'file') && + (message as any).attachments?.length > 0 + ) { + (message as any).attachments.forEach((att: any, idx: number) => { + if (!att?.url) return; + renderedUserParts.push( + + + {att.name || 'Image'} + + ); + }); + } + + if (renderedUserParts.length === 0) { + const fallbackText = + extractUserTextPrompt(message as any) || + (isVideoModel ? "已发送视频生成请求" : "已发送请求"); + renderedUserParts.push( + + {fallbackText} - )} - {imageUrls.length > 0 && ( -
- {imageUrls.map((url, idx) => ( -
- setFullscreenPreview({ url })} - onDoubleClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setFullscreenPreview({ url }); - }} - /> + ); + } + + return renderedUserParts; + })()} +
+
+ ) : ( +
+ {/* 消息内容 */} +
+ {message.parts?.map((part: any, i: number) => { + if (part.type === 'text') { + // 提取并显示图片/视频 + const rawText = part.text || ''; + const imageUrls = extractImageUrls(rawText); + const videoUrls = extractVideoUrls(rawText); + const textWithoutImages = stripMediaMarkup(rawText); + + return ( +
+ {textWithoutImages && ( + + {textWithoutImages} + + )} + {imageUrls.length > 0 && ( +
+ {imageUrls.map((url, idx) => ( +
+ setFullscreenPreview({ url })} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setFullscreenPreview({ url }); + }} + /> +
+ ))}
- ))} -
- )} - {videoUrls.length > 0 && ( -
0 ? "mt-3" : "mt-0")}> - {videoUrls.map((url, idx) => ( -
-
- )} -
- ); - } - return null; - })} -
+ ); + } + return null; + })} +
+
+ )}
- )} +
-
- ); + ); })} {shouldShowImageLoading && ( @@ -4601,13 +4780,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF )} {isLoading && !shouldShowImageLoading && ( -
- - - - - -
+
+
正在思考中...
@@ -5208,7 +5382,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } }} onPaste={(e) => { - // 只粘贴纯文本 + const clipboardItems = Array.from(e.clipboardData?.items || []); + const imageFiles = clipboardItems + .filter((item) => item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + if (imageFiles.length > 0) { + e.preventDefault(); + void handlePastedImages(imageFiles); + return; + } + e.preventDefault(); const text = e.clipboardData.getData('text/plain'); document.execCommand('insertText', false, text); diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 8ba816512..6e090fab5 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -43,6 +43,12 @@ export function CanvasWorkspace() { 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(null); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); @@ -51,6 +57,7 @@ export function CanvasWorkspace() { const [marqueeEnd, setMarqueeEnd] = useState(null); const [isFileDragActive, setIsFileDragActive] = useState(false); const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false); + const [snapGuides, setSnapGuides] = useState(null); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const dragCounterRef = useRef(0); const dragPointerRef = useRef({ x: 0, y: 0 }); @@ -183,14 +190,14 @@ export function CanvasWorkspace() { return Boolean(document.querySelector('[role="dialog"][data-state="open"]')); }, []); - const getSnappedCanvasPosition = useCallback( + const getSnapPreview = useCallback( ( node: CanvasNode, target: Position, ignoreNodeIds: Set = new Set(), - ): Position => { + ): { position: Position; guides: SnapGuideLines | null } => { if (node.type !== "image") { - return target; + return { position: target, guides: null }; } const candidates = nodes.filter( @@ -201,13 +208,25 @@ export function CanvasWorkspace() { ); if (candidates.length === 0) { - return target; + return { position: target, guides: null }; } - let bestOffsetX = 0; - let bestDeltaX = Number.POSITIVE_INFINITY; - let bestOffsetY = 0; - let bestDeltaY = Number.POSITIVE_INFINITY; + 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; @@ -216,19 +235,25 @@ export function CanvasWorkspace() { const selfCenterY = target.y + node.size.height / 2; const selfBottom = target.y + node.size.height; - const pickBestX = (delta: number) => { + const pickBestX = (delta: number, guide?: SnapGuideLines["vertical"]) => { const abs = Math.abs(delta); - if (abs <= SNAP_THRESHOLD && abs < bestDeltaX) { - bestDeltaX = abs; - bestOffsetX = delta; + if (abs <= SNAP_THRESHOLD && abs < bestMatchX.delta) { + bestMatchX = { + offset: delta, + delta: abs, + guide, + }; } }; - const pickBestY = (delta: number) => { + const pickBestY = (delta: number, guide?: SnapGuideLines["horizontal"]) => { const abs = Math.abs(delta); - if (abs <= SNAP_THRESHOLD && abs < bestDeltaY) { - bestDeltaY = abs; - bestOffsetY = delta; + if (abs <= SNAP_THRESHOLD && abs < bestMatchY.delta) { + bestMatchY = { + offset: delta, + delta: abs, + guide, + }; } }; @@ -240,27 +265,70 @@ export function CanvasWorkspace() { 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(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); - pickBestY(targetCenterY - selfCenterY); - pickBestY(targetBottom - selfBottom); + 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 { - x: target.x + (Number.isFinite(bestDeltaX) ? bestOffsetX : 0), - y: target.y + (Number.isFinite(bestDeltaY) ? bestOffsetY : 0), + position: snappedPosition, + guides: { + vertical: bestMatchX.guide, + horizontal: bestMatchY.guide, + }, }; }, [nodes, SNAP_GAP], ); + const getSnappedCanvasPosition = useCallback( + ( + node: CanvasNode, + target: Position, + ignoreNodeIds: Set = 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 }; @@ -337,6 +405,7 @@ export function CanvasWorkspace() { const handleMouseUp = useCallback((e?: React.MouseEvent) => { if (e && !isEventFromCanvasContainer(e.target)) return; setIsPanning(false); + setSnapGuides(null); if (isMarqueeSelecting && marqueeStart && marqueeEnd) { const left = Math.min(marqueeStart.x, marqueeEnd.x); @@ -752,6 +821,7 @@ export function CanvasWorkspace() { }} scale={1} onDragStart={() => { + setSnapGuides(null); if (!isSelected) { selectNode(node.id); groupDragRef.current = null; @@ -777,20 +847,47 @@ export function CanvasWorkspace() { }} 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, + 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; @@ -902,23 +999,6 @@ export function CanvasWorkspace() { onDragOver={handleDragOver} onDrop={handleDrop} > - {/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */} -
- {/* 深色模式点阵(固定大小,不随缩放变化) */} -
- {isFileDragActive && (
@@ -939,6 +1019,91 @@ export function CanvasWorkspace() { /> )} + {snapGuides?.vertical && ( + <> +
+
+ x +
+
+ x +
+ + )} + + {snapGuides?.horizontal && ( + <> +
+
+ x +
+
+ x +
+ + )} + {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
{visibleNodes.map(renderNode)}