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).
|
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
|
||||||
|
|
||||||
import { state } from './store.js';
|
import { state } from './store.js';
|
||||||
|
import { configureMarkdownImages } from './markdown-image-renderer.js';
|
||||||
|
|
||||||
// --- Time / formatting ---
|
// --- Time / formatting ---
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ export function highlightTextNodes(rootEl, query) {
|
|||||||
export function renderMarkdown(text, opts = {}) {
|
export function renderMarkdown(text, opts = {}) {
|
||||||
if (text == null) return '';
|
if (text == null) return '';
|
||||||
// marked is loaded globally via CDN in index.html
|
// marked is loaded globally via CDN in index.html
|
||||||
|
configureMarkdownImages(window.marked);
|
||||||
const html = sanitizeMarkdown(window.marked.parse(text));
|
const html = sanitizeMarkdown(window.marked.parse(text));
|
||||||
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
||||||
: opts.variant === 'compact' ? 'markdown-compact'
|
: 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, .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 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; }
|
.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 {
|
.detail-section-divider {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user