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 ( - - + ); } @@ -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; }