fix(canvas): restore compare toggle and blank double-click fit all
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 01:40:02 +08:00
parent 3dba9c485d
commit d11fd0825d
3 changed files with 132 additions and 4 deletions

View File

@ -229,6 +229,52 @@ function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set<strin
return urlKeys;
}
function collectMessageImageUrls(message: ChatMessage | undefined): string[] {
const urls: string[] = [];
const seen = new Set<string>();
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<string> {
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<string>();
}
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>): 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<string>();
const videoUrlSet = new Set<string>();
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;

View File

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

View File

@ -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) &&