From 89cc655fd1affc823b5bd3bc09fba23fef61922e Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 14:47:24 -0800 Subject: [PATCH] feat: add copy button to code blocks Adds a convenient copy-to-clipboard button on all code snippets in markdown output. Each code block now displays: - Language label in the header - Copy button with visual feedback ("Copied!" for 2 seconds) Refs #68 --- src/lib/components/Markdown.svelte | 90 ++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index de0f48b..81f94da 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -17,7 +17,20 @@ renderer.code = ({ text, lang }) => { const language = lang && hljs.getLanguage(lang) ? lang : "plaintext"; const highlighted = hljs.highlight(text, { language }).value; - return `
${highlighted}
`; + const escapedText = text.replace(/"/g, """).replace(//g, ">"); + return `
+
+ ${language} + +
+
${highlighted}
+
`; }; renderer.codespan = ({ text }) => { @@ -123,6 +136,28 @@ } } + async function handleCopyClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const copyBtn = target.closest(".copy-code-btn") as HTMLButtonElement; + if (copyBtn) { + event.preventDefault(); + const code = copyBtn.dataset.code + ?.replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">"); + if (code) { + await navigator.clipboard.writeText(code); + const textSpan = copyBtn.querySelector(".copy-text"); + if (textSpan) { + textSpan.textContent = "Copied!"; + setTimeout(() => { + textSpan.textContent = "Copy"; + }, 2000); + } + } + } + } + onMount(() => { if (containerElement) { containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => { @@ -138,6 +173,7 @@ onclick={(e) => { handleSpoilerClick(e); handleLinkClick(e); + handleCopyClick(e); }} onkeydown={handleSpoilerKeydown} role="presentation" @@ -163,13 +199,59 @@ margin-bottom: 0; } + .markdown-content :global(.code-block-wrapper) { + margin: 0.75em 0; + border-radius: 6px; + border: 1px solid var(--border-color); + overflow: hidden; + } + + .markdown-content :global(.code-block-header) { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); + padding: 0.4em 0.75em; + border-bottom: 1px solid var(--border-color); + font-size: 0.8em; + } + + .markdown-content :global(.code-block-lang) { + color: var(--text-secondary); + font-family: "JetBrains Mono", "Fira Code", monospace; + text-transform: lowercase; + } + + .markdown-content :global(.copy-code-btn) { + display: flex; + align-items: center; + gap: 0.4em; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25em 0.5em; + border-radius: 4px; + font-size: 0.9em; + transition: all 0.15s ease; + } + + .markdown-content :global(.copy-code-btn:hover) { + background: var(--bg-hover); + color: var(--text-primary); + } + + .markdown-content :global(.copy-code-btn svg) { + flex-shrink: 0; + } + .markdown-content :global(.hljs-code-block) { background: var(--bg-code, #1e1e2e); - border-radius: 6px; + border-radius: 0; padding: 1em; - margin: 0.75em 0; + margin: 0; overflow-x: auto; - border: 1px solid var(--border-color); + border: none; } .markdown-content :global(.hljs-code-block code) {