fix(canvas): restore compare toggle and blank double-click fit all
This commit is contained in:
parent
3dba9c485d
commit
d11fd0825d
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) &&
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user