Drag was the only way to move a search hit into a bucket, which meant opening the main board to find a drop target first. Right-click on a search-hit now opens a small bucket picker anchored at the cursor: clicking a bucket PATCHes the card's bucket_id and re-runs the current search so the row updates in place. Includes an "(unsorted)" entry that clears the bucket. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
406 lines
19 KiB
JavaScript
406 lines
19 KiB
JavaScript
// ── Search ──
|
|
|
|
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');
|
|
const results = $('search-results');
|
|
const footer = $('search-footer');
|
|
const previewEmpty = $('search-preview-empty');
|
|
const previewContent = $('search-preview-content');
|
|
const bucketFilter = $('search-bucket-filter');
|
|
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({ 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) {
|
|
document.activeElement.blur();
|
|
}
|
|
}
|
|
|
|
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 cards;
|
|
if (bv === 'none') return cards.filter(c => !c.bucket_id);
|
|
return cards.filter(c => c.bucket_id == bv);
|
|
}
|
|
|
|
function updatePreview() {
|
|
const card = hits[active];
|
|
if (!card) { clearPreview(); return; }
|
|
previewEmpty.classList.add('hidden');
|
|
previewContent.classList.remove('hidden');
|
|
$('search-preview-color').style.background = card.color;
|
|
const q = input.value.trim();
|
|
const words = q ? q.toLowerCase().split(/\s+/).filter(Boolean) : [];
|
|
$('search-preview-title').innerHTML = words.length ? highlight(esc(card.title), words) : esc(card.title);
|
|
let html = renderMd(card.body);
|
|
if (words.length) {
|
|
for (const w of words) html = html.replace(new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'), '$1$2<mark>$3</mark>');
|
|
}
|
|
$('search-preview-body').innerHTML = html;
|
|
|
|
const ptags = parseTags(card);
|
|
const ptagEl = $('search-preview-tags');
|
|
if (ptags.length) {
|
|
ptagEl.classList.remove('hidden');
|
|
ptagEl.innerHTML = ptags.map(t => tagChipHtml(t)).join('');
|
|
ptagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
|
|
seg.addEventListener('click', e => {
|
|
closeSearch();
|
|
const tag = seg.dataset.tag;
|
|
if (e.ctrlKey || e.metaKey) {
|
|
const idx = state.activeTags.indexOf(tag);
|
|
if (idx >= 0) state.activeTags.splice(idx, 1);
|
|
else state.activeTags.push(tag);
|
|
} else {
|
|
state.activeTags = [tag];
|
|
}
|
|
window._renderTagBar?.();
|
|
window._renderBoard();
|
|
});
|
|
});
|
|
} else {
|
|
ptagEl.classList.add('hidden');
|
|
}
|
|
|
|
$('search-preview-meta').textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
|
|
}
|
|
|
|
function renderHome() {
|
|
const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6);
|
|
if (!recent.length) { results.innerHTML = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel cursor-blink">no cards yet</div>`; return; }
|
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>` +
|
|
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
|
hits = recent; active = recent.length ? 0 : -1;
|
|
footer.classList.remove('hidden');
|
|
bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
|
}
|
|
|
|
function search(query) {
|
|
const q = query.toLowerCase().trim();
|
|
if (!q) { renderHome(); return; }
|
|
|
|
// Split into #tag filters and regular words
|
|
const parts = q.split(/\s+/).filter(Boolean);
|
|
const tagFilters = parts.filter(p => p.startsWith('#') && p.length > 1).map(p => p.substring(1));
|
|
const words = parts.filter(p => !p.startsWith('#'));
|
|
const textQ = words.join(' ');
|
|
|
|
// Pre-filter by tags (must match ALL)
|
|
let pool = getPool();
|
|
if (tagFilters.length) {
|
|
pool = pool.filter(c => {
|
|
const ct = (c.tags || '').toLowerCase().split(',');
|
|
return tagFilters.every(tf => ct.some(t => t === tf || t.startsWith(tf + '/') || t.includes(tf)));
|
|
});
|
|
}
|
|
|
|
// 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(); 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(); updateReplaceState(); return;
|
|
}
|
|
|
|
const scored = pool.map(c => {
|
|
const title = (c.title || '').toLowerCase(), body = stripMd(c.body || '').toLowerCase();
|
|
const tags = (c.tags || '').toLowerCase();
|
|
let score = tagFilters.length ? 50 : 0, matched = tagFilters.length > 0;
|
|
if (title.includes(textQ)) { score += 150; matched = true; }
|
|
if (body.includes(textQ)) { score += 40 + countSubstring(body, textQ) * 10; matched = true; }
|
|
if (tags.includes(textQ)) { score += 60; matched = true; }
|
|
let allWords = true;
|
|
for (const w of words) {
|
|
const inT = title.includes(w), inB = body.includes(w);
|
|
if (inT) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; }
|
|
if (inB) score += 8 + countSubstring(body, w) * 3;
|
|
if (tags.includes(w)) score += 25;
|
|
if (!inT && !inB) allWords = false;
|
|
}
|
|
if (allWords) { score += 50; matched = true; }
|
|
if (!matched) {
|
|
const tf = fuzzyMatch(textQ, title);
|
|
if (tf !== null) { score += 15 + tf * 2; matched = true; }
|
|
}
|
|
if (!matched) return null;
|
|
return { card: c, score: score - title.length * 0.1 };
|
|
}).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(); 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(); updateReplaceState();
|
|
}
|
|
|
|
function hitHtml(card, idx, words) {
|
|
const title = words ? highlight(esc(card.title), words) : esc(card.title);
|
|
const plain = stripMd(card.body || '');
|
|
let snippet = '';
|
|
if (words && plain) {
|
|
const lower = plain.toLowerCase(), fullQ = words.join(' '), winSize = 160;
|
|
let best = 0, bestScore = -1;
|
|
for (let i = 0; i < lower.length; i += 10) {
|
|
const win = lower.substring(i, i + winSize);
|
|
let ws = win.includes(fullQ) ? 50 : 0;
|
|
let fh = -1, lh = -1;
|
|
for (const w of words) { let p = 0, h = 0; while ((p = win.indexOf(w, p)) !== -1) { h++; if (fh === -1 || p < fh) fh = p; if (p > lh) lh = p + w.length; p += w.length; } ws += h * 8; }
|
|
if (fh !== -1 && lh !== -1) ws += Math.max(0, 30 - (lh - fh) / 2);
|
|
if (ws > bestScore) { bestScore = ws; best = i; }
|
|
}
|
|
const center = Math.min(best + winSize / 2, lower.length);
|
|
const start = Math.max(0, Math.floor(center - winSize / 2));
|
|
const end = Math.min(plain.length, start + winSize);
|
|
snippet = (start > 0 ? '...' : '') + plain.substring(start, end).trim() + (end < plain.length ? '...' : '');
|
|
snippet = highlight(esc(snippet), words);
|
|
} else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); }
|
|
|
|
const htags = parseTags(card);
|
|
const children = getChildren(card.id);
|
|
const isGroup = children.length > 0;
|
|
const canDrag = !isGroup && !card.parent_id;
|
|
return `<div class="search-hit flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-t-raised" data-idx="${idx}" data-card-id="${card.id}" ${canDrag ? 'draggable="true"' : ''}>
|
|
<span class="w-2 h-2 rounded-full shrink-0 mt-1.5" style="background:${card.color}"></span>
|
|
<div class="min-w-0 flex-1"><div class="text-xs text-t-bright font-pixel leading-relaxed truncate flex items-center gap-2">${title}${isGroup ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:${card.color};color:#000;" class="font-pixel">${children.length + 1}</span>` : ''}</div>
|
|
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div>
|
|
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
|
}
|
|
|
|
function openBucketPicker(cardId, x, y) {
|
|
document.querySelector('.search-bucket-picker')?.remove();
|
|
const picker = document.createElement('div');
|
|
picker.className = 'search-bucket-picker';
|
|
// Clamp to viewport so a click near the bottom-right doesn't flow
|
|
// off-screen — rendered invisibly first to measure, then placed.
|
|
picker.style.cssText = `position:fixed;left:0;top:0;visibility:hidden;z-index:300;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:280px;display:flex;flex-direction:column;`;
|
|
|
|
const list = document.createElement('div');
|
|
list.style.cssText = 'overflow-y:auto;flex:1;';
|
|
|
|
const addRow = (label, color, onClick) => {
|
|
const item = document.createElement('button');
|
|
item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
|
|
item.innerHTML = `<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(label)}</span>`;
|
|
item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; });
|
|
item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; });
|
|
item.addEventListener('click', onClick);
|
|
list.appendChild(item);
|
|
};
|
|
|
|
addRow('(unsorted)', 'transparent', async () => {
|
|
picker.remove();
|
|
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: null } });
|
|
await window._reloadAll();
|
|
search(input.value);
|
|
});
|
|
state.buckets.forEach(b => {
|
|
addRow(b.name, b.color || '#64748b', async () => {
|
|
picker.remove();
|
|
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: b.id } });
|
|
await window._reloadAll();
|
|
search(input.value);
|
|
});
|
|
});
|
|
|
|
picker.appendChild(list);
|
|
document.body.appendChild(picker);
|
|
|
|
// Place, then flip if we'd overflow the viewport.
|
|
const r = picker.getBoundingClientRect();
|
|
let left = x, top = y;
|
|
if (left + r.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - r.width - 8);
|
|
if (top + r.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - r.height - 8);
|
|
picker.style.left = left + 'px';
|
|
picker.style.top = top + 'px';
|
|
picker.style.visibility = '';
|
|
|
|
const onDown = (e) => { if (!picker.contains(e.target)) { picker.remove(); cleanup(); } };
|
|
const onEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } };
|
|
const cleanup = () => {
|
|
document.removeEventListener('mousedown', onDown, true);
|
|
document.removeEventListener('keydown', onEsc, true);
|
|
};
|
|
setTimeout(() => {
|
|
document.addEventListener('mousedown', onDown, true);
|
|
document.addEventListener('keydown', onEsc, true);
|
|
}, 0);
|
|
}
|
|
|
|
function bindHits() {
|
|
results.querySelectorAll('.search-hit').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const i = parseInt(el.dataset.idx);
|
|
if (active === i && hits[i]) window._openDetail(hits[i].id);
|
|
else { active = i; updateActive(); }
|
|
});
|
|
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
|
|
el.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
const id = el.dataset.cardId;
|
|
if (!id) return;
|
|
// Highlight the row so the user sees which card the picker
|
|
// will move — mirrors the active-on-click behaviour.
|
|
const i = parseInt(el.dataset.idx);
|
|
if (!isNaN(i)) { active = i; updateActive(); }
|
|
openBucketPicker(id, e.clientX, e.clientY);
|
|
});
|
|
|
|
// Drag: only non-group, non-child cards
|
|
if (el.draggable) {
|
|
el.addEventListener('dragstart', e => {
|
|
e.dataTransfer.setData('text/plain', el.dataset.cardId);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
el.style.opacity = '0.4';
|
|
});
|
|
el.addEventListener('dragend', () => { el.style.opacity = ''; });
|
|
}
|
|
|
|
// Drop target: any card can receive (to form group)
|
|
el.addEventListener('dragover', e => {
|
|
const dragId = e.dataTransfer.types.includes('text/plain') ? true : false;
|
|
if (!dragId) return;
|
|
if (el.dataset.cardId === e.dataTransfer.getData('text/plain')) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
el.style.background = 'var(--t-raised)';
|
|
el.style.borderLeft = `3px solid var(--t-accent)`;
|
|
});
|
|
el.addEventListener('dragleave', () => {
|
|
el.style.background = '';
|
|
el.style.borderLeft = '';
|
|
});
|
|
el.addEventListener('drop', async e => {
|
|
el.style.background = '';
|
|
el.style.borderLeft = '';
|
|
const dragId = e.dataTransfer.getData('text/plain');
|
|
const targetId = el.dataset.cardId;
|
|
if (!dragId || !targetId || dragId === targetId) return;
|
|
e.preventDefault();
|
|
await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: targetId } });
|
|
await window._reloadAll();
|
|
search(input.value);
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateActive() {
|
|
results.querySelectorAll('.search-hit').forEach((el, i) => {
|
|
el.classList.toggle('active', i === active);
|
|
if (i === active) el.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
updatePreview();
|
|
}
|
|
|
|
input.addEventListener('input', () => search(input.value));
|
|
bucketFilter?.addEventListener('change', () => search(input.value));
|
|
input.addEventListener('keydown', e => {
|
|
const n = results.querySelectorAll('.search-hit').length;
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, n - 1); updateActive(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); updateActive(); }
|
|
else if (e.key === 'Enter' && active >= 0 && hits[active]) { e.preventDefault(); window._openDetail(hits[active].id); }
|
|
});
|
|
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);
|
|
});
|