From 28b372265d70536a466e17b933dbe4ceed734f1b Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 21 Sep 2026 13:54:53 +0800 Subject: [PATCH] feat(chat): copy generated images out of the conversation Images an agent embeds in a reply render as plain elements, so they never got Obsidian's own image menu and there was no way to get the picture out of the chat. Right-clicking one now offers "Copy image", which writes the bitmap to the system clipboard so it can be pasted into any application or another note. PNG/JPEG go through Electron's nativeImage + clipboard.writeImage, matching Obsidian's Live Preview behavior; other formats fall back to the async Clipboard API. The embed's HTML now carries its vault path so the menu can resolve the file, and the copy reports success or failure in a notice. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 4 + .../chat/rendering/message-renderer.ts | 40 +++++- src/i18n/locales/de.json | 5 + src/i18n/locales/en.json | 5 + src/i18n/locales/es.json | 5 + src/i18n/locales/fr.json | 5 + src/i18n/locales/ja.json | 5 + src/i18n/locales/ko.json | 5 + src/i18n/locales/pt.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/i18n/locales/zh-TW.json | 5 + src/i18n/types.ts | 4 + src/shared/markdown/image-embed.ts | 2 +- src/shared/obsidian/image-clipboard.ts | 69 ++++++++++ .../message-renderer.image-copy.test.ts | 125 ++++++++++++++++++ tests/unit/i18n/locales.test.ts | 3 + .../unit/shared/markdown/image-embed.test.ts | 7 + .../shared/obsidian/image-clipboard.test.ts | 82 ++++++++++++ 19 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 src/shared/obsidian/image-clipboard.ts create mode 100644 tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts create mode 100644 tests/unit/shared/obsidian/image-clipboard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3224eb7..9d911ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. `Model is queued (3 ahead · ~12s wait)...`, and returns to the usual wording as soon as the request leaves the queue. +- Images an agent embeds in a reply now offer **Copy image** in their + right-click menu, which puts the bitmap on the system clipboard ready to + paste into any application or another note. + ## [1.0.14] - 2026-09-20 ### Changed diff --git a/src/features/chat/rendering/message-renderer.ts b/src/features/chat/rendering/message-renderer.ts index 1eb8076..ce8c41b 100644 --- a/src/features/chat/rendering/message-renderer.ts +++ b/src/features/chat/rendering/message-renderer.ts @@ -24,7 +24,8 @@ import { } from '../../../shared/markdown/markdown-math'; import { replaceMentionTokensWithHtml } from '../../../shared/markdown/mention-chip'; import type { ReferenceChipKind } from '../../../shared/mention/types'; -import { openReferenceChip } from '../../../shared/obsidian/compat'; +import { getVaultFileByPath, openReferenceChip } from '../../../shared/obsidian/compat'; +import { copyVaultImageToClipboard } from '../../../shared/obsidian/image-clipboard'; import { TurnChangesModal } from '../changes/turn-changes-modal'; import { collectTurnChanges } from '../changes/turn-file-changes'; import { turnFileDisplayPath } from '../changes/turn-file-path'; @@ -844,6 +845,10 @@ export class MessageRenderer { this.enhanceMentionChips(el); } + if (processedMarkdown.includes('qoderian-embedded-image')) { + this.enhanceEmbeddedImages(el); + } + // Wrap pre elements and move buttons outside scroll area el.querySelectorAll('pre').forEach((pre) => { // Skip if already wrapped @@ -921,6 +926,39 @@ export class MessageRenderer { }); } + /** + * Binds a copy action onto rendered vault images. Embeds render as plain + * elements, so Obsidian's own image context menu never applies here. + */ + private enhanceEmbeddedImages(el: HTMLElement): void { + el.querySelectorAll('.qoderian-embedded-image').forEach((imageEl) => { + const path = imageEl.dataset.qoderianImagePath; + if (!path) return; + + imageEl.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this.showImageCopyMenu(event, path); + }); + }); + } + + private showImageCopyMenu(event: MouseEvent, path: string): void { + const menu = new Menu(); + menu.addItem((item) => { + item + .setTitle(t('chat.imageEmbed.copyImage')) + .setIcon('copy') + .onClick(() => { + runRendererAction(async () => { + const file = getVaultFileByPath(this.app, path); + const copied = file ? await copyVaultImageToClipboard(this.app, file) : false; + new Notice(copied ? t('chat.imageEmbed.copied') : t('chat.imageEmbed.copyFailed')); + }); + }); + }); + menu.showAtMouseEvent(event); + } + // ============================================ // Copy Button // ============================================ diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3e102b8..aa8a22c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -68,6 +68,11 @@ "closeSession": "Sitzung schließen" }, "chat": { + "imageEmbed": { + "copyImage": "Bild kopieren", + "copied": "Bild in die Zwischenablage kopiert", + "copyFailed": "Bild konnte nicht kopiert werden" + }, "rewind": { "confirmMessage": "Zu diesem Punkt zurückspulen? Dateiänderungen nach dieser Nachricht werden rückgängig gemacht. Das Zurückspulen betrifft keine manuell oder über Bash bearbeiteten Dateien.", "confirmMessageConversationOnly": "Konversation zu diesem Punkt zurückspulen? Dateiänderungen bleiben erhalten.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b3f1141..2a18578 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -68,6 +68,11 @@ "closeSession": "Close session" }, "chat": { + "imageEmbed": { + "copyImage": "Copy image", + "copied": "Image copied to clipboard", + "copyFailed": "Failed to copy image" + }, "rewind": { "confirmMessage": "Rewind to this point? File changes after this message will be reverted. Rewinding does not affect files edited manually or via bash.", "confirmMessageConversationOnly": "Rewind conversation to this point? File changes will be kept.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index bc284ca..972738c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -68,6 +68,11 @@ "closeSession": "Cerrar sesión" }, "chat": { + "imageEmbed": { + "copyImage": "Copiar imagen", + "copied": "Imagen copiada al portapapeles", + "copyFailed": "No se pudo copiar la imagen" + }, "rewind": { "confirmMessage": "¿Rebobinar a este punto? Los cambios de archivos después de este mensaje serán revertidos. El rebobinado no afecta archivos editados manualmente o mediante bash.", "confirmMessageConversationOnly": "¿Rebobinar la conversación hasta este punto? Se conservarán los cambios de archivos.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index af734a9..68b996d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -68,6 +68,11 @@ "closeSession": "Fermer la session" }, "chat": { + "imageEmbed": { + "copyImage": "Copier l'image", + "copied": "Image copiée dans le presse-papiers", + "copyFailed": "Échec de la copie de l'image" + }, "rewind": { "confirmMessage": "Rembobiner jusqu'à ce point ? Les modifications de fichiers après ce message seront annulées. Le rembobinage n'affecte pas les fichiers modifiés manuellement ou via bash.", "confirmMessageConversationOnly": "Rembobiner la conversation jusqu'à ce point ? Les modifications de fichiers seront conservées.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d5320e0..f68151f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -68,6 +68,11 @@ "closeSession": "セッションを閉じる" }, "chat": { + "imageEmbed": { + "copyImage": "画像をコピー", + "copied": "画像をクリップボードにコピーしました", + "copyFailed": "画像のコピーに失敗しました" + }, "rewind": { "confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。", "confirmMessageConversationOnly": "会話をこの時点に巻き戻しますか?ファイル変更は保持されます。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index da5b4ad..b8e1ff3 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -68,6 +68,11 @@ "closeSession": "세션 닫기" }, "chat": { + "imageEmbed": { + "copyImage": "이미지 복사", + "copied": "이미지를 클립보드에 복사했습니다", + "copyFailed": "이미지 복사 실패" + }, "rewind": { "confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.", "confirmMessageConversationOnly": "대화를 이 시점으로 되감으시겠습니까? 파일 변경 사항은 유지됩니다.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 63a2273..82f075f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -68,6 +68,11 @@ "closeSession": "Fechar sessão" }, "chat": { + "imageEmbed": { + "copyImage": "Copiar imagem", + "copied": "Imagem copiada para a área de transferência", + "copyFailed": "Falha ao copiar a imagem" + }, "rewind": { "confirmMessage": "Retroceder até este ponto? As alterações de arquivos após esta mensagem serão revertidas. O retrocesso não afeta arquivos editados manualmente ou via bash.", "confirmMessageConversationOnly": "Retroceder a conversa até este ponto? As alterações de arquivos serão mantidas.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 0d3ede7..d088e40 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -68,6 +68,11 @@ "closeSession": "Закрыть сессию" }, "chat": { + "imageEmbed": { + "copyImage": "Копировать изображение", + "copied": "Изображение скопировано в буфер обмена", + "copyFailed": "Не удалось скопировать изображение" + }, "rewind": { "confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.", "confirmMessageConversationOnly": "Откатить разговор до этой точки? Изменения файлов будут сохранены.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3099e94..ff84d1f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -68,6 +68,11 @@ "closeSession": "关闭会话" }, "chat": { + "imageEmbed": { + "copyImage": "复制图片", + "copied": "图片已复制到剪贴板", + "copyFailed": "复制图片失败" + }, "rewind": { "confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。", "confirmMessageConversationOnly": "将对话回退到此处?文件更改将保留。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 31574ab..ca42762 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -68,6 +68,11 @@ "closeSession": "關閉對話" }, "chat": { + "imageEmbed": { + "copyImage": "複製圖片", + "copied": "圖片已複製到剪貼簿", + "copyFailed": "複製圖片失敗" + }, "rewind": { "confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。", "confirmMessageConversationOnly": "將對話回退到此處?檔案變更將保留。", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 0c89ffb..33b8ad4 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -84,6 +84,10 @@ export type TranslationKey = | 'chat.rewind.cannot' | 'chat.rewind.unavailableStreaming' | 'chat.rewind.unavailableNoUuid' + // Chat - Embedded images + | 'chat.imageEmbed.copyImage' + | 'chat.imageEmbed.copied' + | 'chat.imageEmbed.copyFailed' | 'chat.bangBash.placeholder' | 'chat.bangBash.commandPanel' | 'chat.bangBash.copyAriaLabel' diff --git a/src/shared/markdown/image-embed.ts b/src/shared/markdown/image-embed.ts index d1a05ef..9dc2f52 100644 --- a/src/shared/markdown/image-embed.ts +++ b/src/shared/markdown/image-embed.ts @@ -81,7 +81,7 @@ function createImageHtml( const alt = escapeHtml(altText || file.basename); const style = buildStyleAttribute(altText); - return `${alt}`; + return `${alt}`; } function createFallbackHtml(wikilink: string): string { diff --git a/src/shared/obsidian/image-clipboard.ts b/src/shared/obsidian/image-clipboard.ts new file mode 100644 index 0000000..0975e81 --- /dev/null +++ b/src/shared/obsidian/image-clipboard.ts @@ -0,0 +1,69 @@ +/** + * Qoderian - Vault Image Clipboard + * + * Copies a vault image into the OS clipboard as a bitmap so it can be pasted + * into other applications. Mirrors Obsidian's own "Copy image" in Live Preview: + * desktop PNG/JPEG go through Electron's nativeImage, everything else falls + * back to the async Clipboard API. + */ + +import type { App, TFile } from 'obsidian'; + +interface ElectronClipboardApi { + clipboard?: { + writeImage?: (image: unknown) => void; + }; + nativeImage?: { + createFromBuffer?: (buffer: Buffer) => { isEmpty?: () => boolean }; + }; +} + +const NATIVE_CLIPBOARD_EXTENSIONS = new Set(['png', 'jpg', 'jpeg']); + +function getElectronClipboard(): ElectronClipboardApi | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Electron is exposed only at runtime in Obsidian's renderer. + return require('electron') as ElectronClipboardApi; + } catch { + return null; + } +} + +function writeImageWithNativeImage(data: ArrayBuffer): boolean { + const electron = getElectronClipboard(); + const clipboard = electron?.clipboard; + const createFromBuffer = electron?.nativeImage?.createFromBuffer; + const writeImage = clipboard?.writeImage; + if (!clipboard || typeof createFromBuffer !== 'function' || typeof writeImage !== 'function') { + return false; + } + + const image = createFromBuffer(Buffer.from(data)); + if (!image || image.isEmpty?.()) { + return false; + } + + writeImage.call(clipboard, image); + return true; +} + +async function writeImageWithClipboardItem(data: ArrayBuffer): Promise { + try { + const blob = new Blob([data], { type: 'image/png' }); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + return true; + } catch { + return false; + } +} + +/** Copies a vault image to the OS clipboard. Resolves false when the write failed. */ +export async function copyVaultImageToClipboard(app: App, file: TFile): Promise { + const data = await app.vault.readBinary(file); + + if (NATIVE_CLIPBOARD_EXTENSIONS.has(file.extension.toLowerCase()) && writeImageWithNativeImage(data)) { + return true; + } + + return writeImageWithClipboardItem(data); +} diff --git a/tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts b/tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts new file mode 100644 index 0000000..7f6e257 --- /dev/null +++ b/tests/unit/features/chat/rendering/message-renderer.image-copy.test.ts @@ -0,0 +1,125 @@ +import { createMockEl } from '@test/helpers/mock-element'; +import { MarkdownRenderer, Menu, Notice, TFile } from 'obsidian'; + +import { MessageRenderer } from '@/features/chat/rendering/message-renderer'; + +jest.mock('electron', () => ({ + clipboard: { writeImage: jest.fn() }, + nativeImage: { createFromBuffer: jest.fn(() => ({ isEmpty: () => false })) }, +}), { virtual: true }); + +const electronMock = jest.requireMock('electron') as { + clipboard: { writeImage: jest.Mock }; +}; + +interface MockMenuItem { + title: string; + clickHandler: (() => void) | null; +} + +// The obsidian mock tracks every Menu instance; the published types don't. +const menuInstances = (Menu as unknown as { + instances: Array<{ items: MockMenuItem[] }>; +}).instances; + +const IMAGE_PATH = 'vibe_images/chart.png'; +const EMBED_MARKDOWN = `Here is the render:\n\n![[${IMAGE_PATH}]]`; + +const renderMock = MarkdownRenderer.render as unknown as jest.Mock; + +function createVaultFile(): TFile { + return Object.assign(new TFile(), { + path: IMAGE_PATH, + name: 'chart.png', + basename: 'chart', + extension: 'png', + }); +} + +function renderEmbeddedImageSpan(el: any): void { + const imageEl = el.createSpan({ cls: 'qoderian-embedded-image' }); + imageEl.setAttribute('data-qoderian-image-path', IMAGE_PATH); +} + +function createRenderer(file: TFile | null) { + const messagesEl = createMockEl(); + const container = createMockEl(); + const readBinary = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + const app = { + vault: { + getAbstractFileByPath: jest.fn().mockReturnValue(file), + getResourcePath: jest.fn().mockReturnValue(`app://local/${IMAGE_PATH}`), + readBinary, + }, + metadataCache: { getFirstLinkpathDest: jest.fn().mockReturnValue(null) }, + }; + const plugin = { app, settings: { expandFileEditsByDefault: false, mediaFolder: '' } }; + const component = { registerDomEvent: jest.fn() }; + + return { + app, + container, + readBinary, + renderer: new MessageRenderer(plugin as any, component as any, messagesEl), + }; +} + +async function flushAsyncAction(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function clickCopyMenuItem(container: ReturnType): Promise { + const imageEl = container.querySelector('.qoderian-embedded-image'); + expect(imageEl).not.toBeNull(); + imageEl.dispatchEvent({ type: 'contextmenu', preventDefault: jest.fn() }); + + const menu = menuInstances[menuInstances.length - 1]; + await menu.items[0]?.clickHandler?.(); + await flushAsyncAction(); +} + +describe('MessageRenderer embedded image copy', () => { + beforeEach(() => { + jest.clearAllMocks(); + menuInstances.length = 0; + renderMock.mockReset(); + renderMock.mockImplementation(async (_app: unknown, _markdown: string, el: any) => { + renderEmbeddedImageSpan(el); + }); + }); + + it('opens a copy menu on right-click and writes the image to the clipboard', async () => { + const file = createVaultFile(); + const { container, readBinary, renderer } = createRenderer(file); + + await renderer.renderContent(container, EMBED_MARKDOWN); + expect(container.querySelector('.qoderian-embedded-image')).not.toBeNull(); + + const preventDefault = jest.fn(); + container.querySelector('.qoderian-embedded-image').dispatchEvent({ type: 'contextmenu', preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + + const menu = menuInstances[menuInstances.length - 1]; + expect(menu.items[0]?.title).toBe('Copy image'); + + await menu.items[0]?.clickHandler?.(); + await flushAsyncAction(); + + expect(readBinary).toHaveBeenCalledWith(file); + expect(electronMock.clipboard.writeImage).toHaveBeenCalledTimes(1); + expect(Notice).toHaveBeenCalledWith('Image copied to clipboard'); + }); + + it('reports a failure when the image file disappeared before the copy', async () => { + const file = createVaultFile(); + const { app, container, renderer } = createRenderer(file); + + await renderer.renderContent(container, EMBED_MARKDOWN); + app.vault.getAbstractFileByPath.mockReturnValue(null); + + await clickCopyMenuItem(container); + + expect(electronMock.clipboard.writeImage).not.toHaveBeenCalled(); + expect(Notice).toHaveBeenCalledWith('Failed to copy image'); + }); +}); diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts index 7eb64c4..a432964 100644 --- a/tests/unit/i18n/locales.test.ts +++ b/tests/unit/i18n/locales.test.ts @@ -31,6 +31,9 @@ const localizedKeys = [ 'chat.rewind.menuCodeAndConversation', 'chat.rewind.noticeConversationOnly', 'chat.rewind.noticeConversationOnlySaveFailed', + 'chat.imageEmbed.copyImage', + 'chat.imageEmbed.copied', + 'chat.imageEmbed.copyFailed', 'chat.fork.errorMessageNotFound', 'chat.fork.errorNoSession', 'chat.fork.errorNoActiveTab', diff --git a/tests/unit/shared/markdown/image-embed.test.ts b/tests/unit/shared/markdown/image-embed.test.ts index fcf07bf..d564e64 100644 --- a/tests/unit/shared/markdown/image-embed.test.ts +++ b/tests/unit/shared/markdown/image-embed.test.ts @@ -68,6 +68,13 @@ describe('replaceImageEmbedsWithHtml', () => { expect(result).toContain('image'); expect(result).toContain(' { + const app = createMockApp(new Map([['attachments/diagram.png', 'app://local/attachments/diagram.png']])); + const result = replaceImageEmbedsWithHtml('![[diagram.png]]', app); + + expect(result).toContain('data-qoderian-image-path="attachments/diagram.png"'); + }); }); describe('alt text and dimensions', () => { diff --git a/tests/unit/shared/obsidian/image-clipboard.test.ts b/tests/unit/shared/obsidian/image-clipboard.test.ts new file mode 100644 index 0000000..607d239 --- /dev/null +++ b/tests/unit/shared/obsidian/image-clipboard.test.ts @@ -0,0 +1,82 @@ +import type { App, TFile } from 'obsidian'; + +import { copyVaultImageToClipboard } from '@/shared/obsidian/image-clipboard'; + +jest.mock('electron', () => ({ + clipboard: { writeImage: jest.fn() }, + nativeImage: { createFromBuffer: jest.fn() }, +}), { virtual: true }); + +const electronMock = jest.requireMock('electron') as { + clipboard: { writeImage: jest.Mock }; + nativeImage: { createFromBuffer: jest.Mock }; +}; + +class ClipboardItemStub { + constructor(readonly items: Record) {} +} + +const writeMock = jest.fn, [unknown[]]>(); + +function createApp(data: ArrayBuffer): App { + return { + vault: { readBinary: jest.fn().mockResolvedValue(data) }, + } as unknown as App; +} + +function createFile(path: string): TFile { + return { + path, + extension: path.split('.').pop() ?? '', + } as unknown as TFile; +} + +describe('copyVaultImageToClipboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + electronMock.nativeImage.createFromBuffer.mockReturnValue({ isEmpty: () => false }); + writeMock.mockResolvedValue(undefined); + Object.defineProperty(globalThis, 'navigator', { + value: { clipboard: { write: writeMock } }, + configurable: true, + }); + (globalThis as { ClipboardItem?: unknown }).ClipboardItem = ClipboardItemStub; + }); + + it('writes PNG images through Electron nativeImage', async () => { + const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('vibe_images/chart.png')); + + expect(result).toBe(true); + expect(electronMock.nativeImage.createFromBuffer).toHaveBeenCalledTimes(1); + expect(electronMock.clipboard.writeImage).toHaveBeenCalledTimes(1); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('routes non-PNG formats to the async clipboard API', async () => { + const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('diagram.webp')); + + expect(result).toBe(true); + expect(electronMock.nativeImage.createFromBuffer).not.toHaveBeenCalled(); + expect(writeMock).toHaveBeenCalledTimes(1); + expect(writeMock.mock.calls[0][0][0]).toBeInstanceOf(ClipboardItemStub); + }); + + it('falls back to the async clipboard API when nativeImage yields an empty image', async () => { + electronMock.nativeImage.createFromBuffer.mockReturnValue({ isEmpty: () => true }); + + const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('chart.png')); + + expect(result).toBe(true); + expect(electronMock.clipboard.writeImage).not.toHaveBeenCalled(); + expect(writeMock).toHaveBeenCalledTimes(1); + }); + + it('resolves false when every clipboard path is unavailable', async () => { + electronMock.nativeImage.createFromBuffer.mockReturnValue(null); + writeMock.mockRejectedValue(new Error('clipboard denied')); + + const result = await copyVaultImageToClipboard(createApp(new ArrayBuffer(8)), createFile('chart.png')); + + expect(result).toBe(false); + }); +});