diff --git a/src/components/message/generated-images-block.tsx b/src/components/message/generated-images-block.tsx index d85c91b9f..b15c02222 100644 --- a/src/components/message/generated-images-block.tsx +++ b/src/components/message/generated-images-block.tsx @@ -1,14 +1,13 @@ "use client" -import { memo, useCallback, useState } from "react" +import { memo, useState } from "react" import Image from "next/image" import { AlertCircle, Download, ImagePlus } from "lucide-react" 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 { downloadImage } from "@/lib/image-download" -import { toErrorMessage } from "@/lib/app-error" +import { ImageActions, useImageActions } from "./image-actions" import { cn } from "@/lib/utils" interface GeneratedImagesBlockProps { @@ -73,21 +72,7 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({ const isFailed = image === null && (status === "failed" || status === "completed") - const handleDownload = useCallback( - async (img: UserImageDisplay) => { - try { - await downloadImage({ - data: img.data, - mime_type: img.mime_type, - suggestedName: img.name, - }) - } catch (err) { - const message = toErrorMessage(err) - window.alert(t("downloadFailed", { message })) - } - }, - [t] - ) + const { canCopy, copy, download } = useImageActions() const trimmedPrompt = typeof revisedPrompt === "string" ? revisedPrompt.trim() : "" @@ -112,7 +97,10 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({ ) : null} {image ? ( -
+ -
+ ) : isFailed ? (
setPreviewOpen(open)} - onDownload={image ? () => void handleDownload(image) : undefined} + onDownload={image ? () => void download(image) : undefined} downloadLabel={t("downloadImage")} + onCopy={image && canCopy ? () => void copy(image) : undefined} + copyLabel={t("copyImage")} + renderImage={ + image + ? (preview) => {preview} + : undefined + } />
) diff --git a/src/components/message/image-actions.test.tsx b/src/components/message/image-actions.test.tsx new file mode 100644 index 000000000..3167c7fb9 --- /dev/null +++ b/src/components/message/image-actions.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { ClipboardImageUnsupportedError } from "@/lib/copy-image" + +const mocks = vi.hoisted(() => ({ + canCopyImageToClipboard: vi.fn(() => true), + copyImageToClipboard: vi.fn(async () => {}), + downloadImage: vi.fn(async () => true), + toastSuccess: vi.fn(), + toastError: vi.fn(), + ancestorContextMenu: vi.fn(), + ancestorPointerDown: vi.fn(), +})) + +vi.mock("@/lib/copy-image", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + canCopyImageToClipboard: mocks.canCopyImageToClipboard, + copyImageToClipboard: mocks.copyImageToClipboard, + } +}) + +vi.mock("@/lib/image-download", () => ({ downloadImage: mocks.downloadImage })) + +vi.mock("sonner", () => ({ + toast: { success: mocks.toastSuccess, error: mocks.toastError }, +})) + +import { ImageActions } from "./image-actions" + +const IMAGE = { + data: "QQ==", + mime_type: "image/png", + name: "shot.png", + uri: null, +} + +function renderActions() { + return render( + // The outer handlers stand in for the conversation panel's own context + // menu, which wraps the whole transcript. + +
+ + + +
+
+ ) +} + +/** The context-menu trigger wrapped around the image. */ +function trigger(): HTMLElement { + const element = document.querySelector("[data-image-actions]") + if (!element) throw new Error("expected a context-menu trigger on the image") + return element +} + +function item(name: string): HTMLElement { + return screen.getByRole("menuitem", { name }) +} + +describe("ImageActions", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.canCopyImageToClipboard.mockReturnValue(true) + mocks.copyImageToClipboard.mockResolvedValue(undefined) + mocks.downloadImage.mockResolvedValue(true) + }) + + it("opens on right-click, and only on right-click", () => { + renderActions() + // A left click belongs to the thumbnail (it opens the preview). + fireEvent.click(trigger()) + expect(screen.queryByRole("menu")).toBeNull() + + // Radix suppresses the native menu to put its own in that place — the + // contrast with the no-clipboard case below, which leaves it alone. + expect(fireEvent.contextMenu(trigger())).toBe(false) + expect(screen.getByRole("menu")).toBeInTheDocument() + }) + + it("keeps the right-click from also opening the conversation menu", () => { + renderActions() + fireEvent.contextMenu(trigger()) + + expect(screen.getByRole("menu")).toBeInTheDocument() + // The transcript is wrapped in the conversation panel's own context menu; + // both opening at once is what this stopPropagation prevents. + expect(mocks.ancestorContextMenu).not.toHaveBeenCalled() + }) + + it("keeps a touch long-press from arming the conversation menu too", () => { + renderActions() + // jsdom's fireEvent.pointerDown drops `pointerType`, so pin it by hand. + const event = new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, "pointerType", { value: "touch" }) + fireEvent(trigger(), event) + + expect(mocks.ancestorPointerDown).not.toHaveBeenCalled() + }) + + it("copies the image and reports success", async () => { + renderActions() + fireEvent.contextMenu(trigger()) + fireEvent.click(item("Copy image")) + + await waitFor(() => { + expect(mocks.copyImageToClipboard).toHaveBeenCalledWith({ + data: IMAGE.data, + mime_type: IMAGE.mime_type, + }) + }) + await waitFor(() => { + expect(mocks.toastSuccess).toHaveBeenCalledWith("Image copied") + }) + }) + + it("reports an unsupported clipboard in the user's language, not ours", async () => { + mocks.copyImageToClipboard.mockRejectedValue( + new ClipboardImageUnsupportedError() + ) + renderActions() + fireEvent.contextMenu(trigger()) + fireEvent.click(item("Copy image")) + + await waitFor(() => { + expect(mocks.toastError).toHaveBeenCalledWith( + enMessages.Folder.chat.messageList.copyImageUnsupported + ) + }) + // The typed error's own English text must not reach the toast. + expect(mocks.toastError).not.toHaveBeenCalledWith( + expect.stringContaining("This environment cannot") + ) + }) + + it("passes any other failure through with the browser's message", async () => { + mocks.copyImageToClipboard.mockRejectedValue(new Error("Denied")) + renderActions() + fireEvent.contextMenu(trigger()) + fireEvent.click(item("Copy image")) + + await waitFor(() => { + expect(mocks.toastError).toHaveBeenCalledWith( + "Could not copy image: Denied" + ) + }) + }) + + it("downloads from the menu", async () => { + renderActions() + fireEvent.contextMenu(trigger()) + fireEvent.click(item("Download image")) + + await waitFor(() => { + expect(mocks.downloadImage).toHaveBeenCalledWith({ + data: IMAGE.data, + mime_type: IMAGE.mime_type, + suggestedName: IMAGE.name, + }) + }) + }) + + describe("without a usable clipboard (non-secure web context)", () => { + beforeEach(() => { + mocks.canCopyImageToClipboard.mockReturnValue(false) + }) + + it("offers no menu of its own, so the native image menu can appear", () => { + renderActions() + const event = fireEvent.contextMenu(trigger()) + + expect(screen.queryByRole("menu")).toBeNull() + // Not preventing the default is what lets the browser's own menu — which + // copies images with no secure-context requirement — take over. + expect(event).toBe(true) + expect(mocks.ancestorContextMenu).not.toHaveBeenCalled() + }) + + it("still shields the ancestor from a touch long-press", () => { + renderActions() + const event = new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, "pointerType", { value: "touch" }) + fireEvent(trigger(), event) + + expect(mocks.ancestorPointerDown).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/message/image-actions.tsx b/src/components/message/image-actions.tsx new file mode 100644 index 000000000..45ae91019 --- /dev/null +++ b/src/components/message/image-actions.tsx @@ -0,0 +1,162 @@ +"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 { + canCopyImageToClipboard, + ClipboardImageUnsupportedError, + 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" + +/** + * The copy/download pair behind every transcript image, shared by the + * right-click menu here, the hover button on the thumbnail and the preview + * dialog — one image can be reached three ways and all three should report + * success and failure identically. + * + * Both handlers take the image rather than closing over one, so a list of + * thumbnails can call the hook once and use it for every row. + */ +export function useImageActions(): { + canCopy: boolean + copy: (image: UserImageDisplay) => Promise + download: (image: UserImageDisplay) => Promise +} { + const t = useTranslations("Folder.chat.messageList") + + const copy = useCallback( + async (image: UserImageDisplay) => { + try { + await copyImageToClipboard({ + data: image.data, + mime_type: image.mime_type, + }) + toast.success(t("copiedImage")) + } catch (err) { + // Our own "can't be done here" carries an English developer string; + // anything else is a browser error worth showing verbatim. + toast.error( + err instanceof ClipboardImageUnsupportedError + ? t("copyImageUnsupported") + : t("copyImageFailed", { message: toErrorMessage(err) }) + ) + } + }, + [t] + ) + + const download = useCallback( + async (image: UserImageDisplay) => { + try { + await downloadImage({ + data: image.data, + mime_type: image.mime_type, + suggestedName: image.name, + }) + } catch (err) { + toast.error(t("downloadFailed", { message: toErrorMessage(err) })) + } + }, + [t] + ) + + return { canCopy: canCopyImageToClipboard(), copy, download } +} + +/** + * 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. + * + * `className` lands on the trigger itself rather than on a wrapper around it, + * so callers keep the box they had before: the styled element stays the flex + * item its parent laid out, with its own `shrink-0` and display. + */ +export function ImageActions({ + image, + className, + children, +}: { + image: UserImageDisplay + className?: string + children: ReactNode +}) { + const t = useTranslations("Folder.chat.messageList") + const { canCopy, copy, download } = useImageActions() + + // Radix's own handler still runs on this element; only the ancestor + // conversation-panel trigger is cut off. + const stopContextMenu = (event: { stopPropagation: () => void }) => + event.stopPropagation() + // Touch and pen open a context menu from a long press, which the ancestor + // arms on pointerdown — stop that press from reaching it. Mouse presses keep + // bubbling, so the panel's selection bookkeeping is untouched. + const stopNonMousePointerDown = (event: { + pointerType: string + stopPropagation: () => void + }) => { + if (event.pointerType !== "mouse") event.stopPropagation() + } + + // Non-secure web context (the server build over plain HTTP on a LAN): no + // clipboard write exists, so a Copy row could only ever fail. Drop our menu + // and let the browser's own image menu through instead — it copies and saves + // images natively, with no secure-context requirement. Blocking the ancestor + // without calling preventDefault is what lets it appear. + if (!canCopy) { + return ( +
+ {children} +
+ ) + } + + return ( + + +
+ {children} +
+
+ {/* The menu is portaled, but its click still bubbles through the React + tree to whatever the trigger sits inside — in the preview dialog that + is a backdrop that closes on click, which would shut the preview the + moment an action was picked. */} + event.stopPropagation()}> + void copy(image)}> + + {t("copyImage")} + + void download(image)}> + + {t("downloadImage")} + + +
+ ) +} diff --git a/src/components/message/user-image-attachments.tsx b/src/components/message/user-image-attachments.tsx index e5154fa23..18dd4e192 100644 --- a/src/components/message/user-image-attachments.tsx +++ b/src/components/message/user-image-attachments.tsx @@ -1,13 +1,12 @@ "use client" -import { useCallback, useState } from "react" +import { useState } from "react" import Image from "next/image" 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 { downloadImage } from "@/lib/image-download" -import { toErrorMessage } from "@/lib/app-error" +import { ImageActions, useImageActions } from "./image-actions" interface UserImageAttachmentsProps { images: UserImageDisplay[] @@ -20,22 +19,7 @@ export function UserImageAttachments({ }: UserImageAttachmentsProps) { const t = useTranslations("Folder.chat.messageList") const [previewIndex, setPreviewIndex] = useState(null) - - const handleDownload = useCallback( - async (image: UserImageDisplay) => { - try { - await downloadImage({ - data: image.data, - mime_type: image.mime_type, - suggestedName: image.name, - }) - } catch (err) { - const message = toErrorMessage(err) - window.alert(t("downloadFailed", { message })) - } - }, - [t] - ) + const { canCopy, copy, download } = useImageActions() if (images.length === 0) return null @@ -48,8 +32,9 @@ export function UserImageAttachments({
{images.map((image, index) => ( -
-
+ ))}
void handleDownload(previewImage) : undefined + previewImage ? () => void download(previewImage) : undefined } downloadLabel={t("downloadImage")} + 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 e20e6dabe..4387289fe 100644 --- a/src/components/ui/image-preview-dialog.tsx +++ b/src/components/ui/image-preview-dialog.tsx @@ -1,7 +1,8 @@ "use client" +import type { ReactNode } from "react" 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 +17,14 @@ interface ImagePreviewDialogProps { */ onDownload?: () => void 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({ @@ -25,7 +34,19 @@ function ImagePreviewDialog({ onOpenChange, onDownload, downloadLabel, + onCopy, + copyLabel, + renderImage, }: ImagePreviewDialogProps) { + const image = src ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + {alt} e.stopPropagation()} + className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain" + /> + ) : null return ( @@ -44,6 +65,20 @@ function ImagePreviewDialog({ {alt}
+ {onCopy && ( + + )} {onDownload && (
- {src && ( - /* eslint-disable-next-line @next/next/no-img-element */ - {alt} e.stopPropagation()} - 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 bdf81590d..424ae6b09 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2930,6 +2930,10 @@ "copyMessage": "نسخ", "copied": "تم النسخ", "downloadImage": "تنزيل الصورة", + "copyImage": "نسخ الصورة", + "copiedImage": "تم نسخ الصورة", + "copyImageFailed": "تعذّر نسخ الصورة: {message}", + "copyImageUnsupported": "نسخ هذه الصورة غير مدعوم هنا", "downloadFailed": "فشل التنزيل: {message}", "imageGeneration": "إنشاء الصورة", "imageGenerationPending": "جارٍ إنشاء الصورة…", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..99108e118 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2930,6 +2930,10 @@ "copyMessage": "Kopieren", "copied": "Kopiert", "downloadImage": "Bild herunterladen", + "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 273af3aa0..944fdbec1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2930,6 +2930,10 @@ "copyMessage": "Copy", "copied": "Copied", "downloadImage": "Download image", + "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 9948070a4..069618e57 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2930,6 +2930,10 @@ "copyMessage": "Copiar", "copied": "Copiado", "downloadImage": "Descargar imagen", + "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 9638508d6..084266a8e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2930,6 +2930,10 @@ "copyMessage": "Copier", "copied": "Copié", "downloadImage": "Télécharger l'image", + "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 f44e8c88d..3426f96f7 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2930,6 +2930,10 @@ "copyMessage": "コピー", "copied": "コピー済み", "downloadImage": "画像をダウンロード", + "copyImage": "画像をコピー", + "copiedImage": "画像をコピーしました", + "copyImageFailed": "画像のコピーに失敗しました: {message}", + "copyImageUnsupported": "この環境では画像をコピーできません", "downloadFailed": "ダウンロードに失敗しました: {message}", "imageGeneration": "画像生成", "imageGenerationPending": "画像を生成中…", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..ad80c6e1e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2930,6 +2930,10 @@ "copyMessage": "복사", "copied": "복사됨", "downloadImage": "이미지 다운로드", + "copyImage": "이미지 복사", + "copiedImage": "이미지를 복사했습니다", + "copyImageFailed": "이미지 복사 실패: {message}", + "copyImageUnsupported": "이 환경에서는 이미지를 복사할 수 없습니다", "downloadFailed": "다운로드 실패: {message}", "imageGeneration": "이미지 생성", "imageGenerationPending": "이미지 생성 중…", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..f81a299b8 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2930,6 +2930,10 @@ "copyMessage": "Copiar", "copied": "Copiado", "downloadImage": "Baixar imagem", + "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 f6a2638c1..a7298bff5 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2930,6 +2930,10 @@ "copyMessage": "复制", "copied": "已复制", "downloadImage": "下载图片", + "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 13e7b6eae..556ff860a 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2930,6 +2930,10 @@ "copyMessage": "複製", "copied": "已複製", "downloadImage": "下載圖片", + "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 new file mode 100644 index 000000000..d07b72b38 --- /dev/null +++ b/src/lib/copy-image.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + canCopyImageToClipboard, + ClipboardImageUnsupportedError, + copyImageToClipboard, + 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") + expect(normalizeImageMime(" image/png ")).toBe("image/png") + expect(normalizeImageMime("")).toBe("image/png") + }) +}) + +describe("copyImageToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it("writes a PNG ClipboardItem", async () => { + 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( + "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 + } + } + ) + const pending = copyImageToClipboard({ + data: PNG, + mime_type: "image/jpeg", + }) + 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 () => { + vi.stubGlobal("navigator", { clipboard: {} }) + await expect( + copyImageToClipboard({ data: "QQ==", mime_type: "image/png" }) + ).rejects.toBeInstanceOf(ClipboardImageUnsupportedError) + expect(canCopyImageToClipboard()).toBe(false) + }) +}) diff --git a/src/lib/copy-image.ts b/src/lib/copy-image.ts new file mode 100644 index 000000000..75a47a56b --- /dev/null +++ b/src/lib/copy-image.ts @@ -0,0 +1,135 @@ +/** + * Copy an inline base64 image to the system clipboard as a real image + * (so Paste in another app inserts pixels, not a path). + * + * 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 — 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" && + typeof navigator !== "undefined" && + typeof navigator.clipboard?.write === "function" + ) +} + +export class ClipboardImageUnsupportedError extends Error { + constructor() { + super("This environment cannot copy images to the clipboard") + this.name = "ClipboardImageUnsupportedError" + } +} + +export async function copyImageToClipboard(opts: { + data: string + mime_type: string +}): Promise { + if (!canCopyImageToClipboard()) { + throw new ClipboardImageUnsupportedError() + } + 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" + ? 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 { + const trimmed = mime.trim().toLowerCase() + if (trimmed === "image/jpg") return "image/jpeg" + return trimmed || "image/png" +} + +function base64ToUint8Array(b64: string): Uint8Array { + const binary = atob(b64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes +} + +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") { + 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 { + const canvas = document.createElement("canvas") + canvas.width = img.naturalWidth || img.width + canvas.height = img.naturalHeight || img.height + const ctx = canvas.getContext("2d") + if (!ctx) { + fail(new ClipboardImageUnsupportedError()) + return + } + ctx.drawImage(img, 0, 0) + canvas.toBlob((out) => { + if (!out) { + fail(new ClipboardImageUnsupportedError()) + return + } + URL.revokeObjectURL(url) + resolve(out) + }, "image/png") + } catch (err) { + fail(err) + } + } + img.onerror = () => { + fail(new ClipboardImageUnsupportedError()) + } + img.src = url + }) +}