-
-
+
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 = `
+
+
+
+ `;
+ 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',