From 0f3739605c61ae57e44a6bc3a92e0f2fdc67656d Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 09:26:18 -0500 Subject: [PATCH] Add language labels and copy-to-clipboard for code blocks Custom marked renderer wraps fenced code blocks in a .code-block-wrapper div containing a language label badge (top-right) and a copy button. The language label shows the fenced language identifier in uppercase. A delegated click handler on the document root intercepts clicks on [data-copy-code] buttons, reads the sibling element's text content, writes it to the clipboard, and shows a "Copied!" / "Failed" confirmation that auto-reverts after 1.5 seconds. Delegated handling is necessary because code blocks are rendered via dangerouslySetInnerHTML and don't participate in React's synthetic event system. Co-Authored-By: Claude Opus 4.5 --- src/client/lib/markdown.ts | 9 +++++++++ src/client/main.tsx | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/client/lib/markdown.ts b/src/client/lib/markdown.ts index c730150..f6d2a19 100644 --- a/src/client/lib/markdown.ts +++ b/src/client/lib/markdown.ts @@ -18,6 +18,15 @@ marked.use( }) ); +// Custom renderer to wrap code blocks with copy button +const renderer = new marked.Renderer(); +renderer.code = function ({ text, lang }: { text: string; lang?: string | undefined; escaped?: boolean }) { + const langLabel = lang ? `${escapeHtml(lang)}` : ""; + return `
${langLabel}
${text}
`; +}; + +marked.use({ renderer }); + export function renderMarkdown(text: string): string { if (!text) return ""; try { diff --git a/src/client/main.tsx b/src/client/main.tsx index 0b11abd..eaf3b04 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -3,6 +3,23 @@ import ReactDOM from "react-dom/client"; import { App } from "./app.js"; import "./styles/main.css"; +// Delegated click handler for code block copy buttons +document.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("[data-copy-code]") as HTMLButtonElement | null; + if (!btn) return; + e.stopPropagation(); + const wrapper = btn.closest(".code-block-wrapper"); + const code = wrapper?.querySelector("code"); + if (!code) return; + navigator.clipboard.writeText(code.textContent || "").then(() => { + btn.textContent = "Copied!"; + setTimeout(() => { btn.textContent = "Copy"; }, 1500); + }).catch(() => { + btn.textContent = "Failed"; + setTimeout(() => { btn.textContent = "Copy"; }, 1500); + }); +}); + ReactDOM.createRoot(document.getElementById("root")!).render(