diff --git a/src/lib/common/markdown/Markdown.svelte b/src/lib/common/markdown/Markdown.svelte index 477be265..832cdf97 100644 --- a/src/lib/common/markdown/Markdown.svelte +++ b/src/lib/common/markdown/Markdown.svelte @@ -18,7 +18,15 @@ /** @type {boolean} */ rawText = false, /** @type {boolean} */ - scrollable = false + scrollable = false, + /** + * Puts a copy button on every fenced code block. Off by default: on a log + * row or a state dump the control would be chrome over content nobody + * copies, and it is the message surfaces where a snippet is there to be + * taken somewhere else. + * @type {boolean} + */ + copyableCode = false } = $props(); /** @@ -40,13 +48,54 @@ * paragraph that merely mentions a run link in passing gets the treatment too, which is * the right answer — it is still an offer of a live view, wherever it sits. */ - const liveLinkRenderer = new Renderer(); - const renderParagraph = liveLinkRenderer.paragraph.bind(liveLinkRenderer); - liveLinkRenderer.paragraph = (text) => { - if (!liveRunIdInText(text)) return renderParagraph(text); + /** + * Wraps a fenced code block so it can carry a copy button. + * + * Emitted as part of the markdown HTML rather than attached to the DOM + * afterwards, so it survives every re-render of a streaming message without + * anything having to re-scan for new blocks. Nothing from the message is + * interpolated into it — the code is read back out of the DOM when the button + * is clicked, which keeps the copied text identical to what is on screen and + * keeps message content out of generated markup. + * + * @param {string} codeHtml + */ + function withCopyButton(codeHtml) { + return '
${text}
`; - }; + /* Per instance rather than module-level, because the code renderer below + depends on `copyableCode`, which differs by call site. */ + const markdownRenderer = buildRenderer(); + + function buildRenderer() { + const renderer = new Renderer(); + + const renderParagraph = renderer.paragraph.bind(renderer); + renderer.paragraph = (text) => { + if (!liveRunIdInText(text)) return renderParagraph(text); + + return `${text}
`; + }; + + if (copyableCode) { + const renderCode = renderer.code.bind(renderer); + /** + * @param {string} code + * @param {string | undefined} infostring + * @param {boolean} escaped + */ + renderer.code = (code, infostring, escaped) => withCopyButton(renderCode(code, infostring, escaped)); + } + + return renderer; + } const scrollbarId = `markdown-scrollbar-${uuidv4()}`; const options = { @@ -138,11 +187,84 @@ }; } + /** The button currently showing its "copied" state, if any. */ + /** @type {Element | null} */ + let copiedBtn = null; + /** @type {any} */ + let copyResetTimer = null; + + /** + * @param {Element} btn + * @param {boolean} done + */ + function setCopyState(btn, done) { + const icon = btn.querySelector('i'); + const label = btn.querySelector('.md-code-copy-label'); + btn.classList.toggle('md-code-copy-done', done); + if (icon) icon.className = done ? 'bx bx-check' : 'bx bx-copy'; + if (label) label.textContent = done ? 'Copied!' : 'Copy'; + btn.setAttribute('aria-label', done ? 'Code copied' : 'Copy code'); + } + + /** + * Copies a fenced code block, delegated for the same reason as + * `interceptLinks`: the buttons come from `{@html}`, so Svelte never sees + * those nodes and cannot bind to them. + * + * @param {HTMLElement} node + */ + function interceptCodeCopy(node) { + /** @param {MouseEvent} e */ + const onClick = (e) => { + const btn = /** @type {Element | null} */ (e.target)?.closest?.('.md-code-copy'); + if (!btn) return; + + // The surfaces this renders on put their own click on the block around + // it — collapse a message, jump to its log entry — and reaching for a + // snippet is not a request for either. + e.preventDefault(); + e.stopPropagation(); + + // Read from the DOM, so what lands on the clipboard is exactly what is + // on screen. The trailing newline is `marked`'s, not the author's. + const codeEl = btn.parentElement?.querySelector('pre code') || btn.parentElement?.querySelector('pre'); + const text = (codeEl?.textContent || '').replace(/\n$/, ''); + if (!text) return; + + navigator.clipboard?.writeText(text).then(() => { + if (copiedBtn && copiedBtn !== btn) { + setCopyState(copiedBtn, false); + } + clearTimeout(copyResetTimer); + copiedBtn = btn; + setCopyState(btn, true); + copyResetTimer = setTimeout(() => { + // A streaming re-render can have replaced the button by now. + if (copiedBtn?.isConnected) { + setCopyState(copiedBtn, false); + } + copiedBtn = null; + }, 800); + }).catch(() => { + // Clipboard refused — an insecure context, or permission denied. + // Leave the button alone rather than report a copy that never was. + }); + }; + + node.addEventListener('click', onClick); + return { + destroy() { + clearTimeout(copyResetTimer); + node.removeEventListener('click', onClick); + } + }; + } + let innerText = $derived.by(() => { const normalizedText = typeof text !== 'string' ? `${JSON.stringify(text)}` : text; const markedText = !rawText - ? replaceNewLine(marked(replaceMarkdown(normalizedText || ''), { renderer: liveLinkRenderer })?.toString()) - : marked(normalizedText || '', { breaks: true, renderer: liveLinkRenderer })?.toString(); + ? replaceNewLine(marked(replaceMarkdown(normalizedText || ''), { renderer: markdownRenderer })?.toString()) + : marked(normalizedText || '', { breaks: true, renderer: markdownRenderer })?.toString(); if (!!markedText && markedText.endsWith('
{utcToLocal(message.created_at, 'h:mm:ss A')}
@@ -2789,7 +2795,16 @@
{:else}
-