ui: custom undo/redo stack for editor — up to 200 steps

Native browser undo breaks when ta.value is set programmatically (tab,
enter, ctrl+/) — replaced with a custom snapshot-based undo system.

Stack behaviour:
- oninput → debounced capture (600ms) — groups rapid typing into chunks
- Tab / Enter / Ctrl+/ → immediate capture BEFORE edit, then capture AFTER
  (each programmatic edit gets its own undo step)
- Content load → vvUndoCaptureInitial() seeds the stack with a clean baseline
- Max 200 history entries (VV_UNDO_MAX); old entries dropped from front

Keyboard: Ctrl/Cmd+Z = undo, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z = redo
(both handled in vvEditorKeydown before any other key processing)

Buttons: ↩ Undo / ↪ Redo appear in toolbar beside Cancel/Save when editor
is open; disabled state with 0.28 opacity when no steps available; Undo
button shows step count "↩ Undo 5" so you can see how deep the history is

Cleanup: vvEditorReset() calls vvUndoReset() so stack is cleared on every
editor open/close — fresh baseline each time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-31 20:54:42 -04:00
co-authored by Claude Sonnet 4.6
parent 4722382d49
commit 1ea3f595cc
2 changed files with 124 additions and 8 deletions
+6
View File
@@ -394,6 +394,12 @@
.vv-es-sel { color: #555; }
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
/* 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; }
.vv-undo-redo-btn[disabled] { opacity: 0.28 !important; cursor: default !important; pointer-events: none; }
.vv-undo-count { font-size: 10px; opacity: 0.7; margin-left: 2px; }
/* Suggestions panel — accordion */
#vv-suggestions { overflow-y: auto; }
.vv-sug-block { border-bottom: 1px solid #1e1e1e; }
+118 -8
View File
@@ -390,6 +390,8 @@ $runningScripts = array_unique($runningScripts);
<input type="checkbox" id="vv-invert-log" onchange="vvToggleInvert(this)"> <span>Invert</span>
</label>
<button id="vv-cancel-edit-btn" class="vv-btn-sm vv-delete-btn" onclick="vvBackToSuggestions()" style="display:none">Cancel</button>
<button id="vv-undo-btn" class="vv-btn-sm vv-undo-redo-btn" onclick="vvUndo()" style="display:none" disabled title="Undo (Ctrl+Z)">↩ Undo<span class="vv-undo-count" id="vv-undo-count"></span></button>
<button id="vv-redo-btn" class="vv-btn-sm vv-undo-redo-btn" onclick="vvRedo()" style="display:none" disabled title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↪ Redo</button>
<button id="vv-save-script-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveScript()" style="display:none">Save Script</button>
<button id="vv-save-conf-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveConf()" style="display:none">Save Config</button>
<button id="vv-save-rawconf-btn" class="vv-btn-sm vv-save-script-btn-style" onclick="vvSaveRawConf()" style="display:none">Save Conf</button>
@@ -886,7 +888,7 @@ Still the same two servers, two households, the same media stack running itself.
<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()"
oninput="vvSyncHlOverlay(); vvUndoCapture()" onscroll="vvSyncHlScroll()"
onkeydown="vvEditorKeydown(event)"
onkeyup="vvEditorCursorMoved()" onmouseup="vvEditorCursorMoved()"
onclick="vvEditorCursorMoved()"></textarea>
@@ -972,6 +974,11 @@ let vvWordMatch = null; // word under cursor — highlighted everywhere in o
let vvFindTerm = ''; // active find-bar search string
let vvFindMatches = []; // [{start,end}] in raw text
let vvFindIdx = 0; // current match index
// Undo / redo
let vvUndoStack = []; // [{v,ss,se}] snapshots — v=value, ss=selStart, se=selEnd
let vvRedoStack = [];
let vvUndoTimer = null;
const VV_UNDO_MAX = 200; // max history depth
// Setup mode — injected by PHP when navigating from the first-run wizard
let vvSetupConf = <?= json_encode($vv_setup_conf) ?>;
@@ -1115,6 +1122,8 @@ function vvRestoreInvert() {
function vvBackToSuggestions() {
vvEditorReset();
document.getElementById('vv-editor-status').classList.remove('vv-ed-active');
document.getElementById('vv-undo-btn').style.display = 'none';
document.getElementById('vv-redo-btn').style.display = 'none';
if (vvActiveId) {
const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]');
if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected');
@@ -1543,6 +1552,7 @@ function vvAddScript() {
document.getElementById('vv-editor-body').value = '#!/bin/bash\n\n';
vvShowEditorMode('New Script');
vvSyncHlOverlay();
vvUndoCaptureInitial();
vvEditorCursorMoved();
nameEl.focus();
}
@@ -1567,6 +1577,7 @@ function vvEditScript(id) {
.then(d => {
document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading script';
vvSyncHlOverlay();
vvUndoCaptureInitial();
vvEditorCursorMoved();
})
.catch(() => {
@@ -1582,6 +1593,8 @@ function vvShowEditorMode(title) {
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-undo-btn').style.display = '';
document.getElementById('vv-redo-btn').style.display = '';
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -2616,6 +2629,7 @@ 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();
vvUndoCaptureInitial();
vvEditorCursorMoved();
requestAnimationFrame(vvFitRight);
})
@@ -2626,6 +2640,8 @@ 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-undo-btn').style.display = '';
document.getElementById('vv-redo-btn').style.display = '';
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -2918,6 +2934,90 @@ function vvEditorReset() {
if (inp) { inp.value = ''; inp.classList.remove('vv-find-no-match'); }
const cnt = document.getElementById('vv-find-count');
if (cnt) cnt.textContent = '';
vvUndoReset();
}
// ── Undo / Redo ────────────────────────────────────────────────────────────────
function vvUndoSnapshot() {
const ta = document.getElementById('vv-editor-body');
return ta ? { v: ta.value, ss: ta.selectionStart, se: ta.selectionEnd } : null;
}
function _vvUndoCommit(snap) {
if (!snap) return;
const top = vvUndoStack[vvUndoStack.length - 1];
if (top && top.v === snap.v) return; // identical — skip
vvUndoStack.push(snap);
if (vvUndoStack.length > VV_UNDO_MAX) vvUndoStack.shift();
vvUndoUpdateBtns();
}
// immediate=true: flush debounce and commit now (use before/after programmatic edits)
// immediate=false (default): debounce — groups rapid typing into one undo step
function vvUndoCapture(immediate = false) {
const snap = vvUndoSnapshot();
if (!snap) return;
if (immediate) {
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
_vvUndoCommit(snap);
} else {
if (vvUndoTimer) clearTimeout(vvUndoTimer);
vvUndoTimer = setTimeout(() => { vvUndoTimer = null; _vvUndoCommit(vvUndoSnapshot()); }, 600);
}
}
function vvUndoCaptureInitial() {
// Called after editor content loads — seeds the stack with the initial state
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
vvUndoStack = [];
vvRedoStack = [];
_vvUndoCommit(vvUndoSnapshot());
}
function vvUndo() {
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
if (vvUndoStack.length < 2) return;
const ta = document.getElementById('vv-editor-body');
// Push current state to redo before going back
vvRedoStack.push({ v: ta.value, ss: ta.selectionStart, se: ta.selectionEnd });
vvUndoStack.pop(); // discard current top (that's the state we're leaving)
const prev = vvUndoStack[vvUndoStack.length - 1];
ta.value = prev.v;
ta.selectionStart = prev.ss;
ta.selectionEnd = prev.se;
vvSyncHlOverlay();
vvEditorCursorMoved();
vvUndoUpdateBtns();
}
function vvRedo() {
if (!vvRedoStack.length) return;
const ta = document.getElementById('vv-editor-body');
const next = vvRedoStack.pop();
_vvUndoCommit({ v: ta.value, ss: ta.selectionStart, se: ta.selectionEnd });
ta.value = next.v;
ta.selectionStart = next.ss;
ta.selectionEnd = next.se;
vvSyncHlOverlay();
vvEditorCursorMoved();
vvUndoUpdateBtns();
}
function vvUndoUpdateBtns() {
const ub = document.getElementById('vv-undo-btn');
const rb = document.getElementById('vv-redo-btn');
const uc = document.getElementById('vv-undo-count');
const depth = vvUndoStack.length - 1; // steps available
if (ub) ub.disabled = depth < 1;
if (rb) rb.disabled = vvRedoStack.length === 0;
if (uc) uc.textContent = depth > 0 ? depth : '';
}
function vvUndoReset() {
if (vvUndoTimer) { clearTimeout(vvUndoTimer); vvUndoTimer = null; }
vvUndoStack = []; vvRedoStack = [];
vvUndoUpdateBtns();
}
function vvEditorKeydown(e) {
@@ -2933,6 +3033,16 @@ 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();
@@ -2951,6 +3061,7 @@ function vvEditorKeydown(e) {
// Ctrl/Cmd+/ — toggle line comment
if (e.key === '/' && (e.ctrlKey || e.metaKey)) {
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);
@@ -2960,14 +3071,14 @@ function vvEditorKeydown(e) {
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(); return;
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
// Tab / Shift+Tab — indent / dedent (2 spaces)
if (e.key === 'Tab') {
e.preventDefault();
vvUndoCapture(true); // snapshot before edit
if (end > start && val.slice(start, end).includes('\n')) {
// Multi-line selection: indent/dedent every line in the selection
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);
@@ -2976,23 +3087,22 @@ function vvEditorKeydown(e) {
ta.selectionStart = blockStart;
ta.selectionEnd = blockStart + newBlock.length;
} else if (e.shiftKey) {
// Dedent current line
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);
}
} else {
// Insert 2 spaces at cursor
ta.value = val.slice(0, start) + ' ' + val.slice(end);
ta.selectionStart = ta.selectionEnd = start + 2;
}
vvSyncHlOverlay(); return;
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
// Enter — auto-indent: match current line's leading whitespace; extra indent after {
// 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];
@@ -3000,7 +3110,7 @@ function vvEditorKeydown(e) {
const insert = '\n' + indent + extra;
ta.value = val.slice(0, start) + insert + val.slice(end);
ta.selectionStart = ta.selectionEnd = start + insert.length;
vvSyncHlOverlay(); return;
vvSyncHlOverlay(); vvUndoCapture(true); return;
}
}