Give every code block its own copy and insert, and number the long ones
This commit is contained in:
@@ -159,6 +159,25 @@ function vv_ai_chat_assets(): void {
|
||||
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
|
||||
overflow-x:auto; margin:8px 0; }
|
||||
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
|
||||
/* ── Code cards ─────────────────────────────────────────────────────────────
|
||||
Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on
|
||||
15" panels with no pointer near them, so a control that only exists on hover does not exist. */
|
||||
.vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; }
|
||||
.vv-ai-code pre { margin:0; border:none; border-radius:0; }
|
||||
.vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px;
|
||||
background:#1b1b1b; border-bottom:1px solid #222; font-size:10px; }
|
||||
.vv-ai-code-lang { color:#7a9; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.vv-ai-code-n { color:#555; }
|
||||
.vv-ai-code-sp { flex:1; }
|
||||
.vv-ai-code-btn { background:#232323; border:1px solid #333; color:#bbb; font-size:10px;
|
||||
padding:2px 8px; border-radius:3px; cursor:pointer; line-height:1.5; }
|
||||
.vv-ai-code-btn:hover { background:#2c2c2c; color:#eee; }
|
||||
.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; }
|
||||
/* Generated content, so it stays out of textContent — Copy and Insert return the code alone. */
|
||||
.vv-ai-code.numbered code { counter-reset:vvln; }
|
||||
.vv-ai-cl { counter-increment:vvln; }
|
||||
.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em;
|
||||
text-align:right; color:#3f3f3f; user-select:none; }
|
||||
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
|
||||
.vv-ai-cite:hover { text-decoration:underline; }
|
||||
|
||||
@@ -434,10 +453,41 @@ vv_ai_profiles_script();
|
||||
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||||
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||||
|
||||
// Long blocks get numbered, because "the error is on line 40" is unusable against a wall of
|
||||
// unnumbered text. The number is CSS generated content on a per-line span, which keeps it out
|
||||
// of textContent — so Copy and Insert return the code and never the gutter.
|
||||
const CODE_NUMBER_FROM = 12;
|
||||
|
||||
function codeCard(lang, code) {
|
||||
const lines = code.split('\n');
|
||||
const n = lines.length;
|
||||
const numbered = n >= CODE_NUMBER_FROM;
|
||||
// Joined on the newline rather than made display:block, so <pre> supplies the line break and
|
||||
// textContent comes back byte-identical to what the model wrote.
|
||||
const body = numbered
|
||||
? lines.map(x => `<span class="vv-ai-cl">${x}</span>`).join('\n')
|
||||
: code;
|
||||
|
||||
let head = '<div class="vv-ai-code-h">';
|
||||
head += `<span class="vv-ai-code-lang">${lang || 'text'}</span>`;
|
||||
head += `<span class="vv-ai-code-n">${n} line${n === 1 ? '' : 's'}</span>`;
|
||||
head += '<span class="vv-ai-code-sp"></span>';
|
||||
head += '<button type="button" class="vv-ai-code-btn" data-code-copy>Copy</button>';
|
||||
// Only offered where the page can actually receive it. Without a target this would be a
|
||||
// button that silently does nothing, which is worse than not having one.
|
||||
if (o.onInsertCode) {
|
||||
head += '<button type="button" class="vv-ai-code-btn" data-code-insert>Insert</button>';
|
||||
}
|
||||
head += '</div>';
|
||||
|
||||
return `<div class="vv-ai-code${numbered ? ' numbered' : ''}">${head}`
|
||||
+ `<pre><code>${body}</code></pre></div>`;
|
||||
}
|
||||
|
||||
// ── Minimal markdown, applied strictly after escaping ────────────────
|
||||
function fmt(text) {
|
||||
let h = esc(text);
|
||||
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
|
||||
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => codeCard(l, c));
|
||||
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>');
|
||||
@@ -586,9 +636,54 @@ vv_ai_profiles_script();
|
||||
return;
|
||||
}
|
||||
const off = e.target.closest('[data-offer]');
|
||||
if (off) answerOffer(+off.dataset.offer, off.dataset.yes === '1');
|
||||
if (off) { answerOffer(+off.dataset.offer, off.dataset.yes === '1'); return; }
|
||||
|
||||
// Code-card buttons. The text is read from the DOM rather than carried in a data attribute:
|
||||
// a script full of quotes and backslashes interpolated into an attribute is an escaping bug
|
||||
// waiting to happen, and textContent already holds exactly what the model wrote.
|
||||
const cbtn = e.target.closest('[data-code-copy],[data-code-insert]');
|
||||
if (cbtn) {
|
||||
const card = cbtn.closest('.vv-ai-code');
|
||||
const code = card ? (card.querySelector('code') || {}).textContent || '' : '';
|
||||
if (!code) return;
|
||||
|
||||
if (cbtn.hasAttribute('data-code-insert')) {
|
||||
if (o.onInsertCode) o.onInsertCode(code);
|
||||
flashBtn(cbtn, 'Inserted');
|
||||
return;
|
||||
}
|
||||
|
||||
// navigator.clipboard needs a secure context. Unraid is routinely reached over plain http
|
||||
// on the LAN, where it is simply undefined — so the textarea fallback is the path that
|
||||
// actually runs here, not a legacy nicety.
|
||||
const done = () => flashBtn(cbtn, 'Copied');
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(code).then(done).catch(() => copyFallback(code, done));
|
||||
} else {
|
||||
copyFallback(code, done);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function copyFallback(text, done) {
|
||||
const t = document.createElement('textarea');
|
||||
t.value = text;
|
||||
// Off-screen rather than hidden: display:none and visibility:hidden are not selectable, so
|
||||
// execCommand copies nothing from them.
|
||||
t.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
|
||||
document.body.appendChild(t);
|
||||
t.select();
|
||||
try { document.execCommand('copy'); done(); } catch (_) {}
|
||||
t.remove();
|
||||
}
|
||||
|
||||
function flashBtn(btn, msg) {
|
||||
const was = btn.textContent;
|
||||
btn.textContent = msg;
|
||||
btn.classList.add('ok');
|
||||
setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100);
|
||||
}
|
||||
|
||||
// ── Ask / poll ───────────────────────────────────────────────────────
|
||||
function send() {
|
||||
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
|
||||
|
||||
Reference in New Issue
Block a user