From d5c5470c3c19e4d49ba8d24a26cb1cb86eea3c29 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 10:56:04 +0800 Subject: [PATCH] feat(timeline): download generated images from a hover control Render generated artifacts with a native at their true aspect ratio instead of a background-image fill, and add a hover/focus download button that fetches the blob (falling back to a direct link) and saves it with a sanitized, extension-aware filename. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/CanvasAgentTimeline/index.css | 56 +++++++++- .../components/CanvasAgentTimeline/index.tsx | 104 +++++++++++++++++- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.css b/frontend/src/ui/components/CanvasAgentTimeline/index.css index e97b73a..cd7d870 100644 --- a/frontend/src/ui/components/CanvasAgentTimeline/index.css +++ b/frontend/src/ui/components/CanvasAgentTimeline/index.css @@ -299,9 +299,11 @@ } .moteva-generated-image { + position: relative; width: min(100%, 300px); max-width: 100%; display: block; + aspect-ratio: 1; overflow: hidden; padding: 0; border-radius: 10px; @@ -317,11 +319,57 @@ .moteva-generated-image-preview { width: 100%; - aspect-ratio: 1; + height: 100%; display: block; - background-repeat: no-repeat; - background-position: center; - background-size: contain; + object-fit: contain; +} + +.moteva-generated-download-scrim { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 48px; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.32) 0%, rgba(0, 0, 0, 0) 100%); + opacity: 0; + pointer-events: none; + transition: opacity 180ms ease; +} + +.moteva-generated-download { + position: absolute; + right: 2px; + bottom: 2px; + z-index: 1; + width: 34px; + height: 34px; + display: grid; + place-items: center; + padding: 0; + color: #fff; + background: transparent; + opacity: 0; + transform: translateY(4px); + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.moteva-generated-download svg { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); + stroke-width: 2.4; +} + +.moteva-generated-image:hover .moteva-generated-download-scrim, +.moteva-generated-image:focus-within .moteva-generated-download-scrim, +.moteva-generated-image:hover .moteva-generated-download, +.moteva-generated-image:focus-within .moteva-generated-download { + opacity: 1; +} + +.moteva-generated-image:hover .moteva-generated-download, +.moteva-generated-image:focus-within .moteva-generated-download { + transform: translateY(0); } .moteva-response-card .markdown-content { diff --git a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx index e258800..61d6af2 100644 --- a/frontend/src/ui/components/CanvasAgentTimeline/index.tsx +++ b/frontend/src/ui/components/CanvasAgentTimeline/index.tsx @@ -1,7 +1,7 @@ "use client"; import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; -import { Check, Copy, Eye, Globe2, Heart, Loader2 } from "lucide-react"; +import { Check, Copy, Download, Eye, Globe2, Heart, Loader2 } from "lucide-react"; import type { AgentMessage, CanvasNode } from "@/domain/design"; import { useI18n } from "@/i18n/i18n"; import { MarkdownContent } from "@/ui/components/MarkdownContent"; @@ -143,12 +143,13 @@ function isImageGenerationMessage(message: AgentMessage) { } function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { message?: AgentMessage; nodes?: CanvasNode[]; artifacts?: AgentImageArtifact[] }) { + const { t } = useI18n(); const items = artifacts.length > 0 - ? artifacts.map((artifact) => ({ id: artifact.id, title: artifact.title, url: artifact.url })) + ? artifacts.map((artifact) => ({ id: artifact.id, title: artifact.title, url: artifact.url, width: artifact.width, height: artifact.height })) : nodes .filter((node) => isImageUrl(node.content)) - .map((node) => ({ id: node.id, title: node.title, url: node.content })); + .map((node) => ({ id: node.id, title: node.title, url: node.content, width: node.width, height: node.height })); const title = items[0]?.title || message?.title || "Generated image"; const createdAt = message?.createdAt || new Date().toISOString(); const content = message ? cleanMessageContent(message.content) : ""; @@ -165,9 +166,28 @@ function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { mess {items.length > 0 && (
1 ? "multi" : ""}`}> {items.map((item) => ( - +
+ {item.title +
))}
)} @@ -184,6 +204,17 @@ function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { mess ); } +function imageAspectRatioStyle(width?: number, height?: number) { + const imageWidth = imageDimension(width); + const imageHeight = imageDimension(height); + if (!imageWidth || !imageHeight) return undefined; + return { aspectRatio: `${imageWidth} / ${imageHeight}` }; +} + +function imageDimension(value?: number) { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.round(value) : undefined; +} + function MotevaDateDivider({ value }: { value: string }) { const { locale } = useI18n(); return
{formatAgentDate(value, locale)}
; @@ -525,6 +556,67 @@ function openReferenceImage(url: string) { window.open(url, "_blank", "noopener,noreferrer"); } +async function downloadGeneratedImage(source: string, title: string) { + const trimmed = source.trim(); + if (!trimmed) return; + + try { + const response = await fetch(trimmed); + if (!response.ok) throw new Error(`Failed to load generated image: ${response.status}`); + const blob = await response.blob(); + const mimeType = blob.type || imageMimeTypeFromSource(trimmed); + downloadBlob(blob.type ? blob : blob.slice(0, blob.size, mimeType), imageFileName(title, mimeType)); + } catch { + downloadOriginalImage(trimmed, title); + } +} + +function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +function downloadOriginalImage(source: string, title: string) { + const link = document.createElement("a"); + link.href = source; + link.download = imageFileName(title, imageMimeTypeFromSource(source)); + link.target = "_blank"; + link.rel = "noreferrer"; + link.click(); +} + +function imageFileName(title: string, mimeType: string) { + const baseName = title.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80) || "generated-image"; + if (/\.[a-z0-9]{2,5}$/i.test(baseName)) return baseName; + return `${baseName}.${imageExtensionFromMimeType(mimeType)}`; +} + +function imageMimeTypeFromSource(source: string) { + const dataMatch = source.match(/^data:(image\/[a-zA-Z0-9.+-]+)[;,]/); + if (dataMatch) return dataMatch[1]; + const path = source.split(/[?#]/)[0]?.toLowerCase() || ""; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; + if (path.endsWith(".gif")) return "image/gif"; + if (path.endsWith(".avif")) return "image/avif"; + return "image/webp"; +} + +function imageExtensionFromMimeType(mimeType: string) { + const normalized = mimeType.toLowerCase(); + if (normalized.includes("svg")) return "svg"; + if (normalized.includes("png")) return "png"; + if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg"; + if (normalized.includes("gif")) return "gif"; + if (normalized.includes("avif")) return "avif"; + return "webp"; +} + function hasActiveDocumentTextSelection() { const selection = window.getSelection(); return Boolean(selection && selection.rangeCount > 0 && !selection.isCollapsed && selection.toString().trim());