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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.9.22');
|
define('APP_VERSION', '1.0.0');
|
||||||
|
|
||||||
// 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');
|
||||||
|
|||||||
@@ -907,6 +907,7 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
.search-hit { scroll-margin: 8px; }
|
.search-hit { scroll-margin: 8px; }
|
||||||
.search-hit.active { background: var(--t-raised); }
|
.search-hit.active { background: var(--t-raised); }
|
||||||
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
||||||
|
#search-replace-toggle.active { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
||||||
|
|||||||
+22
-7
@@ -1888,13 +1888,26 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
<div class="flex flex-col border-b border-t-border">
|
||||||
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
<div class="flex items-center gap-3 px-4 py-3">
|
||||||
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
||||||
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
||||||
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
||||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="toggle replace">
|
||||||
</button>
|
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
|
||||||
|
</button>
|
||||||
|
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="search-replace-row" class="hidden flex items-center gap-3 px-4 pb-3">
|
||||||
|
<i class="ph ph-swap text-t-dim text-base"></i>
|
||||||
|
<input type="text" id="search-replace-input" placeholder="replace with..." class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1.5 font-mono focus:border-t-accent focus:outline-none rounded-sm" autocomplete="off" spellcheck="false">
|
||||||
|
<span id="search-replace-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<button id="search-replace-all" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors font-pixel text-[10px]" disabled>
|
||||||
|
<i class="ph ph-swap text-sm"></i> Replace all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 min-h-0">
|
<div class="flex flex-1 min-h-0">
|
||||||
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
|
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
|
||||||
@@ -2148,6 +2161,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||||
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
|
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
|
||||||
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
|
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
|
||||||
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
|
<button id="md-find-replace" title="Find & replace (Ctrl+H)" class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
|
||||||
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||||
|
|||||||
+3
-144
@@ -9,7 +9,7 @@ import './modules/theme.js';
|
|||||||
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
|
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
|
||||||
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
||||||
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.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 { openSearch, closeSearch } from './modules/search.js';
|
||||||
import { showContextMenu } from './modules/context-menu.js';
|
import { showContextMenu } from './modules/context-menu.js';
|
||||||
import { undo, canUndo } from './modules/undo.js';
|
import { undo, canUndo } from './modules/undo.js';
|
||||||
@@ -39,6 +39,7 @@ window._ungroupCard = ungroupCard;
|
|||||||
window._getSelectedIds = getSelectedIds;
|
window._getSelectedIds = getSelectedIds;
|
||||||
window._clearSelection = clearSelection;
|
window._clearSelection = clearSelection;
|
||||||
window._refreshTasks = refreshTasksView;
|
window._refreshTasks = refreshTasksView;
|
||||||
|
window.openFindBar = openFindBar;
|
||||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
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
|
// 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).
|
// 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: '?', 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: '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: '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 —
|
// — 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) => {
|
||||||
@@ -509,148 +510,6 @@ 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, '&').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 ──
|
// ── Boot ──
|
||||||
|
|
||||||
renderShortcutHelp();
|
renderShortcutHelp();
|
||||||
|
|||||||
@@ -1001,7 +1001,7 @@ const replRow = $('replace-row');
|
|||||||
let findMatches = [];
|
let findMatches = [];
|
||||||
let findCurrent = -1;
|
let findCurrent = -1;
|
||||||
|
|
||||||
function openFindBar(withReplace = true) {
|
export function openFindBar(withReplace = true) {
|
||||||
if (!findBar) return;
|
if (!findBar) return;
|
||||||
findBar.classList.remove('hidden');
|
findBar.classList.remove('hidden');
|
||||||
replRow?.classList.toggle('hidden', !withReplace);
|
replRow?.classList.toggle('hidden', !withReplace);
|
||||||
|
|||||||
+92
-10
@@ -3,6 +3,7 @@
|
|||||||
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||||
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
||||||
import { renderMd, stripMd } from './markdown.js';
|
import { renderMd, stripMd } from './markdown.js';
|
||||||
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
const overlay = $('search');
|
const overlay = $('search');
|
||||||
const input = $('search-input');
|
const input = $('search-input');
|
||||||
@@ -11,20 +12,59 @@ const footer = $('search-footer');
|
|||||||
const previewEmpty = $('search-preview-empty');
|
const previewEmpty = $('search-preview-empty');
|
||||||
const previewContent = $('search-preview-content');
|
const previewContent = $('search-preview-content');
|
||||||
const bucketFilter = $('search-bucket-filter');
|
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?.();
|
window._closeTasks?.();
|
||||||
input.value = '';
|
input.value = '';
|
||||||
results.innerHTML = '';
|
results.innerHTML = '';
|
||||||
footer.classList.add('hidden');
|
footer.classList.add('hidden');
|
||||||
active = -1;
|
active = -1;
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
|
setReplaceMode(replace);
|
||||||
input.focus();
|
input.focus();
|
||||||
clearPreview();
|
clearPreview();
|
||||||
renderHome();
|
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() {
|
export function closeSearch() {
|
||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
if (document.activeElement && document.activeElement !== document.body) {
|
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 clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
||||||
|
|
||||||
function getPool() {
|
function getPool() {
|
||||||
|
const cards = (replaceMode && freshCards) ? freshCards : state.cards;
|
||||||
const bv = bucketFilter.value;
|
const bv = bucketFilter.value;
|
||||||
if (!bv) return state.cards;
|
if (!bv) return cards;
|
||||||
if (bv === 'none') return state.cards.filter(c => !c.bucket_id);
|
if (bv === 'none') return cards.filter(c => !c.bucket_id);
|
||||||
return state.cards.filter(c => c.bucket_id == bv);
|
return cards.filter(c => c.bucket_id == bv);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
@@ -90,7 +131,7 @@ function renderHome() {
|
|||||||
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
||||||
hits = recent; active = recent.length ? 0 : -1;
|
hits = recent; active = recent.length ? 0 : -1;
|
||||||
footer.classList.remove('hidden');
|
footer.classList.remove('hidden');
|
||||||
bindHits(); updateActive(); updatePreview();
|
bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(query) {
|
function search(query) {
|
||||||
@@ -115,10 +156,10 @@ function search(query) {
|
|||||||
// Tag-only search (no words)
|
// Tag-only search (no words)
|
||||||
if (!words.length) {
|
if (!words.length) {
|
||||||
hits = pool; active = pool.length ? 0 : -1;
|
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>` +
|
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('');
|
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 => {
|
const scored = pool.map(c => {
|
||||||
@@ -146,10 +187,10 @@ function search(query) {
|
|||||||
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
|
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>` +
|
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('');
|
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) {
|
function hitHtml(card, idx, words) {
|
||||||
@@ -251,3 +292,44 @@ input.addEventListener('keydown', e => {
|
|||||||
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
||||||
$('search-btn')?.addEventListener('click', openSearch);
|
$('search-btn')?.addEventListener('click', openSearch);
|
||||||
$('search-close')?.addEventListener('click', closeSearch);
|
$('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);
|
||||||
|
});
|
||||||
@@ -13,59 +13,37 @@ async function getCard(id) {
|
|||||||
return all.find(c => c.id == id);
|
return all.find(c => c.id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBoardFR() {
|
function openSearchReplace() {
|
||||||
// Ctrl+Shift+H opens the board find/replace overlay
|
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
|
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Board: Find/Replace', () => {
|
describe('Search: Find/Replace', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
document.getElementById('board-find-replace')?.remove();
|
|
||||||
await closeAll();
|
await closeAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Ctrl+Shift+H opens the overlay', async () => {
|
it('Ctrl+Shift+H opens search with replace row visible', async () => {
|
||||||
openBoardFR();
|
openSearchReplace();
|
||||||
// Wait for the overlay (it does an async api('api/cards') fetch)
|
await sleep(200);
|
||||||
for (let i = 0; i < 40; i++) {
|
const overlay = document.getElementById('search');
|
||||||
if (document.getElementById('board-find-replace')) break;
|
expect(overlay?.classList.contains('open')).toBe(true);
|
||||||
await sleep(50);
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
||||||
}
|
expect(document.getElementById('search-replace-input')).toExist();
|
||||||
expect(document.getElementById('board-find-replace')).toExist();
|
expect(document.getElementById('search-replace-all')).toExist();
|
||||||
expect(document.getElementById('bfr-find')).toExist();
|
|
||||||
expect(document.getElementById('bfr-replace')).toExist();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('typing in find shows matching cards', async () => {
|
it('replace toggle shows/hides replace row', async () => {
|
||||||
const c1 = await makeCard('__bfr_match_a__', 'the quick brown fox');
|
openSearchReplace();
|
||||||
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);
|
await sleep(200);
|
||||||
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
||||||
openBoardFR();
|
click('#search-replace-toggle');
|
||||||
for (let i = 0; i < 40; i++) {
|
await sleep(50);
|
||||||
if (document.getElementById('bfr-find')) break;
|
expect(document.getElementById('search-replace-row')?.classList.contains('hidden')).toBe(true);
|
||||||
await sleep(50);
|
click('#search-replace-toggle');
|
||||||
}
|
await sleep(50);
|
||||||
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
||||||
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 () => {
|
it('replace all updates card bodies after confirmation', async () => {
|
||||||
@@ -74,29 +52,26 @@ describe('Board: Find/Replace', () => {
|
|||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
openBoardFR();
|
openSearchReplace();
|
||||||
for (let i = 0; i < 40; i++) {
|
await sleep(200);
|
||||||
if (document.getElementById('bfr-find')) break;
|
|
||||||
await sleep(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
const findInp = document.getElementById('bfr-find');
|
const searchInput = document.getElementById('search-input');
|
||||||
const replInp = document.getElementById('bfr-replace');
|
const replInput = document.getElementById('search-replace-input');
|
||||||
if (!findInp || !replInp) return;
|
if (!searchInput || !replInput) return;
|
||||||
|
|
||||||
findInp.value = 'hello';
|
searchInput.value = 'hello';
|
||||||
findInp.dispatchEvent(new Event('input', { bubbles: true }));
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
await sleep(50);
|
await sleep(200);
|
||||||
replInp.value = 'goodbye';
|
|
||||||
replInp.dispatchEvent(new Event('input', { bubbles: true }));
|
replInput.value = 'goodbye';
|
||||||
|
replInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
|
|
||||||
const applyBtn = document.getElementById('bfr-apply');
|
const applyBtn = document.getElementById('search-replace-all');
|
||||||
expect(applyBtn.disabled).toBe(false);
|
expect(applyBtn.disabled).toBe(false);
|
||||||
click(applyBtn);
|
click(applyBtn);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
// The confirmation dialog should appear
|
|
||||||
const dialog = document.getElementById('dialog');
|
const dialog = document.getElementById('dialog');
|
||||||
if (dialog?.classList.contains('open')) {
|
if (dialog?.classList.contains('open')) {
|
||||||
const okBtn = document.getElementById('dialog-ok');
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
@@ -104,7 +79,6 @@ describe('Board: Find/Replace', () => {
|
|||||||
await sleep(600);
|
await sleep(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify card bodies were updated
|
|
||||||
const updated1 = await getCard(c1.id);
|
const updated1 = await getCard(c1.id);
|
||||||
const updated2 = await getCard(c2.id);
|
const updated2 = await getCard(c2.id);
|
||||||
expect(updated1.body).toBe('goodbye world goodbye');
|
expect(updated1.body).toBe('goodbye world goodbye');
|
||||||
@@ -116,20 +90,16 @@ describe('Board: Find/Replace', () => {
|
|||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
openBoardFR();
|
openSearchReplace();
|
||||||
for (let i = 0; i < 40; i++) {
|
await sleep(200);
|
||||||
if (document.getElementById('bfr-find')) break;
|
|
||||||
await sleep(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
const findInp = document.getElementById('bfr-find');
|
const searchInput = document.getElementById('search-input');
|
||||||
if (!findInp) return;
|
if (!searchInput) return;
|
||||||
findInp.value = 'some';
|
searchInput.value = 'some';
|
||||||
findInp.dispatchEvent(new Event('input', { bubbles: true }));
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
await sleep(50);
|
await sleep(100);
|
||||||
|
|
||||||
const applyBtn = document.getElementById('bfr-apply');
|
const applyBtn = document.getElementById('search-replace-all');
|
||||||
// Replace input is empty → button should be disabled
|
|
||||||
expect(applyBtn.disabled).toBe(true);
|
expect(applyBtn.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user