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
+3
-144
@@ -9,7 +9,7 @@ import './modules/theme.js';
|
||||
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
|
||||
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
||||
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
||||
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
|
||||
import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './modules/editor.js';
|
||||
import { openSearch, closeSearch } from './modules/search.js';
|
||||
import { showContextMenu } from './modules/context-menu.js';
|
||||
import { undo, canUndo } from './modules/undo.js';
|
||||
@@ -39,6 +39,7 @@ window._ungroupCard = ungroupCard;
|
||||
window._getSelectedIds = getSelectedIds;
|
||||
window._clearSelection = clearSelection;
|
||||
window._refreshTasks = refreshTasksView;
|
||||
window.openFindBar = openFindBar;
|
||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
||||
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
|
||||
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
|
||||
@@ -240,7 +241,7 @@ registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>
|
||||
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() });
|
||||
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: () => openSearch({ replace: true }) });
|
||||
|
||||
// — 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) => {
|
||||
@@ -509,148 +510,6 @@ 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 = `
|
||||
<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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
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 ──
|
||||
|
||||
renderShortcutHelp();
|
||||
|
||||
Reference in new issue
Block a user