From a250d590cab37b17191b315c007b041eae3e1e71 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 31 May 2026 20:16:26 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20premium=20editor=20overhaul=20=E2=80=94?= =?UTF-8?q?=20selection,=20word=20highlight,=20find,=20status=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection: - ::selection fix: text selection now visible (bright blue) even when textarea color is transparent — the #1 complaint - Overlay background changed transparent → #vv-editor-inner provides bg, allowing #vv-cur-line to show through Current line highlight: - #vv-cur-line div positioned absolutely behind overlay, tracks cursor line on every keyup/mouseup/click/scroll - Gutter: current line number highlighted in bright white + bold Word/variable occurrence highlight: - Click or position cursor on any identifier → all occurrences highlighted in yellow throughout the overlay (whole-word, case-sensitive) - Select a word → same treatment for the selection text - Clears when cursor moves off the word or find bar opens Find in file (Ctrl+F): - Find bar slides in above editor, pre-filled with selected word - Highlights all matches orange, current match in bright amber - ↑/↓ buttons + Enter/Shift+Enter to navigate between matches - Live match count "N / M" - Red border + "no matches" feedback when term not found - Escape or × closes, returns focus to editor Status bar: - Persistent bar below editor: Ln N, Col N | selection info | language - Shows "Bash" or "Bash / Config" depending on file type - Hides when editor is not open Visual polish: - Editor wrap: unified border + border-radius 4px 4px 0 0, overflow:hidden clips gutter and inner to rounded corners as one block - Status bar border-radius 0 0 4px 4px — completes the block - Gutter: 46px (was 42) — room for 3-digit line numbers - Slim 8px scrollbar with styled track and hover state - vvEditorReset() clears all editor state on open/close Co-Authored-By: Claude Sonnet 4.6 --- Plugin/unraid/css/varaverk.css | 67 +++++++- Plugin/unraid/pages/scheduler.php | 247 +++++++++++++++++++++++++++++- 2 files changed, 299 insertions(+), 15 deletions(-) diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 78b0f34..46e7144 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -324,24 +324,75 @@ border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none; line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box; } -/* Editor layout: gutter + inner area */ -#vv-editor-wrap { display: flex; } -#vv-ln-gutter { width: 42px; min-width: 42px; flex-shrink: 0; +/* Editor layout: gutter + inner area — unified bordered block */ +#vv-editor-wrap { display: flex; border: 1px solid #2e2e2e; border-radius: 4px 4px 0 0; + overflow: hidden; background: #0d0d0d; } +#vv-ln-gutter { width: 46px; min-width: 46px; flex-shrink: 0; background: #0a0a0a; border-right: 1px solid #1c1c1c; font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2; color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0; overflow: hidden; user-select: none; white-space: pre; } -#vv-editor-inner { position: relative; flex: 1; overflow: hidden; } +.vv-gln-cur { color: #ccc; font-weight: bold; } +#vv-editor-inner { position: relative; flex: 1; overflow: hidden; background: #0d0d0d; } -/* Syntax-highlight overlay sits over the transparent textarea */ -#vv-hl-overlay { display: none; position: absolute; top: 1px; left: 1px; right: 1px; bottom: 1px; +/* Current line highlight — sits behind overlay */ +#vv-cur-line { position: absolute; left: 0; right: 0; height: 18px; + background: rgba(255,255,255,0.04); + border-top: 1px solid rgba(255,255,255,0.03); + border-bottom: 1px solid rgba(255,255,255,0.03); + pointer-events: none; display: none; } + +/* Syntax-highlight overlay — transparent bg so cur-line shows through */ +#vv-hl-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: 0; padding: 10px 12px; box-sizing: border-box; font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2; white-space: pre-wrap; word-break: break-all; overflow: hidden; pointer-events: none; user-select: none; - background: #0d0d0d; border: none; border-radius: 3px; } + background: transparent; border: none; border-radius: 0; } .vv-editor-hl #vv-hl-overlay { display: block; } -.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent; } +.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent; + border: none !important; border-radius: 0 !important; } + +/* Visible selection — works even when text is transparent */ +.vv-editor-hl #vv-editor-body::selection { background: rgba(100,149,237,0.35); color: transparent; } +.vv-editor-hl #vv-editor-body::-moz-selection{ background: rgba(100,149,237,0.35); color: transparent; } + +/* Word match and search marks in the overlay */ +.vv-word-mark { background: rgba(255,220,60,0.14); outline: 1px solid rgba(255,220,60,0.35); border-radius: 2px; } +.vv-search-mark { background: rgba(255,140,20,0.22); outline: 1px solid rgba(255,140,20,0.45); border-radius: 2px; } +.vv-search-mark-current{ background: rgba(255,90,0,0.40); outline: 2px solid rgba(255,100,0,0.7); border-radius: 2px; } + +/* Slim scrollbar for editor */ +#vv-editor-body::-webkit-scrollbar { width: 8px; height: 8px; } +#vv-editor-body::-webkit-scrollbar-track { background: #0a0a0a; } +#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; + 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-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; + padding: 0 2px; flex-shrink: 0; } +#vv-find-close:hover { color: #aaa; } + +/* Editor status bar */ +#vv-editor-status { display: none; align-items: center; gap: 14px; 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; } +#vv-editor-status.vv-ed-active { display: flex; } +.vv-es-pos { color: #666; } +.vv-es-sel { color: #555; } +.vv-es-lang { margin-left: auto; color: #4a6a7a; } /* Suggestions panel — accordion */ #vv-suggestions { overflow-y: auto; } diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 2c43ab2..c67ff62 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -872,15 +872,31 @@ Still the same two servers, two households, the same media stack running itself. Name: +
+ + + + + × +
+
+ onkeydown="vvEditorKeydown(event)" + onkeyup="vvEditorCursorMoved()" onmouseup="vvEditorCursorMoved()" + onclick="vvEditorCursorMoved()">
+
+ Ln 1, Col 1 + + +
@@ -951,6 +967,11 @@ let vvConfId = null; let vvSelectedRow = null; let vvCurrentSiId = null; let vvCurrentSiHdr = null; +// Editor state +let vvWordMatch = null; // word under cursor — highlighted everywhere in overlay +let vvFindTerm = ''; // active find-bar search string +let vvFindMatches = []; // [{start,end}] in raw text +let vvFindIdx = 0; // current match index // Setup mode — injected by PHP when navigating from the first-run wizard let vvSetupConf = ; @@ -1092,6 +1113,8 @@ function vvRestoreInvert() { } function vvBackToSuggestions() { + vvEditorReset(); + document.getElementById('vv-editor-status').classList.remove('vv-ed-active'); if (vvActiveId) { const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]'); if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected'); @@ -1520,6 +1543,7 @@ function vvAddScript() { document.getElementById('vv-editor-body').value = '#!/bin/bash\n\n'; vvShowEditorMode('New Script'); vvSyncHlOverlay(); + vvEditorCursorMoved(); nameEl.focus(); } @@ -1543,6 +1567,7 @@ function vvEditScript(id) { .then(d => { document.getElementById('vv-editor-body').value = d.ok ? d.content : '# Error loading script'; vvSyncHlOverlay(); + vvEditorCursorMoved(); }) .catch(() => { document.getElementById('vv-editor-body').value = '# Error loading script'; @@ -1553,7 +1578,10 @@ function vvEditScript(id) { } function vvShowEditorMode(title) { + vvEditorReset(); 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-suggestions').style.display = 'none'; document.getElementById('vv-log-pre').style.display = 'none'; document.getElementById('vv-si-view').style.display = 'none'; @@ -2588,12 +2616,16 @@ 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(); + vvEditorCursorMoved(); requestAnimationFrame(vvFitRight); }) .catch(() => { document.getElementById('vv-editor-body').value = '# Load failed'; }); } 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-suggestions').style.display = 'none'; document.getElementById('vv-log-pre').style.display = 'none'; document.getElementById('vv-si-view').style.display = 'none'; @@ -2671,14 +2703,52 @@ function vvSaveRawConf() { // ── Highlighted conf editor overlay ────────────────────────────────────────── +// ── Overlay helpers ──────────────────────────────────────────────────────────── + +// Annotate text occurrences in already-rendered HTML (text between > and < only). +// Returns modified HTML with wrapping each whole-word match. +function vvMarkInHtml(html, word, cls, caseInsensitive) { + if (!word || word.length < 1) return html; + const hw = word.replace(/&/g,'&').replace(//g,'>'); + const esc = hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const fl = caseInsensitive ? 'gi' : 'g'; + const re = new RegExp('(?((?:[^<])*) + '>' + txt.replace(re, `$&`) + '<' + ); +} + +// Like vvMarkInHtml but marks the curIdx-th match differently. +function vvMarkSearchInHtml(html, term, curIdx) { + if (!term) return html; + const hw = term.replace(/&/g,'&').replace(//g,'>'); + const esc = hw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(esc, 'gi'); + let idx = 0; + return html.replace(/>((?:[^<])*) { + const repl = txt.replace(re, m => { + const cls = idx++ === curIdx ? 'vv-search-mark-current' : 'vv-search-mark'; + return `${m}`; + }); + return '>' + repl + '<'; + }); +} + function vvSyncHlOverlay() { const ov = document.getElementById('vv-hl-overlay'); if (!ov || !document.getElementById('vv-editor').classList.contains('vv-editor-hl')) return; const ta = document.getElementById('vv-editor-body'); - ov.innerHTML = vvHl(ta.value, false) + '\n'; + let html = vvHl(ta.value, false) + '\n'; + if (vvFindTerm) { + html = vvMarkSearchInHtml(html, vvFindTerm, vvFindIdx); + } else if (vvWordMatch) { + html = vvMarkInHtml(html, vvWordMatch, 'vv-word-mark', false); + } + ov.innerHTML = html; ov.scrollTop = ta.scrollTop; ov.scrollLeft = ta.scrollLeft; vvUpdateLineNums(); + vvUpdateCurLine(); } function vvSyncHlScroll() { @@ -2689,17 +2759,165 @@ function vvSyncHlScroll() { ov.scrollLeft = ta.scrollLeft; const gutter = document.getElementById('vv-ln-gutter'); if (gutter) gutter.scrollTop = ta.scrollTop; + vvUpdateCurLine(); } function vvUpdateLineNums() { const gutter = document.getElementById('vv-ln-gutter'); const ta = document.getElementById('vv-editor-body'); if (!gutter || !ta) return; - const count = ta.value.split('\n').length; - let nums = ''; - for (let i = 1; i <= count; i++) nums += i + '\n'; - gutter.textContent = nums; - gutter.scrollTop = ta.scrollTop; + const lines = ta.value.split('\n').length; + const curLn = ta.value.slice(0, ta.selectionStart).split('\n').length; + let html = ''; + for (let i = 1; i <= lines; i++) { + html += i === curLn ? `${i}\n` : `${i}\n`; + } + gutter.innerHTML = html; + gutter.scrollTop = ta.scrollTop; +} + +// Position the current-line highlight strip behind the overlay +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 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'; + cl.style.height = lineH + 'px'; + cl.style.display = ''; +} + +// Called on cursor move (keyup, mouseup, click) — update word match, status bar, cur-line +function vvEditorCursorMoved() { + vvUpdateCurLine(); + vvUpdateEditorStatus(); + + if (vvFindTerm) { vvSyncHlOverlay(); return; } // find mode owns the overlay + + const ta = document.getElementById('vv-editor-body'); + const start = ta.selectionStart; + const end = ta.selectionEnd; + const val = ta.value; + + // If user selected a word (not whitespace, at least 2 chars) → highlight all occurrences + if (end > start) { + const sel = val.slice(start, end); + const word = /^[\w$]{2,}$/.test(sel) ? sel : null; + if (word !== vvWordMatch) { vvWordMatch = word; vvSyncHlOverlay(); } + return; + } + + // Find the identifier word at cursor position + const before = val.slice(0, start); + const after = val.slice(start); + const wBefore = before.match(/[$A-Za-z_][\w]*$/) ?? ['']; + const wAfter = after.match(/^[\w]+/) ?? ['']; + const word = wBefore[0].replace(/^\$/, '') + wAfter[0]; // strip leading $ for matching + const newWord = word.length >= 2 ? word : null; + if (newWord !== vvWordMatch) { vvWordMatch = newWord; vvSyncHlOverlay(); } +} + +function vvUpdateEditorStatus() { + const ta = document.getElementById('vv-editor-body'); + const pos = document.getElementById('vv-es-pos'); + const sel = document.getElementById('vv-es-sel'); + if (!ta || !pos) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const val = ta.value; + const before = val.slice(0, start); + const lines = before.split('\n'); + pos.textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`; + if (end > start) { + const chars = end - start; + const lns = val.slice(start, end).split('\n').length; + sel.textContent = lns > 1 ? `(${chars} chars · ${lns} lines)` : `(${chars} chars)`; + } else { + sel.textContent = ''; + } +} + +// ── Find bar ─────────────────────────────────────────────────────────────────── + +function vvFindOpen() { + const bar = document.getElementById('vv-editor-find'); + bar.classList.add('vv-find-open'); + const inp = document.getElementById('vv-find-input'); + // Pre-fill with selected word if any + const ta = document.getElementById('vv-editor-body'); + const sel = ta.value.slice(ta.selectionStart, ta.selectionEnd); + if (sel && /^\w+$/.test(sel)) { inp.value = sel; } + inp.focus(); inp.select(); + vvFindSearch(); +} + +function vvFindClose() { + document.getElementById('vv-editor-find').classList.remove('vv-find-open'); + document.getElementById('vv-find-input').value = ''; + document.getElementById('vv-find-input').classList.remove('vv-find-no-match'); + document.getElementById('vv-find-count').textContent = ''; + vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0; + document.getElementById('vv-editor-body').focus(); + vvSyncHlOverlay(); +} + +function vvFindSearch() { + const inp = document.getElementById('vv-find-input'); + const cnt = document.getElementById('vv-find-count'); + vvFindTerm = inp.value; + if (!vvFindTerm) { + vvFindMatches = []; vvFindIdx = 0; + cnt.textContent = ''; + inp.classList.remove('vv-find-no-match'); + vvSyncHlOverlay(); return; + } + const ta = document.getElementById('vv-editor-body'); + const re = new RegExp(vvFindTerm.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'); + vvFindMatches = []; + let m; + while ((m = re.exec(ta.value)) !== null) vvFindMatches.push({start: m.index, end: m.index + m[0].length}); + if (!vvFindMatches.length) { + vvFindIdx = 0; + cnt.textContent = 'no matches'; + inp.classList.add('vv-find-no-match'); + } else { + vvFindIdx = Math.min(vvFindIdx, vvFindMatches.length - 1); + cnt.textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`; + inp.classList.remove('vv-find-no-match'); + const cur = vvFindMatches[vvFindIdx]; + ta.selectionStart = cur.start; ta.selectionEnd = cur.end; + } + vvSyncHlOverlay(); +} + +function vvFindNav(dir) { + if (!vvFindMatches.length) return; + vvFindIdx = (vvFindIdx + dir + vvFindMatches.length) % vvFindMatches.length; + document.getElementById('vv-find-count').textContent = `${vvFindIdx + 1} / ${vvFindMatches.length}`; + const ta = document.getElementById('vv-editor-body'); + const cur = vvFindMatches[vvFindIdx]; + 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); + vvSyncHlOverlay(); +} + +function vvFindKeydown(e) { + if (e.key === 'Enter') { e.preventDefault(); vvFindNav(e.shiftKey ? -1 : 1); } + if (e.key === 'Escape') { e.preventDefault(); vvFindClose(); } +} + +function vvEditorReset() { + vvWordMatch = null; vvFindTerm = ''; vvFindMatches = []; vvFindIdx = 0; + const bar = document.getElementById('vv-editor-find'); + if (bar) { bar.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 = ''; } function vvEditorKeydown(e) { @@ -2715,6 +2933,21 @@ function vvEditorKeydown(e) { 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+/ — toggle line comment if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault();