Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion src/features/chat/rendering/message-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -921,6 +926,39 @@ export class MessageRenderer {
});
}

/**
* Binds a copy action onto rendered vault images. Embeds render as plain
* <img> elements, so Obsidian's own image context menu never applies here.
*/
private enhanceEmbeddedImages(el: HTMLElement): void {
el.querySelectorAll<HTMLElement>('.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
// ============================================
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
"closeSession": "セッションを閉じる"
},
"chat": {
"imageEmbed": {
"copyImage": "画像をコピー",
"copied": "画像をクリップボードにコピーしました",
"copyFailed": "画像のコピーに失敗しました"
},
"rewind": {
"confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。",
"confirmMessageConversationOnly": "会話をこの時点に巻き戻しますか?ファイル変更は保持されます。",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
"closeSession": "세션 닫기"
},
"chat": {
"imageEmbed": {
"copyImage": "이미지 복사",
"copied": "이미지를 클립보드에 복사했습니다",
"copyFailed": "이미지 복사 실패"
},
"rewind": {
"confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.",
"confirmMessageConversationOnly": "대화를 이 시점으로 되감으시겠습니까? 파일 변경 사항은 유지됩니다.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
"closeSession": "Закрыть сессию"
},
"chat": {
"imageEmbed": {
"copyImage": "Копировать изображение",
"copied": "Изображение скопировано в буфер обмена",
"copyFailed": "Не удалось скопировать изображение"
},
"rewind": {
"confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.",
"confirmMessageConversationOnly": "Откатить разговор до этой точки? Изменения файлов будут сохранены.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
"closeSession": "关闭会话"
},
"chat": {
"imageEmbed": {
"copyImage": "复制图片",
"copied": "图片已复制到剪贴板",
"copyFailed": "复制图片失败"
},
"rewind": {
"confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。",
"confirmMessageConversationOnly": "将对话回退到此处?文件更改将保留。",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
"closeSession": "關閉對話"
},
"chat": {
"imageEmbed": {
"copyImage": "複製圖片",
"copied": "圖片已複製到剪貼簿",
"copyFailed": "複製圖片失敗"
},
"rewind": {
"confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。",
"confirmMessageConversationOnly": "將對話回退到此處?檔案變更將保留。",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/shared/markdown/image-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function createImageHtml(
const alt = escapeHtml(altText || file.basename);
const style = buildStyleAttribute(altText);

return `<span class="qoderian-embedded-image"><img src="${escapeHtml(src)}" alt="${alt}" loading="lazy"${style}></span>`;
return `<span class="qoderian-embedded-image" data-qoderian-image-path="${escapeHtml(file.path)}"><img src="${escapeHtml(src)}" alt="${alt}" loading="lazy"${style}></span>`;
}

function createFallbackHtml(wikilink: string): string {
Expand Down
69 changes: 69 additions & 0 deletions src/shared/obsidian/image-clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
const data = await app.vault.readBinary(file);

if (NATIVE_CLIPBOARD_EXTENSIONS.has(file.extension.toLowerCase()) && writeImageWithNativeImage(data)) {
return true;
}

return writeImageWithClipboardItem(data);
}
Loading
Loading