From dca8fe6855410ef9c09adc369ff12e2accedf64a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 8 May 2026 18:39:10 +0200 Subject: [PATCH] Add copy buttons to markdown code blocks --- src/client/src/components/FormattedText.ts | 49 +++++++++++++++++++++- src/client/src/components/shared.ts | 8 +++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts index 798f747..5d3e35c 100644 --- a/src/client/src/components/FormattedText.ts +++ b/src/client/src/components/FormattedText.ts @@ -9,8 +9,55 @@ export class FormattedText extends LitElement { @property() text = ""; override render() { - return html`
${unsafeHTML(toSafeMarkdownHtml(this.text))}
`; + return html`
${unsafeHTML(toSafeMarkdownHtml(this.text))}
`; + } + + override updated(): void { + this.enhanceCodeBlocks(); + } + + private enhanceCodeBlocks(): void { + this.renderRoot.querySelectorAll("pre").forEach((element) => { + if (!(element instanceof HTMLPreElement) || element.parentElement?.classList.contains("code-block-wrapper") === true) return; + const code = element.querySelector("code"); + if (!(code instanceof HTMLElement)) return; + const wrapper = document.createElement("div"); + wrapper.className = "code-block-wrapper"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "code-copy-button"; + button.textContent = "Copy"; + button.setAttribute("aria-label", "Copy code block"); + element.before(wrapper); + wrapper.append(element, button); + }); + } + + private readonly onFormattedClick = (event: MouseEvent): void => { + if (!(event.target instanceof HTMLButtonElement) || !event.target.classList.contains("code-copy-button")) return; + const wrapper = event.target.closest(".code-block-wrapper"); + if (!(wrapper instanceof HTMLElement)) return; + const code = wrapper.querySelector("pre code"); + if (!(code instanceof HTMLElement)) return; + void this.copyCode(code.textContent, event.target); + }; + + private async copyCode(text: string, button: HTMLButtonElement): Promise { + const ok = await writeClipboard(text); + button.textContent = ok ? "Copied" : "Failed"; + window.setTimeout(() => { + button.textContent = "Copy"; + }, 1200); } static override styles = formattedTextStyles; } + +async function writeClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 7ca2065..b3f2f56 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -162,13 +162,17 @@ export const chatStyles = css` export const formattedTextStyles = css` :host { display: block; } .formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; } - p, ul, ol, pre, blockquote, table { margin: 0 0 10px; } - :is(p, ul, ol, pre, blockquote, table):last-child { margin-bottom: 0; } + p, ul, ol, pre, blockquote, table, .code-block-wrapper { margin: 0 0 10px; } + :is(p, ul, ol, pre, blockquote, table, .code-block-wrapper):last-child { margin-bottom: 0; } ul, ol { padding-left: 22px; } li + li { margin-top: 3px; } code { border: 1px solid #30363d; border-radius: 4px; background: #0d1117; padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .code-block-wrapper { position: relative; } + .code-block-wrapper pre { margin: 0; padding-right: 58px; } pre { border: 1px solid #30363d; border-radius: 8px; background: #0d1117; padding: 10px; overflow: auto; } pre code { border: 0; padding: 0; background: transparent; } + .code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 3px 7px; font: 12px system-ui, sans-serif; cursor: pointer; } + .code-copy-button:hover { color: #e6edf3; border-color: #58a6ff; } blockquote { border-left: 3px solid #30363d; padding-left: 10px; color: #8b949e; } a { color: #58a6ff; } h1, h2, h3, h4 { margin: 14px 0 8px; line-height: 1.2; }