From 196646a7ff0a7bc68a5f4d3a7655370d213010e6 Mon Sep 17 00:00:00 2001 From: Tenggan Zhang <38639187+TeigenZhang@users.noreply.github.com> Date: Tue, 26 May 2026 06:00:37 +0800 Subject: [PATCH] feat(web): one-click copy button on response-viewer code blocks (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap every fenced code block in the response viewer with a positioned .rv-code-wrap toolbar (outside the
scroll container so buttons stay put during horizontal scroll). All blocks get a copy button; ASCII diagrams keep their existing line-wrap toggle alongside it. _copyText() prefers the async Clipboard API and falls back to a hidden-textarea + execCommand path, so copy works over plain HTTP too. The button shows a 1.5s ✓ / ✕ feedback state after each attempt. Co-authored-by: Teigen--- src/web/public/app.js | 82 ++++++++++++++++++++++++++++++++++----- src/web/public/styles.css | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 10 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 8f3fbf4d..45be1f43 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1128,22 +1128,40 @@ class CodemanApp { const DIAGRAM_CHAR = /[─-╿▀-▟]/; const tmpl = document.createElement('template'); tmpl.innerHTML = html; + // Every fenced code block gets a positioned wrapper with an action + // toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the + // scroll container so its buttons stay put during horizontal + // scroll. All blocks get a one-click copy button; ASCII diagrams keep + // the additional line-wrap toggle. tmpl.content.querySelectorAll('pre > code').forEach((code) => { - if (!DIAGRAM_CHAR.test(code.textContent || '')) return; const pre = code.parentElement; - pre.classList.add('rv-diagram'); + const isDiagram = DIAGRAM_CHAR.test(code.textContent || ''); const wrap = document.createElement('div'); - wrap.className = 'rv-diagram-wrap'; + wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap'; - const btn = document.createElement('button'); - btn.className = 'rv-wrap-toggle'; - btn.type = 'button'; - btn.setAttribute('aria-label', 'Toggle line wrapping'); - btn.setAttribute('title', 'Toggle line wrapping'); + const actions = document.createElement('div'); + actions.className = 'rv-code-actions'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'rv-copy-btn'; + copyBtn.type = 'button'; + copyBtn.setAttribute('aria-label', 'Copy code'); + copyBtn.setAttribute('title', 'Copy code'); + actions.appendChild(copyBtn); + + if (isDiagram) { + pre.classList.add('rv-diagram'); + const toggle = document.createElement('button'); + toggle.className = 'rv-wrap-toggle'; + toggle.type = 'button'; + toggle.setAttribute('aria-label', 'Toggle line wrapping'); + toggle.setAttribute('title', 'Toggle line wrapping'); + actions.appendChild(toggle); + } pre.parentNode.insertBefore(wrap, pre); - wrap.appendChild(btn); + wrap.appendChild(actions); wrap.appendChild(pre); }); return tmpl.innerHTML; @@ -1162,7 +1180,23 @@ class CodemanApp { _bindResponseViewerInteractions(body) { if (!body || body.dataset.rvBound === '1') return; body.dataset.rvBound = '1'; - body.addEventListener('click', (ev) => { + body.addEventListener('click', async (ev) => { + // One-click copy: lift the raw source from the sibling. + const copyBtn = ev.target.closest('.rv-copy-btn'); + if (copyBtn) { + ev.preventDefault(); + ev.stopPropagation(); + const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code'); + const ok = code ? await this._copyText(code.textContent || '') : false; + copyBtn.classList.remove('rv-copied', 'rv-copy-failed'); + copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed'); + clearTimeout(copyBtn._resetTimer); + copyBtn._resetTimer = setTimeout(() => { + copyBtn.classList.remove('rv-copied', 'rv-copy-failed'); + }, 1500); + return; + } + const btn = ev.target.closest('.rv-wrap-toggle'); if (!btn) return; ev.preventDefault(); @@ -1175,6 +1209,34 @@ class CodemanApp { }); } + /** + * Copy text to the clipboard. Prefers the async Clipboard API (secure + * contexts); falls back to a hidden-textarea + execCommand path so copy + * still works over plain HTTP. Returns true on success. + */ + async _copyText(text) { + if (!text) return false; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { /* secure-context write failed — try the legacy path */ } + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } + } + async toggleResponseViewer() { const viewer = document.getElementById('responseViewer'); const backdrop = document.getElementById('responseViewerBackdrop'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4dd21f1c..b96277ef 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -8223,6 +8223,74 @@ kbd { content: '⤢'; } +/* ── Code block one-click copy ────────────────────────────────────────────── + Every fenced code block is wrapped in .rv-code-wrap with an action toolbar + pinned to its top-right. Regular blocks get the relative positioning here; + ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its + centering margins). */ +.rv-text .rv-code-wrap:not(.rv-diagram-wrap), +.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) { + position: relative; + margin: 1em 0; +} + +.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre, +.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre { + margin: 0; + padding-right: 44px; /* reserve room for the copy button */ +} + +/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */ +.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram, +.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram { + padding-right: 76px; +} + +.rv-code-actions { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + gap: 4px; + z-index: 2; +} + +/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */ +.rv-code-actions .rv-wrap-toggle { + position: static; + top: auto; + right: auto; +} + +.rv-copy-btn { + width: 28px; + height: 24px; + padding: 0; + border: 1px solid #2f2f45; + border-radius: 5px; + background: rgba(20, 20, 32, 0.92); + color: #8b8b97; + font-size: 13px; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s, border-color 0.15s; +} + +.rv-copy-btn:hover, +.rv-copy-btn:active { + color: #e0e0ec; + border-color: #4a4a65; +} + +.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */ +.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; } +.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */ +.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; } +.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */ + .rv-text ul, .rv-text ol, .response-viewer-body > ul, .response-viewer-body > ol { margin: 0.6em 0;