feat(timeline): download generated images from a hover control

Render generated artifacts with a native <img> 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 10:56:04 +08:00
parent a549c7b14b
commit d5c5470c3c
2 changed files with 150 additions and 10 deletions
@@ -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 {
@@ -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 && (
<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 || title}>
<span className="moteva-generated-image-preview" style={{ backgroundImage: `url("${cssUrl(canvasImageUrl(item.url))}")` }} aria-hidden="true" />
</button>
<div className="moteva-generated-image" key={item.id || item.url} title={item.title || title} style={imageAspectRatioStyle(item.width, item.height)}>
<img
className="moteva-generated-image-preview"
src={canvasImageUrl(item.url)}
alt={item.title || title}
width={imageDimension(item.width)}
height={imageDimension(item.height)}
draggable={false}
/>
<span className="moteva-generated-download-scrim" aria-hidden="true" />
<button
className="moteva-generated-download"
type="button"
aria-label={t("download")}
title={t("download")}
onClick={() => {
void downloadGeneratedImage(item.url, item.title || title);
}}
>
<Download size={22} />
</button>
</div>
))}
</div>
)}
@@ -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 <div className="moteva-date-divider">{formatAgentDate(value, locale)}</div>;
@@ -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());