Sidebar (lazy tree, keyboard navigation, saved searches), page view with typed properties, tasks, tags, calendar and clip dialogs, settings with shortcut rebinding, canvas, database and board views. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
171 lines
8.9 KiB
Svelte
171 lines
8.9 KiB
Svelte
<script>
|
|
import Icon from './Icon.svelte';
|
|
import { store, iconOf } from '$lib/state.svelte.js';
|
|
import { api } from '$lib/api.js';
|
|
import { esc, mark } from '$lib/text.js';
|
|
|
|
// "Search notes for …" from the right-click menu opens the palette with that text
|
|
let q = $state(store.paletteQuery || '');
|
|
store.paletteQuery = '';
|
|
let idx = $state(0);
|
|
let hits = $state([]);
|
|
let closing = $state(false);
|
|
let listEl = $state();
|
|
let timer;
|
|
let seq = 0;
|
|
|
|
// Full-text hits come from the SQLite index; titles are matched locally so results appear instantly.
|
|
$effect(() => {
|
|
const query = q.trim();
|
|
clearTimeout(timer);
|
|
if (!query) { hits = []; return; }
|
|
const mine = ++seq;
|
|
timer = setTimeout(async () => {
|
|
try {
|
|
const r = await api.search(query);
|
|
if (mine === seq) hits = r;
|
|
} catch { /* index unavailable */ }
|
|
}, 90);
|
|
});
|
|
|
|
let ql = $derived(q.trim().toLowerCase());
|
|
let pageItems = $derived.by(() => {
|
|
const out = [];
|
|
const seen = new Set();
|
|
if (!ql) {
|
|
// Recently opened first (minus the page you're on), topped up with other pages.
|
|
const list = store.recentPages.filter((p) => p.id !== store.currentId);
|
|
for (const p of store.pages) if (list.length < 8 && p.exists && p.id !== store.currentId && !list.includes(p)) list.push(p);
|
|
for (const p of list.slice(0, 8)) out.push({ page: p, snip: '' });
|
|
return out;
|
|
}
|
|
for (const p of store.pages) {
|
|
if (p.title.toLowerCase().includes(ql)) { out.push({ page: p, snip: '', rank: p.title.toLowerCase().startsWith(ql) ? 0 : 1 }); seen.add(p.id); }
|
|
}
|
|
out.sort((a, b) => a.rank - b.rank);
|
|
for (const h of hits) {
|
|
const p = store.pages.find((x) => x.id === h.id);
|
|
if (!p) continue;
|
|
if (seen.has(p.id)) { const o = out.find((x) => x.page.id === p.id); if (o) o.snip = h.snip; continue; }
|
|
out.push({ page: p, snip: h.snip });
|
|
seen.add(p.id);
|
|
}
|
|
return out.slice(0, 9);
|
|
});
|
|
|
|
let actions = $derived(
|
|
[
|
|
{ label: 'New page', icon: 'plus', run: () => store.newPage(null) },
|
|
{ label: "Open today's note", icon: 'sun', run: () => store.openDaily() },
|
|
{ label: 'New board', icon: 'board', run: () => store.newBoard(null) },
|
|
{ label: 'New database', icon: 'table', run: () => store.newDatabase(null) },
|
|
{ label: 'Import a CSV file as a new database', icon: 'download', run: () => store.pickCsv(null) },
|
|
...(store.isDatabase ? [
|
|
{ label: 'Import a CSV file into this database', icon: 'download', run: () => store.pickCsv(store.currentId) },
|
|
{ label: 'Export this database as CSV', icon: 'export', run: () => store.exportCsv() }
|
|
] : []),
|
|
{ label: 'New canvas', icon: 'canvas', run: () => store.newCanvas(null) },
|
|
...(store.doc && !store.isDatabase ? [{ label: store.isBoard ? 'Turn this board into a document' : 'Turn this page into a board', icon: store.isBoard ? 'file' : 'board', run: () => store.setBoard(!store.isBoard) }] : []),
|
|
{ label: store.currentId && store.isPinned(store.currentId) ? 'Remove this page from favorites' : 'Add this page to favorites', icon: 'star', run: () => store.togglePin() },
|
|
...(store.doc ? [{ label: 'Show version history', icon: 'clock', run: () => (store.historyOpen = store.currentId) }] : []),
|
|
{ label: 'Open a daily note by date', icon: 'calendar', run: () => (store.calendarOpen = true) },
|
|
{ label: 'Manage tags', icon: 'hash', run: () => (store.tagManagerOpen = true) },
|
|
{ label: 'Clip a web page as a note', icon: 'globe', run: () => (store.clipOpen = true) },
|
|
{ label: 'Import a folder (Obsidian or Notion export)', icon: 'download', run: () => store.importNotes('folder') },
|
|
{ label: 'Import a .zip export (Notion or Obsidian)', icon: 'download', run: () => store.importNotes('zip') },
|
|
{ label: 'Export all notes as a zip', icon: 'export', run: () => store.exportVaultZip() },
|
|
{ label: 'Open tasks', icon: 'check', run: () => (store.tasksOpen = true) },
|
|
{ label: 'Quick capture', icon: 'pen', run: () => api.openCapture?.() },
|
|
{ label: 'Open trash', icon: 'trash', run: () => (store.trashOpen = true) },
|
|
{ label: 'Settings', icon: 'gear', run: () => (store.settingsOpen = true) },
|
|
{ label: 'Open graph view', icon: 'graph', run: () => store.openGraph() },
|
|
...(store.doc && !store.isBoard && !store.isDatabase ? [{ label: store.outlineOpen ? 'Hide outline' : 'Show outline', icon: 'outline', run: () => store.toggleOutline() }] : []),
|
|
{ label: store.theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme', icon: store.theme === 'dark' ? 'sun' : 'moon', run: () => store.applyTheme(store.theme === 'dark' ? 'light' : 'dark') },
|
|
...(store.isCanvas && store.canvasView ? [{ label: 'Find in this canvas', icon: 'search', run: () => setTimeout(() => store.canvasView?.find(), 60) }] : []),
|
|
...(store.isCanvas && store.canvasView
|
|
? ['png', 'svg', 'pdf'].map((k) => ({ label: `Export this canvas as ${k.toUpperCase()}`, icon: 'export', run: () => store.canvasView?.export(k) }))
|
|
: [
|
|
{ label: 'Export this page as Markdown', icon: 'export', run: () => (store.exportFormat = 'md') },
|
|
{ label: 'Export this page as HTML', icon: 'export', run: () => (store.exportFormat = 'html') },
|
|
{ label: 'Export this page as PDF', icon: 'export', run: () => (store.exportFormat = 'pdf') }
|
|
]),
|
|
|
|
{ label: 'Show or hide the sidebar', icon: 'panel', run: () => store.toggleSidebar() },
|
|
{ label: 'Open a different notes folder', icon: 'folder', run: () => store.pick('choose') }
|
|
].filter((a) => !ql || a.label.toLowerCase().includes(ql))
|
|
);
|
|
// With something typed, offer to keep the search in the sidebar (always shown, whatever the text).
|
|
let saveable = $derived(!!ql && !store.isSearchSaved(ql));
|
|
let allActions = $derived(saveable ? [...actions, { label: 'Save this search to the sidebar', icon: 'star', run: () => store.saveSearch(q) }] : actions);
|
|
|
|
let total = $derived(pageItems.length + allActions.length);
|
|
$effect(() => { if (idx >= total) idx = 0; });
|
|
|
|
const snipHtml = (s) =>
|
|
esc(String(s || '').replace(/^---[\s\S]*?---/, '').replace(/[*_`#>]|\[[ xX]\]/g, ''))
|
|
.replace(/\u0001/g, '<mark>')
|
|
.replace(/\u0002/g, '</mark>');
|
|
const where = (p) => store.ancestors(p.id).map((a) => a.title).join(' / ') || store.vault?.name || '';
|
|
|
|
function close() {
|
|
closing = true;
|
|
setTimeout(() => { store.paletteOpen = false; closing = false; q = ''; idx = 0; }, 150);
|
|
}
|
|
function run(i) {
|
|
const fn = i < pageItems.length ? () => store.openPage(pageItems[i].page.id) : allActions[i - pageItems.length]?.run;
|
|
close();
|
|
if (fn) setTimeout(fn, 120);
|
|
}
|
|
function onkeydown(e) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (!total) return;
|
|
idx = (idx + (e.key === 'ArrowDown' ? 1 : -1) + total) % total;
|
|
requestAnimationFrame(() => listEl?.querySelector('.pi.on')?.scrollIntoView({ block: 'nearest' }));
|
|
}
|
|
if (e.key === 'Enter') { e.preventDefault(); run(idx); }
|
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
|
}
|
|
function focusOnMount(el) { el.focus(); }
|
|
</script>
|
|
|
|
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
|
|
<div class="panel" role="dialog" aria-label="Search notes">
|
|
<div class="pal-top">
|
|
<Icon name="search" />
|
|
<input bind:value={q} placeholder="Search notes or type a command" autocomplete="off" spellcheck="false" use:focusOnMount {onkeydown} oninput={() => (idx = 0)} />
|
|
<kbd>Esc</kbd>
|
|
</div>
|
|
<div class="pal-list" bind:this={listEl}>
|
|
{#if pageItems.length}
|
|
<div class="pop-label">{ql ? 'Pages' : 'Recent pages'}</div>
|
|
{#each pageItems as item, i (item.page.id)}
|
|
<button class="pi" class:on={idx === i} onmousemove={() => (idx = i)} onclick={() => run(i)}>
|
|
<span class="row-ico"><Icon name={iconOf(item.page)} /></span>
|
|
<span class="pi-t">
|
|
<b>{@html mark(item.page.title, ql)}</b>
|
|
{#if item.snip}<small>{@html snipHtml(item.snip)}</small>{/if}
|
|
</span>
|
|
<span class="pi-path">{where(item.page)}</span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
{#if allActions.length}
|
|
<div class="pop-label">Actions</div>
|
|
{#each allActions as a, k (a.label)}
|
|
{@const i = pageItems.length + k}
|
|
<button class="pi" class:on={idx === i} onmousemove={() => (idx = i)} onclick={() => run(i)}>
|
|
<span class="row-ico"><Icon name={a.icon} /></span>
|
|
<span class="pi-t"><b>{@html mark(a.label, ql)}</b></span>
|
|
<span class="pi-enter"><Icon name="enter" /></span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
{#if !total}
|
|
<div class="pal-empty">No notes match “{q}”.</div>
|
|
{/if}
|
|
</div>
|
|
<div class="pal-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span></div>
|
|
</div>
|
|
</div>
|