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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user