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>
303 lines
14 KiB
Svelte
303 lines
14 KiB
Svelte
<script>
|
|
import Icon from './Icon.svelte';
|
|
import TreeNode from './TreeNode.svelte';
|
|
import { store, iconOf } from '$lib/state.svelte.js';
|
|
import { api, isDesktop } from '$lib/api.js';
|
|
import { tagHex } from '$lib/tagColors.js';
|
|
import { comboOf, format } from '$lib/shortcuts.js';
|
|
|
|
let roots = $derived(store.children(null));
|
|
let tagged = $derived(
|
|
store.filterTag
|
|
? store.pages.filter((p) => p.tags.includes(store.filterTag)).sort((a, b) => a.title.localeCompare(b.title))
|
|
: []
|
|
);
|
|
// Many tags: a search box over them, and the list scrolls inside a short box instead of pushing the page tree away.
|
|
const TAG_FIND_AT = 8;
|
|
let tagQ = $state('');
|
|
let shownTags = $derived.by(() => {
|
|
const q = tagQ.trim().toLowerCase().replace(/^#/, '');
|
|
return q ? store.tagCounts.filter(([t]) => t.toLowerCase().includes(q)) : store.tagCounts;
|
|
});
|
|
let mod = $derived(isDesktop && window.vault.platform === 'darwin' ? '⌘' : 'Ctrl');
|
|
const mac = isDesktop && window.vault.platform === 'darwin';
|
|
const key = (id) => format(comboOf(id, store.prefs.shortcuts), mac);
|
|
|
|
// Tab lands on the list as one stop; the arrow keys then move between rows (TreeNode), starting from the open page.
|
|
function focusTree(e) {
|
|
if (e.target !== e.currentTarget) return;
|
|
const rows = [...e.currentTarget.querySelectorAll('.row[data-id]')].filter((r) => r.offsetHeight > 0);
|
|
(rows.find((r) => r.classList.contains('active')) || rows[0])?.focus();
|
|
}
|
|
|
|
/* drag a page onto another to nest it, or onto empty space / the Pages header for the top level */
|
|
let treeEl = $state();
|
|
|
|
// A page opened from search, a link or Back: bring its row into view (after its folders have opened).
|
|
$effect(() => {
|
|
const id = store.currentId;
|
|
if (!id || !treeEl) return;
|
|
const t = setTimeout(() => {
|
|
const row = [...treeEl.querySelectorAll('.row[data-id]')].find((r) => r.dataset.id === id);
|
|
row?.scrollIntoView({ block: 'nearest' });
|
|
}, 120);
|
|
return () => clearTimeout(t);
|
|
});
|
|
let drag = null; // { id, x, y, ghost, started, target, hoverTimer, hoverId }
|
|
let dropOn = $state(undefined); // page id, null = top level, undefined = nowhere
|
|
let dragging = $state(null);
|
|
|
|
const canDrop = (id, parent) => {
|
|
const page = store.pages.find((p) => p.id === id);
|
|
return !!page && page.parent !== parent && parent !== id && !(parent && parent.startsWith(id + '/'));
|
|
};
|
|
|
|
function onTreeDown(e) {
|
|
if (e.button !== 0 || e.target.closest('button, input')) return;
|
|
const row = e.target.closest('.row[data-id]');
|
|
if (!row || store.renaming) return;
|
|
drag = { id: row.dataset.id, x: e.clientX, y: e.clientY, ghost: null, started: false };
|
|
document.addEventListener('pointermove', onMove);
|
|
document.addEventListener('pointerup', onUp);
|
|
document.addEventListener('pointercancel', stop);
|
|
document.addEventListener('keydown', onKey, true);
|
|
}
|
|
function begin() {
|
|
drag.started = true;
|
|
dragging = drag.id;
|
|
const page = store.pages.find((p) => p.id === drag.id);
|
|
const g = document.createElement('div');
|
|
g.className = 'page-ghost';
|
|
g.textContent = page ? page.title : drag.id;
|
|
document.body.append(g);
|
|
drag.ghost = g;
|
|
document.body.classList.add('page-dragging');
|
|
getSelection()?.removeAllRanges();
|
|
}
|
|
function onMove(e) {
|
|
if (!drag.started) {
|
|
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return;
|
|
begin();
|
|
}
|
|
drag.ghost.style.transform = `translate(${e.clientX + 12}px, ${e.clientY + 10}px)`;
|
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
const row = el && el.closest('.sidebar .row[data-id]');
|
|
const toRoot = !row && el && (el.closest('.tree') || el.closest('.sec-pages'));
|
|
const want = row ? row.dataset.id : toRoot ? null : undefined;
|
|
dropOn = want !== undefined && canDrop(drag.id, want) ? want : undefined;
|
|
// Over an open canvas, letting go puts the page on it as a note card.
|
|
store.canvasView?.dropHint(!!(el && el.closest('.cv')));
|
|
// Over the page itself, its left or right half takes the page into split view.
|
|
const main = el && !el.closest('.cv') && el.closest('.main');
|
|
if (main && store.view === 'page' && drag.id !== store.currentId) {
|
|
const r = main.getBoundingClientRect();
|
|
store.splitHint = e.clientX < r.left + r.width / 2 ? 'left' : 'right';
|
|
} else store.splitHint = null;
|
|
// Hovering a closed page with sub-pages opens it after a moment.
|
|
if (row && row.dataset.id !== drag.hoverId) {
|
|
clearTimeout(drag.hoverTimer);
|
|
drag.hoverId = row.dataset.id;
|
|
const id = row.dataset.id;
|
|
drag.hoverTimer = setTimeout(() => { if (!store.open[id] && store.children(id).length) store.toggleFold(id); }, 650);
|
|
} else if (!row) {
|
|
clearTimeout(drag.hoverTimer);
|
|
drag.hoverId = null;
|
|
}
|
|
// Scroll the list near its edges.
|
|
if (treeEl) {
|
|
const r = treeEl.getBoundingClientRect();
|
|
if (e.clientY < r.top + 24) treeEl.scrollTop -= 8;
|
|
else if (e.clientY > r.bottom - 24) treeEl.scrollTop += 8;
|
|
}
|
|
}
|
|
function onUp(e) {
|
|
const d = drag, to = dropOn, side = store.splitHint;
|
|
stop();
|
|
if (!d.started) return;
|
|
// The click that follows a drag should not open a page.
|
|
window.addEventListener('click', swallow, true);
|
|
setTimeout(() => window.removeEventListener('click', swallow, true), 0);
|
|
const over = document.elementFromPoint(e.clientX, e.clientY);
|
|
if (over && over.closest('.cv') && store.canvasView) {
|
|
store.canvasView.addPageAt(d.id, e.clientX, e.clientY);
|
|
return;
|
|
}
|
|
if (side) { store.dropToSide(d.id, side); return; }
|
|
if (to !== undefined) store.movePage(d.id, to);
|
|
}
|
|
const swallow = (e) => e.stopPropagation();
|
|
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stop(); } };
|
|
function stop() {
|
|
if (!drag) return;
|
|
clearTimeout(drag.hoverTimer);
|
|
drag.ghost?.remove();
|
|
store.canvasView?.dropHint(false);
|
|
store.splitHint = null;
|
|
document.body.classList.remove('page-dragging');
|
|
document.removeEventListener('pointermove', onMove);
|
|
document.removeEventListener('pointerup', onUp);
|
|
document.removeEventListener('pointercancel', stop);
|
|
document.removeEventListener('keydown', onKey, true);
|
|
drag = null;
|
|
dropOn = undefined;
|
|
dragging = null;
|
|
}
|
|
</script>
|
|
|
|
<aside class="sidebar island" aria-label="Notes">
|
|
<div class="sb-inner">
|
|
<div class="ws">
|
|
<div class="ws-mark">{(store.vault?.name?.[0] || 'N').toUpperCase()}</div>
|
|
<div class="ws-txt">
|
|
<strong>{store.vault?.name}</strong>
|
|
</div>
|
|
<button class="btn-icon" aria-label="Hide sidebar" title="Hide sidebar ({key('sidebar')})" onclick={() => store.toggleSidebar()}>
|
|
<Icon name="panel" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="qas" class:row={store.quickLayout === 'horizontal'}>
|
|
<button class="qa" aria-label="Search" title="Search ({key('palette')})" onclick={() => (store.paletteOpen = true)}><Icon name="search" /><span class="qa-t">Search</span><kbd>{key('palette')}</kbd></button>
|
|
<button class="qa" aria-label="New page" title="New page ({key('newPage')})" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" /><span class="qa-t">New page</span><kbd>{key('newPage')}</kbd></button>
|
|
<button class="qa" aria-label="Today" title="Today ({key('daily')})" onclick={() => store.openDaily()}><Icon name="sun" /><span class="qa-t">Today</span><kbd>{key('daily')}</kbd></button>
|
|
<button class="qa" class:on={store.view === 'graph'} aria-label="Graph view" title="Graph view ({key('graph')})" onclick={() => (store.view === 'graph' ? store.closeGraph() : store.openGraph())}><Icon name="graph" /><span class="qa-t">Graph view</span><kbd>{key('graph')}</kbd></button>
|
|
</div>
|
|
|
|
{#if store.pinned.length}
|
|
<div class="sec"><span>Favorites</span></div>
|
|
<div class="favs" role="tree" aria-label="Favorites" tabindex="-1" onpointerdown={onTreeDown}>
|
|
{#each store.pinned as p (p.id)}
|
|
<div
|
|
class="row fav"
|
|
class:active={store.currentId === p.id}
|
|
class:drop-in={dropOn === p.id}
|
|
class:lifted={dragging === p.id}
|
|
data-id={p.id}
|
|
role="treeitem"
|
|
aria-selected={store.currentId === p.id}
|
|
tabindex="0"
|
|
onclick={() => store.openPage(p.id)}
|
|
onkeydown={(e) => e.key === 'Enter' && store.openPage(p.id)}
|
|
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}
|
|
>
|
|
<span class="row-ico"><Icon name={iconOf(p)} /></span>
|
|
<span class="name">{p.title}</span>
|
|
<button
|
|
class="row-add"
|
|
tabindex="-1"
|
|
aria-label="Remove {p.title} from favorites"
|
|
title="Remove from favorites"
|
|
onclick={(e) => { e.stopPropagation(); store.togglePin(p.id); }}
|
|
><Icon name="x" /></button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if store.searches.length}
|
|
<div class="sec"><span>Saved searches</span></div>
|
|
<div class="favs" role="list" aria-label="Saved searches">
|
|
{#each store.searches as s (s.q)}
|
|
<div class="row fav" role="listitem" tabindex="0" title="Search for {s.q}" onclick={() => store.runSearch(s.q)} onkeydown={(e) => e.key === 'Enter' && store.runSearch(s.q)}>
|
|
<span class="row-ico"><Icon name="search" /></span>
|
|
<span class="name">{s.q}</span>
|
|
<button class="row-add" tabindex="-1" aria-label="Remove saved search {s.q}" title="Remove" onclick={(e) => { e.stopPropagation(); store.removeSearch(s.q); }}><Icon name="x" /></button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="sec sec-pages" class:drop-root={dropOn === null}>
|
|
<span>Pages</span>
|
|
<button class="btn-icon" aria-label="New page" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" /></button>
|
|
</div>
|
|
|
|
<div
|
|
class="tree"
|
|
class:drop-root={dropOn === null}
|
|
bind:this={treeEl}
|
|
role="tree"
|
|
aria-label="Pages"
|
|
tabindex="0"
|
|
onfocus={focusTree}
|
|
onpointerdown={onTreeDown}
|
|
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: null }; }}>
|
|
{#if store.filterTag}
|
|
<div class="filter-head">
|
|
<span>Tagged <b>#{store.filterTag}</b></span>
|
|
<button onclick={() => (store.filterTag = null)}>Clear</button>
|
|
</div>
|
|
{#each tagged as p, i (p.id)}
|
|
<div
|
|
class="row flat"
|
|
class:active={store.currentId === p.id}
|
|
class:drop-in={dropOn === p.id}
|
|
class:lifted={dragging === p.id}
|
|
data-id={p.id}
|
|
style="--i:{i}"
|
|
role="treeitem"
|
|
aria-selected={store.currentId === p.id}
|
|
tabindex="0"
|
|
onclick={() => store.openPage(p.id)}
|
|
onkeydown={(e) => e.key === 'Enter' && store.openPage(p.id)}
|
|
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}
|
|
>
|
|
<span class="row-ico"><Icon name={iconOf(p)} /></span>
|
|
<span class="name">{p.title}</span>
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
{#each roots as p (p.id)}
|
|
<TreeNode page={p} {dropOn} {dragging} />
|
|
{:else}
|
|
<p class="empty-tree">No pages yet.</p>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="sec sec-tags">
|
|
<button class="sec-toggle" aria-expanded={store.tagsOpen} title={store.tagsOpen ? 'Hide tags' : 'Show tags'} onclick={() => store.setTagsOpen(!store.tagsOpen)}>
|
|
<span class="chev" class:open={store.tagsOpen}><Icon name="chev" /></span>Tags{#if store.tagCounts.length}<em>{store.tagCounts.length}</em>{/if}
|
|
</button>
|
|
<!-- with the list folded away, the tag being filtered by stays in view, with a way to clear it -->
|
|
{#if store.tagCounts.length}<button class="btn-icon" aria-label="Manage tags" title="Manage tags" onclick={() => (store.tagManagerOpen = true)}><Icon name="gear" /></button>{/if}
|
|
{#if store.filterTag && !store.tagsOpen}
|
|
<button class="tag on sec-active" class:tinted={!!tagHex(store.tagColors[store.filterTag])} style={tagHex(store.tagColors[store.filterTag]) ? `--tc:${tagHex(store.tagColors[store.filterTag])}` : undefined} title="Stop filtering by this tag" onclick={() => (store.filterTag = null)}><span>#</span>{store.filterTag}<Icon name="x" /></button>
|
|
{/if}
|
|
</div>
|
|
<!-- always there, so it can slide open and shut; `inert` while shut keeps it out of the tab order -->
|
|
<div class="tags-wrap" class:open={store.tagsOpen} inert={!store.tagsOpen}>
|
|
<div class="tags-in">
|
|
{#if store.tagCounts.length > TAG_FIND_AT}
|
|
<div class="tag-find">
|
|
<Icon name="search" />
|
|
<input
|
|
placeholder="Find a tag…"
|
|
aria-label="Find a tag"
|
|
bind:value={tagQ}
|
|
onkeydown={(e) => { if (e.key === 'Escape') { tagQ = ''; e.currentTarget.blur(); } }}
|
|
/>
|
|
{#if tagQ}<button class="btn-icon" aria-label="Clear" onclick={() => (tagQ = '')}><Icon name="x" /></button>{/if}
|
|
</div>
|
|
{/if}
|
|
<div class="tags">
|
|
{#each shownTags as [tag, n] (tag)}
|
|
<button class="tag" class:tinted={!!tagHex(store.tagColors[tag])} class:on={store.filterTag === tag} style={tagHex(store.tagColors[tag]) ? `--tc:${tagHex(store.tagColors[tag])}` : undefined} onclick={() => (store.filterTag = store.filterTag === tag ? null : tag)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, tag }; }}>
|
|
<span>#</span>{tag}<em>{n}</em>
|
|
</button>
|
|
{:else}
|
|
<span class="muted">{store.tagCounts.length ? 'No tag matches' : 'No tags yet'}</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sb-foot">
|
|
<span>{store.pages.filter((p) => p.exists).length} notes</span>
|
|
<button class="btn-icon foot-btn" aria-label="Tasks" title="Tasks ({key('tasks')})" onclick={() => (store.tasksOpen = true)}><Icon name="check" /></button>
|
|
<button class="btn-icon foot-btn foot-trash" aria-label="Trash" title="Trash" onclick={() => (store.trashOpen = true)}><Icon name="trash" /></button>
|
|
<button class="btn-icon foot-btn" aria-label="Settings" title="Settings ({key('settings')})" onclick={() => (store.settingsOpen = true)}><Icon name="gear" /></button>
|
|
</div>
|
|
</div>
|
|
</aside>
|