From d11fd0825d25a1e0564b5557edfcd6537bc1c5fa Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 1 Mar 2026 01:40:02 +0800 Subject: [PATCH] fix(canvas): restore compare toggle and blank double-click fit all --- components/canvas/CanvasChatPanel.tsx | 78 ++++++++++++++++++++++++++- components/canvas/CanvasWorkspace.tsx | 53 +++++++++++++++++- components/canvas/nodes/ImageNode.tsx | 5 +- 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 7e0851567..eac7ed1a7 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -229,6 +229,52 @@ function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set(); + if (!message) return urls; + + const pushUrl = (url?: string | null) => { + const raw = String(url || "").trim(); + if (!raw) return; + const normalized = normalizeMediaUrl(raw); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + urls.push(raw); + }; + + const parts = (message as any).parts || []; + for (const part of parts) { + if (!part) continue; + if ( + (part.type === "file" || part.type === "image" || part.type === "input_image") && + (typeof part.url === "string" || typeof part.data === "string") + ) { + const direct = typeof part.url === "string" ? part.url : part.data; + pushUrl(inferOriginalImageUrl(String(direct || "").trim())); + } + + if (part.type === "text" && typeof part.text === "string") { + extractImageUrls(part.text).forEach((url) => pushUrl(url)); + extractPlainImageUrls(part.text).forEach((url) => pushUrl(url)); + } + } + + const attachments = (message as any).attachments || []; + for (const attachment of attachments) { + pushUrl(inferOriginalImageUrl(String(attachment?.url || "").trim())); + } + + const legacyContent = + typeof (message as any).content === "string" ? (message as any).content : ""; + if (legacyContent) { + extractImageUrls(legacyContent).forEach((url) => pushUrl(url)); + extractPlainImageUrls(legacyContent).forEach((url) => pushUrl(url)); + } + + return urls; +} + function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: number): Set { for (let i = assistantIndex - 1; i >= 0; i -= 1) { const message = messages[i]; @@ -238,6 +284,15 @@ function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: num return new Set(); } +function getNearestUserImageUrls(messages: ChatMessage[], assistantIndex: number): string[] { + for (let i = assistantIndex - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role !== "user") continue; + return collectMessageImageUrls(message); + } + return []; +} + function extractAssistantImageUrls(text: string, referenceUrlKeys?: Set): string[] { const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim; const markdownUrls: string[] = []; @@ -3068,6 +3123,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex); + const nearestUserImageUrls = getNearestUserImageUrls(messages, messageIndex); const imageUrlSet = new Set(); const videoUrlSet = new Set(); const parts = (message.parts || []) as any[]; @@ -3195,6 +3251,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const spacing = 20; const x = 100 + (existingNodes % 3) * (dimensions.width + spacing); const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing); + const compareSourceUrlRaw = + nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] || + nearestUserImageUrls[0] || + ""; + const compareSourceUrl = String(compareSourceUrlRaw || "").trim(); + const normalizedCompareSource = normalizeImageIdentityUrl(compareSourceUrl); + const canUseCompareSource = + Boolean(normalizedCompareSource) && + normalizedCompareSource !== imageEntry.sourceImageKey; + const sourceNodeId = canUseCompareSource + ? useCanvasStore + .getState() + .nodes.find((node) => { + if (node.type !== "image") return false; + const src = String(((node.data as any) || {}).src || "").trim(); + return normalizeImageIdentityUrl(src) === normalizedCompareSource; + })?.id || "" + : ""; const nodeId = addNode({ type: "image", @@ -3205,15 +3279,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF status: "completed", title: "图片", prompt: generationPrompt || "对话生成", + originalSrc: canUseCompareSource ? compareSourceUrl : undefined, metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, generationPrompt: generationPrompt || undefined, sourceMessageId: message.id, sourceImageKey: imageEntry.sourceImageKey, + compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, }, }, - connections: [], + connections: sourceNodeId ? [sourceNodeId] : [], }); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index f88bdce56..efd9b3cc0 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -37,7 +37,11 @@ function isNodeInViewport( ); } -export function CanvasWorkspace() { +interface CanvasWorkspaceProps { + occlusionRight?: number; +} + +export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) { const SNAP_THRESHOLD = 10; const SNAP_GAP = 12; const SELECTION_COLOR = "#27a8ff"; @@ -63,6 +67,7 @@ export function CanvasWorkspace() { const dragCounterRef = useRef(0); const dragPointerRef = useRef({ x: 0, y: 0 }); const suppressCanvasClickRef = useRef(false); + const lastBlankCanvasClickAtRef = useRef(0); const groupDragRef = useRef<{ nodeId: string; startX: number; @@ -356,6 +361,44 @@ export function CanvasWorkspace() { 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; @@ -365,9 +408,15 @@ export function CanvasWorkspace() { return; } if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { + const now = Date.now(); + const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320; + lastBlankCanvasClickAtRef.current = now; clearSelection(); + if (isDoubleBlankClick) { + fitAllImageNodesIntoViewport(); + } } - }, [clearSelection, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); + }, [clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); // 处理画布平移开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index ee13f112f..670ab065e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -297,7 +297,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null); const displayTitle = getImageNodeTitle(data, node.id); const isMultiSelected = isSelected && selectedNodeIds.length > 1; - const compareSource = String(data.originalSrc || fallbackCompareSource || "").trim(); + const metadataCompareSource = String(data.metadata?.compareSourceUrl || "").trim(); + const compareSource = String( + data.originalSrc || metadataCompareSource || fallbackCompareSource || "" + ).trim(); const hasCompareSource = Boolean(data.src) && Boolean(compareSource) &&