From f14ab42804244e8f30c238521a342d30f621b4b0 Mon Sep 17 00:00:00 2001 From: Ganesh Srinivasson Date: Tue, 21 Apr 2026 10:40:07 +0200 Subject: [PATCH] Add click-to-copy for fenced code blocks --- src/viewer.css | 47 +++++++++++++++++++++++ src/viewer.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/viewer.css b/src/viewer.css index bca0a0f..bf9f910 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -196,6 +196,7 @@ body { } .document pre { + position: relative; overflow-x: auto; padding: 20px 22px; border-radius: 14px; @@ -203,6 +204,41 @@ body { color: var(--code-block-text); } +.document pre.code-block--copyable { + cursor: copy; + outline: 1px solid transparent; + outline-offset: 2px; + transition: outline-color 0.15s; +} + +.document pre.code-block--copyable:hover, +.document pre.code-block--copyable:focus-visible { + outline-color: var(--accent); +} + +.document pre.code-block--copyable::after { + content: attr(data-copy-label); + position: absolute; + top: 8px; + right: 10px; + padding: 5px 7px; + border: 1px solid rgba(216, 226, 240, 0.22); + border-radius: 6px; + background: rgba(0, 0, 0, 0.36); + color: var(--code-block-text); + font: 500 11px/1 "SF Pro Text", "Segoe UI", sans-serif; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; +} + +.document pre.code-block--copyable:hover::after, +.document pre.code-block--copyable:focus-visible::after, +.document pre.code-block--copyable[data-copy-state="copied"]::after, +.document pre.code-block--copyable[data-copy-state="failed"]::after { + opacity: 0.88; +} + .document pre code { padding: 0; background: transparent; @@ -260,6 +296,12 @@ body { transform: translateY(1px); } +.clipboard-fallback-input { + position: fixed; + top: -9999px; + left: -9999px; +} + .theme-toggle { position: fixed; top: 14px; @@ -394,4 +436,9 @@ body:hover .theme-toggle { .find-panel { display: none; } + + /* separating for a clean merge */ + .document pre.code-block--copyable::after { + display: none; + } } diff --git a/src/viewer.js b/src/viewer.js index 7a0e463..260221e 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -96,6 +96,7 @@ function applyRenderedContent() { contentEl.innerHTML = renderedContentHtml || "

"; + setupCodeBlockCopy(contentEl); disableTaskCheckboxes(contentEl); finalizeLinks(contentEl); finalizeImages(contentEl); @@ -407,6 +408,105 @@ } } + async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + console.warn(error); + } + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.className = "clipboard-fallback-input"; + textarea.setAttribute("readonly", ""); + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + if (!document.execCommand("copy")) { + throw new Error("Copy command was rejected."); + } + } finally { + textarea.remove(); + } + } + + function hasSelectionInside(element) { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) { + return false; + } + + return element.contains(selection.anchorNode) || element.contains(selection.focusNode); + } + + function setCodeBlockCopyState(pre, state) { + pre.dataset.copyState = state; + if (state === "copied") { + pre.dataset.copyLabel = "Copied"; + } else if (state === "failed") { + pre.dataset.copyLabel = "Copy failed"; + } else { + pre.dataset.copyLabel = "Click to copy"; + } + } + + function flashCodeBlockCopyState(pre, state) { + setCodeBlockCopyState(pre, state); + window.clearTimeout(Number(pre.dataset.copyTimer || 0)); + const timer = window.setTimeout(() => { + setCodeBlockCopyState(pre, "ready"); + delete pre.dataset.copyTimer; + }, 1400); + pre.dataset.copyTimer = String(timer); + } + + async function copyCodeBlock(pre, code) { + try { + await copyTextToClipboard(code.textContent || ""); + flashCodeBlockCopyState(pre, "copied"); + } catch (error) { + console.error(error); + flashCodeBlockCopyState(pre, "failed"); + } + } + + function setupCodeBlockCopy(root) { + const codeBlocks = root.querySelectorAll("pre > code"); + + for (const code of codeBlocks) { + const pre = code.parentElement; + if (!pre) continue; + + pre.classList.add("code-block--copyable"); + pre.setAttribute("role", "button"); + pre.setAttribute("tabindex", "0"); + pre.setAttribute("aria-label", "Copy code block"); + setCodeBlockCopyState(pre, "ready"); + + pre.addEventListener("click", () => { + if (hasSelectionInside(pre)) { + return; + } + + copyCodeBlock(pre, code); + }); + + pre.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + copyCodeBlock(pre, code); + }); + } + } + initTheme(); if (!payloadEl) {