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:
co-authored by
Claude Sonnet 4.6
parent
4722382d49
commit
1ea3f595cc
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user