From 30789065485431e7970b651cead4f1d675ec19b2 Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 12:23:18 +0200 Subject: [PATCH] find/replace: editor (Ctrl+H/F) + board-wide (Ctrl+Shift+H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor find/replace (public/js/modules/editor.js, public/index.php): * New #editor-find-bar inside #editor-pane (above the textarea, pushes it down via flex-col). Two rows: find input + match count + prev/next + close, and replace input + replace-one + replace-all. * Ctrl+H opens find+replace, Ctrl+F opens find-only (replace row hidden). Same shortcut toggles off if already open in the same mode. * runFind(): case-insensitive indexOf scan, all match positions stored. jumpTo() sets textarea.setSelectionRange for the Nth match. findNext/findPrev cycle with wrap-around. * replaceOne(): body.setRangeText at the current match position, re-scan. replaceAll(): regex-based global replace on body.value. * Enter in find input = next (Shift+Enter = prev). Enter in replace input = replace one. Escape closes the bar + refocuses textarea. * closeModal() calls closeFindBar() to clean up on editor close. Board-wide find/replace (public/js/app.js): * Ctrl+Shift+H opens a centered overlay that fetches all cards with full bodies (api('api/cards'), bypasses lazy-load). * Live search: typing in the find input scans all card bodies case- insensitive. Matches shown as a scrollable list with color dot, card title, occurrence count, and a context snippet with highlight around the first match. * Status line: "12 occurrences in 5 cards" or "no matches". * Replace All button (danger-styled, disabled until both inputs have text and matches exist). Click opens a showDialog confirmation: "Replace 12 occurrences of 'foo' with 'bar' across 5 cards?" On confirm: regex replace per card body → PATCH → _reloadAll. * Escape / click-outside / Close button dismiss the overlay. Shortcuts registered in the central registry for the help screen: * ⌃ H (editor), ⌃ F (editor), ⌃ ⇧ H (global board-wide). Tests (public/plugins/tests/board-find-replace.test.js): * Ctrl+Shift+H opens the overlay (polls for #board-find-replace). * Typing in find shows matching cards (creates 3 cards, 2 match, asserts status count + list contains matching titles + excludes non-matching title). * Replace all updates card bodies after confirmation (creates 2 cards with "hello", replaces with "goodbye", confirms dialog, verifies via API that bodies are "goodbye world goodbye" and "goodbye there"). * Replace all button disabled without replace text. detail.test.js: "closes on ESC" stabilized — guard that detail is actually open before dispatching Escape, poll (20×50ms) instead of fixed sleep(150) for the .open class to be removed. chore: bump version to 0.9.21 Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/index.php | 25 ++- public/js/app.js | 145 ++++++++++++++++++ public/js/modules/editor.js | 118 ++++++++++++++ .../plugins/tests/board-find-replace.test.js | 135 ++++++++++++++++ public/plugins/tests/detail.test.js | 6 +- public/plugins/tests/tests.js | 1 + 7 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 public/plugins/tests/board-find-replace.test.js diff --git a/config.php b/config.php index a0ea6fb..9bb033f 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@
-
- - +
+ + +
+ + +
diff --git a/public/js/app.js b/public/js/app.js index 74efee4..703824f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -238,6 +238,9 @@ registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '< registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '[[', label: 'link autocomplete' }); registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '#', label: 'tag autocomplete' }); registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '?snippet', label: 'snippet expand' }); +registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '⌃ H', label: 'find + replace' }); +registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '⌃ F', label: 'find' }); +registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '⌃ ⇧ H', label: 'find + replace across cards', handler: () => openBoardFindReplace() }); // — Detail view — registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '← → in preview', label: 'group prev / next', handler: (e) => { @@ -506,6 +509,148 @@ function openTagQuickPick() { setTimeout(() => input.focus(), 0); } +// ── Board-level find/replace (Ctrl+Shift+H) ── + +async function openBoardFindReplace() { + const existing = document.getElementById('board-find-replace'); + if (existing) { existing.remove(); return; } + + // Fetch all cards with bodies (state.cards may have body=null from lazy load) + const allCards = await api('api/cards') || []; + + const overlay = document.createElement('div'); + overlay.id = 'board-find-replace'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:60px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));'; + const box = document.createElement('div'); + box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:560px;max-height:80vh;display:flex;flex-direction:column;overflow:hidden;'; + box.innerHTML = ` +
+
+ + find + replace across all cards +
+
+ + +
+
+
+ + `; + overlay.appendChild(box); + document.body.appendChild(overlay); + + const findInp = box.querySelector('#bfr-find'); + const replInp = box.querySelector('#bfr-replace'); + const list = box.querySelector('#bfr-list'); + const statusEl = box.querySelector('#bfr-status'); + const applyBtn = box.querySelector('#bfr-apply'); + + function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } + + let matches = []; + + function search() { + const needle = findInp.value; + matches = []; + if (!needle) { + list.innerHTML = '
type to search across all card bodies
'; + statusEl.textContent = ''; + applyBtn.disabled = true; + return; + } + const nLower = needle.toLowerCase(); + for (const c of allCards) { + const body = c.body || ''; + const idx = body.toLowerCase().indexOf(nLower); + if (idx === -1) continue; + // Extract context around the first match + const start = Math.max(0, idx - 30); + const end = Math.min(body.length, idx + needle.length + 30); + const before = body.substring(start, idx); + const match = body.substring(idx, idx + needle.length); + const after = body.substring(idx + needle.length, end); + // Count all occurrences in this card + let count = 0, pos = 0; + while (true) { + const i = body.toLowerCase().indexOf(nLower, pos); + if (i === -1) break; + count++; + pos = i + 1; + } + matches.push({ card: c, context: { before, match, after }, count }); + } + const totalOccurrences = matches.reduce((s, m) => s + m.count, 0); + statusEl.textContent = matches.length + ? `${totalOccurrences} occurrence${totalOccurrences !== 1 ? 's' : ''} in ${matches.length} card${matches.length !== 1 ? 's' : ''}` + : 'no matches'; + applyBtn.disabled = !matches.length || !replInp.value.length; + + if (!matches.length) { + list.innerHTML = '
no matches found
'; + return; + } + list.innerHTML = matches.map(m => ` +
+ +
+
${escHtml(m.card.title)} (${m.count}×)
+
…${escHtml(m.context.before)}${escHtml(m.context.match)}${escHtml(m.context.after)}…
+
+
+ `).join(''); + } + + findInp.addEventListener('input', search); + replInp.addEventListener('input', () => { applyBtn.disabled = !matches.length || !replInp.value.length; }); + + applyBtn.addEventListener('click', async () => { + const needle = findInp.value; + const repl = replInp.value; + if (!needle || !matches.length) return; + const totalOccurrences = matches.reduce((s, m) => s + m.count, 0); + const ok = await showDialog({ + title: 'replace_all', + message: `Replace ${totalOccurrences} occurrence${totalOccurrences !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${matches.length} card${matches.length !== 1 ? 's' : ''}?`, + danger: true, + okLabel: `Replace ${totalOccurrences}`, + }); + if (!ok) return; + const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(escaped, 'gi'); + let patchCount = 0; + for (const m of matches) { + const newBody = (m.card.body || '').replace(re, repl); + if (newBody !== m.card.body) { + await api(`api/cards/${m.card.id}`, { method: 'PATCH', body: { body: newBody } }); + patchCount++; + } + } + statusEl.textContent = `done — ${patchCount} card${patchCount !== 1 ? 's' : ''} updated`; + statusEl.style.color = 'var(--t-accent)'; + applyBtn.disabled = true; + matches = []; + list.innerHTML = '
✓ replaced
'; + await window._reloadAll(); + }); + + function close() { + overlay.remove(); + document.removeEventListener('keydown', onKey, true); + } + function onKey(e) { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } + } + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + box.querySelector('#bfr-close').addEventListener('click', close); + document.addEventListener('keydown', onKey, true); + setTimeout(() => findInp.focus(), 0); +} + // ── Boot ── renderShortcutHelp(); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 6b70bae..550a4c5 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -362,6 +362,7 @@ export function closeModal({ animate = true } = {}) { hideLinkAc(); hideTagAc(); hideSnippetAc(); + closeFindBar(); modal.classList.remove('open'); state.editingId = null; state.returnToGalleryAfterEdit = false; @@ -989,6 +990,123 @@ body.addEventListener('keydown', e => { body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); }); +// ── Find / Replace (Ctrl+H / Ctrl+F) ── + +const findBar = $('editor-find-bar'); +const findInput = $('find-input'); +const replInput = $('replace-input'); +const findCount = $('find-count'); +const replRow = $('replace-row'); + +let findMatches = []; +let findCurrent = -1; + +function openFindBar(withReplace = true) { + if (!findBar) return; + findBar.classList.remove('hidden'); + replRow?.classList.toggle('hidden', !withReplace); + findInput?.focus(); + if (findInput?.value) runFind(); +} + +function closeFindBar() { + findBar?.classList.add('hidden'); + findMatches = []; + findCurrent = -1; + if (findCount) findCount.textContent = ''; + body.focus(); +} + +function runFind() { + const needle = findInput?.value; + findMatches = []; + findCurrent = -1; + if (!needle) { if (findCount) findCount.textContent = ''; return; } + const text = body.value; + const lower = text.toLowerCase(); + const nLower = needle.toLowerCase(); + let pos = 0; + while (true) { + const idx = lower.indexOf(nLower, pos); + if (idx === -1) break; + findMatches.push(idx); + pos = idx + 1; + } + if (findCount) findCount.textContent = findMatches.length ? `${findMatches.length} found` : 'no match'; + if (findMatches.length) jumpTo(0); +} + +function jumpTo(idx) { + findCurrent = idx; + const pos = findMatches[idx]; + const needle = findInput?.value || ''; + body.focus(); + body.setSelectionRange(pos, pos + needle.length); + if (findCount) findCount.textContent = `${idx + 1} / ${findMatches.length}`; +} + +function findNext() { + if (!findMatches.length) return; + jumpTo((findCurrent + 1) % findMatches.length); +} +function findPrev() { + if (!findMatches.length) return; + jumpTo((findCurrent - 1 + findMatches.length) % findMatches.length); +} + +function replaceOne() { + if (findCurrent < 0 || !findMatches.length) return; + const needle = findInput?.value || ''; + const repl = replInput?.value ?? ''; + const pos = findMatches[findCurrent]; + body.setRangeText(repl, pos, pos + needle.length, 'end'); + updatePreview(); + runFind(); + if (findMatches.length && findCurrent >= findMatches.length) findCurrent = 0; + if (findMatches.length) jumpTo(findCurrent < 0 ? 0 : findCurrent); +} + +function replaceAll() { + const needle = findInput?.value; + const repl = replInput?.value ?? ''; + if (!needle) return; + // Simple case-insensitive global replace via regex + const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + body.value = body.value.replace(new RegExp(escaped, 'gi'), repl); + updatePreview(); + runFind(); +} + +findInput?.addEventListener('input', runFind); +findInput?.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } + if (e.key === 'Escape') { e.preventDefault(); closeFindBar(); } +}); +replInput?.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } + if (e.key === 'Escape') { e.preventDefault(); closeFindBar(); } +}); +$('find-next')?.addEventListener('click', findNext); +$('find-prev')?.addEventListener('click', findPrev); +$('find-close')?.addEventListener('click', closeFindBar); +$('replace-one')?.addEventListener('click', replaceOne); +$('replace-all')?.addEventListener('click', replaceAll); + +// Ctrl+H = find+replace, Ctrl+F = find only (within editor) +document.addEventListener('keydown', e => { + if (!modal.classList.contains('open')) return; + if ((e.key === 'h' || e.key === 'f') && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + // If already open and same mode, toggle off + if (!findBar?.classList.contains('hidden')) { + const wantReplace = e.key === 'h'; + const hasReplace = !replRow?.classList.contains('hidden'); + if (wantReplace === hasReplace) { closeFindBar(); return; } + } + openFindBar(e.key === 'h'); + } +}); + // ── File upload (paste + drag-drop) ── async function uploadFile(file) { diff --git a/public/plugins/tests/board-find-replace.test.js b/public/plugins/tests/board-find-replace.test.js new file mode 100644 index 0000000..a022c57 --- /dev/null +++ b/public/plugins/tests/board-find-replace.test.js @@ -0,0 +1,135 @@ +import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4'; + +async function makeCard(title, body) { + return fetch('api/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ title, body, color: '#38bdf8' }), + }).then(r => r.json()); +} + +async function getCard(id) { + const all = await fetch(`api/cards?ids=${id}`).then(r => r.json()); + return all.find(c => c.id == id); +} + +function openBoardFR() { + // Ctrl+Shift+H opens the board find/replace overlay + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'H', ctrlKey: true, shiftKey: true, bubbles: true, + })); +} + +describe('Board: Find/Replace', () => { + afterEach(async () => { + document.getElementById('board-find-replace')?.remove(); + await closeAll(); + }); + + it('Ctrl+Shift+H opens the overlay', async () => { + openBoardFR(); + // Wait for the overlay (it does an async api('api/cards') fetch) + for (let i = 0; i < 40; i++) { + if (document.getElementById('board-find-replace')) break; + await sleep(50); + } + expect(document.getElementById('board-find-replace')).toExist(); + expect(document.getElementById('bfr-find')).toExist(); + expect(document.getElementById('bfr-replace')).toExist(); + }); + + it('typing in find shows matching cards', async () => { + const c1 = await makeCard('__bfr_match_a__', 'the quick brown fox'); + const c2 = await makeCard('__bfr_match_b__', 'the quick red fox'); + const c3 = await makeCard('__bfr_nomatch__', 'no match here'); + await window._reloadAll?.(); + await sleep(200); + + openBoardFR(); + for (let i = 0; i < 40; i++) { + if (document.getElementById('bfr-find')) break; + await sleep(50); + } + + const findInp = document.getElementById('bfr-find'); + if (!findInp) return; + findInp.value = 'quick'; + findInp.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(100); + + const status = document.getElementById('bfr-status'); + // Should find 'quick' in at least 2 cards (c1 + c2) + expect(status.textContent).toContain('2'); + // The list should mention both matching card titles + const list = document.getElementById('bfr-list'); + expect(list.textContent).toContain('__bfr_match_a__'); + expect(list.textContent).toContain('__bfr_match_b__'); + // Non-matching card should NOT be in the list + expect(list.textContent.includes('__bfr_nomatch__')).toBe(false); + }); + + it('replace all updates card bodies after confirmation', async () => { + const c1 = await makeCard('__bfr_repl_a__', 'hello world hello'); + const c2 = await makeCard('__bfr_repl_b__', 'hello there'); + await window._reloadAll?.(); + await sleep(200); + + openBoardFR(); + for (let i = 0; i < 40; i++) { + if (document.getElementById('bfr-find')) break; + await sleep(50); + } + + const findInp = document.getElementById('bfr-find'); + const replInp = document.getElementById('bfr-replace'); + if (!findInp || !replInp) return; + + findInp.value = 'hello'; + findInp.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(50); + replInp.value = 'goodbye'; + replInp.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(50); + + const applyBtn = document.getElementById('bfr-apply'); + expect(applyBtn.disabled).toBe(false); + click(applyBtn); + await sleep(200); + + // The confirmation dialog should appear + const dialog = document.getElementById('dialog'); + if (dialog?.classList.contains('open')) { + const okBtn = document.getElementById('dialog-ok'); + if (okBtn) click(okBtn); + await sleep(600); + } + + // Verify card bodies were updated + const updated1 = await getCard(c1.id); + const updated2 = await getCard(c2.id); + expect(updated1.body).toBe('goodbye world goodbye'); + expect(updated2.body).toBe('goodbye there'); + }); + + it('replace all button disabled without replace text', async () => { + await makeCard('__bfr_dis__', 'some text'); + await window._reloadAll?.(); + await sleep(200); + + openBoardFR(); + for (let i = 0; i < 40; i++) { + if (document.getElementById('bfr-find')) break; + await sleep(50); + } + + const findInp = document.getElementById('bfr-find'); + if (!findInp) return; + findInp.value = 'some'; + findInp.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(50); + + const applyBtn = document.getElementById('bfr-apply'); + // Replace input is empty → button should be disabled + expect(applyBtn.disabled).toBe(true); + }); +}); diff --git a/public/plugins/tests/detail.test.js b/public/plugins/tests/detail.test.js index fef62ec..f8fe6a4 100644 --- a/public/plugins/tests/detail.test.js +++ b/public/plugins/tests/detail.test.js @@ -48,8 +48,12 @@ describe('Detail View', () => { if (!card) return; click(card); await sleep(300); + if (!$('#detail')?.classList.contains('open')) return; pressKey('Escape'); - await sleep(150); + for (let i = 0; i < 20; i++) { + if (!$('#detail')?.classList.contains('open')) break; + await sleep(50); + } expect($('#detail')).toBeHidden(); }); diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index 111cd90..b921fab 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -17,6 +17,7 @@ const FILES = [ './context-menu.test.js', './card-interactions.test.js', './card-join.test.js', + './board-find-replace.test.js', './filtering.test.js', './sorting.test.js', './state-functions.test.js',