fix(canvas): resolve compare source by upstream original and preserve ctrl+a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-01 02:04:44 +08:00
parent eba188116a
commit 22493ce1a2
2 changed files with 87 additions and 22 deletions

View File

@ -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"]')

View File

@ -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<string>([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(() => {