ui: Find & Replace, multi-line comments, indent guides, font size

Find & Replace (Ctrl+H):
- Two-row find bar: expand › arrow shows replace row (VS Code pattern)
- Replace button (Enter): replaces current match, advances to next
- Replace All: replaces all occurrences, shows count
- Find input searches live; replace row preserved across open/close

Multi-line comment toggle (Ctrl+/):
- With multi-line selection: checks if ALL lines are commented
  → if all commented, uncomments all; otherwise comments all
- Empty lines in selection are skipped
- After toggle: block stays selected (same as VS Code)
- Single-line behavior unchanged

Indent guides:
- CSS repeating-linear-gradient on #vv-editor-inner: 1px guide every 2ch
- First guide at indent level 1 (col 2); then 4, 6, 8... matching 2-space indents
- Shows through the transparent overlay; scales automatically with font size
- Very subtle (0.045 opacity) — visible but not distracting

Font size A− / A+ in status bar (9–22px):
- Persisted to localStorage, restored on every editor open
- Updates textarea, overlay, gutter, and cur-line height together
- All scroll calculations use vvLH() (fontSizePx × 1.5) instead of
  hardcoded 18 — find nav, goto, select-next-occurrence all correct at
  any font size

Also: fixed duplicate id="vv-find-close" in goto bar (now .vv-find-x class)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-31 21:20:46 -04:00
co-authored by Claude Sonnet 4.6
parent beac98427d
commit 586c7c36c1
2 changed files with 176 additions and 30 deletions
+136 -21
View File
@@ -469,11 +469,13 @@ $runningScripts = array_unique($runningScripts);
<li class="vv-info-sep">Editor Keyboard Shortcuts</li>
<li><strong>Ctrl+S</strong> — save · <strong>Ctrl+Z / Ctrl+Y</strong> — undo / redo (200 steps)</li>
<li><strong>Ctrl+/</strong> — toggle line comment · <strong>Tab / Shift+Tab</strong> — indent / dedent</li>
<li><strong>Ctrl+F</strong> — find · <strong>Ctrl+G</strong> — go to line · <strong>Ctrl+D</strong> — select next occurrence</li>
<li><strong>Ctrl+L</strong> — select line · <strong>Ctrl+Shift+K</strong> — delete line</li>
<li><strong>Ctrl+F</strong> — find · <strong>Ctrl+H</strong> — find &amp; replace · <strong>Ctrl+G</strong> — go to line</li>
<li><strong>Ctrl+D</strong> — select next occurrence · <strong>Ctrl+L</strong> — select line · <strong>Ctrl+Shift+K</strong> — delete line</li>
<li><strong>Alt+↑/↓</strong> — move line · <strong>Alt+Shift+↓</strong> — duplicate line below</li>
<li><strong>Home</strong> — smart (first non-space → col 0)</li>
<li><strong>( { [ " ` </strong> — auto-close pair; wraps selection if text is selected; <strong>Backspace</strong> deletes both when between them</li>
<li><strong>( { [ " ` </strong> — auto-close pair; wraps selection; Backspace deletes both when between them</li>
<li><strong>A / A+</strong> in status bar — font size (922px, persists across sessions)</li>
<li><strong>Indent guides</strong> — faint vertical lines every 2 spaces in the background</li>
</ul>
</div>
</div>
@@ -883,19 +885,29 @@ Still the same two servers, two households, the same media stack running itself.
<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 class="vv-find-row">
<span class="vv-find-expand" id="vv-find-expand" onclick="vvFindToggleReplace()" title="Toggle Replace (Ctrl+H)"></span>
<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 class="vv-find-x" onclick="vvFindClose()" title="Close (Esc)">×</span>
</div>
<div class="vv-replace-row" id="vv-replace-row">
<span style="width:14px;flex-shrink:0"></span>
<input type="text" id="vv-replace-input" placeholder="Replace with…"
onkeydown="vvReplaceKeydown(event)">
<button class="vv-btn-sm vv-repl-btn" onclick="vvReplaceOne()" title="Replace (Enter)">Replace</button>
<button class="vv-btn-sm vv-repl-btn" onclick="vvReplaceAll()" title="Replace All">All</button>
</div>
</div>
<div id="vv-goto-bar">
<span style="color:#666;font-size:12px;flex-shrink:0">Go to line</span>
<input type="number" id="vv-goto-input" min="1" placeholder="#"
oninput="vvGotoApply()" onkeydown="vvGotoKeydown(event)">
<span class="vv-goto-info" id="vv-goto-info"></span>
<span id="vv-find-close" onclick="vvGotoClose()" title="Close (Esc)">×</span>
<span class="vv-find-x" onclick="vvGotoClose()" title="Close (Esc)">×</span>
</div>
<div id="vv-editor-wrap">
<pre id="vv-ln-gutter" aria-hidden="true"></pre>
@@ -912,6 +924,9 @@ Still the same two servers, two households, the same media stack running itself.
<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>
<button class="vv-es-btn" onclick="vvFontSize(-1)" title="Decrease font size">A</button>
<span id="vv-es-fontsize">12px</span>
<button class="vv-es-btn" onclick="vvFontSize(1)" title="Increase font size">A+</button>
<span class="vv-es-lang" id="vv-es-lang"></span>
</div>
</div>
@@ -997,6 +1012,10 @@ const VV_UNDO_MAX = 200; // max history depth
// Auto-close pairs
const VV_PAIRS = {'(':')', '[':']', '{':'}', '"':'"', "'":"'", '`':'`'};
const VV_CLOSE = new Set([')', ']', '}', '"', "'", '`']);
// Editor display prefs
let vvFontSizePx = parseInt(localStorage.getItem('vv-editor-fontsize') || '12') || 12;
// Line height helper — used everywhere instead of hardcoded 18
function vvLH() { return vvFontSizePx * 1.5; }
// Setup mode — injected by PHP when navigating from the first-run wizard
let vvSetupConf = <?= json_encode($vv_setup_conf) ?>;
@@ -1613,6 +1632,7 @@ function vvShowEditorMode(title) {
document.getElementById('vv-es-lang').textContent = 'Bash';
document.getElementById('vv-undo-btn').style.display = '';
document.getElementById('vv-redo-btn').style.display = '';
vvRestoreEditorPrefs();
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -2815,7 +2835,7 @@ 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 lineH = vvLH();
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';
@@ -2935,7 +2955,7 @@ function vvFindNav(dir) {
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);
ta.scrollTop = Math.max(0, lineN * vvLH() - 80);
vvSyncHlOverlay();
}
@@ -2944,6 +2964,85 @@ function vvFindKeydown(e) {
if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); }
}
// ── Find & Replace ────────────────────────────────────────────────────────────
function vvFindToggleReplace(forceOpen) {
const row = document.getElementById('vv-replace-row');
const btn = document.getElementById('vv-find-expand');
const show = forceOpen !== undefined ? forceOpen : !row.classList.contains('vv-repl-open');
row.classList.toggle('vv-repl-open', show);
btn.classList.toggle('open', show);
if (show) document.getElementById('vv-replace-input').focus();
}
function vvReplaceOne() {
if (!vvFindMatches.length) return;
const ta = document.getElementById('vv-editor-body');
const rep = document.getElementById('vv-replace-input').value;
const m = vvFindMatches[vvFindIdx];
vvUndoCapture(true);
ta.value = ta.value.slice(0, m.start) + rep + ta.value.slice(m.end);
const prevIdx = vvFindIdx;
vvFindSearch();
if (vvFindMatches.length) {
vvFindIdx = Math.min(prevIdx, vvFindMatches.length - 1);
document.getElementById('vv-find-count').textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`;
const cur = vvFindMatches[vvFindIdx];
ta.selectionStart = cur.start; ta.selectionEnd = cur.end;
}
vvSyncHlOverlay(); vvUndoCapture(true);
}
function vvReplaceAll() {
if (!vvFindTerm) return;
const ta = document.getElementById('vv-editor-body');
const rep = document.getElementById('vv-replace-input').value;
const re = new RegExp(vvFindTerm.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi');
let n = 0;
vvUndoCapture(true);
ta.value = ta.value.replace(re, () => { n++; return rep; });
ta.selectionStart = ta.selectionEnd = 0;
vvFindSearch();
document.getElementById('vv-find-count').textContent = n ? `replaced ${n}` : 'no matches';
vvUndoCapture(true);
}
function vvReplaceKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); vvReplaceOne(); }
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); vvFindNav(-1); }
if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); }
}
// ── Font size ─────────────────────────────────────────────────────────────────
function vvFontSize(delta) {
vvFontSizePx = Math.max(9, Math.min(22, vvFontSizePx + delta));
localStorage.setItem('vv-editor-fontsize', vvFontSizePx);
const fs = vvFontSizePx + 'px';
const lh = (vvFontSizePx * 1.5) + 'px';
const els = ['vv-editor-body', 'vv-hl-overlay', 'vv-ln-gutter'];
els.forEach(id => {
const el = document.getElementById(id);
if (el) { el.style.fontSize = fs; el.style.lineHeight = lh; }
});
const cl = document.getElementById('vv-cur-line');
if (cl) cl.style.height = lh;
const fs_el = document.getElementById('vv-es-fontsize');
if (fs_el) fs_el.textContent = fs;
vvSyncHlOverlay(); vvUpdateCurLine(); vvUpdateEditorStatus();
}
function vvRestoreEditorPrefs() {
const saved = parseInt(localStorage.getItem('vv-editor-fontsize') || '12') || 12;
if (saved !== 12) {
vvFontSizePx = 12; // reset so vvFontSize applies the delta correctly
vvFontSize(saved - 12);
} else {
const fs_el = document.getElementById('vv-es-fontsize');
if (fs_el) fs_el.textContent = '12px';
}
}
// ── Go to line ────────────────────────────────────────────────────────────────
function vvGotoOpen() {
@@ -2969,7 +3068,7 @@ function vvGotoApply() {
let pos = 0;
for (let i = 0; i < n - 1; i++) pos += lines[i].length + 1;
ta.selectionStart = ta.selectionEnd = pos;
ta.scrollTop = Math.max(0, (n - 5) * 18);
ta.scrollTop = Math.max(0, (n - 5) * vvLH());
vvSyncHlOverlay();
vvEditorCursorMoved();
}
@@ -3046,7 +3145,7 @@ function vvSelectNextOccurrence() {
let m = re.exec(v) ?? (() => { re.lastIndex = 0; return re.exec(v); })();
if (!m || m.index === s) return;
ta.selectionStart = m.index; ta.selectionEnd = m.index + word.length;
ta.scrollTop = Math.max(0, (v.slice(0, m.index).split('\n').length - 5) * 18);
ta.scrollTop = Math.max(0, (v.slice(0, m.index).split('\n').length - 5) * vvLH());
vvWordMatch = word.replace(/^\$/, '');
vvSyncHlOverlay(); vvEditorCursorMoved();
}
@@ -3054,8 +3153,12 @@ function vvSelectNextOccurrence() {
function vvEditorReset() {
vvWordMatch = null; vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0;
document.getElementById('vv-editor-find')?.classList.remove('vv-find-open');
document.getElementById('vv-replace-row')?.classList.remove('vv-repl-open');
document.getElementById('vv-find-expand')?.classList.remove('open');
const inp = document.getElementById('vv-find-input');
if (inp) { inp.value = ''; inp.classList.remove('vv-find-no-match'); }
const rep = document.getElementById('vv-replace-input');
if (rep) rep.value = '';
const cnt = document.getElementById('vv-find-count');
if (cnt) cnt.textContent = '';
document.getElementById('vv-goto-bar')?.classList.remove('vv-goto-open');
@@ -3164,6 +3267,7 @@ function vvEditorKeydown(e) {
if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); vvRedo(); return; }
if (e.key === 's') { e.preventDefault(); if (vvRawConfFile) vvSaveRawConf(); else vvSaveScript(); return; }
if (e.key === 'f') { e.preventDefault(); vvFindOpen(); return; }
if (e.key === 'h') { e.preventDefault(); vvFindOpen(); vvFindToggleReplace(true); return; }
if (e.key === 'g') { e.preventDefault(); vvGotoOpen(); return; }
if (e.key === 'l' && !e.shiftKey) {
// Select current line
@@ -3190,14 +3294,25 @@ function vvEditorKeydown(e) {
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
if (e.key === '/') {
// Toggle line comment
// Toggle line comment — single line or entire multi-line selection
e.preventDefault(); vvUndoCapture(true);
const ls = val.lastIndexOf('\n', start - 1) + 1;
const le = val.indexOf('\n', start);
const ln = val.slice(ls, le === -1 ? undefined : le);
const nl = /^\s*#/.test(ln) ? ln.replace(/^(\s*)#\s?/, '$1') : ln.replace(/^(\s*)/, '$1# ');
ta.value = val.slice(0, ls) + nl + val.slice(le === -1 ? val.length : le);
ta.selectionStart = ta.selectionEnd = start + (nl.length - ln.length);
const bs = val.lastIndexOf('\n', start - 1) + 1;
const be = end > start ? val.indexOf('\n', end - 1) : val.indexOf('\n', start);
const block = val.slice(bs, be === -1 ? val.length : be);
const lines = block.split('\n');
const allCommented = lines.every(l => !l.trim() || /^\s*#/.test(l));
const newBlock = lines.map(l =>
!l.trim() ? l
: allCommented ? l.replace(/^(\s*)#\s?/, '$1')
: l.replace(/^(\s*)/, '$1# ')
).join('\n');
ta.value = val.slice(0, bs) + newBlock + val.slice(be === -1 ? val.length : be);
if (end > start) {
ta.selectionStart = bs; ta.selectionEnd = bs + newBlock.length; // keep block selected
} else {
const curLineDelta = newBlock.split('\n')[0].length - lines[0].length;
ta.selectionStart = ta.selectionEnd = start + curLineDelta;
}
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
return; // let browser handle other Ctrl combos