From 72642099a22f4456f03d68cb3aa1216a7df8fd6c Mon Sep 17 00:00:00 2001
From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:18:56 -0700
Subject: [PATCH 1/3] feat(chat): right-click Copy image on transcript pictures
---
.../message/generated-images-block.tsx | 22 +++++
src/components/message/image-actions.tsx | 86 ++++++++++++++++
.../message/user-image-attachments.tsx | 26 ++++-
src/components/ui/image-preview-dialog.tsx | 26 ++++-
src/i18n/messages/ar.json | 3 +
src/i18n/messages/de.json | 3 +
src/i18n/messages/en.json | 3 +
src/i18n/messages/es.json | 3 +
src/i18n/messages/fr.json | 3 +
src/i18n/messages/ja.json | 3 +
src/i18n/messages/ko.json | 3 +
src/i18n/messages/pt.json | 3 +
src/i18n/messages/zh-CN.json | 3 +
src/i18n/messages/zh-TW.json | 3 +
src/lib/copy-image.test.ts | 57 +++++++++++
src/lib/copy-image.ts | 98 +++++++++++++++++++
16 files changed, 343 insertions(+), 2 deletions(-)
create mode 100644 src/components/message/image-actions.tsx
create mode 100644 src/lib/copy-image.test.ts
create mode 100644 src/lib/copy-image.ts
diff --git a/src/components/message/generated-images-block.tsx b/src/components/message/generated-images-block.tsx
index d85c91b9f..135465611 100644
--- a/src/components/message/generated-images-block.tsx
+++ b/src/components/message/generated-images-block.tsx
@@ -7,8 +7,11 @@ import { useTranslations } from "next-intl"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import type { ToolCallStatus } from "@/lib/types"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
+import { ImageActions } from "./image-actions"
+import { copyImageToClipboard } from "@/lib/copy-image"
import { downloadImage } from "@/lib/image-download"
import { toErrorMessage } from "@/lib/app-error"
+import { toast } from "sonner"
import { cn } from "@/lib/utils"
interface GeneratedImagesBlockProps {
@@ -89,6 +92,21 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
[t]
)
+ const handleCopy = useCallback(
+ async (img: UserImageDisplay) => {
+ try {
+ await copyImageToClipboard({
+ data: img.data,
+ mime_type: img.mime_type,
+ })
+ toast.success(t("copiedImage"))
+ } catch (err) {
+ toast.error(t("copyImageFailed", { message: toErrorMessage(err) }))
+ }
+ },
+ [t]
+ )
+
const trimmedPrompt =
typeof revisedPrompt === "string" ? revisedPrompt.trim() : ""
@@ -112,6 +130,7 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
) : null}
{image ? (
+
+
) : isFailed ? (
setPreviewOpen(open)}
onDownload={image ? () => void handleDownload(image) : undefined}
downloadLabel={t("downloadImage")}
+ onCopy={image ? () => void handleCopy(image) : undefined}
+ copyLabel={t("copyImage")}
/>
)
diff --git a/src/components/message/image-actions.tsx b/src/components/message/image-actions.tsx
new file mode 100644
index 000000000..6b4e2fb4a
--- /dev/null
+++ b/src/components/message/image-actions.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import { type ReactNode, useCallback } from "react"
+import { Copy, Download } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { toast } from "sonner"
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import { copyImageToClipboard } from "@/lib/copy-image"
+import { downloadImage } from "@/lib/image-download"
+import { toErrorMessage } from "@/lib/app-error"
+import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
+
+/**
+ * Right-click menu on a transcript image: Copy image / Download image.
+ *
+ * The conversation panel wraps the whole transcript in its own context
+ * menu, so this trigger stops the event from bubbling — same contract as
+ * `FileReferenceActions`. Right-clicking the image is about the image;
+ * right-clicking anywhere else still gets the conversation menu.
+ */
+export function ImageActions({
+ image,
+ children,
+}: {
+ image: UserImageDisplay
+ children: ReactNode
+}) {
+ const t = useTranslations("Folder.chat.messageList")
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await copyImageToClipboard({
+ data: image.data,
+ mime_type: image.mime_type,
+ })
+ toast.success(t("copiedImage"))
+ } catch (err) {
+ toast.error(t("copyImageFailed", { message: toErrorMessage(err) }))
+ }
+ }, [image, t])
+
+ const handleDownload = useCallback(async () => {
+ try {
+ await downloadImage({
+ data: image.data,
+ mime_type: image.mime_type,
+ suggestedName: image.name,
+ })
+ } catch (err) {
+ window.alert(t("downloadFailed", { message: toErrorMessage(err) }))
+ }
+ }, [image, t])
+
+ return (
+
+
+ event.stopPropagation()}
+ onPointerDown={(event) => {
+ if (event.pointerType !== "mouse") event.stopPropagation()
+ }}
+ >
+ {children}
+
+
+
+ void handleCopy()}>
+
+ {t("copyImage")}
+
+ void handleDownload()}>
+
+ {t("downloadImage")}
+
+
+
+ )
+}
diff --git a/src/components/message/user-image-attachments.tsx b/src/components/message/user-image-attachments.tsx
index e5154fa23..4e120c4a8 100644
--- a/src/components/message/user-image-attachments.tsx
+++ b/src/components/message/user-image-attachments.tsx
@@ -6,8 +6,11 @@ import { Download } from "lucide-react"
import { useTranslations } from "next-intl"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
+import { ImageActions } from "./image-actions"
+import { copyImageToClipboard } from "@/lib/copy-image"
import { downloadImage } from "@/lib/image-download"
import { toErrorMessage } from "@/lib/app-error"
+import { toast } from "sonner"
interface UserImageAttachmentsProps {
images: UserImageDisplay[]
@@ -37,6 +40,21 @@ export function UserImageAttachments({
[t]
)
+ const handleCopy = useCallback(
+ async (image: UserImageDisplay) => {
+ try {
+ await copyImageToClipboard({
+ data: image.data,
+ mime_type: image.mime_type,
+ })
+ toast.success(t("copiedImage"))
+ } catch (err) {
+ toast.error(t("copyImageFailed", { message: toErrorMessage(err) }))
+ }
+ },
+ [t]
+ )
+
if (images.length === 0) return null
const previewImage =
@@ -48,8 +66,11 @@ export function UserImageAttachments({
{images.map((image, index) => (
-
void handleDownload(previewImage) : undefined
}
downloadLabel={t("downloadImage")}
+ onCopy={previewImage ? () => void handleCopy(previewImage) : undefined}
+ copyLabel={t("copyImage")}
/>
)
diff --git a/src/components/ui/image-preview-dialog.tsx b/src/components/ui/image-preview-dialog.tsx
index e20e6dabe..a6626c0a0 100644
--- a/src/components/ui/image-preview-dialog.tsx
+++ b/src/components/ui/image-preview-dialog.tsx
@@ -1,7 +1,7 @@
"use client"
import { Dialog as DialogPrimitive } from "radix-ui"
-import { Download, X } from "lucide-react"
+import { Copy, Download, X } from "lucide-react"
import { cn } from "@/lib/utils"
interface ImagePreviewDialogProps {
@@ -16,6 +16,8 @@ interface ImagePreviewDialogProps {
*/
onDownload?: () => void
downloadLabel?: string
+ onCopy?: () => void
+ copyLabel?: string
}
function ImagePreviewDialog({
@@ -25,6 +27,8 @@ function ImagePreviewDialog({
onOpenChange,
onDownload,
downloadLabel,
+ onCopy,
+ copyLabel,
}: ImagePreviewDialogProps) {
return (
@@ -44,6 +48,20 @@ function ImagePreviewDialog({
{alt}
+ {onCopy && (
+
+ )}
{onDownload && (
@@ -113,11 +78,20 @@ export function UserImageAttachments({
if (!open) setPreviewIndex(null)
}}
onDownload={
- previewImage ? () => void handleDownload(previewImage) : undefined
+ previewImage ? () => void download(previewImage) : undefined
}
downloadLabel={t("downloadImage")}
- onCopy={previewImage ? () => void handleCopy(previewImage) : undefined}
+ onCopy={
+ previewImage && canCopy ? () => void copy(previewImage) : undefined
+ }
copyLabel={t("copyImage")}
+ renderImage={
+ previewImage
+ ? (image) => (
+ {image}
+ )
+ : undefined
+ }
/>
)
diff --git a/src/components/ui/image-preview-dialog.tsx b/src/components/ui/image-preview-dialog.tsx
index a6626c0a0..4387289fe 100644
--- a/src/components/ui/image-preview-dialog.tsx
+++ b/src/components/ui/image-preview-dialog.tsx
@@ -1,5 +1,6 @@
"use client"
+import type { ReactNode } from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Copy, Download, X } from "lucide-react"
import { cn } from "@/lib/utils"
@@ -18,6 +19,12 @@ interface ImagePreviewDialogProps {
downloadLabel?: string
onCopy?: () => void
copyLabel?: string
+ /**
+ * Wrap the image element — used to hang a right-click menu off it, so the
+ * blown-up picture offers the same actions as its thumbnail did. A render
+ * prop keeps this ui/ component free of message-specific imports.
+ */
+ renderImage?: (image: ReactNode) => ReactNode
}
function ImagePreviewDialog({
@@ -29,7 +36,17 @@ function ImagePreviewDialog({
downloadLabel,
onCopy,
copyLabel,
+ renderImage,
}: ImagePreviewDialogProps) {
+ const image = src ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+
e.stopPropagation()}
+ className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
+ />
+ ) : null
return (
@@ -85,21 +102,7 @@ function ImagePreviewDialog({
- {src && (
- /* eslint-disable-next-line @next/next/no-img-element */
-
e.stopPropagation()}
- onContextMenu={(e) => {
- if (!onCopy) return
- e.preventDefault()
- e.stopPropagation()
- onCopy()
- }}
- className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
- />
- )}
+ {image && (renderImage ? renderImage(image) : image)}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 40037b7c4..424ae6b09 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -2930,9 +2930,10 @@
"copyMessage": "نسخ",
"copied": "تم النسخ",
"downloadImage": "تنزيل الصورة",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "نسخ الصورة",
+ "copiedImage": "تم نسخ الصورة",
+ "copyImageFailed": "تعذّر نسخ الصورة: {message}",
+ "copyImageUnsupported": "نسخ هذه الصورة غير مدعوم هنا",
"downloadFailed": "فشل التنزيل: {message}",
"imageGeneration": "إنشاء الصورة",
"imageGenerationPending": "جارٍ إنشاء الصورة…",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index e5f2a331b..99108e118 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -2930,9 +2930,10 @@
"copyMessage": "Kopieren",
"copied": "Kopiert",
"downloadImage": "Bild herunterladen",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "Bild kopieren",
+ "copiedImage": "Bild kopiert",
+ "copyImageFailed": "Bild konnte nicht kopiert werden: {message}",
+ "copyImageUnsupported": "Das Kopieren dieses Bildes wird hier nicht unterstützt",
"downloadFailed": "Download fehlgeschlagen: {message}",
"imageGeneration": "Bildgenerierung",
"imageGenerationPending": "Bild wird generiert…",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 47205203f..944fdbec1 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -2933,6 +2933,7 @@
"copyImage": "Copy image",
"copiedImage": "Image copied",
"copyImageFailed": "Could not copy image: {message}",
+ "copyImageUnsupported": "Copying this image is not supported here",
"downloadFailed": "Download failed: {message}",
"imageGeneration": "Image generation",
"imageGenerationPending": "Generating image…",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 2ad94b029..069618e57 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -2930,9 +2930,10 @@
"copyMessage": "Copiar",
"copied": "Copiado",
"downloadImage": "Descargar imagen",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "Copiar imagen",
+ "copiedImage": "Imagen copiada",
+ "copyImageFailed": "No se pudo copiar la imagen: {message}",
+ "copyImageUnsupported": "Aquí no se puede copiar esta imagen",
"downloadFailed": "Descarga fallida: {message}",
"imageGeneration": "Generación de imágenes",
"imageGenerationPending": "Generando imagen…",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 0f250e7a2..084266a8e 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -2930,9 +2930,10 @@
"copyMessage": "Copier",
"copied": "Copié",
"downloadImage": "Télécharger l'image",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "Copier l'image",
+ "copiedImage": "Image copiée",
+ "copyImageFailed": "Échec de la copie de l'image : {message}",
+ "copyImageUnsupported": "La copie de cette image n'est pas prise en charge ici",
"downloadFailed": "Échec du téléchargement : {message}",
"imageGeneration": "Génération d'image",
"imageGenerationPending": "Génération de l'image…",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index edbebb5a5..3426f96f7 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -2930,9 +2930,10 @@
"copyMessage": "コピー",
"copied": "コピー済み",
"downloadImage": "画像をダウンロード",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "画像をコピー",
+ "copiedImage": "画像をコピーしました",
+ "copyImageFailed": "画像のコピーに失敗しました: {message}",
+ "copyImageUnsupported": "この環境では画像をコピーできません",
"downloadFailed": "ダウンロードに失敗しました: {message}",
"imageGeneration": "画像生成",
"imageGenerationPending": "画像を生成中…",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 6ecae3eec..ad80c6e1e 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -2930,9 +2930,10 @@
"copyMessage": "복사",
"copied": "복사됨",
"downloadImage": "이미지 다운로드",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "이미지 복사",
+ "copiedImage": "이미지를 복사했습니다",
+ "copyImageFailed": "이미지 복사 실패: {message}",
+ "copyImageUnsupported": "이 환경에서는 이미지를 복사할 수 없습니다",
"downloadFailed": "다운로드 실패: {message}",
"imageGeneration": "이미지 생성",
"imageGenerationPending": "이미지 생성 중…",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 8203b7e7e..f81a299b8 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -2930,9 +2930,10 @@
"copyMessage": "Copiar",
"copied": "Copiado",
"downloadImage": "Baixar imagem",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "Copiar imagem",
+ "copiedImage": "Imagem copiada",
+ "copyImageFailed": "Não foi possível copiar a imagem: {message}",
+ "copyImageUnsupported": "Não é possível copiar esta imagem aqui",
"downloadFailed": "Falha no download: {message}",
"imageGeneration": "Geração de imagem",
"imageGenerationPending": "Gerando imagem…",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 792d1b710..a7298bff5 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -2930,9 +2930,10 @@
"copyMessage": "复制",
"copied": "已复制",
"downloadImage": "下载图片",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "复制图片",
+ "copiedImage": "已复制图片",
+ "copyImageFailed": "复制图片失败:{message}",
+ "copyImageUnsupported": "当前环境不支持复制该图片",
"downloadFailed": "下载失败:{message}",
"imageGeneration": "图片生成",
"imageGenerationPending": "正在生成图片…",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 4fefa9c1a..556ff860a 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -2930,9 +2930,10 @@
"copyMessage": "複製",
"copied": "已複製",
"downloadImage": "下載圖片",
- "copyImage": "Copy image",
- "copiedImage": "Image copied",
- "copyImageFailed": "Could not copy image: {message}",
+ "copyImage": "複製圖片",
+ "copiedImage": "已複製圖片",
+ "copyImageFailed": "複製圖片失敗:{message}",
+ "copyImageUnsupported": "目前環境不支援複製該圖片",
"downloadFailed": "下載失敗:{message}",
"imageGeneration": "圖片生成",
"imageGenerationPending": "正在生成圖片…",
diff --git a/src/lib/copy-image.test.ts b/src/lib/copy-image.test.ts
index f338bf54d..d07b72b38 100644
--- a/src/lib/copy-image.test.ts
+++ b/src/lib/copy-image.test.ts
@@ -7,6 +7,55 @@ import {
normalizeImageMime,
} from "./copy-image"
+// 1x1 PNG
+const PNG =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+
+type FakeItem = { items: Record> }
+
+/**
+ * jsdom ships no object-URL implementation, so install one. Returns the
+ * `revokeObjectURL` spy, which is how the leak-on-failure test checks that
+ * every exit from the raster releases its URL.
+ */
+function stubObjectUrls(): ReturnType {
+ const revoke = vi.fn()
+ vi.stubGlobal("URL", {
+ ...URL,
+ createObjectURL: vi.fn(() => "blob:stub"),
+ revokeObjectURL: revoke,
+ })
+ return revoke
+}
+
+/** Stub `ClipboardItem` + `clipboard.write`, and collect what was written. */
+function stubClipboard(): {
+ writes: FakeItem[]
+ write: ReturnType
+} {
+ const writes: FakeItem[] = []
+ vi.stubGlobal(
+ "ClipboardItem",
+ class FakeItem {
+ constructor(public items: Record>) {}
+ }
+ )
+ const write = vi.fn(async (items: FakeItem[]) => {
+ for (const item of items) {
+ try {
+ await Promise.all(Object.values(item.items))
+ } catch {
+ // Like the real API: a rejected representation fails the write with an
+ // error of the clipboard's own, losing the reason it rejected for.
+ throw new Error("The write was cancelled")
+ }
+ }
+ writes.push(...items)
+ })
+ vi.stubGlobal("navigator", { clipboard: { write } })
+ return { writes, write }
+}
+
describe("normalizeImageMime", () => {
it("normalizes jpeg aliases and empty", () => {
expect(normalizeImageMime("image/JPG")).toBe("image/jpeg")
@@ -16,35 +65,86 @@ describe("normalizeImageMime", () => {
})
describe("copyImageToClipboard", () => {
- const writes: ClipboardItem[] = []
-
afterEach(() => {
- writes.length = 0
vi.unstubAllGlobals()
+ vi.restoreAllMocks()
})
it("writes a PNG ClipboardItem", async () => {
- // 1x1 PNG
- const png =
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+ const { writes } = stubClipboard()
+ await copyImageToClipboard({ data: PNG, mime_type: "image/png" })
+ expect(writes).toHaveLength(1)
+ const png = await writes[0].items["image/png"]
+ expect(png).toBeInstanceOf(Blob)
+ expect(png.type).toBe("image/png")
+ })
+
+ // WebKit only honours a clipboard write issued inside the user gesture, so
+ // the raster must not be awaited before `write()` is called. Anything that
+ // reintroduces an `await` ahead of the write breaks copying JPEGs on the
+ // desktop app, and this is the assertion that catches it.
+ it("calls write synchronously, before the raster resolves", () => {
+ const { write } = stubClipboard()
+ stubObjectUrls()
+ let decodeStarted = false
vi.stubGlobal(
- "ClipboardItem",
- class FakeItem {
- constructor(public items: Record) {}
+ "Image",
+ class {
+ onload: (() => void) | null = null
+ onerror: (() => void) | null = null
+ set src(_value: string) {
+ // A real decode never completes in the same tick.
+ decodeStarted = true
+ }
}
)
- vi.stubGlobal("navigator", {
- clipboard: {
- write: async (items: ClipboardItem[]) => {
- writes.push(...items)
- },
- },
+ const pending = copyImageToClipboard({
+ data: PNG,
+ mime_type: "image/jpeg",
})
- await copyImageToClipboard({ data: png, mime_type: "image/png" })
- expect(writes).toHaveLength(1)
- const item = writes[0] as unknown as { items: Record }
- expect(item.items["image/png"]).toBeInstanceOf(Blob)
- expect(item.items["image/png"].type).toBe("image/png")
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(decodeStarted).toBe(true)
+ // The write never settles because the stubbed decode never fires; keep the
+ // rejection handled so it can't surface as an unhandled rejection.
+ pending.catch(() => {})
+ })
+
+ it("reports the raster failure rather than the write's own error", async () => {
+ stubClipboard()
+ stubObjectUrls()
+ vi.stubGlobal(
+ "Image",
+ class {
+ onload: (() => void) | null = null
+ onerror: (() => void) | null = null
+ set src(_value: string) {
+ setTimeout(() => this.onerror?.(), 0)
+ }
+ }
+ )
+ await expect(
+ copyImageToClipboard({ data: PNG, mime_type: "image/jpeg" })
+ ).rejects.toBeInstanceOf(ClipboardImageUnsupportedError)
+ })
+
+ it("releases the object URL when the canvas is unavailable", async () => {
+ stubClipboard()
+ const revoke = stubObjectUrls()
+ vi.stubGlobal(
+ "Image",
+ class {
+ onload: (() => void) | null = null
+ onerror: (() => void) | null = null
+ set src(_value: string) {
+ setTimeout(() => this.onload?.(), 0)
+ }
+ }
+ )
+ // jsdom has no 2d context, so this takes the `!ctx` bail-out.
+ await expect(
+ copyImageToClipboard({ data: PNG, mime_type: "image/jpeg" })
+ ).rejects.toBeInstanceOf(ClipboardImageUnsupportedError)
+ expect(revoke).toHaveBeenCalledWith("blob:stub")
})
it("throws when the clipboard cannot take images", async () => {
diff --git a/src/lib/copy-image.ts b/src/lib/copy-image.ts
index 75bb5dda9..75a47a56b 100644
--- a/src/lib/copy-image.ts
+++ b/src/lib/copy-image.ts
@@ -5,9 +5,20 @@
* Chrome / Edge / Tauri webview accept `image/png` on `ClipboardItem`.
* JPEG / webp / gif are rewritten to PNG via a canvas so the write is
* not rejected. Environments without `clipboard.write` throw a typed
- * error the UI can surface.
+ * error the UI can surface — see {@link canCopyImageToClipboard}, which
+ * callers use to leave the action out entirely rather than offer one
+ * that can only fail.
*/
+/**
+ * Whether this environment can take an image on the clipboard at all.
+ *
+ * False in a non-secure context — the server build served over plain HTTP on
+ * a LAN, where `navigator.clipboard` and `ClipboardItem` are both undefined
+ * (`installClipboardFallback` in `@/lib/utils` only backfills `writeText`).
+ * Loopback origins such as `localhost` are a secure-context exception, as is
+ * the desktop app's custom protocol, so this is true there.
+ */
export function canCopyImageToClipboard(): boolean {
return (
typeof ClipboardItem !== "undefined" &&
@@ -32,11 +43,36 @@ export async function copyImageToClipboard(opts: {
}
const bytes = base64ToUint8Array(opts.data)
const sourceType = normalizeImageMime(opts.mime_type)
+
+ // WebKit — the webview the desktop app runs on — only honours a clipboard
+ // write issued inside the user gesture, and a rasterized JPEG takes an image
+ // decode plus `canvas.toBlob` to produce. Awaiting that first spends the
+ // transient activation and the write then fails with NotAllowedError, so
+ // hand `ClipboardItem` the pending Blob instead and let the browser await
+ // it: `write()` is called synchronously, still inside the gesture. It also
+ // fixes the ordering — two copies in a row land in the order they were
+ // asked for, not the order their rasters happened to finish in.
const png =
sourceType === "image/png"
- ? new Blob([bytes as BlobPart], { type: "image/png" })
- : await rasterToPngBlob(bytes, sourceType)
- await navigator.clipboard.write([new ClipboardItem({ "image/png": png })])
+ ? Promise.resolve(new Blob([bytes as BlobPart], { type: "image/png" }))
+ : rasterToPngBlob(bytes, sourceType)
+
+ // `write()` reports a rejected value as a generic error of its own, so keep
+ // the real reason and rethrow that instead. Observing without rethrowing is
+ // deliberate: it hands `png` a handler up front, so a `write()` that fails
+ // first for an unrelated reason (a denied permission) can't leave the raster
+ // rejection unhandled. Attached before the write so it runs first, and
+ // `rasterError` is set by the time the write's own rejection surfaces.
+ let rasterError: unknown = null
+ png.catch((err: unknown) => {
+ rasterError = err
+ })
+
+ try {
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png })])
+ } catch (err) {
+ throw rasterError ?? err
+ }
}
export function normalizeImageMime(mime: string): string {
@@ -53,19 +89,20 @@ function base64ToUint8Array(b64: string): Uint8Array {
}
function rasterToPngBlob(bytes: Uint8Array, mime: string): Promise {
+ // Node / tests without a DOM: nothing can decode the image. Only PNG reaches
+ // this function, and the caller already short-circuits it.
if (typeof document === "undefined" || typeof Image === "undefined") {
- // Node / tests without a DOM: keep the original bytes tagged as PNG
- // only when they already are; otherwise fail closed.
- if (mime === "image/png") {
- return Promise.resolve(
- new Blob([bytes as BlobPart], { type: "image/png" })
- )
- }
return Promise.reject(new ClipboardImageUnsupportedError())
}
return new Promise((resolve, reject) => {
const blob = new Blob([bytes as BlobPart], { type: mime })
const url = URL.createObjectURL(blob)
+ // Every exit from here has to release the object URL, or a failed copy
+ // pins the decoded image for the lifetime of the document.
+ const fail = (err: unknown) => {
+ URL.revokeObjectURL(url)
+ reject(err)
+ }
const img = new Image()
img.onload = () => {
try {
@@ -74,26 +111,24 @@ function rasterToPngBlob(bytes: Uint8Array, mime: string): Promise {
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext("2d")
if (!ctx) {
- reject(new ClipboardImageUnsupportedError())
+ fail(new ClipboardImageUnsupportedError())
return
}
ctx.drawImage(img, 0, 0)
canvas.toBlob((out) => {
- URL.revokeObjectURL(url)
if (!out) {
- reject(new ClipboardImageUnsupportedError())
+ fail(new ClipboardImageUnsupportedError())
return
}
+ URL.revokeObjectURL(url)
resolve(out)
}, "image/png")
} catch (err) {
- URL.revokeObjectURL(url)
- reject(err)
+ fail(err)
}
}
img.onerror = () => {
- URL.revokeObjectURL(url)
- reject(new ClipboardImageUnsupportedError())
+ fail(new ClipboardImageUnsupportedError())
}
img.src = url
})