diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.css b/frontend/src/ui/components/CanvasAgentTimeline/index.css
index b8fbfb9..e97b73a 100644
--- a/frontend/src/ui/components/CanvasAgentTimeline/index.css
+++ b/frontend/src/ui/components/CanvasAgentTimeline/index.css
@@ -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);
}
diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx
index ca1a74c..e258800 100644
--- a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx
+++ b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx
@@ -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 && (
1 ? "multi" : ""}`}>
{items.map((item) => (
-
@@ -436,11 +437,35 @@ function renderUserMessageContent(content: string) {
}
function MessageReferenceTag({ reference }: { reference: PromptImageReference }) {
- const tagRef = useRef(null);
- const [previewLoaded, setPreviewLoaded] = useState(false);
+ const tagRef = useRef(null);
+ const previewTimerRef = useRef(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) => {
+ if (event.buttons !== 0) return;
+ cancelPreviewTimer();
+ previewTimerRef.current = window.setTimeout(() => {
+ previewTimerRef.current = null;
+ openPreview();
+ }, imagePreviewDelayMs);
+ };
+
+ const hidePreview = () => {
+ cancelPreviewTimer();
+ setPreviewVisible(false);
};
return (
-
-
+ event.preventDefault()}
+ onClick={() => openReferenceImage(reference.url)}
+ >
+
{reference.name}
- {previewLoaded &&
}
-
+
+ {previewVisible &&
}
+
+
);
}
@@ -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, "")
diff --git a/frontend/src/ui/components/PromptComposer/index.css b/frontend/src/ui/components/PromptComposer/index.css
index 1b61164..932ca9b 100644
--- a/frontend/src/ui/components/PromptComposer/index.css
+++ b/frontend/src/ui/components/PromptComposer/index.css
@@ -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);
}
diff --git a/frontend/src/ui/components/PromptComposer/index.tsx b/frontend/src/ui/components/PromptComposer/index.tsx
index 32bb5aa..c684b90 100644
--- a/frontend/src/ui/components/PromptComposer/index.tsx
+++ b/frontend/src/ui/components/PromptComposer/index.tsx
@@ -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("[data-reference-id]")).find((chip) => chip.dataset.referenceId === id) ?? null;
}