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
+40 -9
View File
@@ -368,24 +368,37 @@
#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;
/* Editor find bar — two-row (find + optional replace) */
#vv-editor-find { display: none; flex-direction: column; gap: 0; 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-row { display: flex; align-items: center; gap: 6px; }
.vv-replace-row { display: none; align-items: center; gap: 6px;
margin-top: 4px; padding-top: 4px; border-top: 1px solid #222; }
.vv-replace-row.vv-repl-open { display: flex; }
.vv-find-expand { font-size: 10px; color: #555; cursor: pointer; flex-shrink: 0;
width: 14px; text-align: center; user-select: none;
transition: transform 0.15s; line-height: 1; }
.vv-find-expand:hover { color: #aaa; }
.vv-find-expand.open { transform: rotate(90deg); color: #888; }
#vv-find-input, #vv-replace-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, #vv-replace-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;
.vv-repl-btn { border-color: #444 !important; color: #777 !important; }
.vv-repl-btn:hover { border-color: #777 !important; color: #ddd !important; }
.vv-find-x { color: #555; cursor: pointer; font-size: 16px; line-height: 1;
padding: 0 2px; flex-shrink: 0; }
#vv-find-close:hover { color: #aaa; }
.vv-find-x:hover { color: #aaa; }
/* Editor status bar */
#vv-editor-status { display: none; align-items: center; gap: 14px; padding: 3px 10px;
#vv-editor-status { display: none; align-items: center; gap: 10px; 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; }
@@ -393,6 +406,24 @@
.vv-es-pos { color: #666; }
.vv-es-sel { color: #555; }
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
/* Status bar action buttons */
.vv-es-btn { background: none; border: none; cursor: pointer; color: #555;
font-size: 10px; padding: 1px 4px; border-radius: 2px; font-family: monospace; }
.vv-es-btn:hover { color: #aaa; background: rgba(255,255,255,0.05); }
.vv-es-btn.active { color: #6495ed; }
#vv-es-fontsize { color: #444; font-size: 10px; min-width: 26px; text-align: center; }
/* Indent guides — 1px lines every 2 chars, starting at indent level 1 */
#vv-editor-inner {
position: relative; flex: 1; overflow: hidden;
background-color: #0d0d0d;
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.045) 0 1px,
transparent 1px 2ch
);
background-position: calc(12px + 2ch) 0;
}
/* Go to line bar */
#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px;
+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