ui: premium editor overhaul — selection, word highlight, find, status bar

Selection:
- ::selection fix: text selection now visible (bright blue) even when
  textarea color is transparent — the #1 complaint
- Overlay background changed transparent → #vv-editor-inner provides bg,
  allowing #vv-cur-line to show through

Current line highlight:
- #vv-cur-line div positioned absolutely behind overlay, tracks cursor
  line on every keyup/mouseup/click/scroll
- Gutter: current line number highlighted in bright white + bold

Word/variable occurrence highlight:
- Click or position cursor on any identifier → all occurrences highlighted
  in yellow throughout the overlay (whole-word, case-sensitive)
- Select a word → same treatment for the selection text
- Clears when cursor moves off the word or find bar opens

Find in file (Ctrl+F):
- Find bar slides in above editor, pre-filled with selected word
- Highlights all matches orange, current match in bright amber
- ↑/↓ buttons + Enter/Shift+Enter to navigate between matches
- Live match count "N / M"
- Red border + "no matches" feedback when term not found
- Escape or × closes, returns focus to editor

Status bar:
- Persistent bar below editor: Ln N, Col N | selection info | language
- Shows "Bash" or "Bash / Config" depending on file type
- Hides when editor is not open

Visual polish:
- Editor wrap: unified border + border-radius 4px 4px 0 0, overflow:hidden
  clips gutter and inner to rounded corners as one block
- Status bar border-radius 0 0 4px 4px — completes the block
- Gutter: 46px (was 42) — room for 3-digit line numbers
- Slim 8px scrollbar with styled track and hover state
- vvEditorReset() clears all editor state on open/close

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-31 20:16:26 -04:00
co-authored by Claude Sonnet 4.6
parent 753f99ac0c
commit a250d590ca
2 changed files with 299 additions and 15 deletions
+59 -8
View File
@@ -324,24 +324,75 @@
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box; }
/* Editor layout: gutter + inner area */
#vv-editor-wrap { display: flex; }
#vv-ln-gutter { width: 42px; min-width: 42px; flex-shrink: 0;
/* Editor layout: gutter + inner area — unified bordered block */
#vv-editor-wrap { display: flex; border: 1px solid #2e2e2e; border-radius: 4px 4px 0 0;
overflow: hidden; background: #0d0d0d; }
#vv-ln-gutter { width: 46px; min-width: 46px; flex-shrink: 0;
background: #0a0a0a; border-right: 1px solid #1c1c1c;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0;
overflow: hidden; user-select: none; white-space: pre; }
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; }
.vv-gln-cur { color: #ccc; font-weight: bold; }
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; background: #0d0d0d; }
/* Syntax-highlight overlay sits over the transparent textarea */
#vv-hl-overlay { display: none; position: absolute; top: 1px; left: 1px; right: 1px; bottom: 1px;
/* Current line highlight — sits behind overlay */
#vv-cur-line { position: absolute; left: 0; right: 0; height: 18px;
background: rgba(255,255,255,0.04);
border-top: 1px solid rgba(255,255,255,0.03);
border-bottom: 1px solid rgba(255,255,255,0.03);
pointer-events: none; display: none; }
/* Syntax-highlight overlay — transparent bg so cur-line shows through */
#vv-hl-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
margin: 0; padding: 10px 12px; box-sizing: border-box;
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
white-space: pre-wrap; word-break: break-all; overflow: hidden;
pointer-events: none; user-select: none;
background: #0d0d0d; border: none; border-radius: 3px; }
background: transparent; border: none; border-radius: 0; }
.vv-editor-hl #vv-hl-overlay { display: block; }
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent; }
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent;
border: none !important; border-radius: 0 !important; }
/* Visible selection — works even when text is transparent */
.vv-editor-hl #vv-editor-body::selection { background: rgba(100,149,237,0.35); color: transparent; }
.vv-editor-hl #vv-editor-body::-moz-selection{ background: rgba(100,149,237,0.35); color: transparent; }
/* Word match and search marks in the overlay */
.vv-word-mark { background: rgba(255,220,60,0.14); outline: 1px solid rgba(255,220,60,0.35); border-radius: 2px; }
.vv-search-mark { background: rgba(255,140,20,0.22); outline: 1px solid rgba(255,140,20,0.45); border-radius: 2px; }
.vv-search-mark-current{ background: rgba(255,90,0,0.40); outline: 2px solid rgba(255,100,0,0.7); border-radius: 2px; }
/* Slim scrollbar for editor */
#vv-editor-body::-webkit-scrollbar { width: 8px; height: 8px; }
#vv-editor-body::-webkit-scrollbar-track { background: #0a0a0a; }
#vv-editor-body::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; }
#vv-editor-body::-webkit-scrollbar-thumb:hover { background: #444; }
/* Editor find bar */
#vv-editor-find { display: none; align-items: center; gap: 6px; padding: 5px 8px;
margin-bottom: 5px; background: #161616; border: 1px solid #333;
border-radius: 4px; font-size: 12px; }
#vv-editor-find.vv-find-open { display: flex; }
#vv-find-input { flex: 1; min-width: 0; background: #0d0d0d; border: 1px solid #333;
color: #ddd; padding: 4px 8px; border-radius: 3px;
font-family: monospace; font-size: 12px; }
#vv-find-input:focus { border-color: #6495ed; outline: none; box-shadow: 0 0 0 1px rgba(100,149,237,0.25); }
#vv-find-input.vv-find-no-match { border-color: #c62828; box-shadow: 0 0 0 1px rgba(198,40,40,0.3); }
.vv-find-count { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; min-width: 64px; text-align: right; }
.vv-find-nav-btn{ padding: 2px 8px !important; }
#vv-find-close { color: #555; cursor: pointer; font-size: 16px; line-height: 1;
padding: 0 2px; flex-shrink: 0; }
#vv-find-close:hover { color: #aaa; }
/* Editor status bar */
#vv-editor-status { display: none; align-items: center; gap: 14px; padding: 3px 10px;
background: #0e0e0e; border: 1px solid #2e2e2e; border-top: none;
border-radius: 0 0 4px 4px; font-family: monospace;
font-size: 10px; color: #444; user-select: none; flex-shrink: 0; }
#vv-editor-status.vv-ed-active { display: flex; }
.vv-es-pos { color: #666; }
.vv-es-sel { color: #555; }
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
/* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; }
+240 -7
View File
@@ -872,15 +872,31 @@ Still the same two servers, two households, the same media stack running itself.
<span style="color:#888;font-size:12px;flex-shrink:0;">Name:</span>
<input type="text" id="vv-editor-name" class="vv-cron" style="flex:1" placeholder="script_name (no .sh)">
</div>
<div id="vv-editor-find">
<input type="text" id="vv-find-input" placeholder="Find…"
oninput="vvFindSearch()" onkeydown="vvFindKeydown(event)">
<span class="vv-find-count" id="vv-find-count"></span>
<button class="vv-btn-sm vv-find-nav-btn" onclick="vvFindNav(-1)" title="Previous (Shift+Enter)">↑</button>
<button class="vv-btn-sm vv-find-nav-btn" onclick="vvFindNav(1)" title="Next (Enter)">↓</button>
<span id="vv-find-close" onclick="vvFindClose()" title="Close (Esc)">×</span>
</div>
<div id="vv-editor-wrap">
<pre id="vv-ln-gutter" aria-hidden="true"></pre>
<div id="vv-editor-inner">
<div id="vv-cur-line"></div>
<pre id="vv-hl-overlay" aria-hidden="true"></pre>
<textarea id="vv-editor-body" class="vv-editor-body" spellcheck="false"
oninput="vvSyncHlOverlay()" onscroll="vvSyncHlScroll()"
onkeydown="vvEditorKeydown(event)"></textarea>
onkeydown="vvEditorKeydown(event)"
onkeyup="vvEditorCursorMoved()" onmouseup="vvEditorCursorMoved()"
onclick="vvEditorCursorMoved()"></textarea>
</div>
</div>
<div id="vv-editor-status">
<span class="vv-es-pos" id="vv-es-pos">Ln 1, Col 1</span>
<span class="vv-es-sel" id="vv-es-sel"></span>
<span class="vv-es-lang" id="vv-es-lang"></span>
</div>
</div>
<!-- Arrange workspace (shown when arrange mode is active) -->
@@ -951,6 +967,11 @@ let vvConfId = null;
let vvSelectedRow = null;
let vvCurrentSiId = null;
let vvCurrentSiHdr = null;
// Editor state
let vvWordMatch = null; // word under cursor — highlighted everywhere in overlay
let vvFindTerm = ''; // active find-bar search string
let vvFindMatches = []; // [{start,end}] in raw text
let vvFindIdx = 0; // current match index
// Setup mode — injected by PHP when navigating from the first-run wizard
let vvSetupConf = <?= json_encode($vv_setup_conf) ?>;
@@ -1092,6 +1113,8 @@ function vvRestoreInvert() {
}
function vvBackToSuggestions() {
vvEditorReset();
document.getElementById('vv-editor-status').classList.remove('vv-ed-active');
if (vvActiveId) {
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
@@ -1520,6 +1543,7 @@ function vvAddScript() {
document.getElementById('vv-editor-body').value = '#!/bin/bash\n\n';
vvShowEditorMode('New Script');
vvSyncHlOverlay();
vvEditorCursorMoved();
nameEl.focus();
}
@@ -1543,6 +1567,7 @@ function vvEditScript(id) {
.then(d => {
document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading script';
vvSyncHlOverlay();
vvEditorCursorMoved();
})
.catch(() => {
document.getElementById('vv-editor-body').value = '# Error loading script';
@@ -1553,7 +1578,10 @@ function vvEditScript(id) {
}
function vvShowEditorMode(title) {
vvEditorReset();
document.getElementById('vv-editor').classList.add('vv-editor-hl');
document.getElementById('vv-editor-status').classList.add('vv-ed-active');
document.getElementById('vv-es-lang').textContent = 'Bash';
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -2588,12 +2616,16 @@ function vvEditRawConf(file) {
document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading file';
document.getElementById('vv-editor').classList.add('vv-editor-hl');
vvSyncHlOverlay();
vvEditorCursorMoved();
requestAnimationFrame(vvFitRight);
})
.catch(() => { document.getElementById('vv-editor-body').value = '# Load failed'; });
}
function vvShowRawConfMode(title) {
vvEditorReset();
document.getElementById('vv-editor-status').classList.add('vv-ed-active');
document.getElementById('vv-es-lang').textContent = 'Bash / Config';
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -2671,14 +2703,52 @@ function vvSaveRawConf() {
// ── Highlighted conf editor overlay ──────────────────────────────────────────
// ── Overlay helpers ────────────────────────────────────────────────────────────
// Annotate text occurrences in already-rendered HTML (text between > and < only).
// Returns modified HTML with <span class=cls> wrapping each whole-word match.
function vvMarkInHtml(html, word, cls, caseInsensitive) {
if (!word || word.length < 1) return html;
const hw = word.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const esc = hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fl = caseInsensitive ? 'gi' : 'g';
const re = new RegExp('(?<![\\w$])' + esc + '(?![\\w])', fl);
return html.replace(/>((?:[^<])*)</g, (_, txt) =>
'>' + txt.replace(re, `<span class="${cls}">$&</span>`) + '<'
);
}
// Like vvMarkInHtml but marks the curIdx-th match differently.
function vvMarkSearchInHtml(html, term, curIdx) {
if (!term) return html;
const hw = term.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const esc = hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(esc, 'gi');
let idx = 0;
return html.replace(/>((?:[^<])*)</g, (_, txt) => {
const repl = txt.replace(re, m => {
const cls = idx++ === curIdx ? 'vv-search-mark-current' : 'vv-search-mark';
return `<span class="${cls}">${m}</span>`;
});
return '>' + repl + '<';
});
}
function vvSyncHlOverlay() {
const ov = document.getElementById('vv-hl-overlay');
if (!ov || !document.getElementById('vv-editor').classList.contains('vv-editor-hl')) return;
const ta = document.getElementById('vv-editor-body');
ov.innerHTML = vvHl(ta.value, false) + '\n';
let html = vvHl(ta.value, false) + '\n';
if (vvFindTerm) {
html = vvMarkSearchInHtml(html, vvFindTerm, vvFindIdx);
} else if (vvWordMatch) {
html = vvMarkInHtml(html, vvWordMatch, 'vv-word-mark', false);
}
ov.innerHTML = html;
ov.scrollTop = ta.scrollTop;
ov.scrollLeft = ta.scrollLeft;
vvUpdateLineNums();
vvUpdateCurLine();
}
function vvSyncHlScroll() {
@@ -2689,17 +2759,165 @@ function vvSyncHlScroll() {
ov.scrollLeft = ta.scrollLeft;
const gutter = document.getElementById('vv-ln-gutter');
if (gutter) gutter.scrollTop = ta.scrollTop;
vvUpdateCurLine();
}
function vvUpdateLineNums() {
const gutter = document.getElementById('vv-ln-gutter');
const ta = document.getElementById('vv-editor-body');
if (!gutter || !ta) return;
const count = ta.value.split('\n').length;
let nums = '';
for (let i = 1; i <= count; i++) nums += i + '\n';
gutter.textContent = nums;
gutter.scrollTop = ta.scrollTop;
const lines = ta.value.split('\n').length;
const curLn = ta.value.slice(0, ta.selectionStart).split('\n').length;
let html = '';
for (let i = 1; i <= lines; i++) {
html += i === curLn ? `<span class="vv-gln-cur">${i}</span>\n` : `${i}\n`;
}
gutter.innerHTML = html;
gutter.scrollTop = ta.scrollTop;
}
// Position the current-line highlight strip behind the overlay
function vvUpdateCurLine() {
const ta = document.getElementById('vv-editor-body');
const cl = document.getElementById('vv-cur-line');
if (!cl || !ta) return;
const lineH = 18; // font-size(12) × line-height(1.5)
const padTop = 10; // overlay padding-top
const lineN = ta.value.slice(0, ta.selectionStart).split('\n').length - 1; // 0-indexed
cl.style.top = (padTop + lineN * lineH - ta.scrollTop) + 'px';
cl.style.height = lineH + 'px';
cl.style.display = '';
}
// Called on cursor move (keyup, mouseup, click) — update word match, status bar, cur-line
function vvEditorCursorMoved() {
vvUpdateCurLine();
vvUpdateEditorStatus();
if (vvFindTerm) { vvSyncHlOverlay(); return; } // find mode owns the overlay
const ta = document.getElementById('vv-editor-body');
const start = ta.selectionStart;
const end = ta.selectionEnd;
const val = ta.value;
// If user selected a word (not whitespace, at least 2 chars) → highlight all occurrences
if (end > start) {
const sel = val.slice(start, end);
const word = /^[\w$]{2,}$/.test(sel) ? sel : null;
if (word !== vvWordMatch) { vvWordMatch = word; vvSyncHlOverlay(); }
return;
}
// Find the identifier word at cursor position
const before = val.slice(0, start);
const after = val.slice(start);
const wBefore = before.match(/[$A-Za-z_][\w]*$/) ?? [''];
const wAfter = after.match(/^[\w]+/) ?? [''];
const word = wBefore[0].replace(/^\$/, '') + wAfter[0]; // strip leading $ for matching
const newWord = word.length >= 2 ? word : null;
if (newWord !== vvWordMatch) { vvWordMatch = newWord; vvSyncHlOverlay(); }
}
function vvUpdateEditorStatus() {
const ta = document.getElementById('vv-editor-body');
const pos = document.getElementById('vv-es-pos');
const sel = document.getElementById('vv-es-sel');
if (!ta || !pos) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const val = ta.value;
const before = val.slice(0, start);
const lines = before.split('\n');
pos.textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`;
if (end > start) {
const chars = end - start;
const lns = val.slice(start, end).split('\n').length;
sel.textContent = lns > 1 ? `(${chars} chars · ${lns} lines)` : `(${chars} chars)`;
} else {
sel.textContent = '';
}
}
// ── Find bar ───────────────────────────────────────────────────────────────────
function vvFindOpen() {
const bar = document.getElementById('vv-editor-find');
bar.classList.add('vv-find-open');
const inp = document.getElementById('vv-find-input');
// Pre-fill with selected word if any
const ta = document.getElementById('vv-editor-body');
const sel = ta.value.slice(ta.selectionStart, ta.selectionEnd);
if (sel && /^\w+$/.test(sel)) { inp.value = sel; }
inp.focus(); inp.select();
vvFindSearch();
}
function vvFindClose() {
document.getElementById('vv-editor-find').classList.remove('vv-find-open');
document.getElementById('vv-find-input').value = '';
document.getElementById('vv-find-input').classList.remove('vv-find-no-match');
document.getElementById('vv-find-count').textContent = '';
vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0;
document.getElementById('vv-editor-body').focus();
vvSyncHlOverlay();
}
function vvFindSearch() {
const inp = document.getElementById('vv-find-input');
const cnt = document.getElementById('vv-find-count');
vvFindTerm = inp.value;
if (!vvFindTerm) {
vvFindMatches = []; vvFindIdx = 0;
cnt.textContent = '';
inp.classList.remove('vv-find-no-match');
vvSyncHlOverlay(); return;
}
const ta = document.getElementById('vv-editor-body');
const re = new RegExp(vvFindTerm.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi');
vvFindMatches = [];
let m;
while ((m = re.exec(ta.value)) !== null) vvFindMatches.push({start: m.index, end: m.index + m[0].length});
if (!vvFindMatches.length) {
vvFindIdx = 0;
cnt.textContent = 'no matches';
inp.classList.add('vv-find-no-match');
} else {
vvFindIdx = Math.min(vvFindIdx, vvFindMatches.length - 1);
cnt.textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`;
inp.classList.remove('vv-find-no-match');
const cur = vvFindMatches[vvFindIdx];
ta.selectionStart = cur.start; ta.selectionEnd = cur.end;
}
vvSyncHlOverlay();
}
function vvFindNav(dir) {
if (!vvFindMatches.length) return;
vvFindIdx = (vvFindIdx + dir + vvFindMatches.length) % vvFindMatches.length;
document.getElementById('vv-find-count').textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`;
const ta = document.getElementById('vv-editor-body');
const cur = vvFindMatches[vvFindIdx];
ta.selectionStart = cur.start; ta.selectionEnd = cur.end;
ta.focus();
const lineN = ta.value.slice(0, cur.start).split('\n').length - 1;
ta.scrollTop = Math.max(0, lineN * 18 - 80);
vvSyncHlOverlay();
}
function vvFindKeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); vvFindNav(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); }
}
function vvEditorReset() {
vvWordMatch = null; vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0;
const bar = document.getElementById('vv-editor-find');
if (bar) { bar.classList.remove('vv-find-open'); }
const inp = document.getElementById('vv-find-input');
if (inp) { inp.value = ''; inp.classList.remove('vv-find-no-match'); }
const cnt = document.getElementById('vv-find-count');
if (cnt) cnt.textContent = '';
}
function vvEditorKeydown(e) {
@@ -2715,6 +2933,21 @@ function vvEditorKeydown(e) {
return;
}
// Ctrl/Cmd+F — open find bar
if (e.key === 'f' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
vvFindOpen();
return;
}
// Escape — close find bar if open
if (e.key === 'Escape') {
const bar = document.getElementById('vv-editor-find');
if (bar && bar.classList.contains('vv-find-open')) {
e.preventDefault(); vvFindClose(); return;
}
}
// Ctrl/Cmd+/ — toggle line comment
if (e.key === '/' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();