mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(web): one-click copy button on response-viewer code blocks (#98)
Wrap every fenced code block in the response viewer with a positioned .rv-code-wrap toolbar (outside the <pre> 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 <teigen@TeigendeMac-mini.local>
This commit is contained in:
+72
-10
@@ -1128,22 +1128,40 @@ class CodemanApp {
|
|||||||
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
|
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
|
||||||
const tmpl = document.createElement('template');
|
const tmpl = document.createElement('template');
|
||||||
tmpl.innerHTML = html;
|
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
|
||||||
|
// <pre> 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) => {
|
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
|
||||||
if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
|
|
||||||
const pre = code.parentElement;
|
const pre = code.parentElement;
|
||||||
pre.classList.add('rv-diagram');
|
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
|
||||||
|
|
||||||
const wrap = document.createElement('div');
|
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');
|
const actions = document.createElement('div');
|
||||||
btn.className = 'rv-wrap-toggle';
|
actions.className = 'rv-code-actions';
|
||||||
btn.type = 'button';
|
|
||||||
btn.setAttribute('aria-label', 'Toggle line wrapping');
|
const copyBtn = document.createElement('button');
|
||||||
btn.setAttribute('title', 'Toggle line wrapping');
|
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);
|
pre.parentNode.insertBefore(wrap, pre);
|
||||||
wrap.appendChild(btn);
|
wrap.appendChild(actions);
|
||||||
wrap.appendChild(pre);
|
wrap.appendChild(pre);
|
||||||
});
|
});
|
||||||
return tmpl.innerHTML;
|
return tmpl.innerHTML;
|
||||||
@@ -1162,7 +1180,23 @@ class CodemanApp {
|
|||||||
_bindResponseViewerInteractions(body) {
|
_bindResponseViewerInteractions(body) {
|
||||||
if (!body || body.dataset.rvBound === '1') return;
|
if (!body || body.dataset.rvBound === '1') return;
|
||||||
body.dataset.rvBound = '1';
|
body.dataset.rvBound = '1';
|
||||||
body.addEventListener('click', (ev) => {
|
body.addEventListener('click', async (ev) => {
|
||||||
|
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||||
|
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');
|
const btn = ev.target.closest('.rv-wrap-toggle');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
ev.preventDefault();
|
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() {
|
async toggleResponseViewer() {
|
||||||
const viewer = document.getElementById('responseViewer');
|
const viewer = document.getElementById('responseViewer');
|
||||||
const backdrop = document.getElementById('responseViewerBackdrop');
|
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||||
|
|||||||
@@ -8223,6 +8223,74 @@ kbd {
|
|||||||
content: '⤢';
|
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,
|
.rv-text ul, .rv-text ol,
|
||||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||||
margin: 0.6em 0;
|
margin: 0.6em 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user