From 1ea3f595cc9819c1c16e778835a27c6c116552a5 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 31 May 2026 20:54:42 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20custom=20undo/redo=20stack=20for=20editor?= =?UTF-8?q?=20=E2=80=94=20up=20to=20200=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Plugin/unraid/css/varaverk.css | 6 ++ Plugin/unraid/pages/scheduler.php | 126 ++++++++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 46e7144..270d2c5 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -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; } diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index c67ff62..78c531b 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -390,6 +390,8 @@ $runningScripts = array_unique($runningScripts); Invert + + @@ -886,7 +888,7 @@ Still the same two servers, two households, the same media stack running itself.
@@ -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 = ; @@ -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; } }