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); + }); +});