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 && ( - -
+
+ + +
) : isFailed ? (
-
- - -
+
+ + +
))}
diff --git a/src/lib/copy-image.ts b/src/lib/copy-image.ts index 80ff7f988..75bb5dda9 100644 --- a/src/lib/copy-image.ts +++ b/src/lib/copy-image.ts @@ -57,7 +57,9 @@ function rasterToPngBlob(bytes: Uint8Array, mime: string): Promise { // 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.resolve( + new Blob([bytes as BlobPart], { type: "image/png" }) + ) } return Promise.reject(new ClipboardImageUnsupportedError()) } From 0cedb4e7f1e91d78b18296d8b8bf089c6dedff9a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 20 Aug 2026 16:03:56 +0800 Subject: [PATCH 3/3] fix(chat): make Copy image survive WebKit, and offer it only where it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rasterized path awaited an image decode and a canvas encode before it called `clipboard.write()`. WebKit only honours a write issued inside the user gesture, so on the desktop app — which is WKWebView — copying a JPEG, webp or gif spent the transient activation on the decode and then failed with NotAllowedError. `ClipboardItem` takes a pending Blob, so the write now goes out synchronously and the browser awaits the raster itself. Two copies in a row also land in the order they were asked for now, rather than the order their rasters happened to finish in. `write()` reports a rejected representation as an error of its own, so the real reason is captured on the way past and rethrown in its place. That observer is attached before the write rather than chained into it: the promise then always has a handler, so a write that fails first for an unrelated reason can't leave the raster rejection unhandled. Served over plain HTTP on a LAN, neither `ClipboardItem` nor `clipboard.write` exists — that is why `installClipboardFallback` is there, and it only backfills `writeText`. The row was offered anyway and could only ever end in an error toast. `canCopyImageToClipboard` was already exported for this and unused; it now decides whether the menu is built at all. Where it isn't, the trigger still shields the transcript menu from the event but stops short of preventing the default, so the browser's own image menu takes over — and its Copy image has no secure-context requirement. The nine non-English locales carried the English strings. They are translated, and the typed error's developer text no longer reaches a toast: it maps to a message of its own instead of being interpolated raw. The rest is what the extra wrapper cost. `ImageActions` takes a className and puts it on the trigger, so the styled box is the flex item its parent laid out again — with its shrink-0, and without a stray line box under the image. The copy/download pair moved into `useImageActions`, so the menu, the hover button and the preview dialog report success and failure the same way instead of drifting across three copies. Right-clicking the blown-up preview opens that same menu rather than silently copying, through a render prop that keeps ui/ free of message-specific imports. Also: the raster released its object URL on every exit but one. Assistant markdown images are still Streamdown's own; copying those needs a URL fetch path and is left alone here. --- .../message/generated-images-block.tsx | 107 ++++----- src/components/message/image-actions.test.tsx | 205 ++++++++++++++++++ src/components/message/image-actions.tsx | 144 +++++++++--- .../message/user-image-attachments.tsx | 108 ++++----- src/components/ui/image-preview-dialog.tsx | 33 +-- src/i18n/messages/ar.json | 7 +- src/i18n/messages/de.json | 7 +- src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 7 +- src/i18n/messages/fr.json | 7 +- src/i18n/messages/ja.json | 7 +- src/i18n/messages/ko.json | 7 +- src/i18n/messages/pt.json | 7 +- src/i18n/messages/zh-CN.json | 7 +- src/i18n/messages/zh-TW.json | 7 +- src/lib/copy-image.test.ts | 140 ++++++++++-- src/lib/copy-image.ts | 71 ++++-- 17 files changed, 624 insertions(+), 248 deletions(-) create mode 100644 src/components/message/image-actions.test.tsx diff --git a/src/components/message/generated-images-block.tsx b/src/components/message/generated-images-block.tsx index 9f3ea6a93..b15c02222 100644 --- a/src/components/message/generated-images-block.tsx +++ b/src/components/message/generated-images-block.tsx @@ -1,17 +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 { 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 { ImageActions, useImageActions } from "./image-actions" import { cn } from "@/lib/utils" interface GeneratedImagesBlockProps { @@ -76,36 +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 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 { canCopy, copy, download } = useImageActions() const trimmedPrompt = typeof revisedPrompt === "string" ? revisedPrompt.trim() : "" @@ -130,35 +97,36 @@ 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 ? () => void handleCopy(image) : undefined} + 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 index 6b4e2fb4a..45ae91019 100644 --- a/src/components/message/image-actions.tsx +++ b/src/components/message/image-actions.tsx @@ -10,11 +10,70 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" -import { copyImageToClipboard } from "@/lib/copy-image" +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. * @@ -22,62 +81,79 @@ import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter" * 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() - 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]) + // 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() + } - 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]) + // 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 (
event.stopPropagation()} - onPointerDown={(event) => { - if (event.pointerType !== "mouse") event.stopPropagation() - }} + className={className} + onContextMenu={stopContextMenu} + onPointerDown={stopNonMousePointerDown} > {children}
- - void handleCopy()}> - + {/* 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 handleDownload()}> - + void download(image)}> + {t("downloadImage")} diff --git a/src/components/message/user-image-attachments.tsx b/src/components/message/user-image-attachments.tsx index b5c06fda7..18dd4e192 100644 --- a/src/components/message/user-image-attachments.tsx +++ b/src/components/message/user-image-attachments.tsx @@ -1,16 +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 { 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 { ImageActions, useImageActions } from "./image-actions" interface UserImageAttachmentsProps { images: UserImageDisplay[] @@ -23,37 +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 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] - ) + const { canCopy, copy, download } = useImageActions() if (images.length === 0) return null @@ -69,35 +35,34 @@ export function UserImageAttachments({ -
- - -
+ +
))}
@@ -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 */ + {alt} 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 */ - {alt} 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 })