search: integrate find/replace into search overlay, add toolbar buttons
Moves board-wide find/replace from standalone overlay into the search panel as a toggleable replace row. Adds editor toolbar find/replace button. Ctrl+Shift+H now opens search in replace mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
838dfe2a6e
commit
d1b26e625a
7 files changed
+159
-232
No files matched your search
+92
-10
@@ -3,6 +3,7 @@
|
||||
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
||||
import { renderMd, stripMd } from './markdown.js';
|
||||
import { showDialog } from './dialog.js';
|
||||
|
||||
const overlay = $('search');
|
||||
const input = $('search-input');
|
||||
@@ -11,20 +12,59 @@ const footer = $('search-footer');
|
||||
const previewEmpty = $('search-preview-empty');
|
||||
const previewContent = $('search-preview-content');
|
||||
const bucketFilter = $('search-bucket-filter');
|
||||
let active = -1, hits = [];
|
||||
const replaceToggle = $('search-replace-toggle');
|
||||
const replaceRow = $('search-replace-row');
|
||||
const replaceInput = $('search-replace-input');
|
||||
const replaceStatus = $('search-replace-status');
|
||||
const replaceAllBtn = $('search-replace-all');
|
||||
let active = -1, hits = [], replaceMode = false, freshCards = null;
|
||||
|
||||
export function openSearch() {
|
||||
export function openSearch({ replace = false } = {}) {
|
||||
window._closeTasks?.();
|
||||
input.value = '';
|
||||
results.innerHTML = '';
|
||||
footer.classList.add('hidden');
|
||||
active = -1;
|
||||
overlay.classList.add('open');
|
||||
setReplaceMode(replace);
|
||||
input.focus();
|
||||
clearPreview();
|
||||
renderHome();
|
||||
}
|
||||
|
||||
async function setReplaceMode(on) {
|
||||
replaceMode = on;
|
||||
replaceRow?.classList.toggle('hidden', !on);
|
||||
replaceToggle?.classList.toggle('active', on);
|
||||
if (on) {
|
||||
replaceInput.value = '';
|
||||
freshCards = await api('api/cards') || [];
|
||||
} else {
|
||||
freshCards = null;
|
||||
}
|
||||
updateReplaceState();
|
||||
}
|
||||
|
||||
function updateReplaceState() {
|
||||
if (!replaceMode || !replaceAllBtn) return;
|
||||
const q = input.value.trim();
|
||||
const hasRepl = replaceInput?.value.length > 0;
|
||||
const hasMatch = hits.length > 0 && q.length > 0;
|
||||
replaceAllBtn.disabled = !(hasMatch && hasRepl);
|
||||
if (q && hits.length) {
|
||||
let total = 0;
|
||||
const nLower = q.toLowerCase();
|
||||
for (const c of hits) {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
let pos = 0;
|
||||
while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; total++; pos = i + 1; }
|
||||
}
|
||||
replaceStatus.textContent = total ? `${total} in ${hits.length} card${hits.length !== 1 ? 's' : ''}` : '';
|
||||
} else {
|
||||
replaceStatus.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSearch() {
|
||||
overlay.classList.remove('open');
|
||||
if (document.activeElement && document.activeElement !== document.body) {
|
||||
@@ -35,10 +75,11 @@ export function closeSearch() {
|
||||
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
||||
|
||||
function getPool() {
|
||||
const cards = (replaceMode && freshCards) ? freshCards : state.cards;
|
||||
const bv = bucketFilter.value;
|
||||
if (!bv) return state.cards;
|
||||
if (bv === 'none') return state.cards.filter(c => !c.bucket_id);
|
||||
return state.cards.filter(c => c.bucket_id == bv);
|
||||
if (!bv) return cards;
|
||||
if (bv === 'none') return cards.filter(c => !c.bucket_id);
|
||||
return cards.filter(c => c.bucket_id == bv);
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
@@ -90,7 +131,7 @@ function renderHome() {
|
||||
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
||||
hits = recent; active = recent.length ? 0 : -1;
|
||||
footer.classList.remove('hidden');
|
||||
bindHits(); updateActive(); updatePreview();
|
||||
bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||
}
|
||||
|
||||
function search(query) {
|
||||
@@ -115,10 +156,10 @@ function search(query) {
|
||||
// Tag-only search (no words)
|
||||
if (!words.length) {
|
||||
hits = pool; active = pool.length ? 0 : -1;
|
||||
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
||||
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
|
||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${pool.length} tagged</span></div>` +
|
||||
pool.map((c, i) => hitHtml(c, i, null)).join('');
|
||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return;
|
||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState(); return;
|
||||
}
|
||||
|
||||
const scored = pool.map(c => {
|
||||
@@ -146,10 +187,10 @@ function search(query) {
|
||||
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||
|
||||
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
|
||||
if (!scored.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
||||
if (!scored.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
|
||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>` +
|
||||
scored.map((s, i) => hitHtml(s.card, i, words)).join('');
|
||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview();
|
||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||
}
|
||||
|
||||
function hitHtml(card, idx, words) {
|
||||
@@ -251,3 +292,44 @@ input.addEventListener('keydown', e => {
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
||||
$('search-btn')?.addEventListener('click', openSearch);
|
||||
$('search-close')?.addEventListener('click', closeSearch);
|
||||
replaceToggle?.addEventListener('click', () => setReplaceMode(!replaceMode));
|
||||
replaceInput?.addEventListener('input', updateReplaceState);
|
||||
|
||||
replaceAllBtn?.addEventListener('click', async () => {
|
||||
const needle = input.value.trim();
|
||||
const repl = replaceInput.value;
|
||||
if (!needle || !hits.length) return;
|
||||
let totalOcc = 0;
|
||||
const nLower = needle.toLowerCase();
|
||||
for (const c of hits) {
|
||||
let pos = 0;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; totalOcc++; pos = i + 1; }
|
||||
}
|
||||
const ok = await showDialog({
|
||||
title: 'replace_all',
|
||||
message: `Replace ${totalOcc} occurrence${totalOcc !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${hits.length} card${hits.length !== 1 ? 's' : ''}?`,
|
||||
danger: true, okLabel: `Replace ${totalOcc}`,
|
||||
});
|
||||
if (!ok) return;
|
||||
const freshCards = await api('api/cards') || [];
|
||||
const freshMap = new Map(freshCards.map(c => [c.id, c]));
|
||||
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(escaped, 'gi');
|
||||
let patched = 0;
|
||||
for (const c of hits) {
|
||||
const fresh = freshMap.get(c.id);
|
||||
const body = fresh?.body || c.body || '';
|
||||
const newBody = body.replace(re, repl);
|
||||
if (newBody !== body) {
|
||||
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body: newBody } });
|
||||
patched++;
|
||||
}
|
||||
}
|
||||
replaceStatus.textContent = `done — ${patched} card${patched !== 1 ? 's' : ''} updated`;
|
||||
replaceStatus.style.color = 'var(--t-accent)';
|
||||
replaceAllBtn.disabled = true;
|
||||
await window._reloadAll();
|
||||
search(input.value);
|
||||
setTimeout(() => { replaceStatus.style.color = ''; }, 3000);
|
||||
});
|
||||
Reference in new issue
Block a user