feat(ui): components, routes and styles
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>
This commit is contained in:
1 parent
c0587cb267
commit
27fafa0bcd
39 files changed
+8744
-94
No files matched your search
@@ -0,0 +1,170 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user