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;
|
border-radius: 10px;
|
||||||
background: #f5f5f3;
|
background: #f5f5f3;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moteva-generated-grid.multi .moteva-generated-image {
|
.moteva-generated-grid.multi .moteva-generated-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moteva-generated-image img {
|
.moteva-generated-image-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moteva-response-card .markdown-content {
|
.moteva-response-card .markdown-content {
|
||||||
@@ -450,16 +454,22 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #2f343b;
|
color: #2f343b;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-reference-tag > img {
|
.message-reference-thumb {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
object-fit: cover;
|
|
||||||
background: #eef0f3;
|
background: #eef0f3;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-reference-tag span {
|
.message-reference-tag span {
|
||||||
@@ -496,7 +506,7 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-reference-tag:hover i {
|
.message-reference-tag.preview-open i {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { Check, Copy, Eye, Globe2, Heart, Loader2 } from "lucide-react";
|
||||||
import type { AgentMessage, CanvasNode } from "@/domain/design";
|
import type { AgentMessage, CanvasNode } from "@/domain/design";
|
||||||
import { useI18n } from "@/i18n/i18n";
|
import { useI18n } from "@/i18n/i18n";
|
||||||
@@ -41,6 +41,7 @@ type AgentImageArtifact = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyButtonHideDelayMs = 3000;
|
const copyButtonHideDelayMs = 3000;
|
||||||
|
const imagePreviewDelayMs = 180;
|
||||||
|
|
||||||
export function CanvasAgentTimeline({ messages, nodes, active }: { messages: AgentMessage[]; nodes: CanvasNode[]; active: boolean }) {
|
export function CanvasAgentTimeline({ messages, nodes, active }: { messages: AgentMessage[]; nodes: CanvasNode[]; active: boolean }) {
|
||||||
const turns = groupCanvasAgentTurns(messages);
|
const turns = groupCanvasAgentTurns(messages);
|
||||||
@@ -164,8 +165,8 @@ function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { mess
|
|||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className={`moteva-generated-grid ${items.length > 1 ? "multi" : ""}`}>
|
<div className={`moteva-generated-grid ${items.length > 1 ? "multi" : ""}`}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button className="moteva-generated-image" type="button" key={item.id || item.url} title={item.title}>
|
<button className="moteva-generated-image" type="button" key={item.id || item.url} title={item.title || title}>
|
||||||
<img src={canvasImageUrl(item.url)} alt={item.title || title} />
|
<span className="moteva-generated-image-preview" style={{ backgroundImage: `url("${cssUrl(canvasImageUrl(item.url))}")` }} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -436,11 +437,35 @@ function renderUserMessageContent(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MessageReferenceTag({ reference }: { reference: PromptImageReference }) {
|
function MessageReferenceTag({ reference }: { reference: PromptImageReference }) {
|
||||||
const tagRef = useRef<HTMLAnchorElement | null>(null);
|
const tagRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const [previewLoaded, setPreviewLoaded] = useState(false);
|
const previewTimerRef = useRef<number | null>(null);
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
const [previewPosition, setPreviewPosition] = useState<{ left: number; top: number } | null>(null);
|
const [previewPosition, setPreviewPosition] = useState<{ left: number; top: number } | null>(null);
|
||||||
const loadPreview = () => {
|
const thumbnailUrl = referenceThumbnailUrl(reference.url);
|
||||||
setPreviewLoaded(true);
|
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();
|
const rect = tagRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
const size = Math.min(236, Math.max(168, window.innerWidth - 24));
|
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 belowTop = rect.bottom + 12;
|
||||||
const top = aboveTop >= 12 ? aboveTop : clampNumber(belowTop, 12, window.innerHeight - size - 12);
|
const top = aboveTop >= 12 ? aboveTop : clampNumber(belowTop, 12, window.innerHeight - size - 12);
|
||||||
setPreviewPosition({ left, top });
|
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 (
|
return (
|
||||||
<a ref={tagRef} className="message-reference-tag" href={reference.url} target="_blank" rel="noreferrer" onMouseEnter={loadPreview} onFocus={loadPreview}>
|
<button
|
||||||
<img src={referenceThumbnailUrl(reference.url)} alt="" />
|
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>
|
<span>{reference.name}</span>
|
||||||
<i style={previewPosition ? { left: previewPosition.left, top: previewPosition.top } : undefined}>{previewLoaded && <img src={referencePreviewUrl(reference.url)} alt={reference.name} />}</i>
|
<i style={previewPosition ? { left: previewPosition.left, top: previewPosition.top } : undefined}>
|
||||||
</a>
|
{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));
|
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) {
|
function cleanVisibleUserText(content: string) {
|
||||||
return content
|
return content
|
||||||
.replace(/^模型选择[::].*$/gm, "")
|
.replace(/^模型选择[::].*$/gm, "")
|
||||||
|
|||||||
@@ -137,8 +137,10 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
object-fit: cover;
|
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reference-hover-preview {
|
.reference-hover-preview {
|
||||||
@@ -164,8 +166,7 @@
|
|||||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reference-chip:hover .reference-hover-preview,
|
.reference-chip.preview-open .reference-hover-preview {
|
||||||
.reference-chip:focus-within .reference-hover-preview {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function clampNumber(value: number, min: number, max: number) {
|
|||||||
|
|
||||||
const caretAnchor = "\u200B";
|
const caretAnchor = "\u200B";
|
||||||
const caretAnchorPattern = /\u200B/g;
|
const caretAnchorPattern = /\u200B/g;
|
||||||
|
const imagePreviewDelayMs = 180;
|
||||||
|
|
||||||
function stripCaretAnchors(value: string) {
|
function stripCaretAnchors(value: string) {
|
||||||
return value.replace(caretAnchorPattern, "");
|
return value.replace(caretAnchorPattern, "");
|
||||||
@@ -123,10 +124,10 @@ function createReferenceChip(reference: UploadedReferenceImage, removeLabel: str
|
|||||||
chip.setAttribute("aria-label", reference.name);
|
chip.setAttribute("aria-label", reference.name);
|
||||||
chip.setAttribute("aria-description", removeLabel);
|
chip.setAttribute("aria-description", removeLabel);
|
||||||
|
|
||||||
const thumb = document.createElement("img");
|
const thumb = document.createElement("span");
|
||||||
thumb.className = "reference-chip-thumb";
|
thumb.className = "reference-chip-thumb";
|
||||||
thumb.src = reference.external ? reference.previewUrl : thumbnailImageUrl(reference.previewUrl);
|
thumb.style.backgroundImage = `url("${cssUrl(reference.external ? reference.previewUrl : thumbnailImageUrl(reference.previewUrl))}")`;
|
||||||
thumb.alt = "";
|
thumb.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.textContent = reference.name;
|
name.textContent = reference.name;
|
||||||
@@ -136,7 +137,15 @@ function createReferenceChip(reference: UploadedReferenceImage, removeLabel: str
|
|||||||
const previewImage = document.createElement("img");
|
const previewImage = document.createElement("img");
|
||||||
previewImage.dataset.src = reference.external ? reference.previewUrl : canvasImageUrl(reference.previewUrl);
|
previewImage.dataset.src = reference.external ? reference.previewUrl : canvasImageUrl(reference.previewUrl);
|
||||||
previewImage.alt = reference.name;
|
previewImage.alt = reference.name;
|
||||||
|
previewImage.draggable = false;
|
||||||
preview.append(previewImage);
|
preview.append(previewImage);
|
||||||
|
let previewTimer: number | null = null;
|
||||||
|
|
||||||
|
const cancelPreviewTimer = () => {
|
||||||
|
if (!previewTimer) return;
|
||||||
|
window.clearTimeout(previewTimer);
|
||||||
|
previewTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadPreview = () => {
|
const loadPreview = () => {
|
||||||
if (!previewImage.src && previewImage.dataset.src) previewImage.src = previewImage.dataset.src;
|
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`;
|
preview.style.width = `${width}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showPreview = () => {
|
const openPreview = () => {
|
||||||
loadPreview();
|
loadPreview();
|
||||||
positionPreview();
|
positionPreview();
|
||||||
|
chip.classList.add("preview-open");
|
||||||
window.addEventListener("scroll", positionPreview, true);
|
window.addEventListener("scroll", positionPreview, true);
|
||||||
window.addEventListener("resize", positionPreview);
|
window.addEventListener("resize", positionPreview);
|
||||||
|
document.addEventListener("selectionchange", closePreviewOnTextSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPreview = () => {
|
||||||
|
cancelPreviewTimer();
|
||||||
|
previewTimer = window.setTimeout(() => {
|
||||||
|
previewTimer = null;
|
||||||
|
openPreview();
|
||||||
|
}, imagePreviewDelayMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hidePreview = () => {
|
const hidePreview = () => {
|
||||||
|
cancelPreviewTimer();
|
||||||
|
chip.classList.remove("preview-open");
|
||||||
|
previewImage.removeAttribute("src");
|
||||||
window.removeEventListener("scroll", positionPreview, true);
|
window.removeEventListener("scroll", positionPreview, true);
|
||||||
window.removeEventListener("resize", positionPreview);
|
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("mouseleave", hidePreview);
|
||||||
chip.addEventListener("focusin", showPreview);
|
chip.addEventListener("mousedown", hidePreview);
|
||||||
chip.addEventListener("focusout", hidePreview);
|
chip.addEventListener("focusout", hidePreview);
|
||||||
|
chip.addEventListener("dragstart", () => {
|
||||||
|
hidePreview();
|
||||||
|
});
|
||||||
previewImage.addEventListener("load", positionPreview);
|
previewImage.addEventListener("load", positionPreview);
|
||||||
|
|
||||||
chip.append(thumb, name, preview);
|
chip.append(thumb, name, preview);
|
||||||
return chip;
|
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) {
|
function findReferenceChip(root: HTMLElement, id: string) {
|
||||||
return Array.from(root.querySelectorAll<HTMLElement>("[data-reference-id]")).find((chip) => chip.dataset.referenceId === id) ?? null;
|
return Array.from(root.querySelectorAll<HTMLElement>("[data-reference-id]")).find((chip) => chip.dataset.referenceId === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user