fix(renderer): route markdown images through media element
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user