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:
1 parent
3eb5202aac
commit
3078906548
7 files changed
+427
-5
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user