ui: editor QoL — line ops, auto-close, goto, smart home, Ctrl+D

Line operations:
- Alt+↑/↓   — move current line up / down (swaps with adjacent line)
- Alt+Shift+↓/↑ — duplicate line below / above
- Ctrl+Shift+K  — delete current line
- Ctrl+L        — select entire current line (including newline)

Auto-close brackets and quotes:
- ( [ { " ' ` — inserts matching close, cursor placed inside
- Wrap selection — if text is selected, typing an opener wraps it
- Skip-over — typing a close char when it's already next → jumps past it
- Smart backspace — deletes BOTH chars when cursor sits between a matched pair
- Guards: skip ' auto-close after word chars (contractions); skip auto-close
  when next char is a word char (prevents disrupting existing text)

Navigation:
- Ctrl+G — Go to Line bar (pre-filled with current line, shows "of N")
- Smart Home — first press → first non-whitespace; second → col 0
- Ctrl+D — first press: select word at cursor; subsequent: cycle through
  all occurrences (wraps around), integrates with word highlight overlay

Keyboard shortcuts panel:
- Added "Editor Keyboard Shortcuts" section to the How do I use this panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-31 21:02:56 -04:00
co-authored by Claude Sonnet 4.6
parent 1ea3f595cc
commit beac98427d
2 changed files with 255 additions and 61 deletions
+10
View File
@@ -394,6 +394,16 @@
.vv-es-sel { color: #555; }
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
/* Go to line bar */
#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px;
margin-bottom: 5px; background: #161616; border: 1px solid #333;
border-radius: 4px; font-size: 12px; }
#vv-goto-bar.vv-goto-open { display: flex; }
#vv-goto-input { width: 68px; background: #0d0d0d; border: 1px solid #333; color: #ddd;
padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
#vv-goto-input:focus { border-color: #6495ed; outline: none; }
.vv-goto-info { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; }
/* Undo / Redo buttons */
.vv-undo-redo-btn { border-color: #444 !important; color: #666 !important; }
.vv-undo-redo-btn:not([disabled]):hover { border-color: #777 !important; color: #ccc !important; background: #1e1e1e !important; }
+245 -61
View File
@@ -466,6 +466,14 @@ $runningScripts = array_unique($runningScripts);
<li><strong>Child (orch OFF)</strong> — enter a cron to run it standalone</li>
<li><strong>Rsync badge</strong> — toggle writes TIER_RSYNC_ENABLED directly to master.conf</li>
<li><strong>Rsync (orch OFF)</strong> — fill location + standalone cron + Save; both required for independent firing</li>
<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>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>
</ul>
</div>
</div>
@@ -882,6 +890,13 @@ Still the same two servers, two households, the same media stack running itself.
<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-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>
</div>
<div id="vv-editor-wrap">
<pre id="vv-ln-gutter" aria-hidden="true"></pre>
<div id="vv-editor-inner">
@@ -979,6 +994,9 @@ let vvUndoStack = []; // [{v,ss,se}] snapshots — v=value, ss=selStart, s
let vvRedoStack = [];
let vvUndoTimer = null;
const VV_UNDO_MAX = 200; // max history depth
// Auto-close pairs
const VV_PAIRS = {'(':')', '[':']', '{':'}', '"':'"', "'":"'", '`':'`'};
const VV_CLOSE = new Set([')', ']', '}', '"', "'", '`']);
// Setup mode — injected by PHP when navigating from the first-run wizard
let vvSetupConf = <?= json_encode($vv_setup_conf) ?>;
@@ -2926,14 +2944,121 @@ function vvFindKeydown(e) {
if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); }
}
// ── Go to line ────────────────────────────────────────────────────────────────
function vvGotoOpen() {
const bar = document.getElementById('vv-goto-bar');
if (!bar) return;
bar.classList.add('vv-goto-open');
const ta = document.getElementById('vv-editor-body');
const inp = document.getElementById('vv-goto-input');
inp.value = ta.value.slice(0, ta.selectionStart).split('\n').length;
document.getElementById('vv-goto-info').textContent = 'of ' + ta.value.split('\n').length;
inp.focus(); inp.select();
}
function vvGotoClose() {
document.getElementById('vv-goto-bar').classList.remove('vv-goto-open');
document.getElementById('vv-editor-body').focus();
}
function vvGotoApply() {
const ta = document.getElementById('vv-editor-body');
const lines = ta.value.split('\n');
const n = Math.max(1, Math.min(parseInt(document.getElementById('vv-goto-input').value) || 1, lines.length));
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);
vvSyncHlOverlay();
vvEditorCursorMoved();
}
function vvGotoKeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); vvGotoApply(); vvGotoClose(); }
if (e.key === 'Escape') { e.preventDefault(); vvGotoClose(); }
}
// ── Line operations ───────────────────────────────────────────────────────────
function vvMoveLine(dir) {
const ta = document.getElementById('vv-editor-body');
const v = ta.value, s = ta.selectionStart;
vvUndoCapture(true);
const ls = v.lastIndexOf('\n', s - 1) + 1;
const le = v.indexOf('\n', s);
if (dir === -1) {
if (ls === 0) return;
const pls = v.lastIndexOf('\n', ls - 2) + 1;
const prev = v.slice(pls, ls - 1), cur = v.slice(ls, le === -1 ? v.length : le);
ta.value = v.slice(0, pls) + cur + '\n' + prev + v.slice(le === -1 ? v.length : le);
ta.selectionStart = ta.selectionEnd = pls + (s - ls);
} else {
if (le === -1) return;
const nle = v.indexOf('\n', le + 1);
const next = v.slice(le + 1, nle === -1 ? v.length : nle), cur = v.slice(ls, le);
ta.value = v.slice(0, ls) + next + '\n' + cur + v.slice(nle === -1 ? v.length : nle);
ta.selectionStart = ta.selectionEnd = ls + next.length + 1 + (s - ls);
}
vvSyncHlOverlay(); vvUndoCapture(true); vvEditorCursorMoved();
}
function vvDuplicateLine(above) {
const ta = document.getElementById('vv-editor-body');
const v = ta.value, s = ta.selectionStart;
vvUndoCapture(true);
const ls = v.lastIndexOf('\n', s - 1) + 1;
const le = v.indexOf('\n', s);
const cur = v.slice(ls, le === -1 ? v.length : le);
if (above) {
ta.value = v.slice(0, ls) + cur + '\n' + v.slice(ls);
ta.selectionStart = ta.selectionEnd = ls + cur.length + 1 + (s - ls);
} else {
const ins = le === -1 ? v.length : le;
ta.value = v.slice(0, ins) + '\n' + cur + v.slice(ins);
ta.selectionStart = ta.selectionEnd = ins + 1 + (s - ls);
}
vvSyncHlOverlay(); vvUndoCapture(true); vvEditorCursorMoved();
}
// ── Select next occurrence (Ctrl+D) ──────────────────────────────────────────
function vvSelectNextOccurrence() {
const ta = document.getElementById('vv-editor-body');
const v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
let word;
if (e > s) {
word = v.slice(s, e);
} else {
const wb = v.slice(0, s).match(/[\w$]+$/) ?? [''];
const wa = v.slice(s).match(/^[\w]+/) ?? [''];
word = wb[0] + wa[0];
if (word.length < 1) return;
// First call: just select the word at cursor
const ws = s - wb[0].length;
ta.selectionStart = ws; ta.selectionEnd = ws + word.length;
vvWordMatch = word.replace(/^\$/, '');
vvSyncHlOverlay(); vvEditorCursorMoved(); return;
}
if (!word) return;
const re = new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'g');
re.lastIndex = e;
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);
vvWordMatch = word.replace(/^\$/, '');
vvSyncHlOverlay(); vvEditorCursorMoved();
}
function vvEditorReset() {
vvWordMatch = null; vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0;
const bar = document.getElementById('vv-editor-find');
if (bar) { bar.classList.remove('vv-find-open'); }
document.getElementById('vv-editor-find')?.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 = '';
document.getElementById('vv-goto-bar')?.classList.remove('vv-goto-open');
vvUndoReset();
}
@@ -3033,64 +3158,91 @@ function vvEditorKeydown(e) {
return;
}
// Ctrl/Cmd+Z — undo
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault(); vvUndo(); return;
}
// Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z — redo
if ((e.key === 'y' && (e.ctrlKey || e.metaKey)) ||
(e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey)) {
e.preventDefault(); vvRedo(); 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 shortcuts ────────────────────────────────────────────────────
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); vvUndo(); return; }
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 === 'g') { e.preventDefault(); vvGotoOpen(); return; }
if (e.key === 'l' && !e.shiftKey) {
// Select current line
e.preventDefault();
const ls = val.lastIndexOf('\n', start - 1) + 1;
const le = val.indexOf('\n', start);
ta.selectionStart = ls;
ta.selectionEnd = le === -1 ? val.length : le + 1;
vvEditorCursorMoved(); return;
}
if (e.key === 'd' && !e.shiftKey) { e.preventDefault(); vvSelectNextOccurrence(); return; }
if (e.key === 'k' && e.shiftKey) {
// Delete current line
e.preventDefault(); vvUndoCapture(true);
const ls = val.lastIndexOf('\n', start - 1) + 1;
const le = val.indexOf('\n', start);
if (le === -1) {
ta.value = ls > 0 ? val.slice(0, ls - 1) : '';
ta.selectionStart = ta.selectionEnd = Math.max(0, ls - 1);
} else {
ta.value = val.slice(0, ls) + val.slice(le + 1);
ta.selectionStart = ta.selectionEnd = ls;
}
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
if (e.key === '/') {
// Toggle line comment
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);
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
return; // let browser handle other Ctrl combos
}
// Ctrl/Cmd+/ — toggle line comment
if (e.key === '/' && (e.ctrlKey || e.metaKey)) {
// ── Escape ─────────────────────────────────────────────────────────────────
if (e.key === 'Escape') {
const fb = document.getElementById('vv-editor-find');
if (fb?.classList.contains('vv-find-open')) { e.preventDefault(); vvFindClose(); return; }
const gb = document.getElementById('vv-goto-bar');
if (gb?.classList.contains('vv-goto-open')) { e.preventDefault(); vvGotoClose(); return; }
}
// ── Alt shortcuts (line moves) ─────────────────────────────────────────────
if (e.altKey) {
if (e.key === 'ArrowUp' && !e.shiftKey) { e.preventDefault(); vvMoveLine(-1); return; }
if (e.key === 'ArrowDown' && !e.shiftKey) { e.preventDefault(); vvMoveLine(1); return; }
if (e.key === 'ArrowDown' && e.shiftKey) { e.preventDefault(); vvDuplicateLine(false); return; }
if (e.key === 'ArrowUp' && e.shiftKey) { e.preventDefault(); vvDuplicateLine(true); return; }
}
// ── Smart Home ─────────────────────────────────────────────────────────────
if (e.key === 'Home' && !e.altKey && !e.shiftKey) {
e.preventDefault();
vvUndoCapture(true); // snapshot before edit
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
const lineEnd = val.indexOf('\n', start);
const line = val.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
const newLine = /^\s*#/.test(line)
? line.replace(/^(\s*)#\s?/, '$1')
: line.replace(/^(\s*)/, '$1# ');
const delta = newLine.length - line.length;
ta.value = val.slice(0, lineStart) + newLine + val.slice(lineEnd === -1 ? val.length : lineEnd);
ta.selectionStart = ta.selectionEnd = start + delta;
vvSyncHlOverlay(); vvUndoCapture(true); return;
const ls = val.lastIndexOf('\n', start - 1) + 1;
const fns = ls + val.slice(ls).match(/^(\s*)/)[1].length;
ta.selectionStart = ta.selectionEnd = start === fns ? ls : fns;
vvEditorCursorMoved(); return;
}
// Tab / Shift+Tab — indent / dedent (2 spaces)
// ── Tab / Shift+Tab ────────────────────────────────────────────────────────
if (e.key === 'Tab') {
e.preventDefault();
vvUndoCapture(true); // snapshot before edit
e.preventDefault(); vvUndoCapture(true);
if (end > start && val.slice(start, end).includes('\n')) {
const blockStart = val.lastIndexOf('\n', start - 1) + 1;
const blockEnd = val.indexOf('\n', end - 1);
const block = val.slice(blockStart, blockEnd === -1 ? val.length : blockEnd);
const newBlock = e.shiftKey ? block.replace(/^ /gm, '') : block.replace(/^/gm, ' ');
ta.value = val.slice(0, blockStart) + newBlock + val.slice(blockEnd === -1 ? val.length : blockEnd);
ta.selectionStart = blockStart;
ta.selectionEnd = blockStart + newBlock.length;
const bs = val.lastIndexOf('\n', start - 1) + 1;
const be = val.indexOf('\n', end - 1);
const blk = val.slice(bs, be === -1 ? val.length : be);
const nb = e.shiftKey ? blk.replace(/^ /gm, '') : blk.replace(/^/gm, ' ');
ta.value = val.slice(0, bs) + nb + val.slice(be === -1 ? val.length : be);
ta.selectionStart = bs; ta.selectionEnd = bs + nb.length;
} else if (e.shiftKey) {
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
if (val.slice(lineStart, lineStart + 2) === ' ') {
ta.value = val.slice(0, lineStart) + val.slice(lineStart + 2);
ta.selectionStart = ta.selectionEnd = Math.max(lineStart, start - 2);
const ls = val.lastIndexOf('\n', start - 1) + 1;
if (val.slice(ls, ls + 2) === ' ') {
ta.value = val.slice(0, ls) + val.slice(ls + 2);
ta.selectionStart = ta.selectionEnd = Math.max(ls, start - 2);
}
} else {
ta.value = val.slice(0, start) + ' ' + val.slice(end);
@@ -3099,19 +3251,51 @@ function vvEditorKeydown(e) {
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
// Enter auto-indent
// ── Enter auto-indent ──────────────────────────────────────────────────────
if (e.key === 'Enter') {
e.preventDefault();
vvUndoCapture(true); // snapshot before edit
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
const currentLine = val.slice(lineStart, start);
const indent = currentLine.match(/^(\s*)/)[1];
const extra = /\{\s*$/.test(currentLine) ? ' ' : '';
const insert = '\n' + indent + extra;
ta.value = val.slice(0, start) + insert + val.slice(end);
ta.selectionStart = ta.selectionEnd = start + insert.length;
e.preventDefault(); vvUndoCapture(true);
const ls = val.lastIndexOf('\n', start - 1) + 1;
const curL = val.slice(ls, start);
const ind = curL.match(/^(\s*)/)[1];
const xtra = /\{\s*$/.test(curL) ? ' ' : '';
const ins = '\n' + ind + xtra;
ta.value = val.slice(0, start) + ins + val.slice(end);
ta.selectionStart = ta.selectionEnd = start + ins.length;
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
// ── Auto-close brackets and quotes (no modifier keys) ─────────────────────
if (!e.altKey) {
// Skip over closing char if already there
if (VV_CLOSE.has(e.key) && start === end && val[start] === e.key) {
e.preventDefault();
ta.selectionStart = ta.selectionEnd = start + 1;
vvEditorCursorMoved(); return;
}
// Smart backspace: delete matched pair
if (e.key === 'Backspace' && start === end && start > 0 && VV_PAIRS[val[start-1]] === val[start]) {
e.preventDefault(); vvUndoCapture(true);
ta.value = val.slice(0, start - 1) + val.slice(start + 1);
ta.selectionStart = ta.selectionEnd = start - 1;
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
// Auto-close opening bracket/quote
if (e.key in VV_PAIRS) {
if (e.key === "'" && /\w/.test(val[start - 1] ?? '')) return; // skip contractions
if (end === start && /\w/.test(val[start] ?? '')) return; // skip if next is word char
e.preventDefault(); vvUndoCapture(true);
const cl = VV_PAIRS[e.key];
if (end > start) {
// Wrap selection in pair
ta.value = val.slice(0, start) + e.key + val.slice(start, end) + cl + val.slice(end);
ta.selectionStart = start + 1; ta.selectionEnd = end + 1;
} else {
ta.value = val.slice(0, start) + e.key + cl + val.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1;
}
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
}
}
// ── Script browser tree ───────────────────────────────────────────────────────