find/replace: editor (Ctrl+H/F) + board-wide (Ctrl+Shift+H)

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 <mark>
  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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 12:23:18 +02:00
1 parent 3eb5202aac
commit 3078906548
7 files changed
+427 -5

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '0.9.20'); define('APP_VERSION', '0.9.21');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
define('DB_PATH', __DIR__ . '/data/hyprcrd.db'); define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
+22 -3
View File
@@ -2155,9 +2155,28 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
<div class="flex flex-1 min-h-0 overflow-hidden"> <div class="flex flex-1 min-h-0 overflow-hidden">
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div> <div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden" style="flex:1 1 0%;min-width:0;"> <div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden flex flex-col" style="flex:1 1 0%;min-width:0;">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div> <!-- Find / Replace bar (Ctrl+H) -->
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea> <div id="editor-find-bar" class="hidden shrink-0 border-b border-t-border bg-t-surface px-3 py-2" style="z-index:5;">
<div class="flex items-center gap-2 mb-1.5">
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
<button id="find-prev" class="toolbar-btn" title="previous"><i class="ph ph-caret-up text-xs"></i></button>
<button id="find-next" class="toolbar-btn" title="next"><i class="ph ph-caret-down text-xs"></i></button>
<button id="find-close" class="toolbar-btn" title="close"><i class="ph ph-x text-xs"></i></button>
</div>
<div id="replace-row" class="flex items-center gap-2">
<i class="ph ph-swap text-xs text-t-dim"></i>
<input id="replace-input" type="text" placeholder="replace…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
<button id="replace-one" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Replace</button>
<button id="replace-all" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">All</button>
</div>
</div>
<div class="relative flex-1 min-h-0">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
</div>
</div> </div>
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div> <div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div> <div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
+145
View File
@@ -238,6 +238,9 @@ registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' }); registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' }); registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' }); registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>H</kbd>', label: 'find + replace' });
registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>F</kbd>', label: 'find' });
registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>H</kbd>', label: 'find + replace across cards', handler: () => openBoardFindReplace() });
// — Detail view — // — Detail view —
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => { registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => {
@@ -506,6 +509,148 @@ function openTagQuickPick() {
setTimeout(() => input.focus(), 0); 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 = `
<div style="padding:12px 14px;border-bottom:1px solid var(--t-border);display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<i class="ph ph-magnifying-glass text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">find + replace across all cards</span>
</div>
<div style="display:flex;gap:8px;">
<input id="bfr-find" type="text" placeholder="find…" style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:7px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
<input id="bfr-replace" type="text" placeholder="replace with…" style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:7px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
</div>
</div>
<div id="bfr-list" style="flex:1;overflow-y:auto;padding:6px 0;"></div>
<div id="bfr-footer" style="padding:10px 14px;border-top:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
<span id="bfr-status" class="font-pixel text-[9px] text-t-dim flex-1"></span>
<button id="bfr-apply" class="font-pixel text-[10px] px-3 py-1.5 border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" disabled>Replace all</button>
<button id="bfr-close" class="font-pixel text-[10px] px-3 py-1.5 border border-t-border text-t-muted hover:text-t-bright transition-colors">Close</button>
</div>
`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
let matches = [];
function search() {
const needle = findInp.value;
matches = [];
if (!needle) {
list.innerHTML = '<div style="padding:24px 14px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">type to search across all card bodies</div>';
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 = '<div style="padding:24px 14px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no matches found</div>';
return;
}
list.innerHTML = matches.map(m => `
<div style="padding:6px 14px;display:flex;align-items:flex-start;gap:10px;border-bottom:1px solid var(--t-border);">
<span style="width:6px;height:6px;border-radius:50%;background:${m.card.color || '#64748b'};flex-shrink:0;margin-top:5px;"></span>
<div style="flex:1;min-width:0;">
<div class="font-pixel text-[10px] text-t-bright" style="margin-bottom:2px;">${escHtml(m.card.title)} <span class="text-t-dim">(${m.count}×)</span></div>
<div style="font-size:11px;color:var(--t-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">…${escHtml(m.context.before)}<mark style="background:color-mix(in srgb, var(--t-accent) 30%, transparent);color:var(--t-bright);padding:0 2px;border-radius:2px;">${escHtml(m.context.match)}</mark>${escHtml(m.context.after)}…</div>
</div>
</div>
`).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 = '<div style="padding:24px 14px;text-align:center;font-size:11px;color:var(--t-accent);">✓ replaced</div>';
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 ── // ── Boot ──
renderShortcutHelp(); renderShortcutHelp();
+118
View File
@@ -362,6 +362,7 @@ export function closeModal({ animate = true } = {}) {
hideLinkAc(); hideLinkAc();
hideTagAc(); hideTagAc();
hideSnippetAc(); hideSnippetAc();
closeFindBar();
modal.classList.remove('open'); modal.classList.remove('open');
state.editingId = null; state.editingId = null;
state.returnToGalleryAfterEdit = false; state.returnToGalleryAfterEdit = false;
@@ -989,6 +990,123 @@ body.addEventListener('keydown', e => {
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); }); 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) ── // ── File upload (paste + drag-drop) ──
async function uploadFile(file) { async function uploadFile(file) {
@@ -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);
});
});
+5 -1
View File
@@ -48,8 +48,12 @@ describe('Detail View', () => {
if (!card) return; if (!card) return;
click(card); click(card);
await sleep(300); await sleep(300);
if (!$('#detail')?.classList.contains('open')) return;
pressKey('Escape'); pressKey('Escape');
await sleep(150); for (let i = 0; i < 20; i++) {
if (!$('#detail')?.classList.contains('open')) break;
await sleep(50);
}
expect($('#detail')).toBeHidden(); expect($('#detail')).toBeHidden();
}); });
+1
View File
@@ -17,6 +17,7 @@ const FILES = [
'./context-menu.test.js', './context-menu.test.js',
'./card-interactions.test.js', './card-interactions.test.js',
'./card-join.test.js', './card-join.test.js',
'./board-find-replace.test.js',
'./filtering.test.js', './filtering.test.js',
'./sorting.test.js', './sorting.test.js',
'./state-functions.test.js', './state-functions.test.js',