From 22493ce1a2e9b2ca29d9795eddfe7c75c700613c Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 1 Mar 2026 02:04:44 +0800 Subject: [PATCH] fix(canvas): resolve compare source by upstream original and preserve ctrl+a --- app/canvas/[id]/page.tsx | 14 +++- components/canvas/nodes/ImageNode.tsx | 95 +++++++++++++++++++++------ 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index fdfe5bae3..477fe25ba 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -474,8 +474,20 @@ export default function CanvasEditorPage({ params }: PageProps) { const handleKeyDown = (e: KeyboardEvent) => { // 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素) const target = e.target as HTMLElement; + if (target instanceof HTMLInputElement) { + const inputType = String(target.type || "").toLowerCase(); + const isTextInput = + inputType === "text" || + inputType === "search" || + inputType === "url" || + inputType === "email" || + inputType === "password" || + inputType === "number"; + if (isTextInput) { + return; + } + } if ( - target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable || target.closest('[contenteditable="true"]') diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 30c9341bd..31e4bd401 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef, useEffect, useCallback } from "react"; +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -28,6 +28,7 @@ import { imageGenerationService } from "@/services/canvas"; import type { CanvasTextEditPayloadItem } from "@/services/canvas"; import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas"; import { cn } from "@/lib/utils"; +import { inferOriginalImageUrl } from "@/lib/image-url"; import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { ImageAnnotator } from "@/components/image-annotator"; @@ -61,6 +62,29 @@ const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTe const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); +const normalizeMediaUrl = (url: string): string => { + const raw = String(url || "").trim(); + if (!raw) return ""; + + try { + const parsed = new URL(raw); + return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase(); + } catch { + return raw + .split("#")[0] + .split("?")[0] + .replace(/\/+$/, "") + .toLowerCase(); + } +}; + +const normalizeImageIdentityUrl = (url: string): string => { + const raw = String(url || "").trim(); + if (!raw) return ""; + const canonical = inferOriginalImageUrl(raw); + return normalizeMediaUrl(canonical || raw); +}; + const normalizeImageTitle = (raw?: string) => { const title = (raw || "").trim(); return title || ""; @@ -270,6 +294,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const addNode = useCanvasStore((state) => state.addNode); const addPendingChatAttachment = useCanvasStore((state) => state.addPendingChatAttachment); const setPickerMode = useCanvasStore((state) => state.setPickerMode); + const allNodes = useCanvasStore((state) => state.nodes); // 🆕 物品拾取相关状态 const isPickerMode = useCanvasStore((state) => state.isPickerMode); @@ -277,18 +302,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const isPickerLoading = useCanvasStore((state) => state.isPickerLoading); const setPickerLoading = useCanvasStore((state) => state.setPickerLoading); const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject); - const fallbackCompareSource = useCanvasStore((state) => { - if (data.originalSrc) return ""; - if (!Array.isArray(node.connections) || node.connections.length === 0) return ""; - for (const sourceNodeId of node.connections) { - const sourceNode = state.nodes.find((item) => item.id === sourceNodeId); - if (sourceNode?.type !== "image") continue; - const sourceUrl = String(((sourceNode.data as any) || {}).src || "").trim(); - if (sourceUrl) return sourceUrl; - } - return ""; - }); - // 当前节点是否处于拾取模式 const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id; @@ -296,14 +309,54 @@ 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 metadataCompareSource = String(data.metadata?.compareSourceUrl || "").trim(); - const compareSource = String( - data.originalSrc || metadataCompareSource || fallbackCompareSource || "" - ).trim(); - const hasCompareSource = - Boolean(data.src) && - Boolean(compareSource) && - compareSource !== data.src; + const compareSource = useMemo(() => { + const targetIdentity = normalizeImageIdentityUrl(data.src || ""); + if (!targetIdentity) return ""; + + const candidates: string[] = []; + const pushCandidate = (value: unknown) => { + const raw = String(value || "").trim(); + if (!raw) return; + candidates.push(raw); + }; + + const visited = new Set([node.id]); + const queue = Array.isArray(node.connections) ? [...node.connections] : []; + let safety = 0; + + while (queue.length > 0 && safety < 120) { + safety += 1; + const sourceNodeId = queue.shift(); + if (!sourceNodeId || visited.has(sourceNodeId)) continue; + visited.add(sourceNodeId); + + const sourceNode = allNodes.find((item) => item.id === sourceNodeId); + if (!sourceNode || sourceNode.type !== "image") continue; + const sourceData = (sourceNode.data as any) || {}; + pushCandidate(sourceData.src); + pushCandidate(sourceData.originalSrc); + pushCandidate(sourceData.metadata?.compareSourceUrl); + + if (Array.isArray(sourceNode.connections) && sourceNode.connections.length > 0) { + queue.push(...sourceNode.connections); + } + } + + pushCandidate(data.originalSrc); + pushCandidate(data.metadata?.compareSourceUrl); + + for (const candidate of candidates) { + const identity = normalizeImageIdentityUrl(candidate); + if (!identity) continue; + if (identity !== targetIdentity) { + return candidate; + } + } + + return ""; + }, [allNodes, data.metadata, data.originalSrc, data.src, node.connections, node.id]); + + const hasCompareSource = Boolean(data.src) && Boolean(compareSource); // 退出拾取模式时清空状态 useEffect(() => {