From c079c503768f4806a77aa29c1b95673797483926 Mon Sep 17 00:00:00 2001 From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:25:58 +0800 Subject: [PATCH] fix(renderer): route markdown images through media element --- .../renderer/src/markdown-image-renderer.js | 50 +++++++++++++++++++ app/src/renderer/src/utils.js | 2 + app/src/renderer/styles/detail.css | 14 ++++++ 3 files changed, 66 insertions(+) create mode 100644 app/src/renderer/src/markdown-image-renderer.js diff --git a/app/src/renderer/src/markdown-image-renderer.js b/app/src/renderer/src/markdown-image-renderer.js new file mode 100644 index 0000000..84c11e0 --- /dev/null +++ b/app/src/renderer/src/markdown-image-renderer.js @@ -0,0 +1,50 @@ +import { SESSION_IMAGE_TAG } from './session-image-element.js'; + +const SAFE_IMAGE_PROTOCOLS = new Set(['blob:', 'file:', 'http:', 'https:']); +let configuredMarked = null; + +function decodeMarkedAttribute(value) { + const decoder = document.createElement('textarea'); + decoder.innerHTML = String(value ?? ''); + return decoder.value; +} + +function isSafeImageSource(source) { + try { + const url = new URL(source, document.baseURI); + return SAFE_IMAGE_PROTOCOLS.has(url.protocol) + || (url.protocol === 'data:' && /^data:image\//i.test(source)); + } catch { + return false; + } +} + +function imageFallback(alt) { + const fallback = document.createElement('span'); + fallback.className = 'session-image-fallback'; + fallback.textContent = alt || 'Image unavailable'; + return fallback.outerHTML; +} + +export function renderSessionMarkdownImage(href, title, text) { + const source = decodeMarkedAttribute(href).trim(); + const alt = decodeMarkedAttribute(text); + const accessibleTitle = decodeMarkedAttribute(title); + if (!source || !isSafeImageSource(source)) return imageFallback(alt); + + const image = document.createElement(SESSION_IMAGE_TAG); + image.setAttribute('src', source); + image.setAttribute('alt', alt); + if (accessibleTitle) image.setAttribute('title', accessibleTitle); + return image.outerHTML; +} + +export function configureMarkdownImages(marked) { + if (!marked || configuredMarked === marked) return; + marked.use({ + renderer: { + image: renderSessionMarkdownImage, + }, + }); + configuredMarked = marked; +} diff --git a/app/src/renderer/src/utils.js b/app/src/renderer/src/utils.js index 743b58c..1efcba4 100644 --- a/app/src/renderer/src/utils.js +++ b/app/src/renderer/src/utils.js @@ -2,6 +2,7 @@ // Pure helpers with no side-effects on global state (except formatProjectLabel which reads store). import { state } from './store.js'; +import { configureMarkdownImages } from './markdown-image-renderer.js'; // --- Time / formatting --- @@ -95,6 +96,7 @@ export function highlightTextNodes(rootEl, query) { export function renderMarkdown(text, opts = {}) { if (text == null) return ''; // marked is loaded globally via CDN in index.html + configureMarkdownImages(window.marked); const html = sanitizeMarkdown(window.marked.parse(text)); const cls = opts.variant === 'msg' ? 'markdown-msg' : opts.variant === 'compact' ? 'markdown-compact' diff --git a/app/src/renderer/styles/detail.css b/app/src/renderer/styles/detail.css index 98813e1..a45080d 100644 --- a/app/src/renderer/styles/detail.css +++ b/app/src/renderer/styles/detail.css @@ -363,6 +363,20 @@ .markdown-msg th, .markdown-msg td { border: 1px solid var(--hairline); padding: 5px 9px; text-align: left; } .markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; } .markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } +.session-image-fallback { + display: block; + max-width: 100%; + margin: 0.7em 0; + padding: 10px 12px; + overflow: hidden; + border: 1px solid var(--hairline-strong); + border-radius: 6px; + color: var(--muted); + background: rgba(0,0,0,0.22); + font: 12px/1.5 var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} .detail-section-divider { display: flex; align-items: center; gap: 10px;