fix(references): make image thumbnails drag-safe with delayed hover preview

Render reference thumbnails and generated images as background-image spans
instead of <img> so they no longer produce drag ghosts or interfere with
text selection. Open the hover preview after a short delay, dismiss it on
text selection, pointer-down, and drag start, and open the full image via
click instead of an anchor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 02:18:06 +08:00
parent f23c847e13
commit 398dec422d
4 changed files with 144 additions and 25 deletions
@@ -307,17 +307,21 @@
border-radius: 10px;
background: #f5f5f3;
box-shadow: none;
user-select: none;
-webkit-user-select: none;
}
.moteva-generated-grid.multi .moteva-generated-image {
width: 100%;
}
.moteva-generated-image img {
.moteva-generated-image-preview {
width: 100%;
aspect-ratio: 1;
display: block;
object-fit: contain;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.moteva-response-card .markdown-content {
@@ -450,16 +454,22 @@
border-radius: 999px;
color: #2f343b;
background: #fff;
font: inherit;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.message-reference-tag > img {
.message-reference-thumb {
width: 22px;
height: 22px;
flex: 0 0 auto;
border-radius: 5px;
object-fit: cover;
background: #eef0f3;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.message-reference-tag span {
@@ -496,7 +506,7 @@
object-fit: contain;
}
.message-reference-tag:hover i {
.message-reference-tag.preview-open i {
opacity: 1;
transform: translateY(0) scale(1);
}
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { Check, Copy, Eye, Globe2, Heart, Loader2 } from "lucide-react";
import type { AgentMessage, CanvasNode } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
@@ -41,6 +41,7 @@ type AgentImageArtifact = {
};
const copyButtonHideDelayMs = 3000;
const imagePreviewDelayMs = 180;
export function CanvasAgentTimeline({ messages, nodes, active }: { messages: AgentMessage[]; nodes: CanvasNode[]; active: boolean }) {
const turns = groupCanvasAgentTurns(messages);
@@ -164,8 +165,8 @@ function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { mess
{items.length > 0 && (
<div className={`moteva-generated-grid ${items.length > 1 ? "multi" : ""}`}>
{items.map((item) => (
<button className="moteva-generated-image" type="button" key={item.id || item.url} title={item.title}>
<img src={canvasImageUrl(item.url)} alt={item.title || title} />
<button className="moteva-generated-image" type="button" key={item.id || item.url} title={item.title || title}>
<span className="moteva-generated-image-preview" style={{ backgroundImage: `url("${cssUrl(canvasImageUrl(item.url))}")` }} aria-hidden="true" />
</button>
))}
</div>
@@ -436,11 +437,35 @@ function renderUserMessageContent(content: string) {
}
function MessageReferenceTag({ reference }: { reference: PromptImageReference }) {
const tagRef = useRef<HTMLAnchorElement | null>(null);
const [previewLoaded, setPreviewLoaded] = useState(false);
const tagRef = useRef<HTMLButtonElement | null>(null);
const previewTimerRef = useRef<number | null>(null);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewPosition, setPreviewPosition] = useState<{ left: number; top: number } | null>(null);
const loadPreview = () => {
setPreviewLoaded(true);
const thumbnailUrl = referenceThumbnailUrl(reference.url);
const previewUrl = referencePreviewUrl(reference.url);
useEffect(() => {
return () => {
if (previewTimerRef.current) window.clearTimeout(previewTimerRef.current);
};
}, []);
useEffect(() => {
if (!previewVisible) return;
const closeOnSelection = () => {
if (hasActiveDocumentTextSelection()) setPreviewVisible(false);
};
document.addEventListener("selectionchange", closeOnSelection);
return () => document.removeEventListener("selectionchange", closeOnSelection);
}, [previewVisible]);
const cancelPreviewTimer = () => {
if (!previewTimerRef.current) return;
window.clearTimeout(previewTimerRef.current);
previewTimerRef.current = null;
};
const openPreview = () => {
const rect = tagRef.current?.getBoundingClientRect();
if (!rect) return;
const size = Math.min(236, Math.max(168, window.innerWidth - 24));
@@ -449,14 +474,42 @@ function MessageReferenceTag({ reference }: { reference: PromptImageReference })
const belowTop = rect.bottom + 12;
const top = aboveTop >= 12 ? aboveTop : clampNumber(belowTop, 12, window.innerHeight - size - 12);
setPreviewPosition({ left, top });
setPreviewVisible(true);
};
const schedulePreviewFromPointer = (event: ReactMouseEvent<HTMLButtonElement>) => {
if (event.buttons !== 0) return;
cancelPreviewTimer();
previewTimerRef.current = window.setTimeout(() => {
previewTimerRef.current = null;
openPreview();
}, imagePreviewDelayMs);
};
const hidePreview = () => {
cancelPreviewTimer();
setPreviewVisible(false);
};
return (
<a ref={tagRef} className="message-reference-tag" href={reference.url} target="_blank" rel="noreferrer" onMouseEnter={loadPreview} onFocus={loadPreview}>
<img src={referenceThumbnailUrl(reference.url)} alt="" />
<button
ref={tagRef}
type="button"
className={`message-reference-tag ${previewVisible ? "preview-open" : ""}`}
draggable={false}
onMouseEnter={schedulePreviewFromPointer}
onMouseLeave={hidePreview}
onMouseDown={hidePreview}
onBlur={hidePreview}
onDragStart={(event) => event.preventDefault()}
onClick={() => openReferenceImage(reference.url)}
>
<span className="message-reference-thumb" style={{ backgroundImage: `url("${cssUrl(thumbnailUrl)}")` }} aria-hidden="true" />
<span>{reference.name}</span>
<i style={previewPosition ? { left: previewPosition.left, top: previewPosition.top } : undefined}>{previewLoaded && <img src={referencePreviewUrl(reference.url)} alt={reference.name} />}</i>
</a>
<i style={previewPosition ? { left: previewPosition.left, top: previewPosition.top } : undefined}>
{previewVisible && <img src={previewUrl} alt={reference.name} draggable={false} />}
</i>
</button>
);
}
@@ -464,6 +517,19 @@ function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function cssUrl(value: string) {
return value.replace(/["\\\n\r]/g, "\\$&");
}
function openReferenceImage(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
function hasActiveDocumentTextSelection() {
const selection = window.getSelection();
return Boolean(selection && selection.rangeCount > 0 && !selection.isCollapsed && selection.toString().trim());
}
function cleanVisibleUserText(content: string) {
return content
.replace(/^模型选择[:].*$/gm, "")
@@ -137,8 +137,10 @@
flex: 0 0 auto;
overflow: hidden;
border-radius: 5px;
object-fit: cover;
background: #f0f2f5;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.reference-hover-preview {
@@ -164,8 +166,7 @@
transition: opacity 0.16s ease, transform 0.16s ease;
}
.reference-chip:hover .reference-hover-preview,
.reference-chip:focus-within .reference-hover-preview {
.reference-chip.preview-open .reference-hover-preview {
opacity: 1;
transform: translateY(0);
}
@@ -107,6 +107,7 @@ function clampNumber(value: number, min: number, max: number) {
const caretAnchor = "\u200B";
const caretAnchorPattern = /\u200B/g;
const imagePreviewDelayMs = 180;
function stripCaretAnchors(value: string) {
return value.replace(caretAnchorPattern, "");
@@ -123,10 +124,10 @@ function createReferenceChip(reference: UploadedReferenceImage, removeLabel: str
chip.setAttribute("aria-label", reference.name);
chip.setAttribute("aria-description", removeLabel);
const thumb = document.createElement("img");
const thumb = document.createElement("span");
thumb.className = "reference-chip-thumb";
thumb.src = reference.external ? reference.previewUrl : thumbnailImageUrl(reference.previewUrl);
thumb.alt = "";
thumb.style.backgroundImage = `url("${cssUrl(reference.external ? reference.previewUrl : thumbnailImageUrl(reference.previewUrl))}")`;
thumb.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.textContent = reference.name;
@@ -136,7 +137,15 @@ function createReferenceChip(reference: UploadedReferenceImage, removeLabel: str
const previewImage = document.createElement("img");
previewImage.dataset.src = reference.external ? reference.previewUrl : canvasImageUrl(reference.previewUrl);
previewImage.alt = reference.name;
previewImage.draggable = false;
preview.append(previewImage);
let previewTimer: number | null = null;
const cancelPreviewTimer = () => {
if (!previewTimer) return;
window.clearTimeout(previewTimer);
previewTimer = null;
};
const loadPreview = () => {
if (!previewImage.src && previewImage.dataset.src) previewImage.src = previewImage.dataset.src;
@@ -162,28 +171,61 @@ function createReferenceChip(reference: UploadedReferenceImage, removeLabel: str
preview.style.width = `${width}px`;
};
const showPreview = () => {
const openPreview = () => {
loadPreview();
positionPreview();
chip.classList.add("preview-open");
window.addEventListener("scroll", positionPreview, true);
window.addEventListener("resize", positionPreview);
document.addEventListener("selectionchange", closePreviewOnTextSelection);
};
const showPreview = () => {
cancelPreviewTimer();
previewTimer = window.setTimeout(() => {
previewTimer = null;
openPreview();
}, imagePreviewDelayMs);
};
const hidePreview = () => {
cancelPreviewTimer();
chip.classList.remove("preview-open");
previewImage.removeAttribute("src");
window.removeEventListener("scroll", positionPreview, true);
window.removeEventListener("resize", positionPreview);
document.removeEventListener("selectionchange", closePreviewOnTextSelection);
};
chip.addEventListener("mouseenter", showPreview);
function closePreviewOnTextSelection() {
if (hasActiveDocumentTextSelection()) hidePreview();
}
chip.addEventListener("mouseenter", (event) => {
if (event.buttons !== 0) return;
showPreview();
});
chip.addEventListener("mouseleave", hidePreview);
chip.addEventListener("focusin", showPreview);
chip.addEventListener("mousedown", hidePreview);
chip.addEventListener("focusout", hidePreview);
chip.addEventListener("dragstart", () => {
hidePreview();
});
previewImage.addEventListener("load", positionPreview);
chip.append(thumb, name, preview);
return chip;
}
function cssUrl(value: string) {
return value.replace(/["\\\n\r]/g, "\\$&");
}
function hasActiveDocumentTextSelection() {
const selection = window.getSelection();
return Boolean(selection && selection.rangeCount > 0 && !selection.isCollapsed && selection.toString().trim());
}
function findReferenceChip(root: HTMLElement, id: string) {
return Array.from(root.querySelectorAll<HTMLElement>("[data-reference-id]")).find((chip) => chip.dataset.referenceId === id) ?? null;
}