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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user