feat(ui): hover copy button on markdown code blocks
Nightly Build / build (push) Successful in 7m53s

This commit is contained in:
2026-08-07 18:21:17 +01:00
parent d3fd9bd3af
commit c96ceee037
5 changed files with 94 additions and 1 deletions
+49 -1
View File
@@ -1,6 +1,7 @@
import { LitElement } from 'lit';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { t } from './i18n.js';
marked.use({ breaks: true, gfm: true });
@@ -34,7 +35,54 @@ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
export function renderMarkdown(text) {
// `target` is not in DOMPurify's default attribute allow-list, so the
// external-link hook above needs it whitelisted here to survive sanitization.
return DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
const html = DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
// Wrap fenced code blocks in .md-code-wrap so a copy button can float over
// them on hover. `<pre>` reaches this point only from a marked code block —
// a literal one in the source text is escaped by sanitize, so the string
// replace cannot wrap anything else.
const btn = `<button type="button" class="md-code-copy" title="${t('chat.copy_code')}"><i class="bi bi-clipboard"></i></button>`;
return html.replaceAll('<pre>', `<div class="md-code-wrap">${btn}<pre>`)
.replaceAll('</pre>', '</pre></div>');
}
// One delegated listener serves every copy button renderMarkdown has ever
// emitted: the buttons live inside `unsafeHTML` fragments, so no per-element
// handler could be attached at render time.
document.addEventListener('click', (ev) => {
const btn = ev.target.closest?.('.md-code-copy');
if (!btn) return;
const pre = btn.closest('.md-code-wrap')?.querySelector('pre');
if (!pre) return;
copyToClipboard(pre.textContent ?? '').then((ok) => {
if (!ok) return;
const icon = btn.querySelector('i');
btn.classList.add('copied');
btn.title = t('chat.copied');
icon?.classList.replace('bi-clipboard', 'bi-check');
setTimeout(() => {
btn.classList.remove('copied');
btn.title = t('chat.copy_code');
icon?.classList.replace('bi-check', 'bi-clipboard');
}, 1500);
});
});
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// The Clipboard API requires a secure context; a plain-http LAN box needs
// the legacy fallback.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
try { return document.execCommand('copy'); }
catch { return false; }
finally { ta.remove(); }
}
}
// Disable Shadow DOM so Bootstrap CSS flows through naturally.