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 {
|
.moteva-generated-image {
|
||||||
|
position: relative;
|
||||||
width: min(100%, 300px);
|
width: min(100%, 300px);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
|
aspect-ratio: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -317,11 +319,57 @@
|
|||||||
|
|
||||||
.moteva-generated-image-preview {
|
.moteva-generated-image-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
background-repeat: no-repeat;
|
object-fit: contain;
|
||||||
background-position: center;
|
}
|
||||||
background-size: 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 {
|
.moteva-response-card .markdown-content {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
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 type { AgentMessage, CanvasNode } from "@/domain/design";
|
||||||
import { useI18n } from "@/i18n/i18n";
|
import { useI18n } from "@/i18n/i18n";
|
||||||
import { MarkdownContent } from "@/ui/components/MarkdownContent";
|
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[] }) {
|
function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { message?: AgentMessage; nodes?: CanvasNode[]; artifacts?: AgentImageArtifact[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const items =
|
const items =
|
||||||
artifacts.length > 0
|
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
|
: nodes
|
||||||
.filter((node) => isImageUrl(node.content))
|
.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 title = items[0]?.title || message?.title || "Generated image";
|
||||||
const createdAt = message?.createdAt || new Date().toISOString();
|
const createdAt = message?.createdAt || new Date().toISOString();
|
||||||
const content = message ? cleanMessageContent(message.content) : "";
|
const content = message ? cleanMessageContent(message.content) : "";
|
||||||
@@ -165,9 +166,28 @@ function MotevaGeneratedResponse({ message, nodes = [], artifacts = [] }: { mess
|
|||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className={`moteva-generated-grid ${items.length > 1 ? "multi" : ""}`}>
|
<div className={`moteva-generated-grid ${items.length > 1 ? "multi" : ""}`}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button className="moteva-generated-image" type="button" key={item.id || item.url} title={item.title || title}>
|
<div className="moteva-generated-image" key={item.id || item.url} title={item.title || title} style={imageAspectRatioStyle(item.width, item.height)}>
|
||||||
<span className="moteva-generated-image-preview" style={{ backgroundImage: `url("${cssUrl(canvasImageUrl(item.url))}")` }} aria-hidden="true" />
|
<img
|
||||||
</button>
|
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>
|
</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 }) {
|
function MotevaDateDivider({ value }: { value: string }) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
return <div className="moteva-date-divider">{formatAgentDate(value, locale)}</div>;
|
return <div className="moteva-date-divider">{formatAgentDate(value, locale)}</div>;
|
||||||
@@ -525,6 +556,67 @@ function openReferenceImage(url: string) {
|
|||||||
window.open(url, "_blank", "noopener,noreferrer");
|
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() {
|
function hasActiveDocumentTextSelection() {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
return Boolean(selection && selection.rangeCount > 0 && !selection.isCollapsed && selection.toString().trim());
|
return Boolean(selection && selection.rangeCount > 0 && !selection.isCollapsed && selection.toString().trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user