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:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent c0587cb267
commit 27fafa0bcd
39 files changed
+8742 -92

No files matched your search

+947 -41
View File
File diff suppressed because it is too large. Load diff
+71
View File
@@ -0,0 +1,71 @@
<script>
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let { id } = $props();
let items = $state([]);
let loose = $state([]); // plain-text mentions of this page's title that are not links
let openLoose = $state(false);
let busy = $state(null);
// Reload when the page changes or anything in the folder does (store.pages is replaced on every refresh).
$effect(() => {
const target = id;
store.pages;
let stale = false;
api.backlinks(target).then((r) => { if (!stale) items = r || []; }, () => {});
api.unlinked?.(target).then((r) => { if (!stale) loose = r || []; }, () => {});
return () => { stale = true; };
});
async function link(p) {
if (busy) return;
busy = p.id;
try {
await store.flush();
await api.linkMention(p.id, id);
loose = loose.filter((x) => x.id !== p.id);
await store.refresh();
api.backlinks(id).then((r) => (items = r || []), () => {});
} catch (err) {
toast(errorText(err), 'x');
} finally {
busy = null;
}
}
</script>
{#if items.length}
<section class="backlinks" aria-label="Linked from">
<h2><Icon name="enter" />Linked from {items.length} {items.length === 1 ? 'page' : 'pages'}</h2>
{#each items as p (p.id)}
<button class="bl" onclick={() => store.openPage(p.id)}>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="bl-t"><b>{p.title}</b>{#if p.snip}<small>{p.snip}</small>{/if}</span>
</button>
{/each}
</section>
{/if}
{#if loose.length}
<section class="backlinks unlinked" aria-label="Unlinked mentions">
<h2>
<button class="sec-toggle" aria-expanded={openLoose} onclick={() => (openLoose = !openLoose)}>
<span class="chev" class:open={openLoose}><Icon name="chev" /></span>Mentioned without a link<em>{loose.length}</em>
</button>
</h2>
{#if openLoose}
{#each loose as p (p.id)}
<div class="bl bl-loose">
<button class="bl-open" onclick={() => store.openPage(p.id)}>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="bl-t"><b>{p.title}</b>{#if p.snip}<small>{p.snip}</small>{/if}</span>
</button>
<button class="btn" disabled={busy === p.id} title="Turn the first mention into a link" onclick={() => link(p)}><Icon name="link" />Link it</button>
</div>
{/each}
{/if}
</section>
{/if}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let closing = $state(false);
let url = $state('');
let busy = $state(false);
async function go() {
if (!url.trim() || busy) return;
busy = true;
await store.clipWeb(url);
busy = false;
close();
}
function close() {
closing = true;
setTimeout(() => { store.clipOpen = false; closing = false; }, 150);
}
const focusOnMount = (el) => el.focus();
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Clip a web page">
<div class="pal-top">
<Icon name="globe" />
<input bind:value={url} placeholder="Paste a web address to save as a note" autocomplete="off" spellcheck="false" use:focusOnMount
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }} />
<button class="btn primary" disabled={busy || !url.trim()} onclick={go}>{busy ? 'Clipping…' : 'Clip'}</button>
</div>
<div class="pal-foot"><span>Saves the page’s main text as Markdown, with its address in the page’s front matter. Public web pages only.</span></div>
</div>
</div>
+192
View File
@@ -0,0 +1,192 @@
<script>
import { isCanvasId } from '$lib/canvas.js';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, isDesktop } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { TAG_COLORS } from '$lib/tagColors.js';
let el = $state();
let armed = $state(false);
let subOpen = $state(false);
let subTimer;
let armTimer;
let renamingTag = $state(false);
let flip = $state(false);
let pos = $state({ x: store.menu.x, y: store.menu.y });
let page = $derived(store.menu.id ? store.pages.find((p) => p.id === store.menu.id) : null);
let tag = $derived(store.menu.tag || null);
let kids = $derived(page ? store.pages.filter((p) => p.id.startsWith(page.id + '/')).length : 0);
// Keep the menu inside the window.
$effect(() => {
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(store.menu.x, innerWidth - r.width - 8)),
y: Math.max(8, Math.min(store.menu.y, innerHeight - r.height - 8))
};
el.querySelector('.mi')?.focus();
});
// Open the submenu on the other side when there is no room to its right.
function placeSub(node) {
const r = node.parentElement.getBoundingClientRect();
flip = r.right + node.offsetWidth + 8 > innerWidth;
// Near the bottom of the window the side menu moves up so that it stays whole.
const over = r.top - 6 + node.offsetHeight + 8 - innerHeight;
node.style.top = over > 0 ? `${-6 - over}px` : '';
}
const close = () => (store.menu = null);
function run(kind, arg) {
const id = page && page.id;
const forTag = tag; // read before the menu closes
if (kind === 'delete') {
if (!armed) {
armed = true;
clearTimeout(armTimer);
armTimer = setTimeout(() => (armed = false), 3000);
return;
}
close();
store.removePage(id);
return;
}
close();
if (kind === 'open') store.openPage(id);
if (kind === 'split') store.openSplit(id);
if (kind === 'tagDelete') store.deleteTag(forTag);
if (kind === 'tagManage') store.tagManagerOpen = true;
if (kind === 'new') store.templatesOpen = { parent: null };
if (kind === 'child') store.templatesOpen = { parent: id };
if (kind === 'childBoard') store.newBoard(id);
if (kind === 'newBoard') store.newBoard(null);
if (kind === 'newDatabase') store.newDatabase(null);
if (kind === 'importCsv') store.pickCsv(null);
if (kind === 'childDatabase') store.newDatabase(id);
if (kind === 'newCanvas') store.newCanvas(null);
if (kind === 'childCanvas') store.newCanvas(id);
if (kind === 'rename') store.renaming = id;
if (kind === 'duplicate') store.duplicatePage(id);
if (kind === 'top') store.movePage(id, null);
if (kind === 'pin') store.togglePin(id);
if (kind === 'trash') store.trashOpen = true;
if (kind === 'tagColor') store.setTagColor(forTag, arg);
if (kind === 'reveal') api.reveal(id);
if (kind === 'path') {
api.filePath(id).then((p) =>
navigator.clipboard.writeText(p).then(() => toast('File path copied', 'copy'), () => toast(p, 'file'))
);
}
}
// Hovering opens the submenu at once; leaving waits a moment so a diagonal mouse path does not close it.
function subEnter() { clearTimeout(subTimer); subOpen = true; }
function subLeave() { clearTimeout(subTimer); subTimer = setTimeout(() => (subOpen = false), 150); }
function finishTagRename(input, save) {
const name = input.value.trim();
close();
if (save && name && name !== tag) store.renameTag(tag, name);
}
const focusSelect = (input) => { input.focus(); input.select(); };
function onpointerdown(e) {
if (el && !el.contains(e.target)) close();
}
function onkeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
if (subOpen) subOpen = false; // the innermost layer first
else close();
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const items = [...el.querySelectorAll('.mi')];
const i = items.indexOf(document.activeElement);
items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
}
</script>
<svelte:window {onpointerdown} {onkeydown} onblur={close} onresize={close} />
<div class="pop menu ctx" bind:this={el} role="menu" tabindex="-1" style="left:{pos.x}px;top:{pos.y}px" oncontextmenu={(e) => e.preventDefault()}>
{#if tag}
<div class="tag-swatches" role="group" aria-label="Tag colour">
{#each TAG_COLORS as [name, hex] (name)}
<button class="cv-swatch" class:on={store.tagColors[tag] === name} style="--sw:{hex}" data-color={name} aria-label={name} title={name[0].toUpperCase() + name.slice(1)} onclick={() => run('tagColor', name)}></button>
{/each}
<button class="cv-swatch" class:on={!store.tagColors[tag]} data-color="none" aria-label="No colour" title="No colour" onclick={() => run('tagColor', null)}></button>
</div>
<div class="sepline"></div>
{#if renamingTag}
<input class="ctx-input" value={tag} aria-label="New tag name" use:focusSelect
onkeydown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finishTagRename(e.currentTarget, true); }
if (e.key === 'Escape') { e.preventDefault(); close(); }
}} />
{:else}
<button class="mi" role="menuitem" onclick={() => (renamingTag = true)}><Icon name="pen" />Rename tag</button>
<button class="mi" role="menuitem" onclick={() => run('tagDelete')}><Icon name="trash" />Remove tag from all pages</button>
<button class="mi" role="menuitem" onclick={() => run('tagManage')}><Icon name="hash" />Manage tags…</button>
{/if}
{:else if page && store.menu.add}
<button class="mi" role="menuitem" onclick={() => run('child')}><Icon name="file" />Page</button>
<button class="mi" role="menuitem" onclick={() => run('childBoard')}><Icon name="board" />Board</button>
<button class="mi" role="menuitem" onclick={() => run('childDatabase')}><Icon name="table" />Database</button>
<button class="mi" role="menuitem" onclick={() => run('childCanvas')}><Icon name="canvas" />Canvas</button>
{:else if page}
<button class="mi" role="menuitem" onclick={() => run('open')}><Icon name="file" />Open</button>
{#if page.exists && page.id !== store.currentId}
<button class="mi" role="menuitem" onclick={() => run('split')}><Icon name="columns" />Open in split</button>
{/if}
{#if !isCanvasId(page.id)}
<div class="mi-sub" role="none" onpointerenter={subEnter} onpointerleave={subLeave}>
<button class="mi" role="menuitem" aria-haspopup="menu" aria-expanded={subOpen} onclick={() => (subOpen = !subOpen)}
onkeydown={(e) => { if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); subOpen = true; queueMicrotask(() => e.currentTarget.parentElement.querySelector('.sub .mi')?.focus()); } }}>
<Icon name="plus" /><span class="mi-label">Add inside</span><Icon name="chevRight" />
</button>
{#if subOpen}
<div class="pop menu sub" class:left={flip} role="menu" use:placeSub
onkeydown={(e) => { if (e.key === 'ArrowLeft' || e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); subOpen = false; e.currentTarget.parentElement.querySelector('.mi')?.focus(); } }}>
<button class="mi" role="menuitem" onclick={() => run('child')}><Icon name="file" />Page</button>
<button class="mi" role="menuitem" onclick={() => run('childBoard')}><Icon name="board" />Board</button>
<button class="mi" role="menuitem" onclick={() => run('childDatabase')}><Icon name="table" />Database</button>
<button class="mi" role="menuitem" onclick={() => run('childCanvas')}><Icon name="canvas" />Canvas</button>
</div>
{/if}
</div>
{/if}
<button class="mi" role="menuitem" onclick={() => run('pin')}>
<Icon name="star" />{store.isPinned(page.id) ? 'Remove from favorites' : 'Add to favorites'}
</button>
<button class="mi" role="menuitem" onclick={() => run('rename')}><Icon name="pen" />Rename</button>
{#if page.exists}
<button class="mi" role="menuitem" onclick={() => run('duplicate')}><Icon name="layers" />Duplicate</button>
{/if}
{#if page.parent}
<button class="mi" role="menuitem" onclick={() => run('top')}><Icon name="folder" />Move to top level</button>
{/if}
<div class="sepline"></div>
{#if isDesktop}
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
{/if}
<button class="mi" role="menuitem" onclick={() => run('path')}><Icon name="copy" />Copy file path</button>
<div class="sepline"></div>
<button class="mi danger" class:armed role="menuitem" onclick={() => run('delete')}>
<Icon name="trash" /><span>{armed ? (kids ? `Click again to trash ${kids + 1} pages` : 'Click again to move to trash') : 'Delete page'}</span>
</button>
{:else}
<button class="mi" role="menuitem" onclick={() => run('new')}><Icon name="plus" />New page</button>
<button class="mi" role="menuitem" onclick={() => run('newBoard')}><Icon name="board" />New board</button>
<button class="mi" role="menuitem" onclick={() => run('newDatabase')}><Icon name="table" />New database</button>
<button class="mi" role="menuitem" onclick={() => run('importCsv')}><Icon name="download" />Import CSV as database</button>
<button class="mi" role="menuitem" onclick={() => run('newCanvas')}><Icon name="canvas" />New canvas</button>
<button class="mi" role="menuitem" onclick={() => run('trash')}><Icon name="trash" />Open trash</button>
{/if}
</div>
+126
View File
@@ -0,0 +1,126 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { COVERS } from '$lib/icons.js';
// onupload(): choose a picture from the computer; onurl(address) -> Promise: take one from the web (rejects with
// a message); hasImage: the cover is one now, so it can be taken away
let { anchor, current, onpick, onclose, onupload, onurl, hasImage = false } = $props();
let linking = $state(false);
let address = $state('');
let busy = $state(false);
let problem = $state('');
async function useAddress() {
const a = address.trim();
if (!a || busy) return;
busy = true;
problem = '';
try {
await onurl(a);
onclose();
} catch (err) {
problem = String((err && err.message) || err).replace(/^Error invoking remote method '[^']+': /, '').replace(/^Error: /, '');
}
busy = false;
}
const focusOnMount = (el) => el.focus();
// a pasted address goes straight in
function onPaste(e) {
const t = (e.clipboardData && e.clipboardData.getData('text')) || '';
if (/^https?:\/\/\S+$/i.test(t.trim())) setTimeout(useAddress, 0);
}
let el = $state();
let pos = $state({ x: 0, y: 0 });
function place() {
if (!anchor || !el) return;
const a = anchor.getBoundingClientRect(), r = el.getBoundingClientRect();
let y = a.bottom + 8;
if (y + r.height > innerHeight - 8) y = Math.max(8, a.top - r.height - 8);
let x = a.left;
if (x + r.width > innerWidth - 8) x = Math.max(8, innerWidth - r.width - 8);
pos = { x, y };
}
onMount(() => {
place();
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
});
</script>
<svelte:window onmousedown={(e) => el && !el.contains(e.target) && anchor && !anchor.contains(e.target) && onclose()} />
<div class="pop cover-picker" bind:this={el} style="left:{pos.x}px; top:{pos.y}px;" role="dialog" aria-label="Pick cover">
{#each Object.entries(COVERS) as [key, colors]}
<button
class="c-opt"
class:on={key === current}
style="--c1:{colors[0]};--c2:{colors[1]}"
onclick={() => { onpick(key); onclose(); }}
aria-label={key}
></button>
{/each}
{#if onupload}
<button class="c-up" onclick={() => { onclose(); onupload(); }}><Icon name="image" />Upload an image…</button>
{/if}
{#if onurl}
{#if linking}
<div class="c-link">
<input
class="c-url"
type="url"
placeholder="Paste an image address"
aria-label="Image address"
spellcheck="false"
bind:value={address}
use:focusOnMount
disabled={busy}
onpaste={onPaste}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); useAddress(); } if (e.key === 'Escape') { e.stopPropagation(); linking = false; problem = ''; } }}
/>
<button class="c-go" disabled={busy || !address.trim()} onclick={useAddress}>{busy ? '…' : 'Add'}</button>
{#if problem}<p class="c-problem">{problem}</p>{/if}
</div>
{:else}
<button class="c-up" onclick={() => (linking = true)}><Icon name="link" />From a link…</button>
{/if}
{/if}
{#if hasImage}
<button class="c-up" onclick={() => { onpick(''); onclose(); }}><Icon name="x" />Remove the image</button>
{/if}
</div>
<style>
.cover-picker { padding: 8px; display: flex; gap: 8px; flex-wrap: wrap; width: 250px; }
.c-up {
flex: 1 0 100%; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; border-radius: 6px;
color: var(--ink-2); font-size: 12.5px; text-align: left; transition: background 0.15s, color 0.15s;
}
.c-up:hover { background: var(--hover); color: var(--ink); }
.c-up :global(.i) { width: 14px; height: 14px; }
.c-up:first-of-type { margin-top: 2px; border-top: 1px solid var(--line); padding-top: 6px; height: 36px; }
.c-link { flex: 1 0 100%; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.c-url {
flex: 1; min-width: 0; height: 30px; border: 0; outline: 0; border-radius: 6px; padding: 0 9px;
background: var(--hover); color: var(--ink); font: 12.5px var(--f-ui);
}
.c-url:focus { box-shadow: inset 0 0 0 1px var(--blush-line); }
.c-go {
height: 30px; padding: 0 12px; border-radius: 6px; background: var(--blush); color: var(--blush-ink);
font-size: 12.5px; font-weight: 550; transition: filter 0.15s, opacity 0.15s;
}
.c-go:hover { filter: brightness(1.06); }
.c-go:disabled { opacity: 0.5; pointer-events: none; }
.c-problem { flex: 1 0 100%; margin: 0; color: var(--danger); font-size: 12px; line-height: 1.4; }
.c-opt {
width: 24px; height: 24px; border-radius: 50%;
background: linear-gradient(135deg, var(--c1), var(--c2));
border: 2px solid transparent;
transition: transform 0.2s var(--ease), border-color 0.2s;
}
.c-opt:hover { transform: scale(1.1); }
.c-opt.on { border-color: var(--ink); transform: scale(1.1); }
</style>
+122
View File
@@ -0,0 +1,122 @@
<script>
// Import a CSV file: choose what each column becomes, then create the records (into a database, or as a new one).
import { untrack } from 'svelte';
import Icon from './Icon.svelte';
import SelectMenu from './SelectMenu.svelte';
import { store } from '$lib/state.svelte.js';
import { parseCsv, autoMap, planImport } from '$lib/csv.js';
import { TYPES, typeInfo } from '$lib/database.js';
const job = untrack(() => store.csvImport);
const parsed = parseCsv(job.text);
const [headers = [], ...rows] = parsed.rows;
let schema = $state(null); // the target database's properties, once read
let cols = $state([]);
let name = $state(job.name);
let busy = $state(false);
let closing = $state(false);
// Match columns to the database's existing properties (or to nothing, for a new database).
(async () => {
let existing = [];
if (job.target) {
try { existing = (await store.readSchema(job.target))?.properties || []; } catch { existing = []; }
}
schema = existing;
cols = autoMap(headers, rows, existing);
})();
const target = $derived(job.target ? store.pages.find((p) => p.id === job.target) : null);
const plan = $derived(schema ? planImport({ rows, columns: $state.snapshot(cols), existing: schema }) : null);
const sample = (i) => rows.slice(0, 3).map((r) => String(r[i] ?? '')).filter(Boolean).join(' · ');
function choices(c) {
return [
{ value: 'title', label: 'Name' },
...(schema || []).map((p) => ({ value: 'p:' + p.key, label: p.name })),
{ value: 'new', label: 'New property' },
{ value: 'skip', label: 'Don’t import' }
];
}
const valueOf = (c) => (c.target === 'existing' ? 'p:' + c.key : c.target);
function choose(c, v) {
// only one column can be the name
if (v === 'title') for (const o of cols) if (o.target === 'title' && o !== c) o.target = 'skip';
if (v.startsWith('p:')) {
const p = schema.find((x) => x.key === v.slice(2));
// one column per property
for (const o of cols) if (o.target === 'existing' && o.key === p.key && o !== c) o.target = 'skip';
Object.assign(c, { target: 'existing', key: p.key, type: p.type });
} else {
c.target = v;
if (v === 'new' && !c.name) c.name = c.header || `Column ${c.index + 1}`;
}
}
const noName = $derived(cols.length > 0 && !cols.some((c) => c.target === 'title'));
const used = $derived(cols.filter((c) => c.target !== 'skip').length);
function close() {
closing = true;
setTimeout(() => { store.csvImport = null; }, 150);
}
async function go() {
if (!plan || busy) return;
busy = true;
const id = await store.importCsv(plan, { target: job.target, name: name.trim() || 'Imported' });
busy = false;
if (id) close();
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel csv" role="dialog" aria-label="Import CSV">
<div class="trash-top">
<Icon name="download" />
<h2>Import CSV</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="csv-body">
<p class="csv-lead">
{rows.length} row{rows.length === 1 ? '' : 's'}, {headers.length} column{headers.length === 1 ? '' : 's'}
{#if target}into <b>{target.title}</b>{:else}as a new database{/if}.
</p>
{#if !target}
<label class="csv-name">Database name <input class="db-find" bind:value={name} placeholder="Imported" /></label>
{/if}
{#if !schema}
<p class="csv-lead">Reading…</p>
{:else}
<div class="csv-cols">
{#each cols as c (c.index)}
<div class="csv-col" class:skip={c.target === 'skip'}>
<div class="csv-src">
<b>{c.header || `Column ${c.index + 1}`}</b>
<small>{sample(c.index) || 'empty'}</small>
</div>
<Icon name="chev" />
<div class="csv-dst">
<SelectMenu options={choices(c)} value={valueOf(c)} label="Import {c.header || 'column'} as" onchange={(v) => choose(c, v)} />
{#if c.target === 'new'}
<input class="db-in csv-pname" bind:value={c.name} aria-label="Property name" placeholder="Property name" />
<SelectMenu options={TYPES.map((t) => ({ value: t.type, label: t.label }))} value={c.type} label="Type" onchange={(t) => (c.type = t)} />
{:else if c.target === 'existing'}
<span class="csv-type"><Icon name={typeInfo(c.type).icon} />{typeInfo(c.type).label}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<div class="trash-foot">
<span>{#if noName}No column is the name, so records will be called “Untitled”.{:else}{used} column{used === 1 ? '' : 's'} imported.{/if}</span>
<span class="csv-actions">
<button class="btn" onclick={close}>Cancel</button>
<button class="btn primary" disabled={!plan || busy || !rows.length} onclick={go}>{busy ? 'Importing…' : `Import ${rows.length} record${rows.length === 1 ? '' : 's'}`}</button>
</span>
</div>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let closing = $state(false);
const start = store.currentDay ? new Date(store.currentDay + 'T12:00:00') : new Date();
let year = $state(start.getFullYear());
let month = $state(start.getMonth());
const iso = (y, m, d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const now = new Date();
const today = iso(now.getFullYear(), now.getMonth(), now.getDate());
const mondayFirst = $derived(store.prefs.weekStart !== 'sunday');
const locale = $derived(store.prefs.locale || undefined);
let title = $derived(new Date(year, month, 1).toLocaleDateString(locale, { month: 'long', year: 'numeric' }));
let heads = $derived(Array.from({ length: 7 }, (_, i) => new Date(2024, 0, (mondayFirst ? 1 : 7) + i).toLocaleDateString(locale, { weekday: 'short' }).slice(0, 2)));
let cells = $derived.by(() => {
const first = new Date(year, month, 1);
const lead = (first.getDay() + (mondayFirst ? 6 : 0)) % 7;
const count = new Date(year, month + 1, 0).getDate();
const out = [];
for (let i = 0; i < lead; i++) out.push(null);
for (let d = 1; d <= count; d++) out.push(iso(year, month, d));
return out;
});
const have = $derived(store.dailyDays);
function go(delta) {
const d = new Date(year, month + delta, 1);
year = d.getFullYear();
month = d.getMonth();
}
function pick(day) {
close();
setTimeout(() => store.openDaily(new Date(day + 'T12:00:00')), 120);
}
function close() {
closing = true;
setTimeout(() => { store.calendarOpen = false; closing = false; }, 150);
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel cal-panel" role="dialog" aria-label="Daily notes calendar">
<div class="cal-top">
<button class="btn-icon" aria-label="Previous month" onclick={() => go(-1)}><Icon name="chevLeft" /></button>
<b>{title}</b>
<button class="btn-icon" aria-label="Next month" onclick={() => go(1)}><Icon name="chevRight" /></button>
</div>
<div class="cal-grid">
{#each heads as h}<span class="cal-h">{h}</span>{/each}
{#each cells as day, i (i)}
{#if day}
<button class="cal-d" class:today={day === today} class:has={have.has(day)} class:cur={day === store.currentDay} onclick={() => pick(day)} title={have.has(day) ? 'Open this day' : 'Create this day’s note'}>
{Number(day.slice(8))}
</button>
{:else}<span></span>{/if}
{/each}
</div>
<div class="cal-foot">
<span><i class="cal-dot"></i> has a note</span>
<button class="btn" onclick={() => pick(today)}>Today</button>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
<script>
import { onMount } from 'svelte';
import { createEditor } from '$lib/editor.js';
import { store } from '$lib/state.svelte.js';
import { editorHooks } from '$lib/editorHooks.js';
let { blocks, onexittop } = $props();
let host = $state();
let ed = $state.raw(null);
onMount(() => {
const instance = createEditor(host, {
blocks,
animate: true,
onChange: (b) => store.edit(b),
onExitTop: () => onexittop?.(),
...editorHooks()
});
ed = instance;
store.editor = instance;
return () => {
instance.destroy();
if (store.editor === instance) store.editor = null;
};
});
// Links turn dim or bright as pages come and go.
$effect(() => {
store.pages;
ed?.refreshLinks();
});
</script>
<div bind:this={host}></div>
+85
View File
@@ -0,0 +1,85 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let closing = $state(false);
let format = $derived(store.exportFormat || 'md');
let content = $derived(format === 'md' || format === 'html' ? store.exportText(format) : null);
let fileName = $derived(`${store.doc?.title || 'Untitled'}.${format === 'md' ? 'md' : format === 'html' ? 'html' : 'pdf'}`);
function close() {
closing = true;
setTimeout(() => { store.exportFormat = null; closing = false; }, 150);
}
function copy() {
if (!content) return;
navigator.clipboard.writeText(content).then(
() => toast(format === 'md' ? 'Markdown copied' : 'HTML copied', 'copy'),
() => toast('Copying is blocked here. Select the text instead.', 'x')
);
}
async function save() {
try {
let where = null;
if (format === 'pdf') {
// Just directly save the PDF through main process
// Generate the exact HTML export string
const htmlDoc = store.exportText('html');
// Render it into PDF offscreen
where = await api.savePdf(fileName, store.doc?.title, htmlDoc);
} else if (format === 'print') {
api.print();
close();
return;
} else {
where = await api.saveExport(fileName, content);
}
if (where) { toast('Exported', 'save'); close(); }
} catch (err) {
toast('Could not export: ' + errorText(err), 'x');
}
}
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Export page">
<div class="exp-top">
<div class="seg {format}" style="width: auto">
<span class="seg-pill"></span>
<button class:on={format === 'md'} onclick={() => (store.exportFormat = 'md')}>Markdown</button>
<button class:on={format === 'html'} onclick={() => (store.exportFormat = 'html')}>HTML</button>
<button class:on={format === 'pdf'} onclick={() => (store.exportFormat = 'pdf')}>PDF</button>
</div>
{#if format !== 'print'}
<span class="exp-name">{fileName}</span>
{/if}
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
{#key format}
{#if format === 'pdf'}
<div class="exp-out swap" style="display: flex; align-items: center; justify-content: center; color: var(--ink-3);">
The PDF will be generated exactly as this page currently looks.
</div>
{:else}
<pre class="exp-out swap">{content}</pre>
{/if}
{/key}
<div class="exp-foot">
{#if content}
<button class="btn" onclick={copy}><Icon name="copy" />Copy</button>
{:else}
<div></div>
{/if}
<button class="btn primary" onclick={save}>
<Icon name={format === 'print' ? 'export' : 'save'} />
{format === 'print' ? 'Print…' : 'Save as…'}
</button>
</div>
</div>
</div>
+161
View File
@@ -0,0 +1,161 @@
<script>
// Find in page (Ctrl+F). Matches are painted with the CSS Custom Highlight API,
// so the editable text itself is never touched.
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let input = $state();
let q = $state('');
let count = $state(0);
let index = $state(0);
let ranges = [];
let timer = 0;
const hl = typeof CSS !== 'undefined' && CSS.highlights ? CSS.highlights : null;
const scroller = () => document.querySelector('.main .scroller');
// Every match as a Range. Each editable area is searched as one string, so a match may cross bold/italic.
function collect(query) {
const out = [];
const needle = query.toLowerCase();
if (!needle) return out;
for (const area of document.querySelectorAll('.main .page .title, .main .blocks .c, .main .blocks .c2, .main .blocks .tcell, .main .blocks .ecell, .main .board .kh-title, .main .board .kc-text')) {
const nodes = [];
let text = '';
const w = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
for (let n; (n = w.nextNode()); ) { nodes.push([n, text.length]); text += n.data; }
const hay = text.toLowerCase();
for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + needle.length)) {
const r = document.createRange();
const place = (pos, end) => {
for (let i = nodes.length - 1; i >= 0; i--) {
const [n, start] = nodes[i];
if (pos > start || (pos === start && !end)) {
end ? r.setEnd(n, Math.min(pos - start, n.length)) : r.setStart(n, pos - start);
return;
}
}
end ? r.setEnd(nodes[0][0], 0) : r.setStart(nodes[0][0], 0);
};
place(at, false);
place(at + needle.length, true);
out.push(r);
}
}
return out;
}
function paint() {
if (!hl) return;
hl.set('find', new Highlight(...ranges));
if (ranges[index]) hl.set('find-current', new Highlight(ranges[index]));
else hl.delete('find-current');
}
function reveal() {
const r = ranges[index];
if (!r) return;
const el = r.startContainer.parentElement;
// A match inside a closed toggle: open it first.
const tg = el && el.closest('.tg:not(.open)');
if (tg) tg.querySelector('.tg-chev')?.click();
const sc = scroller();
if (!sc) return;
requestAnimationFrame(() => {
const box = r.getBoundingClientRect(), view = sc.getBoundingClientRect();
if (box.top < view.top + 40 || box.bottom > view.bottom - 40) {
sc.scrollBy({ top: box.top - view.top - view.height / 3, behavior: 'smooth' });
}
});
}
function run(keepPlace = false) {
const before = ranges[index];
ranges = collect(q);
count = ranges.length;
if (!keepPlace || !before) index = 0;
else {
// Stay on the same spot after an edit: first match at or after where we were.
const i = ranges.findIndex((r) => r.compareBoundaryPoints(Range.START_TO_START, before) >= 0);
index = i < 0 ? 0 : i;
}
paint();
if (!keepPlace) reveal();
}
function step(dir) {
if (!count) return;
index = (index + dir + count) % count;
paint();
reveal();
}
function close(selectMatch) {
const r = ranges[index];
hl?.delete('find');
hl?.delete('find-current');
store.findOpen = 0;
if (selectMatch && r && r.startContainer.isConnected) {
const area = r.startContainer.parentElement?.closest('[contenteditable="true"]');
area?.focus();
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
}
}
function onkeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }
}
// Ctrl+F again (store.findOpen bumps): back to the input with the text selected.
$effect(() => {
store.findOpen;
tick().then(() => { input?.focus(); input?.select(); });
});
// New page: search it from the top.
$effect(() => {
store.doc?.version;
clearTimeout(timer);
timer = setTimeout(() => run(false), 120);
});
onMount(() => {
// Typing in the page moves matches around: re-search shortly after.
const onEdit = (e) => {
if (e.target === input) return;
clearTimeout(timer);
timer = setTimeout(() => run(true), 150);
};
document.addEventListener('input', onEdit, true);
return () => {
document.removeEventListener('input', onEdit, true);
clearTimeout(timer);
hl?.delete('find');
hl?.delete('find-current');
};
});
</script>
<div class="findbar pop" role="search">
<Icon name="search" />
<input
bind:this={input}
bind:value={q}
placeholder="Find in page"
aria-label="Find in page"
spellcheck="false"
oninput={() => run(false)}
{onkeydown}
/>
<span class="find-n">{q ? (count ? `${index + 1} of ${count}` : 'No results') : ''}</span>
<button class="btn-icon" aria-label="Previous match" title="Previous (Shift+Enter)" disabled={!count} onclick={() => step(-1)}>
<Icon name="chev" class="up" />
</button>
<button class="btn-icon" aria-label="Next match" title="Next (Enter)" disabled={!count} onclick={() => step(1)}>
<Icon name="chev" class="down" />
</button>
<button class="btn-icon" aria-label="Close" title="Close (Esc)" onclick={() => close(false)}><Icon name="x" /></button>
</div>
+139
View File
@@ -0,0 +1,139 @@
<script>
// A text field for a page path with a list of existing pages under it, as you type: for choosing a folder.
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { cleanFolder, autoFolder } from '$lib/daily.js';
// value: the chosen path ('' = automatic); onchange(path or '') is called when the choice is made
let { value = '', onchange, label = 'Folder' } = $props();
let input = $state();
let open = $state(false);
let text = $state(value);
let active = $state(0);
let at = $state({ left: 0, top: 0, bottom: null, width: 260, max: 300 });
$effect(() => { if (!open) text = value; });
const auto = $derived(autoFolder(store.pages));
const pages = $derived(store.pages.filter((p) => p.exists && !/\.canvas$/i.test(p.id)));
const q = $derived(text.trim().toLowerCase());
// best matches first: names starting with it, then names containing it, then paths containing it
const found = $derived.by(() => {
if (!q) return pages.slice().sort((a, b) => a.id.localeCompare(b.id)).slice(0, 40);
const rank = (p) => {
const t = p.title.toLowerCase();
return t.startsWith(q) ? 0 : t.includes(q) ? 1 : p.id.toLowerCase().includes(q) ? 2 : 3;
};
return pages.map((p) => [rank(p), p]).filter(([r]) => r < 3).sort((a, b) => a[0] - b[0] || a[1].id.localeCompare(b[1].id)).map(([, p]) => p).slice(0, 40);
});
const typed = $derived(cleanFolder(text));
const isNew = $derived(!!typed && !pages.some((p) => p.id.toLowerCase() === typed.toLowerCase()));
// rows: the automatic choice, matching pages, and "use what I typed" for a folder that doesn't exist yet
const rows = $derived([
...(q ? [] : [{ kind: 'auto' }]),
...found.map((p) => ({ kind: 'page', p })),
...(isNew ? [{ kind: 'new', id: typed }] : [])
]);
function show() {
const r = input.getBoundingClientRect();
const width = Math.max(r.width, 280);
// below the field if there's room (or more room than above), otherwise above it
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 240 && above > below;
const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 6, width, max: Math.min(300, above) }
: { left, top: r.bottom + 6, bottom: null, width, max: Math.min(300, below) };
active = 0;
open = true;
}
function choose(row) {
open = false;
if (!row) return;
const next = row.kind === 'auto' ? '' : row.kind === 'page' ? row.p.id : row.id;
text = next;
if (next !== value) onchange?.(next);
input?.blur();
}
function commitTyped() {
open = false;
const next = typed;
if (next !== value) { text = next; onchange?.(next); }
else text = value;
}
function onkeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open) return show();
const n = rows.length || 1;
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
document.querySelector('.fp-list .mi.act')?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
// a highlighted row if the list was used; otherwise exactly what was typed
if (open && rows[active] && !(text.trim() && rows[active].kind === 'auto')) choose(rows[active]);
else { commitTyped(); input.blur(); }
} else if (e.key === 'Escape' && open) {
e.preventDefault();
e.stopPropagation(); // the dialog stays
open = false;
text = value;
}
}
const mark = (s) => {
const i = q ? s.toLowerCase().indexOf(q) : -1;
return i < 0 ? [s, '', ''] : [s.slice(0, i), s.slice(i, i + q.length), s.slice(i + q.length)];
};
</script>
<svelte:window onresize={() => (open = false)} />
<div class="fp">
<Icon name="folder" />
<input
bind:this={input}
bind:value={text}
class="fp-input"
role="combobox"
aria-label={label}
aria-expanded={open}
aria-autocomplete="list"
placeholder={auto}
spellcheck="false"
autocomplete="off"
onfocus={(e) => { show(); e.currentTarget.select(); }}
onclick={(e) => { if (!open) show(); e.currentTarget.select(); }}
oninput={() => { open = true; active = 0; }}
onblur={() => { if (open) commitTyped(); }}
{onkeydown}
/>
<button class="fp-chev" tabindex="-1" aria-label="Show folders" onmousedown={(e) => { e.preventDefault(); open ? (open = false) : (input.focus(), show()); }}>
<Icon name="chev" />
</button>
</div>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop fp-list" role="listbox" style="left:{at.left}px;{at.top !== null ? `top:${at.top}px;` : `bottom:${at.bottom}px;`}width:{at.width}px;max-height:{at.max}px" class:up={at.top === null} onmousedown={(e) => e.preventDefault()}>
{#each rows as row, i (row.kind + (row.p ? row.p.id : row.id || ''))}
{#if row.kind === 'auto'}
<button class="mi" class:act={i === active} role="option" aria-selected={!value} onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name="sparkle" /><span class="mi-label">Find automatically</span><em>{auto}</em>{#if !value}<Icon name="tick" />{/if}
</button>
{:else if row.kind === 'page'}
{@const [a, m, b] = mark(row.p.title)}
<button class="mi" class:act={i === active} role="option" aria-selected={value === row.p.id} onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name={iconOf(row.p)} /><span class="mi-label">{a}{#if m}<mark>{m}</mark>{/if}{b}</span>{#if row.p.parent}<em>{row.p.parent}</em>{/if}{#if value === row.p.id}<Icon name="tick" />{/if}
</button>
{:else}
<button class="mi" class:act={i === active} role="option" aria-selected="false" onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name="plus" /><span class="mi-label">Use “{row.id}”</span><em>new folder</em>
</button>
{/if}
{:else}
<p class="fp-none">No pages yet.</p>
{/each}
</div>
{/if}
+23 -11
View File
@@ -5,6 +5,7 @@
import Icon from './Icon.svelte';
import WindowControls from './WindowControls.svelte';
import { store } from '$lib/state.svelte.js';
import { nodeColor } from '$lib/graphColors.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { createSimulation, neighbourhood } from '$lib/graph.js';
@@ -49,6 +50,18 @@
const ids = new Set(nodes.map((n) => n.id));
let edges = data.edges.filter(([a, b]) => ids.has(a) && ids.has(b)).map((e) => [...e, 'link']);
if (g.folders) for (const n of nodes) if (n.parent && ids.has(n.parent)) edges.push([n.id, n.parent, 'tree']);
if (g.tags) {
// each tag becomes a node its pages hang from
const tagNodes = new Map();
for (const n of nodes) {
if (n.ghost) continue;
for (const t of n.tags || []) {
if (!tagNodes.has(t)) tagNodes.set(t, { id: '#' + t, title: '#' + t, tags: [], ghost: true, tag: t });
edges.push([n.id, '#' + t, 'tree']);
}
}
nodes = nodes.concat([...tagNodes.values()]);
}
if (g.local && cur && ids.has(cur)) {
const keep = neighbourhood(cur, edges, g.depth || 1);
nodes = nodes.filter((n) => keep.has(n.id));
@@ -62,12 +75,14 @@
sim.params.repel = (store.graph.repel || 420) * g.spacing;
sim.params.linkDistance = (store.graph.linkDistance || 96) * g.spacing;
sim.setData(nodes, edges);
// a big graph cools faster (about 120 ticks to rest instead of 300)
sim.alphaDecay = 1 - Math.pow(0.002, 1 / (nodes.length > 3000 ? 120 : 300));
const kind = new Map(edges.map(([a, b, k]) => [a + '\u0000' + b, k]));
for (const l of sim.links) l.kind = kind.get(l.source.id + '\u0000' + l.target.id) || 'link';
counts = { nodes: nodes.filter((n) => !n.ghost).length, links: sim.links.filter((l) => l.kind === 'link').length };
if (!fitted) {
// A head start so the first frame isn't a tangle; then frame it.
sim.tick(Math.min(220, Math.max(60, 120000 / Math.max(1, nodes.length))));
sim.tickFor(Math.min(220, Math.max(60, 120000 / Math.max(1, nodes.length))), 350);
fitted = fit(false);
}
sim.reheat(0.5);
@@ -86,17 +101,12 @@
};
}
$effect(() => { store.theme; readColors(); draw(); });
// colouring a tag while the graph is open
$effect(() => { store.tagColors; redraw(); });
const colorForNode = (n) => {
if (n.tags && n.tags.length > 0) {
// Pick a consistent color based on the first tag's name
let hash = 0;
for (let i = 0; i < n.tags[0].length; i++) hash = (hash << 5) - hash + n.tags[0].charCodeAt(i);
return colors.tags[Math.abs(hash) % colors.tags.length];
}
return colors.ink3; // Default
};
// A tag with a colour (see the sidebar's tag menu) gives its pages that colour here; the rest are plain.
const colorForNode = (n) => nodeColor(n.tags, store.tagColors, colors.ink3);
const radius = (n) => (n.ghost ? 3.5 : 3.6 + Math.sqrt(n.degree) * 1.7);
@@ -185,7 +195,7 @@
/* animation: run the simulation while it's warm or something is being dragged */
function frame() {
raf = 0;
if (sim.running) sim.tick(sim.nodes.length > 1500 ? 1 : 2);
if (sim.running) sim.tickFor(sim.nodes.length > 1500 ? 1 : 2, 14);
draw();
if (sim.running || gesture?.kind === 'drag') start();
}
@@ -279,6 +289,7 @@
}
}
function open(n) {
if (n.tag) { store.filterTag = n.tag; store.closeGraph(); return; }
if (n.ghost) store.openLink(n.title);
else store.openPage(n.id);
}
@@ -375,6 +386,7 @@
{/if}
<label class="gp-row"><span>Notes without links</span><button class="tgl" class:on={store.graph.orphans} role="switch" aria-checked={store.graph.orphans} aria-label="Notes without links" onclick={() => set({ orphans: !store.graph.orphans })}><span></span></button></label>
<label class="gp-row"><span>Links to missing pages</span><button class="tgl" class:on={store.graph.ghosts} role="switch" aria-checked={store.graph.ghosts} aria-label="Links to missing pages" onclick={() => set({ ghosts: !store.graph.ghosts })}><span></span></button></label>
<label class="gp-row"><span>Tags as nodes</span><button class="tgl" class:on={store.graph.tags} role="switch" aria-checked={!!store.graph.tags} aria-label="Tags as nodes" onclick={() => set({ tags: !store.graph.tags })}><span></span></button></label>
<label class="gp-row"><span>Page hierarchy</span><button class="tgl" class:on={store.graph.folders} role="switch" aria-checked={store.graph.folders} aria-label="Page hierarchy" onclick={() => set({ folders: !store.graph.folders })}><span></span></button></label>
<div class="gp-row">
<span>Spacing</span>
+85
View File
@@ -0,0 +1,85 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { isCanvasId, parseCanvas, fileKind } from '$lib/canvas.js';
let items = $state(null);
let closing = $state(false);
let pageId = store.historyOpen;
const canvas = isCanvasId(pageId);
let about = $state({}); // canvas versions: time -> what was on it
async function load() {
try { items = (await api.listHistory(pageId)) || []; } catch (err) { items = []; toast(errorText(err), 'x'); }
}
// What a canvas held at that time, since a bare date says little about a picture.
function describe(text) {
const { nodes, edges } = parseCanvas(text);
const count = (f) => nodes.filter(f).length;
const parts = [
[count((n) => n.type === 'text'), 'card'],
[count((n) => n.type === 'file' && fileKind(n.file) === 'note'), 'note'],
[count((n) => n.type === 'file' && fileKind(n.file) === 'image'), 'picture'],
[count((n) => n.type === 'link'), 'link'],
[count((n) => n.type === 'group'), 'group'],
[edges.length, 'arrow']
].filter(([n]) => n).map(([n, w]) => `${n} ${w}${n === 1 ? '' : 's'}`);
return parts.length ? parts.join(' · ') : 'Empty';
}
async function loadAbout() {
for (const ts of items || []) {
try { about[ts] = describe((await api.readHistory(pageId, ts)).body); } catch { about[ts] = 'Could not be read'; }
}
}
onMount(async () => {
await load();
if (canvas) loadAbout();
});
function close() {
closing = true;
setTimeout(() => { store.historyOpen = null; closing = false; }, 150);
}
// The page as it is now is kept as a version first, so this can be undone from here too.
function restore(ts) {
close();
store.restoreVersion(pageId, ts);
}
const formatDate = (ts) => new Date(ts).toLocaleString(store.prefs.locale || undefined);
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Version History">
<div class="trash-top">
<Icon name="clock" />
<h2>Version history</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="trash-list scroller">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if items.length === 0}
<div class="trash-empty">No versions found.</div>
{:else}
{#each items as ts}
<div class="tr">
<Icon name={canvas ? 'canvas' : 'file'} />
<span class="tr-t">
<b>{formatDate(ts)}</b>
{#if canvas && about[ts]}<small>{about[ts]}</small>{/if}
</span>
<button class="btn" onclick={() => restore(ts)}><Icon name="undo" />Restore</button>
</div>
{/each}
{/if}
</div>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
<script>
import { paths, PATHS, isEmoji } from '$lib/icons.js';
let { name, class: cls = '' } = $props();
</script>
{#if !PATHS[name] && isEmoji(name)}
<span class="i emo {cls}" aria-hidden="true">{name}</span>
{:else}
<svg class="i {cls}" viewBox="0 0 24 24" aria-hidden="true">
{#each paths(name) as d, i (i)}<path {d} />{/each}
</svg>
{/if}
+110
View File
@@ -0,0 +1,110 @@
<script>
// Popover for choosing a page icon: stroke icons or emoji, with search.
import { onMount, tick, untrack } from 'svelte';
import Icon from './Icon.svelte';
import { PICKER_ICONS, PICKER_EMOJI, isEmoji } from '$lib/icons.js';
let { anchor, current, onpick, onremove, onclose } = $props();
const COLS = 8;
let tab = $state(untrack(() => (isEmoji(current) ? 'emoji' : 'icons')));
let q = $state('');
let el = $state();
let gridEl = $state();
let input = $state();
let pos = $state({ x: 0, y: 0 });
let items = $derived.by(() => {
const list = tab === 'emoji' ? PICKER_EMOJI : PICKER_ICONS;
const ql = q.trim().toLowerCase();
return ql ? list.filter(([v, words]) => (v + ' ' + words).toLowerCase().includes(ql)) : list;
});
function place() {
if (!anchor || !el) return;
const a = anchor.getBoundingClientRect(), r = el.getBoundingClientRect();
let y = a.bottom + 8;
if (y + r.height > innerHeight - 8) y = Math.max(8, a.top - r.height - 8);
pos = { x: Math.max(8, Math.min(a.left, innerWidth - r.width - 8)), y };
}
onMount(() => {
place();
input?.focus();
});
function pick(v) { onpick(v); }
function random() {
const list = tab === 'emoji' ? PICKER_EMOJI : PICKER_ICONS;
const options = list.filter(([v]) => v !== current);
onpick(options[Math.floor(Math.random() * options.length)][0]);
}
async function switchTab(t) {
tab = t;
await tick();
input?.focus();
}
function onSearchKey(e) {
if (e.key === 'Enter' && items.length) { e.preventDefault(); pick(items[0][0]); }
if (e.key === 'ArrowDown' && items.length) { e.preventDefault(); gridEl?.querySelector('button')?.focus(); }
}
// Arrow keys move around the grid; up from the first row goes back to the search box.
function onGridKey(e) {
const buttons = [...gridEl.querySelectorAll('button')];
const i = buttons.indexOf(document.activeElement);
if (i < 0) return;
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLS, ArrowUp: -COLS }[e.key];
if (!step) return;
e.preventDefault();
const n = i + step;
if (n < 0) input?.focus();
else buttons[Math.min(n, buttons.length - 1)]?.focus();
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onclose(); }
}
function onDown(e) {
if (el && !el.contains(e.target) && !anchor?.contains(e.target)) onclose();
}
</script>
<svelte:window onkeydown={onKey} onpointerdown={onDown} onresize={place} />
<div class="pop ipick" bind:this={el} role="dialog" aria-label="Choose an icon" style="left:{pos.x}px;top:{pos.y}px">
<div class="ip-top">
<div class="ip-tabs" role="tablist">
<button class="ip-tab" class:on={tab === 'icons'} role="tab" aria-selected={tab === 'icons'} onclick={() => switchTab('icons')}>Icons</button>
<button class="ip-tab" class:on={tab === 'emoji'} role="tab" aria-selected={tab === 'emoji'} onclick={() => switchTab('emoji')}>Emoji</button>
</div>
<button class="btn-icon" aria-label="Random icon" title="Random" onclick={random}><Icon name="shuffle" /></button>
<button class="btn-icon" aria-label="Remove icon" title="Remove (use the default)" onclick={onremove}><Icon name="trash" /></button>
</div>
<div class="ip-search">
<Icon name="search" />
<input
bind:this={input}
bind:value={q}
placeholder={tab === 'emoji' ? 'Search emoji' : 'Search icons'}
aria-label="Search"
spellcheck="false"
onkeydown={onSearchKey}
/>
</div>
<div class="ip-grid" class:emoji={tab === 'emoji'} bind:this={gridEl} role="listbox" tabindex="-1" onkeydown={onGridKey}>
{#each items as [v, words] (v)}
<button
class="ip-cell"
class:on={v === current}
role="option"
aria-selected={v === current}
aria-label={tab === 'emoji' ? words.split(' ')[0] : v}
title={tab === 'emoji' ? words.split(' ')[0] : v}
onclick={() => pick(v)}
>
{#if tab === 'emoji'}<span class="ip-emo">{v}</span>{:else}<Icon name={v} />{/if}
</button>
{:else}
<p class="ip-none">Nothing matches “{q}”.</p>
{/each}
</div>
</div>
+103
View File
@@ -0,0 +1,103 @@
<script>
// Outline of the open page: its headings, indented by level. Click one to scroll there;
// the entry for the section you're reading stays highlighted as you scroll.
import { tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { scrollToBlock } from '$lib/scroll.js';
let { scroller } = $props(); // the page's scrolling element
let active = $state(null);
let lockUntil = 0; // ignore scroll updates while a click's smooth scroll is still running
let listEl = $state();
// Indent relative to the highest level actually used, so a page of only ## and ### starts flush left.
let base = $derived(store.headings.reduce((m, h) => Math.min(m, h.level), 3));
const blockEl = (id) => scroller?.querySelector(`.block[data-id="${id}"]`);
// The last heading whose top has passed a line just under the top of the page.
function findActive() {
if (!scroller || Date.now() < lockUntil) return;
const line = scroller.getBoundingClientRect().top + 28;
let cur = null;
for (const h of store.headings) {
const el = blockEl(h.id);
if (!el) continue;
if (el.getBoundingClientRect().top <= line) cur = h.id;
else break;
}
active = cur;
}
let frame = 0;
const onScroll = () => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; findActive(); }); };
$effect(() => {
if (!scroller) return;
scroller.addEventListener('scroll', onScroll, { passive: true });
return () => { scroller.removeEventListener('scroll', onScroll); cancelAnimationFrame(frame); frame = 0; };
});
// Headings changed (typing, another page): recompute once the page has rendered.
$effect(() => {
store.headings;
store.doc?.version;
let stale = false;
tick().then(() => setTimeout(() => { if (!stale) findActive(); }, 60));
return () => { stale = true; };
});
// Up/Down move between entries.
$effect(() => {
if (!listEl) return;
listEl.addEventListener('keydown', onKey);
return () => listEl.removeEventListener('keydown', onKey);
});
// Keep the highlighted entry in view when the outline itself scrolls (long pages).
$effect(() => {
if (!active || !listEl) return;
listEl.querySelector('.ol-item.on')?.scrollIntoView({ block: 'nearest' });
});
function jump(h) {
const el = blockEl(h.id);
if (!el || !scroller) return;
active = h.id;
lockUntil = Date.now() + scrollToBlock(scroller, el);
}
function onKey(e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const items = [...listEl.querySelectorAll('.ol-item')];
const i = items.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
items[Math.max(0, Math.min(items.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))].focus();
}
</script>
<aside class="outline" aria-label="Outline">
<header class="ol-head">
<span>Outline</span>
{#if store.headings.length}<em>{store.headings.length}</em>{/if}
<button class="btn-icon" aria-label="Close outline" title="Close outline" onclick={() => store.toggleOutline(false)}><Icon name="x" /></button>
</header>
<div class="ol-list" bind:this={listEl}>
{#each store.headings as h (h.id)}
<button
class="ol-item"
class:on={active === h.id}
class:top={h.level === base}
style="--lv:{h.level - base}"
title={h.text}
aria-current={active === h.id ? 'location' : undefined}
onclick={() => jump(h)}
><span>{h.text}</span></button>
{:else}
<p class="ol-empty">No headings yet.<br />Start a line with <kbd>#</kbd>, <kbd>##</kbd> or <kbd>###</kbd> to make one.</p>
{/each}
</div>
</aside>
+202 -22
View File
@@ -6,9 +6,14 @@
import Board from './Board.svelte';
import Outline from './Outline.svelte';
import { store, iconOf, coverOf } from '$lib/state.svelte.js';
import { COVERS } from '$lib/icons.js';
import { COVERS, isImageCover } from '$lib/icons.js';
import { tagHex } from '$lib/tagColors.js';
import IconPicker from './IconPicker.svelte';
import { api } from '$lib/api.js';
import Database from './Database.svelte';
import PropCell from './PropCell.svelte';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { read, write, typeInfo, computeFormulas, TYPES } from '$lib/database.js';
let doc = $derived(store.doc);
let page = $derived(store.current);
@@ -17,6 +22,27 @@
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
let created = $derived(doc?.meta.created || page?.created || null);
// When this page is a record of a database: that database's properties, shown here with their own editors.
let schema = $state(null); // { dbId, properties }
$effect(() => {
const parent = page && page.parent;
const owner = parent && store.pages.find((x) => x.id === parent);
const mine = doc && doc.id;
if (!owner || !owner.db || store.isDatabase) { schema = null; return; }
store.readSchema(parent).then((s) => { if (store.doc && store.doc.id === mine) schema = s ? { dbId: parent, properties: s.properties } : null; });
});
const schemaKeys = $derived(new Set((schema ? schema.properties : []).map((p) => p.key)));
// formula properties of the database, worked out for this record
const formulas = $derived(schema && doc ? computeFormulas(schema.properties, { title: doc.title, props: doc.meta }) : {});
async function addSchemaOption(p, name) {
const s = await store.readSchema(schema.dbId);
const q = s && s.properties.find((x) => x.key === p.key);
if (!q || (q.options || []).includes(name)) return;
(q.options ||= []).push(name);
await store.saveSchema(schema.dbId, s);
schema = { dbId: schema.dbId, properties: s.properties };
}
let scrollerEl = $state();
let titleEl = $state();
let iconEl = $state();
@@ -40,7 +66,7 @@
const fmtDate = (d) => {
if (!d) return '—';
const t = new Date(String(d).length === 10 ? d + 'T12:00:00' : d);
return isNaN(t) ? String(d) : t.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
return isNaN(t) ? String(d) : t.toLocaleDateString(store.prefs.locale || undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
function restart(el, cls) {
@@ -55,6 +81,37 @@
restart(iconEl, 'spin');
iconEl?.focus();
}
let coverBroken = $state(false);
const coverImage = $derived(isImageCover(doc?.meta.cover) ? doc.meta.cover : null);
$effect(() => { coverImage; coverBroken = false; });
// Pick a picture with the file dialog, or take a dropped one: it is saved next to the page like any image.
async function uploadCover() {
try {
const name = await api.pickImage(doc.id);
if (name) setCover(name);
} catch (err) {
toast(errorText(err), 'x');
}
}
// A picture from a web address: downloaded by the app and saved next to the page. Throws a message for the picker.
async function coverFromUrl(address) {
const name = await api.saveImageFromUrl(doc.id, address);
if (name) setCover(name);
}
async function dropCover(e) {
const file = [...(e.dataTransfer?.files || [])].find((f) => /^image\//.test(f.type));
if (!file) return;
e.preventDefault();
coverOver = false;
try {
if (file.size > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
const name = await api.saveImage(doc.id, file.name || 'cover.png', new Uint8Array(await file.arrayBuffer()));
if (name) setCover(name);
} catch (err) {
toast(errorText(err), 'x');
}
}
let coverOver = $state(false);
function setCover(v) {
pickingCover = false;
store.setMeta({ cover: v });
@@ -113,6 +170,52 @@
let addingProp = $state(false);
let newPropEl = $state();
/* A plain page's own properties are typed by their value: true/false is a checkbox, a number a number, YYYY-MM-DD a
date, a list a multi-select, an address a link. Nothing but the value is stored, so Obsidian reads them as is. */
const PLAIN_TYPES = ['text', 'number', 'date', 'checkbox', 'multiselect', 'url'];
const HIDDEN_KEYS = ['icon', 'cover', 'tags', 'created', 'ink-colors', 'kanban-plugin', 'ink-database', 'source'];
function inferType(v) {
if (typeof v === 'boolean' || /^(true|false)$/i.test(String(v))) return 'checkbox';
if (Array.isArray(v)) return 'multiselect';
if (typeof v === 'number' || /^-?\d+(\.\d+)?$/.test(String(v).trim())) return 'number';
if (/^\d{4}-\d{2}-\d{2}/.test(String(v))) return 'date';
if (/^https?:\/\/\S+$/i.test(String(v).trim())) return 'url';
return 'text';
}
const customKeys = $derived(doc ? Object.keys(doc.meta).filter((k) => doc.meta[k] !== undefined && !HIDDEN_KEYS.includes(k) && !schemaKeys.has(k)) : []);
// the values other pages give this key, as the choices of a list property
function optionsFor(k) {
const seen = new Set();
for (const p of store.pages) {
const v = p.props && p.props[k];
for (const x of Array.isArray(v) ? v : []) if (typeof x === 'string' && x.trim()) seen.add(x.trim());
}
const mine = doc.meta[k];
for (const x of Array.isArray(mine) ? mine : []) seen.add(String(x));
return [...seen].sort((a, b) => a.localeCompare(b));
}
const propFor = (k) => {
const type = inferType(doc.meta[k]);
return { key: k, name: k, type, options: type === 'multiselect' ? optionsFor(k) : [] };
};
const DEFAULTS = { text: '', number: '0', date: () => new Date().toISOString().slice(0, 10), checkbox: 'false', multiselect: [], url: '' };
const startValue = (t) => (typeof DEFAULTS[t] === 'function' ? DEFAULTS[t]() : DEFAULTS[t]);
function convertValue(v, from, to) {
if (to === 'multiselect') return Array.isArray(v) ? v : String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const plain = Array.isArray(v) ? v.join(', ') : String(v ?? '');
if (to === 'checkbox') return /^(true|yes|1|x|on)$/i.test(plain) ? 'true' : 'false';
if (to === 'number') { const n = Number(plain.replace(',', '.')); return Number.isFinite(n) && plain.trim() ? String(n) : '0'; }
if (to === 'date') return /^\d{4}-\d{2}-\d{2}/.test(plain) ? plain.slice(0, 10) : startValue('date');
return plain;
}
let typeMenu = $state(null); // { key, x, y } for changing a property's type
let newType = $state('text');
function setType(k, to) {
const from = inferType(doc.meta[k]);
typeMenu = null;
if (from !== to) store.setMeta({ [k]: convertValue(doc.meta[k], from, to) });
}
function updateMeta(k, v) {
if (Array.isArray(doc.meta[k])) {
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
@@ -141,13 +244,15 @@
function commitNewProp(k) {
k = k.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
if (k && !doc.meta[k] && !['icon', 'cover', 'tags', 'created'].includes(k)) {
store.setMeta({ [k]: '' });
if (k && doc.meta[k] === undefined && !HIDDEN_KEYS.includes(k)) {
store.setMeta({ [k]: startValue(newType) });
}
addingProp = false;
}
</script>
<svelte:window onclick={(e) => { if (typeMenu && !e.target.closest?.('.ptype, .pk-type')) typeMenu = null; }} />
<div class="pagewrap">
<div class="scroller" bind:this={scrollerEl}>
{#if doc}
@@ -157,12 +262,26 @@
{/each}
</datalist>
{#key doc.version}
<div class="cover enter" bind:this={coverEl} style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}">
<div
class="cover enter"
class:photo={coverImage && !coverBroken}
class:over={coverOver}
bind:this={coverEl}
style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}"
role="presentation"
ondragover={(e) => { if ([...(e.dataTransfer?.items || [])].some((i) => i.kind === 'file')) { e.preventDefault(); coverOver = true; } }}
ondragleave={() => (coverOver = false)}
ondrop={dropCover}
>
{#if coverImage}
<img class="cover-img" src={api.mediaUrl(doc.id, coverImage)} alt="" draggable="false" onerror={() => (coverBroken = true)} />
{/if}
<button class="cover-btn" bind:this={coverBtnEl} onclick={() => (pickingCover = !pickingCover)}><Icon name="image" />Change cover</button>
</div>
<article class="page enter" class:wide={store.isBoard}>
<article class="page enter" class:wide={store.isBoard || store.isDatabase}>
<div class="head">
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}>
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: doc.id }; }}>
<Icon name={icon} />
</button>
<h1
@@ -176,12 +295,39 @@
onblur={() => store.commitTitle(titleEl.textContent)}
onpaste={onTitlePaste}
></h1>
{#if !store.isDatabase}
<div class="props">
{#if schema}
{#each schema.properties as p (p.key)}
<div class="prop">
<span class="pk"><Icon name={typeInfo(p.type).icon} />{p.name}</span>
<div class="pv db-pv">
<PropCell
prop={p}
value={p.type === 'formula' ? formulas[p.key]?.text ?? '' : read(doc.meta[p.key], p.type)}
error={p.type === 'formula' ? formulas[p.key]?.error || '' : ''}
recordId={doc.id}
choices={p.type === 'relation' ? store.records(schema.dbId).filter((r) => r.id !== doc.id).map((r) => r.title) : []}
plain
onchange={(v) => store.setMeta({ [p.key]: write(v, p.type) })}
onaddoption={(n) => addSchemaOption(p, n)}
/>
</div>
</div>
{/each}
{/if}
<div class="prop">
<span class="pk"><Icon name="hash" />Tags</span>
<div class="pv tagv">
{#each tags as t (t)}
<span class="chip in" class:out={removing === t}>
<span
class="chip in"
class:tinted={!!tagHex(store.tagColors[t])}
class:out={removing === t}
style={tagHex(store.tagColors[t]) ? `--tc:${tagHex(store.tagColors[t])}` : undefined}
role="presentation"
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, tag: t }; }}
>
{t}<button class="chip-x" aria-label="Remove tag {t}" onclick={() => removeTag(t)}><Icon name="x" /></button>
</span>
{/each}
@@ -212,44 +358,75 @@
<span class="pk"><Icon name="file" />File</span>
<button class="pv mono pv-link" title="Show in folder" onclick={() => api.reveal(doc.id)}>{store.relPath(doc.id)}</button>
</div>
{#each Object.keys(doc.meta).filter(k => doc.meta[k] !== undefined && !['icon', 'cover', 'tags', 'created'].includes(k)) as pk (pk)}
{#each customKeys as pk (pk)}
{@const cp = propFor(pk)}
<div class="prop">
<span class="pk">
<Icon name="list" />
<button class="pk-type" aria-label="Change type of {pk}" title="Type: {typeInfo(cp.type).label}"
onclick={(e) => { const r = e.currentTarget.getBoundingClientRect(); typeMenu = typeMenu && typeMenu.key === pk ? null : { key: pk, x: r.left, y: r.bottom + 4 }; }}>
<Icon name={typeInfo(cp.type).icon} />
</button>
<input class="custom-pk" value={pk} onchange={(e) => renameMeta(pk, e.target.value)} />
</span>
<div style="display:flex; gap: 4px; align-items: center; width: 100%;">
<input
class="pv custom-pv"
value={Array.isArray(doc.meta[pk]) ? doc.meta[pk].join(', ') : doc.meta[pk]}
onchange={(e) => updateMeta(pk, e.target.value)}
placeholder="Empty"
{#if cp.type === 'text'}
<input class="pv custom-pv" value={doc.meta[pk]} onchange={(e) => updateMeta(pk, e.target.value)} placeholder="Empty" />
{:else}
<div class="pv db-pv">
<PropCell
prop={cp}
value={read(doc.meta[pk], cp.type)}
recordId={doc.id}
plain
onchange={(v) => store.setMeta({ [pk]: write(v, cp.type) })}
onaddoption={() => {}}
/>
</div>
{/if}
<button class="chip-x" aria-label="Remove property" onclick={() => removeMeta(pk)}><Icon name="x" /></button>
</div>
</div>
{/each}
{#if addingProp}
<div class="prop">
<span class="pk"><Icon name="list" /><input class="custom-pk" bind:this={newPropEl} placeholder="Key" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => commitNewProp(e.target.value)} /></span>
<span class="pk"><Icon name={typeInfo(newType).icon} /><input class="custom-pk" bind:this={newPropEl} placeholder="{typeInfo(newType).label} property name" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => { if (!e.relatedTarget?.classList?.contains('type-chip')) commitNewProp(e.target.value); }} /></span>
<span class="pv custom-pv"></span>
</div>
{/if}
<div class="prop">
<span class="pk"></span>
<div class="pv"><button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button></div>
<div class="pv tagv">
{#if addingProp}
{#each PLAIN_TYPES as t (t)}
<button class="chip-add type-chip" class:on={newType === t} onclick={() => { newType = t; newPropEl?.focus(); }}><Icon name={typeInfo(t).icon} />{typeInfo(t).label}</button>
{/each}
{:else}
<button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button>
{/if}
</div>
</div>
</div>
{#if !store.isBoard}
{/if}
</div>
{#if !store.isBoard && !store.isDatabase}
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
<Backlinks id={doc.id} />
{/if}
</article>
{#if typeMenu}
<div class="ptype" role="menu" style="left:{typeMenu.x}px;top:{typeMenu.y}px">
{#each PLAIN_TYPES as t (t)}
<button class="mi" class:on={inferType(doc.meta[typeMenu.key]) === t} role="menuitem" onclick={() => setType(typeMenu.key, t)}><Icon name={typeInfo(t).icon} />{typeInfo(t).label}</button>
{/each}
</div>
{/if}
{#if pickingCover}
<CoverPicker
anchor={coverBtnEl}
current={cover}
current={coverImage ? '' : cover}
hasImage={!!coverImage}
onupload={uploadCover}
onurl={coverFromUrl}
onpick={setCover}
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
/>
@@ -263,7 +440,10 @@
onclose={() => { picking = false; iconEl?.focus(); }}
/>
{/if}
{#if store.isBoard}
{#if store.isDatabase}
<Database id={doc.id} body={doc.body} />
<div class="page wide"><Backlinks id={doc.id} /></div>
{:else if store.isBoard}
<Board body={doc.body} />
<div class="page wide"><Backlinks id={doc.id} /></div>
{/if}
@@ -275,5 +455,5 @@
</div>
{/if}
</div>
{#if store.outlineOpen && doc && !store.isBoard}<Outline scroller={scrollerEl} />{/if}
{#if store.outlineOpen && doc && !store.isBoard && !store.isDatabase}<Outline scroller={scrollerEl} />{/if}
</div>
+170
View File
@@ -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>
+192
View File
@@ -0,0 +1,192 @@
<script>
// One property value of a record, editable in place. Used by the database views and by a record's own page.
// value: what database.read() gives for the type; onchange(next): the edited value.
// onaddoption(name): a select option the user typed that the schema doesn't list yet.
import Icon from './Icon.svelte';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { optionColor, formatNumber, barFraction, formulaDisplay } from '$lib/database.js';
let { prop, value, onchange, onaddoption, recordId, suggestions = [], choices = [], plain = false, error = '' } = $props();
let trigger = $state();
let box = $state();
let open = $state(false);
let q = $state('');
let at = $state({ left: 0, top: 0, bottom: null, width: 220, max: 280 });
let adding = $state(false);
let draft = $state('');
const isRelation = $derived(prop.type === 'relation');
const options = $derived(isRelation ? choices : prop.options || []);
const shade = (o) => (isRelation ? 'var(--ink-3)' : optionColor(o, prop));
let editingNumber = $state(false);
const showsFormat = $derived(prop.type === 'number' && prop.format && prop.format !== 'number');
const chosen = $derived(prop.type === 'select' ? (value ? [value] : []) : Array.isArray(value) ? value : []);
const shown = $derived.by(() => {
const s = q.trim().toLowerCase();
return s ? options.filter((o) => o.toLowerCase().includes(s)) : options;
});
const canCreate = $derived(!isRelation && q.trim() && !options.some((o) => o.toLowerCase() === q.trim().toLowerCase()));
function show() {
const r = trigger.getBoundingClientRect();
const width = Math.max(r.width, 220);
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 200 && above > below;
const left = Math.max(8, Math.min(r.left, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 4, width, max: Math.min(300, above) }
: { left, top: r.bottom + 4, bottom: null, width, max: Math.min(300, below) };
q = '';
open = true;
requestAnimationFrame(() => box?.querySelector('input')?.focus());
}
function close(refocus = true) {
open = false;
if (refocus) trigger?.focus();
}
function pick(name) {
if (prop.type === 'select') {
close();
onchange(name === value ? '' : name);
} else {
onchange(chosen.includes(name) ? chosen.filter((x) => x !== name) : [...chosen, name]);
}
}
function create() {
const name = q.trim().replace(/,/g, ' ');
if (!name) return;
const known = options.find((o) => o.toLowerCase() === name.toLowerCase());
if (!known) onaddoption?.(name);
q = '';
if (prop.type === 'select') { close(); onchange(known || name); }
else if (!chosen.includes(known || name)) onchange([...chosen, known || name]);
}
function onfind(e) {
if (e.key === 'Enter') { e.preventDefault(); if (canCreate) create(); else if (shown.length === 1) pick(shown[0]); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
}
function onpointerdown(e) {
if (open && box && !box.contains(e.target) && !trigger?.contains(e.target)) close(false);
}
/* people and files: a row of chips with an add field / button */
function addPerson() {
const name = draft.trim().replace(/,/g, ' ');
draft = '';
adding = false;
if (name && !chosen.includes(name)) onchange([...chosen, name]);
}
async function attach() {
try {
const names = await api.pickFile(recordId);
if (names && names.length) onchange([...new Set([...chosen, ...names])]);
} catch (err) {
toast(errorText(err), 'x');
}
}
async function openFile(name) {
try { await api.openFile(recordId, name); } catch (err) { toast(errorText(err), 'x'); }
}
const focusOnMount = (el) => el.focus();
// The option list is fixed-positioned from the cell's rectangle, so it lives on <body>: inside a page whose parts
// are animated or transformed, "fixed" would be measured from that part instead and the list would land elsewhere.
function portal(node) {
document.body.appendChild(node);
return { destroy() { node.remove(); } };
}
// an input that replaced the formatted number takes the keyboard
const numberFocus = (el, on) => { if (on) { el.focus(); el.select(); } };
</script>
<svelte:window {onpointerdown} />
{#if prop.type === 'formula'}
<span class="db-formula" class:err={!!error} class:empty={!error && (value === '' || value === null || value === undefined)} title={error || (prop.expression || 'Formula')}>
{#if error}<Icon name="bell" />Error{:else if value === 'true' || value === 'false'}<span class="db-ck" class:on={value === 'true'}><Icon name="tick" /></span>{:else}{formulaDisplay(prop, value)}{/if}
</span>
{:else if prop.type === 'checkbox'}
<label class="dbx" class:on={value}>
<input type="checkbox" checked={!!value} onchange={(e) => onchange(e.currentTarget.checked)} aria-label={prop.name} />
<span class="dbx-box"><Icon name="tick" /></span>
</label>
{:else if prop.type === 'select' || prop.type === 'multiselect' || isRelation}
<button class="db-sel" bind:this={trigger} aria-haspopup="listbox" aria-expanded={open} onclick={() => (open ? close(false) : show())}>
{#each chosen as o (o)}<span class="db-opt" style="--oc:{shade(o)}">{o}</span>{/each}
{#if !chosen.length}<span class="db-empty">{plain ? 'Empty' : ''}</span>{/if}
</button>
{#if open}
<div class="pop db-pop" use:portal bind:this={box} style="left:{at.left}px;{at.top !== null ? `top:${at.top}px` : `bottom:${at.bottom}px`};width:{at.width}px;max-height:{at.max}px">
<input class="db-find" placeholder={isRelation ? 'Find a record' : prop.type === 'select' ? 'Find or create an option' : 'Find or add options'} bind:value={q} onkeydown={onfind} />
<div class="db-list" role="listbox">
{#if prop.type === 'select' && value}
<button class="mi" role="option" aria-selected="false" onclick={() => pick(value)}><Icon name="x" /><span>Clear</span></button>
{/if}
{#each shown as o (o)}
<button class="mi" role="option" aria-selected={chosen.includes(o)} onclick={() => pick(o)}>
<span class="db-opt" style="--oc:{shade(o)}">{o}</span>
{#if chosen.includes(o)}<span class="mi-tick"><Icon name="tick" /></span>{/if}
</button>
{/each}
{#if canCreate}
<button class="mi" onclick={create}><Icon name="plus" /><span>Create “{q.trim()}”</span></button>
{:else if !shown.length}
<div class="db-none">{isRelation ? 'No other records.' : 'No options yet. Type a name and press Enter.'}</div>
{/if}
</div>
</div>
{/if}
{:else if prop.type === 'people'}
<div class="db-chips">
{#each chosen as n (n)}
<span class="db-person"><i>{n.slice(0, 1).toUpperCase()}</i>{n}<button class="chip-x" aria-label="Remove {n}" onclick={() => onchange(chosen.filter((x) => x !== n))}><Icon name="x" /></button></span>
{/each}
{#if adding}
<input
class="chip-in db-add"
list="db-people-{prop.key}"
placeholder="Name"
bind:value={draft}
use:focusOnMount
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addPerson(); } if (e.key === 'Escape') { draft = ''; adding = false; } }}
onblur={addPerson}
/>
<datalist id="db-people-{prop.key}">{#each suggestions.filter((s) => !chosen.includes(s)) as s (s)}<option value={s}></option>{/each}</datalist>
{:else}
<button class="chip-add db-plus" aria-label="Add a person" onclick={() => (adding = true)}><Icon name="plus" />{chosen.length ? '' : 'Add'}</button>
{/if}
</div>
{:else if prop.type === 'files'}
<div class="db-chips">
{#each chosen as f (f)}
<span class="db-file"><button class="db-file-name" title="Open {f}" onclick={() => openFile(f)}><Icon name="paperclip" />{f}</button><button class="chip-x" aria-label="Remove {f}" onclick={() => onchange(chosen.filter((x) => x !== f))}><Icon name="x" /></button></span>
{/each}
<button class="chip-add db-plus" aria-label="Attach files" onclick={attach}><Icon name="plus" />{chosen.length ? '' : 'Add'}</button>
</div>
{:else if showsFormat && !editingNumber}
<button class="db-numfmt" aria-label="{prop.name}: edit" onclick={() => (editingNumber = true)}>
{#if prop.format === 'bar'}
<span class="db-barline" role="progressbar" aria-valuenow={value ?? 0} aria-valuemax={prop.max || 100}><i style="width:{barFraction(value, prop) * 100}%"></i></span>
<span class="db-barnum">{value === null ? '' : formatNumber(value, { format: 'commas' })}</span>
{:else}
<span>{formatNumber(value, prop)}</span>
{/if}
</button>
{:else if prop.type === 'number'}
<input
class="db-in num"
type="number"
step="any"
value={value ?? ''}
aria-label={prop.name}
use:numberFocus={editingNumber}
onblur={() => (editingNumber = false)}
onchange={(e) => onchange(e.currentTarget.value === '' ? null : Number(e.currentTarget.value))}
/>
{:else if prop.type === 'date'}
<input class="db-in" type="date" value={value || ''} aria-label={prop.name} onchange={(e) => onchange(e.currentTarget.value)} />
{:else}
<input class="db-in" type={prop.type === 'url' ? 'url' : 'text'} value={value ?? ''} aria-label={prop.name} placeholder={plain ? 'Empty' : ''} onchange={(e) => onchange(e.currentTarget.value)} />
{/if}
+127
View File
@@ -0,0 +1,127 @@
<script>
// A styled replacement for <select>: a field that opens a list, with a search box when the list is long.
import Icon from './Icon.svelte';
// options: [{ value, label, detail? }]; onchange(value)
let { options, value = '', onchange, label = '', placeholder = 'Choose…', searchAt = 12 } = $props();
let trigger = $state();
let box = $state();
let open = $state(false);
let q = $state('');
let active = $state(0);
let at = $state({ left: 0, top: 0, bottom: null, width: 260, max: 300 });
const searchable = $derived(options.length > searchAt);
const current = $derived(options.find((o) => o.value === value));
const shown = $derived.by(() => {
const s = q.trim().toLowerCase();
if (!s) return options;
const rank = (o) => (o.label.toLowerCase().startsWith(s) ? 0 : o.label.toLowerCase().includes(s) ? 1 : (o.detail || '').toLowerCase().includes(s) ? 2 : 3);
return options.map((o) => [rank(o), o]).filter(([r]) => r < 3).sort((a, b) => a[0] - b[0]).map(([, o]) => o);
});
function show() {
const r = trigger.getBoundingClientRect();
const width = Math.max(r.width, 260);
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 260 && above > below;
const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 6, width, max: Math.min(320, above) }
: { left, top: r.bottom + 6, bottom: null, width, max: Math.min(320, below) };
q = '';
active = Math.max(0, options.findIndex((o) => o.value === value));
open = true;
// the search box (or the list) takes the keyboard; the current row is scrolled into view
requestAnimationFrame(() => {
box?.querySelector('.sm-find input')?.focus();
box?.querySelector('.mi.act')?.scrollIntoView({ block: 'nearest' });
});
}
function close(refocus = true) {
open = false;
if (refocus) trigger?.focus();
}
function choose(o) {
if (!o) return;
close();
if (o.value !== value) onchange?.(o.value);
}
function onkeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!shown.length) return;
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length;
box?.querySelector('.mi.act')?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
choose(shown[active]);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation(); // the dialog stays
close();
} else if (e.key === 'Tab') {
close(false);
}
}
function onTriggerKey(e) {
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
e.preventDefault();
open ? close() : show();
}
}
const mark = (s) => {
const t = q.trim().toLowerCase();
const i = t ? s.toLowerCase().indexOf(t) : -1;
return i < 0 ? [s, '', ''] : [s.slice(0, i), s.slice(i, i + t.length), s.slice(i + t.length)];
};
</script>
<svelte:window onresize={() => (open = false)} onpointerdown={(e) => { if (open && !box?.contains(e.target) && !trigger?.contains(e.target)) close(false); }} />
<button
bind:this={trigger}
class="fp sm"
class:open
role="combobox"
aria-label={label}
aria-haspopup="listbox"
aria-expanded={open}
onclick={() => (open ? close() : show())}
onkeydown={onTriggerKey}
>
<span class="sm-val" class:muted={!current}>{current ? current.label : placeholder}</span>
<span class="fp-chev"><Icon name="chev" /></span>
</button>
{#if open}
<div bind:this={box} class="pop fp-list sm-list" class:up={at.top === null} role="listbox" aria-label={label}
style="left:{at.left}px;{at.top !== null ? `top:${at.top}px;` : `bottom:${at.bottom}px;`}width:{at.width}px;max-height:{at.max}px">
{#if searchable}
<div class="sm-find">
<Icon name="search" />
<input bind:value={q} placeholder="Find…" aria-label="Find in the list" spellcheck="false" autocomplete="off" {onkeydown} oninput={() => (active = 0)} />
</div>
{/if}
<div class="sm-rows" tabindex="-1" role="presentation" {onkeydown}>
{#each shown as o, i (o.value)}
{@const [a, m, b] = mark(o.label)}
<button
class="mi"
class:act={i === active}
role="option"
aria-selected={o.value === value}
onmousemove={() => (active = i)}
onclick={() => choose(o)}
>
<span class="mi-label">{a}{#if m}<mark>{m}</mark>{/if}{b}</span>
{#if o.detail}<em>{o.detail}</em>{/if}
{#if o.value === value}<Icon name="tick" />{/if}
</button>
{:else}
<p class="fp-none">Nothing matches.</p>
{/each}
</div>
</div>
{/if}
+587
View File
@@ -0,0 +1,587 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import FolderPicker from './FolderPicker.svelte';
import SelectMenu from './SelectMenu.svelte';
import { store } from '$lib/state.svelte.js';
import { api, isDesktop, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { KINDS } from '$lib/editor.js';
import { DEFAULTS, DATE_FORMATS } from '$lib/prefs.js';
import { formatDate } from '$lib/daily.js';
import { ACTIONS, comboOf, eventCombo, format, valid as validShortcut } from '$lib/shortcuts.js';
let closing = $state(false);
let spell = $state(null); // { enabled, languages, available, system }
let linkPreviews = $state(true);
let words = $state([]);
let newWord = $state('');
let backup = $state({ folder: '', last: 0 });
let backingUp = $state(false);
const p = $derived(store.prefs);
const set = (patch) => store.setPrefs(patch);
const setGraph = (patch) => store.setGraph(patch);
const hidden = $derived(new Set(p.hiddenKinds));
const kinds = KINDS.filter((k) => k.t !== 'p');
let tab = $state('appearance');
const TABS = [
['appearance', 'Appearance', 'palette'], ['editor', 'Editor', 'pen'], ['notes', 'Notes', 'folder'],
['canvas', 'Graph & canvas', 'graph'], ['general', 'General', 'gear'], ['shortcuts', 'Shortcuts', 'key']
];
const UI_FONTS = [['inter', 'Inter'], ['system', 'System'], ['mono', 'Mono']];
const DENSITIES = [['compact', 'Compact'], ['default', 'Default'], ['comfortable', 'Roomy']];
const AUTOSAVE = [[300, 'Fast'], [600, 'Default'], [1500, 'Relaxed'], [3000, 'Slow']];
const BACKUPS = [['off', 'Off'], ['daily', 'Daily'], ['weekly', 'Weekly']];
const CAPTURE_TO = [['daily', "Today's note"], ['inbox', 'Inbox']];
const WEEKS = [['monday', 'Monday'], ['sunday', 'Sunday']];
const NEW_ICON = [['random', 'Random'], ['none', 'None']];
const EXPORT_LINKS = [['wiki', '[[Wiki]]'], ['markdown', 'Markdown']];
const CARD_COLORS = [['', 'None', null], ['1', 'Red', '#e5534b'], ['2', 'Orange', '#e8883a'], ['3', 'Yellow', '#d9b130'], ['4', 'Green', '#44ad6e'], ['5', 'Cyan', '#3aa6c4'], ['6', 'Purple', '#9a6de0']];
const LOCALES = [['', 'System'], ['en-US', 'English (US)'], ['en-GB', 'English (UK)'], ['de-DE', 'Deutsch'], ['fr-FR', 'Français'], ['es-ES', 'Español'], ['it-IT', 'Italiano'], ['pt-BR', 'Português (Brasil)'], ['nl-NL', 'Nederlands'], ['sv-SE', 'Svenska'], ['pl-PL', 'Polski'], ['ru-RU', 'Русский'], ['ja-JP', '日本語'], ['zh-CN', '简体中文'], ['ko-KR', '한국어']];
const mac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
/* rebinding: click a shortcut, press the new keys; Escape cancels, Backspace puts the original back */
let capturing = $state(null); // action id
const comboNow = (id) => comboOf(id, p.shortcuts);
function onCaptureKey(e) {
if (!capturing) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') { capturing = null; return; }
const id = capturing;
if (e.key === 'Backspace' || e.key === 'Delete') {
const next = { ...p.shortcuts };
delete next[id];
set({ shortcuts: next });
capturing = null;
return;
}
const c = eventCombo(e);
if (!c) return; // only modifiers so far
if (!validShortcut(c)) { toast('Use Ctrl (or ⌘) with a key', 'x'); return; }
const clash = ACTIONS.find((a) => a.id !== id && comboOf(a.id, p.shortcuts) === c);
if (clash) { toast(`${format(c, mac)} is already “${clash.label}”`, 'x'); return; }
const def = ACTIONS.find((a) => a.id === id).combo;
const next = { ...p.shortcuts };
if (c === def) delete next[id]; else next[id] = c;
set({ shortcuts: next });
capturing = null;
}
const M = mac ? '⌘' : 'Ctrl';
const SHORTCUTS = [
['General', [
['Command palette', [M, 'K']], ['Settings', [M, ',']], ['New page', [M, 'N']], ["Today's note", [M, 'Shift', 'D']],
['Graph view', [M, 'G']], ['Toggle sidebar', [M, '\\']], ['Split view', [M, 'Shift', '\\']], ['Outline', [M, 'Shift', 'O']],
['Find in page', [M, 'F']], ['Save now', [M, 'S']], ['Back', ['Alt', '←']], ['Forward', ['Alt', '→']]
]],
['Writing', [
['Bold', [M, 'B']], ['Italic', [M, 'I']], ['Inline code', [M, 'E']], ['Strikethrough', [M, 'Shift', 'S']],
['Highlight', [M, 'Shift', 'H']], ['Link', [M, 'K']], ['Duplicate block', [M, 'D']], ['Block menu', ['/']], ['Link to a page', ['[[']]
]],
['Markdown', [
['Heading 1 – 3', ['#', '##', '###']], ['Bulleted list', ['- ']], ['Numbered list', ['1. ']], ['To-do', ['[] ']],
['Quote', ['> ']], ['Code block', ['```']], ['Divider', ['---']]
]]
];
const sample = formatDate(new Date(), 'YYYY-MM-DD');
// [name, label, swatch color]: the swatch shows the dark-theme tone, close enough in either.
const ACCENTS = [['blush', 'Blush', '#e4ada5'], ['amber', 'Amber', '#e0b672'], ['sage', 'Sage', '#a3bf9c'], ['sky', 'Sky', '#8fb6e0'], ['violet', 'Violet', '#b3a1dc']];
// [name, label, dark, light], each [ground, sidebar, raised, text]
const PALETTES = [
['default', 'Default', ['#0e0e0f', '#161617', '#222224', '#ececed'], ['#e7e7e9', '#fbfbfb', '#ffffff', '#1c1c1e']],
['midnight', 'Midnight', ['#0a0e17', '#0f1522', '#1b2336', '#e6eaf2'], ['#dfe5ee', '#f7f9fc', '#ffffff', '#17202e']],
['nord', 'Nord', ['#232831', '#2b303b', '#3a4150', '#e5e9f0'], ['#dde2ea', '#eceff4', '#ffffff', '#2e3440']],
['forest', 'Forest', ['#0c120f', '#121a15', '#1e2922', '#e4ece6'], ['#dfe6df', '#f6f9f5', '#ffffff', '#1a241d']],
['sepia', 'Sepia', ['#17130e', '#1e1912', '#2d261c', '#ece4d6'], ['#e6dccb', '#f8f1e3', '#fffaf0', '#3a2f20']],
['catppuccin', 'Catppuccin', ['#11111b', '#181825', '#313244', '#cdd6f4'], ['#dce0e8', '#eff1f5', '#ffffff', '#4c4f69']],
['catppuccin-dark', 'Catppuccin Dark', ['#08080d', '#0d0d14', '#232333', '#cdd6f4'], ['#dce0e8', '#eff1f5', '#ffffff', '#4c4f69']],
['amoled', 'AMOLED', ['#000000', '#000000', '#161617', '#ececed'], ['#e7e7e9', '#fbfbfb', '#ffffff', '#1c1c1e']]
];
const FONTS = [['sans', 'Sans'], ['serif', 'Serif'], ['mono', 'Mono']];
const SPACING = [['compact', 'Compact'], ['default', 'Default'], ['relaxed', 'Relaxed']];
const STARTS = [['last', 'Last page'], ['today', "Today's note"]];
const THEMES = [['dark', 'Dark', 'moon'], ['light', 'Light', 'sun'], ['system', 'System', 'monitor']];
const LAYOUTS = [['vertical', 'Vertical', 'layoutV'], ['horizontal', 'Horizontal', 'layoutH']];
const WIDTHS = [['narrow', 'Narrow'], ['default', 'Default'], ['wide', 'Wide'], ['full', 'Full']];
const SIZES = { min: 13, max: 22, base: 16 };
// "de-DE" -> "German (Germany)", in the user's own language.
let names;
try { names = new Intl.DisplayNames([navigator.language, 'en'], { type: 'language' }); } catch { names = null; }
const langName = (code) => {
try { return (names && names.of(code)) || code; } catch { return code; }
};
let languages = $derived(
(spell?.available || []).map((c) => ({ code: c, name: langName(c) })).sort((a, b) => a.name.localeCompare(b.name))
);
onMount(async () => {
try { linkPreviews = (await api.getSettings()).linkPreviews !== false; } catch { /* stays on */ }
try { words = await api.spellWords(); } catch { words = []; }
try { backup = await api.backupInfo(); } catch { /* browser build */ }
try { spell = await api.getSpellcheck(); } catch { spell = { enabled: true, languages: [], available: [], system: true }; }
});
function close() {
closing = true;
setTimeout(() => { store.settingsOpen = false; closing = false; }, 150);
}
async function saveSpell(enabled, langs) {
// Plain arrays only: IPC can't send Svelte's reactive proxies.
const clean = [...new Set(langs.filter(Boolean))];
const languages = clean.length ? clean : [...spell.languages];
spell = { ...spell, enabled, languages };
try { await api.setSpellcheck(enabled, languages); } catch (err) { toast(errorText(err), 'x'); }
}
const setMain = (code) => saveSpell(spell.enabled, [code, ...spell.languages.slice(1).filter((c) => c !== code)]);
const setSecond = (code) => saveSpell(spell.enabled, [spell.languages[0], code]);
const autoDaily = () => store.setDailyFolder('');
async function setLinkPreviews() {
linkPreviews = !linkPreviews;
try { await api.setSettings({ linkPreviews }); } catch (err) { toast(errorText(err), 'x'); }
}
function changeFolder() {
close();
setTimeout(() => store.pick('choose'), 160);
}
function toggleKind(t) {
const next = new Set(p.hiddenKinds);
next.has(t) ? next.delete(t) : next.add(t);
set({ hiddenKinds: [...next] });
}
async function addWord() {
const w = newWord.trim();
if (!w || /\s/.test(w)) return;
newWord = '';
try { await api.addSpellWord(w); words = await api.spellWords(); } catch (err) { toast(errorText(err), 'x'); }
}
async function dropWord(w) {
try { await api.removeSpellWord(w); words = words.filter((x) => x !== w); } catch (err) { toast(errorText(err), 'x'); }
}
async function backupNow() {
backingUp = true;
try {
const r = await api.backupNow();
backup = await api.backupInfo();
toast(`Backed up ${r.count} files`, 'check');
} catch (err) { toast(errorText(err), 'x'); }
backingUp = false;
}
async function pickBackupFolder() {
try {
const dir = await api.pickBackupFolder();
if (dir) { set({ backupFolder: dir }); backup = await api.backupInfo(); }
} catch (err) { toast(errorText(err), 'x'); }
}
const resetAll = () => {
if (!confirm('Put every setting back to how it came? Your notes are not touched.')) return;
store.resetSettings();
toast('Settings reset', 'check');
};
// A number box that applies on change (not on every keystroke) and snaps back to the allowed range.
const num = (key) => (e) => { set({ [key]: e.currentTarget.value }); e.currentTarget.value = store.prefs[key]; };
const lastBackup = $derived(backup.last ? new Date(backup.last).toLocaleString(p.locale || undefined) : 'Never');
</script>
<svelte:window onkeydowncapture={onCaptureKey} onkeydown={(e) => e.key === 'Escape' && !capturing && close()} />
{#snippet toggle(label, desc, on, flip)}
<div class="set-row">
<div class="set-t"><b>{label}</b>{#if desc}<small>{desc}</small>{/if}</div>
<button class="tgl" class:on role="switch" aria-checked={on} aria-label={label} onclick={flip}><span></span></button>
</div>
{/snippet}
{#snippet seg(label, desc, options, value, pick)}
<div class="set-row">
<div class="set-t"><b>{label}</b>{#if desc}<small>{desc}</small>{/if}</div>
<div class="seg3" role="radiogroup" aria-label={label}>
{#each options as [v, text] (v)}
<button class:on={value === v} role="radio" aria-checked={value === v} onclick={() => pick(v)}>{text}</button>
{/each}
</div>
</div>
{/snippet}
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel settings" role="dialog" aria-label="Settings">
<div class="set-top">
<Icon name="gear" />
<h2>Settings</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="set-main">
<nav class="set-nav" aria-label="Settings sections">
{#each TABS as [v, label, icon] (v)}
<button class:on={tab === v} aria-current={tab === v ? 'page' : undefined} onclick={() => (tab = v)}><Icon name={icon} />{label}</button>
{/each}
</nav>
<div class="set-body">
{#if tab === 'appearance'}
<h3>Theme</h3>
<div class="set-row">
<div class="set-t"><b>Mode</b><small>System follows your desktop's light or dark setting.</small></div>
<div class="seg3" role="radiogroup" aria-label="Theme">
{#each THEMES as [v, label, icon] (v)}
<button class:on={store.themePref === v} role="radio" aria-checked={store.themePref === v} onclick={(e) => store.setThemePref(v, e.currentTarget)}>
<Icon name={icon} />{label}
</button>
{/each}
</div>
</div>
<div class="set-block">
<div class="set-t"><b>Color theme</b><small>The tint of backgrounds and text. Works in light and dark mode{store.theme === 'light' ? '; AMOLED is dark-only' : ''}.</small></div>
<div class="palettes" role="radiogroup" aria-label="Color theme">
{#each PALETTES as [v, label, dark, light] (v)}
{@const c = store.theme === 'light' ? light : dark}
<button class="pal" class:on={store.palette === v} role="radio" aria-checked={store.palette === v} style="--g:{c[0]};--i:{c[1]};--r:{c[2]};--t:{c[3]}" onclick={() => store.setPalette(v)}>
<i><b></b><b></b><b></b></i>
<span>{label}</span>
</button>
{/each}
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Accent color</b><small>Highlights, links, toggles and the caret.</small></div>
<div class="swatches" role="radiogroup" aria-label="Accent color">
{#each ACCENTS as [v, label, c] (v)}
<button class="swatch" class:on={store.accent === v} style="--c:{c}" role="radio" aria-checked={store.accent === v} aria-label={label} title={label} onclick={() => store.setAccent(v)}>
{#if store.accent === v}<Icon name="check" />{/if}
</button>
{/each}
</div>
</div>
{@render seg('Interface font', 'The font of menus, the sidebar and buttons.', UI_FONTS, p.uiFont, (v) => set({ uiFont: v }))}
<h3>Layout</h3>
<div class="set-row">
<div class="set-t"><b>Text size</b><small>For page text and headings.</small></div>
<div class="set-size">
<span class="a-small">A</span>
<input type="range" min={SIZES.min} max={SIZES.max} step="1" value={store.fontSize} aria-label="Text size" aria-valuetext="{store.fontSize} pixels" oninput={(e) => store.setFontSize(e.currentTarget.value)} />
<span class="a-big">A</span>
<button class="set-val" title="Back to {SIZES.base}" onclick={() => store.setFontSize(SIZES.base)} disabled={store.fontSize === SIZES.base}>{store.fontSize}px</button>
</div>
</div>
<p class="set-preview" style="font-size:var(--fs)">The quick brown fox jumps over the lazy dog.</p>
<div class="set-row">
<div class="set-t"><b>Page width</b><small>How wide text runs on ordinary pages. Boards always use the full width.</small></div>
<div class="seg3" role="radiogroup" aria-label="Page width">
{#each WIDTHS as [v, label] (v)}
<button class:on={store.pageWidth === v} role="radio" aria-checked={store.pageWidth === v} onclick={() => store.setPageWidth(v)}>{label}</button>
{/each}
</div>
</div>
{@render seg('Density', 'Height of rows in the sidebar.', DENSITIES, p.density, (v) => set({ density: v }))}
<div class="set-row">
<div class="set-t"><b>Sidebar width</b><small>Drag to resize.</small></div>
<div class="set-size">
<input type="range" min="220" max="420" step="4" value={p.sidebarWidth} aria-label="Sidebar width" oninput={(e) => set({ sidebarWidth: e.currentTarget.value })} />
<button class="set-val" title="Back to {DEFAULTS.sidebarWidth}" onclick={() => set({ sidebarWidth: DEFAULTS.sidebarWidth })} disabled={p.sidebarWidth === DEFAULTS.sidebarWidth}>{p.sidebarWidth}px</button>
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Sidebar shortcuts</b><small>Search, New page, Today and Graph view, as a list or a row of icons.</small></div>
<div class="seg3" role="radiogroup" aria-label="Sidebar shortcuts">
{#each LAYOUTS as [v, label, icon] (v)}
<button class:on={store.quickLayout === v} role="radio" aria-checked={store.quickLayout === v} data-layout={v} onclick={() => store.setQuickLayout(v)}><Icon name={icon} />{label}</button>
{/each}
</div>
</div>
<h3>Pages</h3>
{@render toggle('Cover image', 'The banner at the top of a page.', p.showCover, () => set({ showCover: !p.showCover }))}
{@render toggle('Page icon', null, p.showIcon, () => set({ showIcon: !p.showIcon }))}
{@render toggle('Linked from', 'The list of pages that link here, under the page.', p.showBacklinks, () => set({ showBacklinks: !p.showBacklinks }))}
<h3>Motion</h3>
{@render toggle('Reduce motion', 'Turns off animations and transitions.', store.reduceMotion, () => store.setReduceMotion(!store.reduceMotion))}
{:else if tab === 'editor'}
<h3>Writing</h3>
{@render seg('Page font', 'For page text and titles.', FONTS, store.pageFont, (v) => store.setPageFont(v))}
<div class="set-row">
<div class="set-t"><b>Custom page font</b><small>The name of a font installed on this computer, e.g. Georgia or Atkinson Hyperlegible. Empty uses the choice above.</small></div>
<input class="set-in" type="text" value={p.customPageFont} spellcheck="false" aria-label="Custom page font" onchange={(e) => set({ customPageFont: e.currentTarget.value.trim() })} />
</div>
<div class="set-row">
<div class="set-t"><b>Custom code font</b><small>For code blocks and inline code.</small></div>
<input class="set-in" type="text" value={p.customCodeFont} spellcheck="false" aria-label="Custom code font" onchange={(e) => set({ customCodeFont: e.currentTarget.value.trim() })} />
</div>
{@render seg('Line spacing', 'Space between lines of text.', SPACING, store.lineSpacing, (v) => store.setLineSpacing(v))}
<p class="set-preview" style="font-size:var(--fs);font-family:var(--f-page);line-height:var(--lh)">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.</p>
<div class="set-row">
<div class="set-t"><b>Code size</b><small>Relative to the page text.</small></div>
<div class="set-size">
<input type="range" min="80" max="140" step="5" value={p.codeSize} aria-label="Code size" oninput={(e) => set({ codeSize: e.currentTarget.value })} />
<button class="set-val" title="Back to 100%" onclick={() => set({ codeSize: 100 })} disabled={p.codeSize === 100}>{p.codeSize}%</button>
</div>
</div>
{@render toggle('Wrap long code lines', 'Instead of scrolling sideways.', p.codeWrap, () => set({ codeWrap: !p.codeWrap }))}
<h3>Typing</h3>
{@render toggle('Close brackets and quotes', 'Typing ( [ { or ` adds the closing one.', p.autoPair, () => set({ autoPair: !p.autoPair }))}
{@render toggle('Smart typography', 'Curly quotes, -- as an em dash, ... as an ellipsis.', p.smartTypography, () => set({ smartTypography: !p.smartTypography }))}
{@render toggle('Markdown shortcuts', 'Typing "# " or "- " at the start of a line makes a heading or a list.', p.mdShortcuts, () => set({ mdShortcuts: !p.mdShortcuts }))}
{@render toggle('Paste formatted text as Markdown', 'Text copied from a web page keeps its headings, lists, links and bold. Off pastes plain text.', p.pasteRich, () => set({ pasteRich: !p.pasteRich }))}
{@render toggle('Typewriter mode', 'Keeps the line you are writing in the middle of the screen.', p.typewriter, () => set({ typewriter: !p.typewriter }))}
{@render seg('Autosave', 'How long after you stop typing the page is saved.', AUTOSAVE, p.autosaveDelay, (v) => set({ autosaveDelay: v }))}
<h3>Slash menu</h3>
<p class="set-sub">Click a block to hide it from the “/” menu. It can still be made with its Markdown shortcut.</p>
<div class="chips">
{#each kinds as k (k.t)}
<button class="chip-t" class:on={!hidden.has(k.t)} aria-pressed={!hidden.has(k.t)} onclick={() => toggleKind(k.t)}>{k.label}</button>
{/each}
</div>
<h3>Spellcheck</h3>
{@render spellcheck()}
{:else if tab === 'notes'}
<h3>Notes folder</h3>
<div class="set-row">
<div class="set-t">
<b>{store.vault?.name || 'No folder'}</b>
<small class="mono" title={store.vault?.path}>{store.vault?.path || '—'}</small>
</div>
<div class="set-btns">
{#if isDesktop}<button class="btn" onclick={() => api.reveal('')}><Icon name="folder" />Show</button>{/if}
<button class="btn" onclick={changeFolder}>Change…</button>
</div>
</div>
<h3>Daily notes</h3>
<div class="set-row">
<div class="set-t">
<b>Folder</b>
<small>Today's note is made here. A path puts it inside other folders, e.g. Private/Daily.</small>
</div>
<FolderPicker value={store.dailyFolder || ''} label="Daily notes folder" onchange={(v) => store.setDailyFolder(v)} />
</div>
{#if store.dailyFolder}
<p class="set-note">Using <b>{store.dailyFolder}</b>. <button class="set-link" onclick={autoDaily}>Find it automatically</button></p>
{:else}
<p class="set-note">Found automatically: <b>{store.dailyFolderNow}</b>. Type a path above to choose another.</p>
{/if}
<div class="set-row">
<div class="set-t"><b>Note name</b><small>How today's note is named. Notes already made keep their names.</small></div>
<SelectMenu
label="Daily note name"
value={p.dateFormat}
options={DATE_FORMATS.map((f) => ({ value: f, label: formatDate(new Date(), f, p.locale), detail: f }))}
onchange={(v) => set({ dateFormat: v })}
/>
</div>
<div class="set-block">
<div class="set-t"><b>Template</b><small>What a new daily note starts with, in Markdown. {'{{date}}'} and {'{{weekday}}'} are filled in.</small></div>
<textarea class="set-area" rows="4" spellcheck="false" placeholder={'## {{weekday}}\n\n- [ ] '} value={p.dailyTemplate} onchange={(e) => set({ dailyTemplate: e.currentTarget.value })}></textarea>
</div>
<h3>New pages</h3>
{@render seg('Icon', 'What a new page starts with.', NEW_ICON, p.newPageIcon, (v) => set({ newPageIcon: v }))}
{@render seg('Cover', null, NEW_ICON, p.newPageCover, (v) => set({ newPageCover: v }))}
<h3>Dates</h3>
{@render seg('Week starts on', 'For calendar views of a database.', WEEKS, p.weekStart, (v) => set({ weekStart: v }))}
<div class="set-row">
<div class="set-t"><b>Language of dates</b><small>Month and day names, e.g. {formatDate(new Date(), 'dddd, MMMM D', p.locale)}.</small></div>
<SelectMenu label="Language of dates" value={p.locale} options={LOCALES.map(([value, label]) => ({ value, label }))} onchange={(v) => set({ locale: v })} />
</div>
<h3>Trash and history</h3>
{@render toggle('Ask before deleting', 'Confirm before a page goes to the trash.', p.confirmTrash, () => set({ confirmTrash: !p.confirmTrash }))}
<div class="set-row">
<div class="set-t"><b>Empty the trash after</b><small>Days a deleted page stays restorable. 0 keeps it until you empty it. Applies at launch.</small></div>
<input class="set-in num" type="number" min="0" max="365" value={p.trashDays} aria-label="Days in trash" onchange={num('trashDays')} />
</div>
<div class="set-row">
<div class="set-t"><b>Versions kept per page</b><small>Older ones are removed.</small></div>
<input class="set-in num" type="number" min="5" max="1000" value={p.historyKeep} aria-label="Versions kept" onchange={num('historyKeep')} />
</div>
<div class="set-row">
<div class="set-t"><b>Minutes between versions</b><small>While you edit, at most one version is kept per this many minutes.</small></div>
<input class="set-in num" type="number" min="1" max="120" value={p.historyGapMin} aria-label="Minutes between versions" onchange={num('historyGapMin')} />
</div>
<h3>Export</h3>
{@render seg('Links in Markdown', 'How links to other pages are written when you export or copy Markdown.', EXPORT_LINKS, p.exportLinks, (v) => set({ exportLinks: v }))}
{:else if tab === 'canvas'}
<h3>Graph view</h3>
<p class="set-sub">What the graph shows when you open it. The graph's own panel changes the same options.</p>
{@render seg('Show', null, [['all', 'All notes'], ['local', 'This page']], store.graph.local ? 'local' : 'all', (v) => setGraph({ local: v === 'local' }))}
{@render toggle('Notes without links', null, store.graph.orphans, () => setGraph({ orphans: !store.graph.orphans }))}
{@render toggle('Links to missing pages', null, store.graph.ghosts, () => setGraph({ ghosts: !store.graph.ghosts }))}
{@render toggle('Tags as nodes', 'Each tag becomes a node its pages hang from. Clicking one filters the sidebar.', !!store.graph.tags, () => setGraph({ tags: !store.graph.tags }))}
{@render toggle('Page hierarchy', null, store.graph.folders, () => setGraph({ folders: !store.graph.folders }))}
<h3>Canvas</h3>
{@render toggle('Preview web links', "Link cards show the page's title and description. This visits the address when a canvas is opened.", linkPreviews, setLinkPreviews)}
{@render toggle('Snap to grid', 'Cards and edges line up on a grid. Hold Alt while dragging to skip it for one move.', p.snapToGrid, () => set({ snapToGrid: !p.snapToGrid }))}
<div class="set-row">
<div class="set-t"><b>New card color</b></div>
<div class="swatches" role="radiogroup" aria-label="New card color">
{#each CARD_COLORS as [v, label, c] (v)}
<button class="swatch" class:on={p.cardColor === v} style="--c:{c || 'transparent'}" role="radio" aria-checked={p.cardColor === v} aria-label={label} title={label} onclick={() => set({ cardColor: v })}>
{#if p.cardColor === v}<Icon name="check" />{/if}
</button>
{/each}
</div>
</div>
{:else if tab === 'general'}
<h3>Startup</h3>
<div class="set-row">
<div class="set-t"><b>Open on launch</b><small>Which page is shown when the app starts.</small></div>
<div class="seg3" role="radiogroup" aria-label="Open on launch">
{#each STARTS as [v, label] (v)}
<button class:on={store.startPage === v} role="radio" aria-checked={store.startPage === v} onclick={() => store.setStartPage(v)}>{label}</button>
{/each}
</div>
</div>
{#if isDesktop}
{@render toggle('Start minimized', null, p.startMinimized, () => set({ startMinimized: !p.startMinimized }))}
<h3>Quick capture</h3>
<div class="set-row">
<div class="set-t"><b>Shortcut</b><small>Works from any app, e.g. CommandOrControl+Alt+N. Empty turns it off. Some Wayland desktops don't allow global shortcuts.</small></div>
<input class="set-in" type="text" value={p.captureShortcut} spellcheck="false" aria-label="Quick capture shortcut" onchange={(e) => set({ captureShortcut: e.currentTarget.value.trim() })} />
</div>
{@render seg('Add to', 'Where the capture window puts what you type. Today\'s note is made if it is missing; Inbox is a page at the top level.', CAPTURE_TO, p.captureTo, (v) => set({ captureTo: v }))}
<h3>Window</h3>
{@render toggle('Always on top', 'Keep the window above other windows.', p.alwaysOnTop, () => set({ alwaysOnTop: !p.alwaysOnTop }))}
<h3>Import and export</h3>
<div class="set-row">
<div class="set-t"><b>Import notes</b><small>An Obsidian vault or a Notion "Markdown &amp; CSV" export, as a folder or a .zip. It goes into a new folder; nothing existing changes.</small></div>
<div class="set-btns">
<button class="btn" onclick={() => store.importNotes('folder')}><Icon name="folder" />Folder…</button>
<button class="btn" onclick={() => store.importNotes('zip')}><Icon name="download" />.zip…</button>
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Export everything</b><small>All pages and pictures as one .zip (without version history and the trash).</small></div>
<div class="set-btns"><button class="btn" onclick={() => store.exportVaultZip()}><Icon name="export" />Export .zip…</button></div>
</div>
<h3>Backups</h3>
{@render seg('Back up automatically', 'A zip of your notes folder (without version history and the trash).', BACKUPS, p.backupEvery, (v) => set({ backupEvery: v }))}
<div class="set-row">
<div class="set-t"><b>Backup folder</b><small class="mono" title={backup.folder}>{backup.folder || '—'}</small></div>
<div class="set-btns">
<button class="btn" onclick={() => api.revealBackups()}><Icon name="folder" />Show</button>
<button class="btn" onclick={pickBackupFolder}>Change…</button>
</div>
</div>
<div class="set-row">
<div class="set-t"><b>Backups kept</b><small>The oldest are deleted.</small></div>
<input class="set-in num" type="number" min="1" max="50" value={p.backupKeep} aria-label="Backups kept" onchange={num('backupKeep')} />
</div>
<div class="set-row">
<div class="set-t"><b>Back up now</b><small>Last backup: {lastBackup}</small></div>
<button class="btn" disabled={backingUp} onclick={backupNow}>{backingUp ? 'Backing up…' : 'Back up now'}</button>
</div>
{/if}
<h3>Reset</h3>
<div class="set-row">
<div class="set-t"><b>Reset all settings</b><small>Appearance, editor and the rest go back to how they came. Your notes and folder are not touched.</small></div>
<button class="btn danger-btn" onclick={resetAll}>Reset…</button>
</div>
{:else}
<h3>Your shortcuts</h3>
<p class="set-sub">Click one, then press the new keys. Escape cancels; Backspace puts the original back.</p>
<div class="keys rebind">
{#each ACTIONS as a (a.id)}
<span>{a.label}</span>
<button class="kd kd-btn" class:capturing={capturing === a.id} aria-label="Change shortcut for {a.label}" onclick={() => (capturing = capturing === a.id ? null : a.id)}>
{#if capturing === a.id}<em>Press keys…</em>{:else}<kbd>{format(comboNow(a.id), mac)}</kbd>{/if}
</button>
{/each}
</div>
{#if Object.keys(p.shortcuts || {}).length}
<button class="btn" style="margin-top:10px" onclick={() => set({ shortcuts: {} })}>Reset all to the originals</button>
{/if}
<h3>Keyboard shortcuts</h3>
{#each SHORTCUTS as [group, rows] (group)}
<p class="set-sub">{group}</p>
<div class="keys">
{#each rows as [label, keys] (label)}
<span>{label}</span>
<span class="kd">{#each keys as k, i (i)}<kbd>{k}</kbd>{/each}</span>
{/each}
</div>
{/each}
{/if}
</div>
</div>
</div>
</div>
{#snippet spellcheck()}
<div class="set-row">
<div class="set-t"><b>Check spelling</b><small>Underlines misspelled words; right-click one for suggestions.</small></div>
<button
class="tgl"
class:on={spell?.enabled}
role="switch"
aria-checked={!!spell?.enabled}
aria-label="Check spelling"
disabled={!spell}
onclick={() => saveSpell(!spell.enabled, spell.languages)}
><span></span></button>
</div>
{#if spell?.enabled}
{#if spell.system}
<p class="set-note">Spelling follows your system's languages here.</p>
{:else}
<div class="set-row">
<div class="set-t"><b>Language</b></div>
<SelectMenu
label="Spellcheck language"
value={spell.languages[0] || ''}
options={languages.map((l) => ({ value: l.code, label: l.name, detail: l.code }))}
onchange={setMain}
/>
</div>
<div class="set-row">
<div class="set-t"><b>Also check</b><small>A second language, for notes that mix two.</small></div>
<SelectMenu
label="Second spellcheck language"
value={spell.languages[1] || ''}
options={[{ value: '', label: 'None' }, ...languages.filter((l) => l.code !== spell.languages[0]).map((l) => ({ value: l.code, label: l.name, detail: l.code }))]}
onchange={setSecond}
/>
</div>
<p class="set-note">Dictionaries are downloaded once per language, then work offline.</p>
{/if}
{/if}
{#if isDesktop}
<div class="set-block">
<div class="set-t"><b>Custom dictionary</b><small>Words you added with “Add to dictionary”. They are never underlined.</small></div>
<form class="set-btns" style="margin-top:10px" onsubmit={(e) => { e.preventDefault(); addWord(); }}>
<input class="set-in" placeholder="Add a word" aria-label="Add a word" bind:value={newWord} />
<button class="btn" type="submit" disabled={!newWord.trim()}>Add</button>
</form>
{#if words.length}
<div class="words">
{#each words as w (w)}
<span class="word">{w}<button aria-label="Remove {w}" onclick={() => dropWord(w)}><Icon name="x" /></button></span>
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
+302
View File
@@ -0,0 +1,302 @@
<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>
+176
View File
@@ -0,0 +1,176 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { createEditor } from '$lib/editor.js';
import { editorHooks } from '$lib/editorHooks.js';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { parseMarkdown, toMarkdown } from '$lib/markdown.js';
import { isBoard } from '$lib/kanban.js';
import { isDatabase } from '$lib/database.js';
import Board from './Board.svelte';
import Database from './Database.svelte';
import CanvasView from './CanvasView.svelte';
import { isCanvasId } from '$lib/canvas.js';
// The side pane: one page of any kind (document, board, database, canvas) with its own save, next to the page
// the store has open. A document gets its own editor; the others get their views with this pane's save passed in.
let { id } = $props();
let host = $state();
let doc = $state.raw(null); // { meta, blocks, body }
let kind = $state('page'); // 'page' | 'board' | 'database' | 'canvas'
let version = $state(0);
let blocks = [];
let raw = null; // the body of a board, database or canvas; null for a document, which saves from blocks
let base = null; // body as this pane last read or wrote it
let dirty = false;
let timer = null;
let chain = Promise.resolve();
let editor = null;
const page = $derived(store.pages.find((p) => p.id === id) || null);
async function load() {
let r;
try { r = await api.read(id); } catch (err) { toast(errorText(err), 'x'); store.split = null; return; }
if (!r.exists && !page) { store.split = null; return; }
apply(r);
}
function apply(r) {
kind = isCanvasId(id) ? 'canvas' : isBoard(r.meta) ? 'board' : isDatabase(r.meta) ? 'database' : 'page';
base = r.body;
raw = kind === 'page' ? null : r.body;
blocks = kind === 'canvas' ? [] : parseMarkdown(r.body);
dirty = false;
doc = { meta: r.meta || {}, blocks, body: r.body };
version++;
}
// a board, database or canvas changed: its new file text
function editRaw(text) {
raw = text;
dirty = true;
clearTimeout(timer);
timer = setTimeout(save, 600);
}
// front matter of a board (its column colours)
function editMeta(patch) {
const meta = { ...doc.meta, ...patch };
for (const k of Object.keys(meta)) if (meta[k] === undefined) delete meta[k];
doc = { ...doc, meta };
editRaw(raw);
}
function edit(b) {
blocks = b;
dirty = true;
clearTimeout(timer);
timer = setTimeout(save, 600);
}
const save = () => {
clearTimeout(timer);
chain = chain.then(write);
return chain;
};
async function write() {
if (!dirty || !doc) return;
dirty = false;
const body = raw ?? toMarkdown(blocks);
try {
const disk = await api.read(id).catch(() => null);
if (disk && disk.exists && disk.body !== base && disk.body !== body) {
await api.keepVersion(id).catch(() => {});
toast('This note was also changed elsewhere. Your version was saved; the other one is in version history.', 'clock', {
label: 'Show versions',
run: () => { store.historyOpen = id; }
});
}
await api.write(id, { ...doc.meta }, body);
base = body;
} catch (err) {
dirty = true;
toast('Could not save: ' + errorText(err), 'x');
}
}
// mount a fresh editor whenever a version of the page is loaded
$effect(() => {
version;
if (!host || !doc || kind !== 'page') return;
const instance = createEditor(host, { blocks: doc.blocks, animate: false, onChange: edit, ...editorHooks(id) });
editor = instance;
return () => {
instance.destroy();
if (editor === instance) editor = null;
};
});
// links dim or brighten as pages come and go
$effect(() => {
store.pages;
editor?.refreshLinks();
});
// the page was deleted or moved away from under the pane
$effect(() => {
if (store.pages.length && !store.pages.some((p) => p.id === id)) store.split = null;
});
onMount(() => {
load();
store.splitSave = save;
const off = api.onChanged((ids) => {
if (!ids.includes(id) || dirty) return; // a pending edit wins and overwrites on save
api.read(id).then((r) => {
if (!r.exists || r.body === base || dirty) return;
apply(r);
}).catch(() => {});
});
const onBlur = () => { save(); };
window.addEventListener('blur', onBlur);
return () => {
off?.();
window.removeEventListener('blur', onBlur);
save();
if (store.splitSave === save) store.splitSave = null;
};
});
</script>
<section class="split" aria-label="Split view">
<header class="split-bar">
<button class="crumb" title="Open in the main pane" onclick={() => store.swapSplit()}>
<Icon name={iconOf(page || { id, title: id })} /><span>{page ? page.title : id}</span>
</button>
<button class="btn-icon" aria-label="Swap panes" title="Swap panes" onclick={() => store.swapSplit()}><Icon name="columns" /></button>
<button class="btn-icon" aria-label="Close split view" title="Close split view" onclick={() => store.closeSplit()}><Icon name="x" /></button>
</header>
{#if doc && kind === 'canvas'}
{#key version}<CanvasView {id} body={doc.body} onsave={editRaw} title={page ? page.title : null} />{/key}
{:else}
<div class="scroller">
{#if doc && kind !== 'page'}
<div class="page wide">
<h1 class="title split-title">{page ? page.title : id}</h1>
</div>
{#key version}
{#if kind === 'board'}
<Board body={doc.body} onsave={editRaw} meta={doc.meta} onmeta={editMeta} pageId={id} />
{:else}
<Database {id} body={doc.body} onsave={editRaw} />
{/if}
{/key}
{:else if doc}
<article class="page">
<h1 class="title split-title">{page ? page.title : id}</h1>
<div bind:this={host}></div>
</article>
{/if}
</div>
{/if}
</section>
+68
View File
@@ -0,0 +1,68 @@
<script>
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { tagHex } from '$lib/tagColors.js';
let closing = $state(false);
let q = $state('');
let editing = $state(null); // tag being renamed
let draft = $state('');
let armed = $state(null); // tag about to be removed
let list = $derived(store.tagCounts.filter(([t]) => t.toLowerCase().includes(q.trim().toLowerCase().replace(/^#/, ''))));
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
const target = $derived(editing ? store.tagCounts.find(([t]) => same(t, draft.trim().replace(/^#/, '').replace(/\s+/g, '-')) && t !== editing) : null);
function begin(t) { editing = t; draft = t; armed = null; }
async function commit() {
const from = editing, to = draft;
editing = null;
if (from && to.trim()) await store.renameTag(from, to);
}
async function remove(t) {
if (armed !== t) { armed = t; return; }
armed = null;
await store.deleteTag(t);
}
function show(t) {
store.filterTag = t;
close();
}
function close() {
closing = true;
setTimeout(() => { store.tagManagerOpen = false; closing = false; }, 150);
}
const focusSelect = (el) => { el.focus(); el.select(); };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && (editing ? (editing = null) : close())} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tasks-panel" role="dialog" aria-label="Tags">
<div class="trash-top">
<Icon name="hash" />
<h2>Tags<span class="muted"> · {store.tagCounts.length}</span></h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="tk-bar"><input class="tk-q" placeholder="Find a tag" aria-label="Find a tag" bind:value={q} /></div>
<div class="trash-list tk-list">
{#each list as [tag, n] (tag)}
<div class="tr">
<span class="tag-dot" style="background:{tagHex(store.tagColors[tag]) || 'var(--ink-3)'}"></span>
{#if editing === tag}
<input class="ctx-input tg-edit" bind:value={draft} aria-label="New name for {tag}" use:focusSelect
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }} />
<button class="btn" onclick={commit}>{target ? `Merge into #${target[0]}` : 'Rename'}</button>
{:else}
<button class="tr-t tg-name" onclick={() => show(tag)} title="Show these pages"><b>#{tag}</b><small>{n} {n === 1 ? 'page' : 'pages'}</small></button>
<button class="btn" onclick={() => begin(tag)}><Icon name="pen" />Rename</button>
<button class="btn danger" class:armed={armed === tag} onclick={() => remove(tag)} title="Takes the tag off every page">{armed === tag ? 'Sure?' : 'Remove'}</button>
{/if}
</div>
{:else}
<div class="trash-empty"><Icon name="hash" />{store.tagCounts.length ? 'No tag matches.' : 'No tags yet. Add one from a page’s Tags row.'}</div>
{/each}
</div>
<div class="trash-foot"><span>Renaming a tag to one that exists merges them. Colors move with a rename.</span></div>
</div>
</div>
+139
View File
@@ -0,0 +1,139 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let items = $state(null);
let closing = $state(false);
let status = $state('open'); // 'open' | 'done' | 'all'
let when = $state('any'); // 'any' | 'overdue' | 'today' | 'week' | 'none'
let tag = $state('');
let q = $state('');
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const now = new Date();
const today = iso(now);
const weekEnd = iso(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7));
async function load() {
try {
await store.flush(); // what is on screen is on disk before it is read here
items = (await api.tasks()) || [];
} catch (err) {
items = [];
toast(errorText(err), 'x');
}
}
onMount(load);
const plain = (t) => t
.replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g, '$2')
.replace(/\[\[([^\]]*)\]\]/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/(?:📅|\bdue:)\s*\d{4}-\d{2}-\d{2}/g, '')
.replace(/\*\*|__|==|`|\*/g, '')
.replace(/\s+/g, ' ')
.trim();
let tags = $derived([...new Set((items || []).flatMap((t) => t.tags))].sort((a, b) => a.localeCompare(b)));
let shown = $derived.by(() => {
const needle = q.trim().toLowerCase();
return (items || []).filter((t) => {
if (status === 'open' && t.done) return false;
if (status === 'done' && !t.done) return false;
if (tag && !t.tags.includes(tag)) return false;
if (when === 'none' && t.due) return false;
if (when === 'overdue' && !(t.due && t.due < today && !t.done)) return false;
if (when === 'today' && t.due !== today) return false;
if (when === 'week' && !(t.due && t.due >= today && t.due <= weekEnd)) return false;
if (needle && !(t.text.toLowerCase().includes(needle) || t.title.toLowerCase().includes(needle))) return false;
return true;
});
});
let groups = $derived.by(() => {
const map = new Map();
for (const t of shown) {
if (!map.has(t.id)) map.set(t.id, { id: t.id, title: t.title, icon: t.icon, tasks: [] });
map.get(t.id).tasks.push(t);
}
return [...map.values()].sort((a, b) => a.title.localeCompare(b.title));
});
let openCount = $derived((items || []).filter((t) => !t.done).length);
async function toggle(t) {
const done = !t.done;
t.done = done; // optimistic; undone below if the file refuses
try {
await api.setTask(t.id, t.line, t.text, done);
await store.refresh();
} catch (err) {
toast(errorText(err), 'x');
await load();
}
}
function openPage(id) {
close();
setTimeout(() => store.openPage(id), 120);
}
function close() {
closing = true;
setTimeout(() => { store.tasksOpen = false; closing = false; }, 150);
}
const dueText = (d) => (d === today ? 'Today' : new Date(d + 'T00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }));
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tasks-panel" role="dialog" aria-label="Tasks">
<div class="trash-top">
<Icon name="check" />
<h2>Tasks{#if items}<span class="muted"> · {openCount} open</span>{/if}</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="tk-bar">
<div class="seg3" role="radiogroup" aria-label="Status">
{#each [['open', 'Open'], ['done', 'Done'], ['all', 'All']] as [v, label] (v)}
<button class:on={status === v} role="radio" aria-checked={status === v} onclick={() => (status = v)}>{label}</button>
{/each}
</div>
<select class="tk-sel" aria-label="Due date" bind:value={when}>
<option value="any">Any date</option>
<option value="overdue">Overdue</option>
<option value="today">Today</option>
<option value="week">Next 7 days</option>
<option value="none">No date</option>
</select>
<select class="tk-sel" aria-label="Tag" bind:value={tag}>
<option value="">Any tag</option>
{#each tags as t (t)}<option value={t}>#{t}</option>{/each}
</select>
<input class="tk-q" placeholder="Filter by text or page" aria-label="Filter tasks" bind:value={q} />
</div>
<div class="trash-list tk-list">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if !groups.length}
<div class="trash-empty"><Icon name="check" />{items.length ? 'No tasks match.' : 'No tasks yet. Write “- [ ] something” on any page.'}</div>
{:else}
{#each groups as g (g.id)}
<div class="tk-group">
<button class="tk-page" onclick={() => openPage(g.id)}><span class="row-ico"><Icon name={iconOf(g)} /></span>{g.title}</button>
{#each g.tasks as t (t.line)}
<label class="tk" class:done={t.done} style="padding-left:{10 + Math.min(t.depth, 4) * 18}px">
<input type="checkbox" checked={t.done} onchange={() => toggle(t)} />
<span class="tk-t">{plain(t.text)}</span>
{#if t.due}<span class="tk-due" class:over={!t.done && t.due < today}>{dueText(t.due)}</span>{/if}
{#each t.tags as x (x)}<span class="tk-tag">#{x}</span>{/each}
</label>
{/each}
</div>
{/each}
{/if}
</div>
<div class="trash-foot"><span>Add a due date with 📅 2026-03-01 or due:2026-03-01 in the task. Boards are left out.</span></div>
</div>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script>
// "New page": a blank page or one of the templates (Markdown files in <notes folder>/.notes/templates).
// Cards show each template's icon, name and the gist of its text. Arrow keys move, Enter creates.
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, isDesktop, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let templates = $state([]); // [{ name, icon, preview }]
let closing = $state(false);
let gridEl = $state();
const parent = store.templatesOpen?.parent || null;
// The first few lines of a template as plain words: no #, -, [ ], > or **.
const gist = (body) =>
String(body || '')
.split('\n')
.map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?|[-*+]\s+(?:\[[ xX]\]\s*)?|\d+[.)]\s+)/, '').replace(/!?\[\[|\]\]|[*_`~]|==/g, '').trim())
.filter(Boolean)
.slice(0, 4)
.join(' · ');
async function load() {
try {
const names = (await api.listTemplates()) || [];
const full = await Promise.all(names.map(async (name) => {
const t = await api.getTemplate(name).catch(() => null);
return { name, icon: t?.meta?.icon ? iconOf({ icon: t.meta.icon, title: name }) : 'file', preview: gist(t?.body) };
}));
templates = full;
} catch (err) {
toast(errorText(err), 'x');
}
}
onMount(() => {
load();
gridEl?.querySelector('.tpl-card')?.focus(); // Enter right away = a blank page
});
function close() {
closing = true;
setTimeout(() => { store.templatesOpen = null; closing = false; }, 150);
}
function create(name) {
close();
store.newPage(parent, name);
}
async function openFolder() {
try {
const problem = await api.openTemplatesFolder();
if (problem) toast(problem, 'x');
} catch (err) {
toast(errorText(err), 'x');
}
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Enter' && !e.isComposing) {
const el = document.activeElement;
const card = el?.closest?.('.tpl-card');
if (card) { e.preventDefault(); card.click(); }
else if (!el?.closest?.('.tpl-panel')) { e.preventDefault(); create(null); } // focus got lost: Enter still means "blank page"
return; // on the footer or close button, the browser's own Enter handling applies
}
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: 'up', ArrowDown: 'down' }[e.key];
if (!step || !gridEl) return;
const cards = [...gridEl.querySelectorAll('.tpl-card')];
const i = cards.indexOf(document.activeElement);
e.preventDefault();
if (i < 0) return cards[0]?.focus();
const cols = cards.filter((c) => c.offsetTop === cards[0].offsetTop).length || 1; // cards in the first row
const n = i + (step === 'up' ? -cols : step === 'down' ? cols : step);
if (n >= 0 && n < cards.length) cards[n].focus();
}
</script>
<svelte:window onkeydown={onKey} onfocus={load} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tpl-panel" role="dialog" aria-label="Templates">
<div class="trash-top">
<Icon name="plus" />
<h2>New page</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<p class="tpl-lead">Start from a blank page{templates.length ? ' or a template' : ''}.</p>
<div class="tpl-grid" bind:this={gridEl} role="group" aria-label="Templates">
<button class="tpl-card blank" data-blank onclick={() => create(null)}>
<span class="tpl-ico"><Icon name="file" /></span>
<span class="tpl-name">Blank page</span>
<span class="tpl-prev">Start with an empty page.</span>
</button>
<button class="tpl-card blank" data-board onclick={() => { close(); store.newBoard(parent); }}>
<span class="tpl-ico"><Icon name="board" /></span>
<span class="tpl-name">Board</span>
<span class="tpl-prev">Kanban columns of cards to move from To do to Done.</span>
</button>
<button class="tpl-card blank" data-database onclick={() => { close(); store.newDatabase(parent); }}>
<span class="tpl-ico"><Icon name="table" /></span>
<span class="tpl-name">Database</span>
<span class="tpl-prev">Records with typed properties, shown as a table, board, calendar, gallery, timeline or list.</span>
</button>
<button class="tpl-card blank" data-canvas onclick={() => { close(); store.newCanvas(parent); }}>
<span class="tpl-ico"><Icon name="canvas" /></span>
<span class="tpl-name">Canvas</span>
<span class="tpl-prev">An open space for cards, notes and images, joined by arrows.</span>
</button>
{#each templates as t (t.name)}
<button class="tpl-card" data-name={t.name} onclick={() => create(t.name)}>
<span class="tpl-ico"><Icon name={t.icon} /></span>
<span class="tpl-name">{t.name}</span>
<span class="tpl-prev">{t.preview || 'An empty template.'}</span>
</button>
{/each}
</div>
<div class="tpl-foot">
<span>
{#if templates.length}Templates are Markdown files in <code>.notes/templates</code>.{:else}No templates yet. Save a Markdown file in <code>.notes/templates</code> and it shows up here.{/if}
</span>
{#if isDesktop}<button class="btn" onclick={openFolder}><Icon name="folder" />Open folder</button>{/if}
</div>
</div>
</div>
+158
View File
@@ -0,0 +1,158 @@
<script>
// The right-click menu for text (replacing the system one): spelling, links, formatting, cut/copy/paste.
// Electron's main process reports what was clicked (only it knows the spelling suggestions); actions that
// need it (clipboard, spelling) go back to it. The menu never takes focus, so the selection stays put.
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
import { api, platform } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
const info = store.textMenu;
const MOD = platform === 'darwin' ? '⌘' : 'Ctrl+';
const target = info.target && info.target.nodeType === 1 ? info.target : info.target?.parentElement;
const webLink = target?.closest('a.xl');
const pageLink = target?.closest('.wl');
const inEditor = !!target?.closest('.main .blocks') && !target.closest('.emb');
const formats = inEditor && info.isEditable && info.selectionText ? store.editor?.formatState() : null;
const snippet = info.selectionText.trim().replace(/\s+/g, ' ');
const FORMATS = [
['bold', '<b>B</b>', 'Bold', MOD + 'B'],
['italic', '<i>I</i>', 'Italic', MOD + 'I'],
['strike', '<s>S</s>', 'Strikethrough', MOD + 'Shift+S'],
['mark', '<span class="fmt-mark">H</span>', 'Highlight', MOD + 'Shift+H'],
['code', null, 'Code', MOD + 'E'],
['weblink', null, 'Link', MOD + 'K']
];
const edit = (action) => () => api.editAction(action);
const copyText = (text, what) => () => navigator.clipboard.writeText(text).then(() => toast(`${what} copied`, 'copy'), () => {});
// Menu rows, in groups; separators go between groups that have rows.
const groups = [];
if (info.misspelledWord) {
groups.push([
...(info.suggestions.length
? info.suggestions.map((s) => ({ label: s, strong: true, run: () => api.replaceMisspelling(s) }))
: [{ label: 'No spelling suggestions', disabled: true }]),
{ label: `Add “${info.misspelledWord}” to dictionary`, icon: 'plus', run: () => api.addToDictionary(info.misspelledWord) }
]);
}
if (webLink) {
const href = webLink.getAttribute('href');
groups.push([
{ label: 'Open link', icon: 'globe', run: () => window.open(href, '_blank', 'noopener') },
{ label: 'Copy link address', icon: 'copy', run: copyText(href, 'Link') },
...(inEditor && info.isEditable
? [
{ label: 'Edit link…', icon: 'pen', run: () => store.editor?.linkAction(webLink, 'edit') },
{ label: 'Remove link', icon: 'unlink', run: () => store.editor?.linkAction(webLink, 'unlink') }
]
: [])
]);
} else if (pageLink) {
groups.push([{ label: 'Open page', icon: 'file', run: () => store.openLink(pageLink.dataset.ref) }]);
}
if (info.mediaType === 'image') groups.push([{ label: 'Copy image', icon: 'image', run: () => api.copyImageAt(info.x, info.y) }]);
const editRows = [];
if (info.isEditable) editRows.push({ label: 'Cut', icon: 'cut', kbd: MOD + 'X', disabled: !info.can.cut, run: edit('cut') });
if (info.isEditable || info.selectionText) editRows.push({ label: 'Copy', icon: 'copy', kbd: MOD + 'C', disabled: !info.can.copy, run: edit('copy') });
if (info.isEditable) {
editRows.push({ label: 'Paste', icon: 'paste', kbd: MOD + 'V', disabled: !info.can.paste, run: edit('paste') });
editRows.push({ label: 'Select all', icon: 'selectAll', kbd: MOD + 'A', disabled: !info.can.selectAll, run: edit('selectAll') });
}
if (editRows.length) groups.push(editRows);
if (snippet && snippet.length <= 60 && store.vault) {
groups.push([{ label: `Search notes for “${snippet.length > 24 ? snippet.slice(0, 23) + '…' : snippet}”`, icon: 'search', run: () => { store.paletteQuery = snippet; store.paletteOpen = true; } }]);
}
const rows = groups.flat();
const enabled = rows.filter((r) => !r.disabled);
let active = $state(-1); // index into `enabled` (keyboard / hover)
let el = $state();
let pos = $state({ x: info.x, y: info.y });
function close() { store.textMenu = null; }
function run(row) {
if (!row || row.disabled) return;
close();
row.run();
}
function runFormat(f) {
close();
store.editor?.format(f);
}
// Nothing to offer here (e.g. plain page background): no menu at all.
if (!rows.length && !formats) queueMicrotask(close);
onMount(async () => {
await tick();
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(info.x, innerWidth - r.width - 8)),
y: info.y + r.height > innerHeight - 8 ? Math.max(8, info.y - r.height) : info.y
};
});
// Keys are read at the window, so focus (and the selection) stays where it was.
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const n = enabled.length;
if (!n) return;
active = active < 0 ? (e.key === 'ArrowDown' ? 0 : n - 1) : (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
return;
}
if (e.key === 'Enter' && active >= 0) { e.preventDefault(); e.stopPropagation(); run(enabled[active]); return; }
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) close(); // typing goes on as usual
}
function onDown(e) { if (el && !el.contains(e.target)) close(); }
</script>
<svelte:window onkeydowncapture={onKey} onpointerdowncapture={onDown} onblur={close} onresize={close} onscrollcapture={close} />
{#if rows.length || formats}
<div
class="pop menu tmenu"
bind:this={el}
role="menu"
tabindex="-1"
style="left:{pos.x}px;top:{pos.y}px"
onmousedown={(e) => e.preventDefault()}
oncontextmenu={(e) => e.preventDefault()}
>
{#if formats}
<div class="tm-fmt" role="group" aria-label="Format">
{#each FORMATS as [f, glyph, label, keys] (f)}
<button class:on={formats[f]} aria-label={label} title="{label} ({keys})" onclick={() => runFormat(f)}>
{#if glyph}{@html glyph}{:else}<Icon name={f === 'code' ? 'code' : 'link'} />{/if}
</button>
{/each}
</div>
{#if rows.length}<div class="sepline"></div>{/if}
{/if}
{#each groups as group, g (g)}
{#if g > 0}<div class="sepline"></div>{/if}
{#each group as row (row.label)}
<button
class="mi"
class:act={active >= 0 && enabled[active] === row}
class:strong={row.strong}
role="menuitem"
disabled={row.disabled}
onmousemove={() => !row.disabled && (active = enabled.indexOf(row))}
onclick={() => run(row)}
>
{#if row.icon}<Icon name={row.icon} />{:else}<span class="mi-pad"></span>{/if}
<span class="mi-label">{row.label}</span>
{#if row.kbd}<kbd>{row.kbd}</kbd>{/if}
</button>
{/each}
{/each}
</div>
{/if}
+11
View File
@@ -0,0 +1,11 @@
<script>
import Icon from './Icon.svelte';
import { toastState, runToastAction } from '$lib/toast.svelte.js';
</script>
<div class="toast" class:show={toastState.show} class:act={!!toastState.action} role="status" aria-live="polite">
<Icon name={toastState.icon} />{toastState.msg}
{#if toastState.action}
<button class="toast-btn" tabindex={toastState.show ? 0 : -1} onclick={runToastAction}>{toastState.action.label}</button>
{/if}
</div>
+62 -4
View File
@@ -12,6 +12,9 @@
let trail = $derived(store.currentId ? [...store.ancestors(store.currentId), store.current].filter(Boolean) : []);
// the database this page is a record of
let owner = $derived(store.current && store.current.parent ? store.pages.find((p) => p.id === store.current.parent && p.db) || null : null);
function toggleMenu() {
menuOpen = !menuOpen;
armed = false;
@@ -25,7 +28,11 @@
}
menuOpen = false;
if (kind === 'md' || kind === 'html' || kind === 'pdf') store.exportFormat = kind;
if (kind === 'png' || kind === 'svg' || kind === 'cpdf') store.canvasView?.export(kind === 'cpdf' ? 'pdf' : kind);
if (kind === 'board') store.setBoard(!store.isBoard);
if (kind === 'csv') store.exportCsv();
if (kind === 'importCsv') store.pickCsv(store.currentId);
if (kind === 'moveOut' && owner) store.movePage(store.currentId, owner.parent);
if (kind === 'reveal') api.reveal(store.currentId);
if (kind === 'history') { store.historyOpen = store.currentId; }
if (kind === 'path') {
@@ -43,29 +50,64 @@
<header class="topbar">
<button class="btn-icon menu-btn" aria-label="Show sidebar" onclick={() => store.toggleSidebar()}><Icon name="panel" /></button>
<div class="nav-btns" role="group" aria-label="Navigation history">
<button
class="btn-icon nav-btn"
aria-label="Back"
title="Back ({platform === 'darwin' ? '⌘[' : 'Alt+Left'})"
disabled={!store.canGoBack}
onclick={() => store.back()}
><Icon name="chevLeft" /></button>
<button
class="btn-icon nav-btn"
aria-label="Forward"
title="Forward ({platform === 'darwin' ? '⌘]' : 'Alt+Right'})"
disabled={!store.canGoForward}
onclick={() => store.forward()}
><Icon name="chevRight" /></button>
</div>
<nav class="crumbs" aria-label="Breadcrumb">
{#each trail as p, i (p.id)}
{#if i > 0}<span class="sep">/</span>{/if}
<button class="crumb" onclick={() => p.id !== store.currentId && store.openPage(p.id)}>
<button class="crumb" onclick={() => p.id !== store.currentId && store.openPage(p.id)}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}>
<Icon name={iconOf(p)} /><span>{p.id === store.currentId && store.titleDraft !== null ? store.titleDraft || 'Untitled' : p.title}</span>
</button>
{/each}
</nav>
{#if store.currentDay}
<div class="day-nav" role="group" aria-label="Daily notes">
<button class="btn-icon" aria-label="Previous daily note" title="Previous daily note" onclick={() => store.stepDaily(-1)}><Icon name="chevLeft" /></button>
<button class="btn-icon" aria-label="Pick a day" title="Pick a day" onclick={() => (store.calendarOpen = true)}><Icon name="calendar" /></button>
<button class="btn-icon" aria-label="Next daily note" title="Next daily note (or today)" onclick={() => store.stepDaily(1)}><Icon name="chevRight" /></button>
</div>
{/if}
{#if store.saving || store.justSaved}
<div class="status" class:saving={store.saving} class:visible={store.saving || store.justSaved}>
<i class="dot"></i><span>{store.saving ? 'Saving…' : 'Saved to disk'}</span>
</div>
{/if}
<span class="wc">{store.words} {store.words === 1 ? 'word' : 'words'}</span>
{#if !store.isCanvas && !store.isDatabase}
<span class="wc" title="About {Math.max(1, Math.round(store.words / 230))} min to read at 230 words a minute">{store.words} {store.words === 1 ? 'word' : 'words'}{#if store.words >= 100} · {Math.max(1, Math.round(store.words / 230))} min{/if}</span>
<button
class="btn-icon"
class:on={store.outlineOpen && !store.isBoard}
aria-label="Outline"
aria-pressed={store.outlineOpen && !store.isBoard}
title={store.isBoard ? 'Boards have no outline' : `Outline (${platform === 'darwin' ? '⌘⇧O' : 'Ctrl+Shift+O'})`}
disabled={!store.doc || store.isBoard}
disabled={!store.doc || store.isBoard || store.isDatabase}
onclick={() => store.toggleOutline()}
><Icon name="outline" /></button>
{/if}
<button
class="btn-icon"
class:on={!!store.split}
aria-label="Split view"
aria-pressed={!!store.split}
title="Split view ({platform === 'darwin' ? '⌘⇧\\' : 'Ctrl+Shift+\\'})"
disabled={!store.doc}
onclick={() => store.toggleSplit()}
><Icon name="columns" /></button>
<button
class="btn-icon"
class:on={store.focusMode}
@@ -88,14 +130,30 @@
<WindowControls />
{#if menuOpen}
<div class="pop menu" bind:this={menuEl} role="menu">
{#if !store.isCanvas}
{#if !store.isDatabase}
<button class="mi" role="menuitem" onclick={() => run('board')}>
<Icon name={store.isBoard ? 'file' : 'board'} />{store.isBoard ? 'Turn into document' : 'Turn into board'}
</button>
<div class="sepline"></div>
{/if}
{#if store.isDatabase}
<button class="mi" role="menuitem" onclick={() => run('csv')}><Icon name="export" />Export as CSV</button>
<button class="mi" role="menuitem" onclick={() => run('importCsv')}><Icon name="download" />Import CSV…</button>
{:else}
<button class="mi" role="menuitem" onclick={() => run('md')}><Icon name="export" />Export as Markdown</button>
<button class="mi" role="menuitem" onclick={() => run('html')}><Icon name="export" />Export as HTML</button>
<button class="mi" role="menuitem" onclick={() => run('pdf')}><Icon name="export" />Export as PDF</button>
{/if}
{#if owner}
<button class="mi" role="menuitem" onclick={() => run('moveOut')}><Icon name="folder" />Move out of {owner.title}</button>
{/if}
{:else}
<button class="mi" role="menuitem" onclick={() => run('png')}><Icon name="export" />Export as PNG</button>
<button class="mi" role="menuitem" onclick={() => run('svg')}><Icon name="export" />Export as SVG</button>
<button class="mi" role="menuitem" onclick={() => run('cpdf')}><Icon name="export" />Export as PDF</button>
<div class="sepline"></div>
{/if}
{#if isDesktop}
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
{/if}
+89
View File
@@ -0,0 +1,89 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let items = $state(null);
let closing = $state(false);
let armed = $state(null); // entry about to be deleted, or '*' for everything
async function load() {
try { items = (await api.trash()) || []; } catch (err) { items = []; toast(errorText(err), 'x'); }
}
onMount(load);
function close() {
closing = true;
setTimeout(() => { store.trashOpen = false; closing = false; }, 150);
}
async function restore(t) {
close();
await store.restorePage(t.entry);
}
async function purge(entry) {
if (armed !== entry) { armed = entry; return; }
armed = null;
try {
await api.purge(entry === '*' ? null : entry);
await load();
} catch (err) {
toast('Could not delete: ' + errorText(err), 'x');
}
}
const ago = (t) => {
const s = (Date.now() - t) / 1000;
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
const d = Math.floor(s / 86400);
return d === 1 ? 'yesterday' : `${d} days ago`;
};
const where = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : 'Top level');
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel" role="dialog" aria-label="Trash">
<div class="trash-top">
<Icon name="trash" />
<h2>Trash</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="trash-list">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if !items.length}
<div class="trash-empty"><Icon name="trash" />Nothing in the trash.</div>
{:else}
{#each items as t (t.entry)}
<div class="tr">
<span class="row-ico"><Icon name={iconOf(t)} /></span>
<span class="tr-t">
<b>{t.title}</b>
<small>{where(t.id)} · {ago(t.deletedAt)}{t.pages > 1 ? ` · ${t.pages} pages` : ''}</small>
</span>
<button class="btn" onclick={() => restore(t)}><Icon name="undo" />Restore</button>
<button
class="btn danger"
class:armed={armed === t.entry}
title="Moves it to the system trash"
onclick={() => purge(t.entry)}
>{armed === t.entry ? 'Sure?' : 'Delete'}</button>
</div>
{/each}
{/if}
</div>
<div class="trash-foot">
<span>Pages stay here for 30 days, then go to the system trash.</span>
{#if items?.length}
<button class="btn danger" class:armed={armed === '*'} onclick={() => purge('*')}>
{armed === '*' ? 'Click again to empty' : 'Empty trash'}
</button>
{/if}
</div>
</div>
</div>
+133
View File
@@ -0,0 +1,133 @@
<script>
import { isCanvasId } from '$lib/canvas.js';
import TreeNode from './TreeNode.svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
let { page, depth = 0, dropOn = undefined, dragging = null } = $props();
// a database's records are shown in the database, not as a folder of pages
let kids = $derived(page.db ? [] : store.children(page.id));
let open = $derived(!!store.open[page.id]);
let active = $derived(store.currentId === page.id);
let name = $derived(active && store.titleDraft !== null ? store.titleDraft || 'Untitled' : page.title);
let renaming = $derived(store.renaming === page.id);
// A folder's rows are made when it is first opened (a vault of thousands of pages would otherwise draw them all),
// and a very long list is shown in steps; the page you are on, or the folder holding it, is always among them.
const STEP = 100;
let seen = $state(false);
$effect(() => { if (open) seen = true; });
let limit = $state(STEP);
const onPath = (id) => store.currentId === id || (!!store.currentId && store.currentId.startsWith(id + '/'));
let shown = $derived(kids.length <= limit ? kids : kids.filter((k, i) => i < limit || onPath(k.id)));
function oncontextmenu(e) {
e.stopPropagation();
if (renaming) return; // native cut/copy/paste for the name input
e.preventDefault();
store.menu = { x: e.clientX, y: e.clientY, id: page.id };
}
function focusSelect(input) {
input.focus();
input.select();
}
function finishRename(input, save) {
if (store.renaming !== page.id) return;
store.renaming = null;
if (save) store.renamePage(page.id, input.value);
}
function onkeydown(e) {
if (renaming) return;
if (e.key === 'F2') { e.preventDefault(); store.renaming = page.id; }
if (e.key === 'Enter') { e.preventDefault(); store.openPage(page.id); }
if (e.key === 'ArrowRight' && kids.length && !open) store.toggleFold(page.id);
else if (e.key === 'ArrowLeft' && kids.length && open) store.toggleFold(page.id);
else if (e.key === 'ArrowLeft' && !open && page.parent) {
// a closed (or leaf) row: Left goes to its parent, as in a file manager
e.preventDefault();
e.currentTarget.closest('.tree, .favs')?.querySelector(`.row[data-id="${CSS.escape(page.parent)}"]`)?.focus();
}
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
e.preventDefault();
const rows = [...e.currentTarget.closest('.tree, .favs').querySelectorAll('.row[data-id]')].filter((r) => r.offsetHeight > 0);
const i = rows.indexOf(e.currentTarget);
const to = e.key === 'Home' ? 0 : e.key === 'End' ? rows.length - 1 : Math.min(rows.length - 1, Math.max(0, i + (e.key === 'ArrowDown' ? 1 : -1)));
rows[to]?.focus();
}
}
</script>
<div class="node">
<div
class="row"
class:active
class:fresh={store.fresh === page.id}
class:ghost={!page.exists}
class:drop-in={dropOn === page.id}
class:lifted={dragging === page.id}
data-id={page.id}
style="--d:{depth}"
role="treeitem"
aria-selected={active}
aria-expanded={kids.length ? open : undefined}
tabindex={active ? 0 : -1}
onclick={(e) => { if (renaming) return; if (e.altKey && page.exists) store.openSplit(page.id); else store.openPage(page.id); }}
{onkeydown}
{oncontextmenu}
>
<button
class="chev"
class:open
class:leaf={!kids.length}
tabindex="-1"
aria-label={open ? 'Collapse' : 'Expand'}
onclick={(e) => { e.stopPropagation(); store.toggleFold(page.id); }}
><Icon name="chev" /></button>
<span class="row-ico"><Icon name={iconOf(page)} /></span>
{#if renaming}
<input
class="name row-rename"
value={page.title}
aria-label="Page name"
use:focusSelect
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); finishRename(e.currentTarget, true); }
if (e.key === 'Escape') { e.preventDefault(); finishRename(e.currentTarget, false); }
}}
onblur={(e) => finishRename(e.currentTarget, true)}
/>
{:else}
<span class="name">{name}</span>
{/if}
{#if !isCanvasId(page.id)}<button
class="row-add"
tabindex="-1"
aria-label="Add inside"
title="Add inside"
onclick={(e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
store.menu = { x: r.left, y: r.bottom + 4, id: page.id, add: true };
}}
><Icon name="plus" /></button>{/if}
</div>
{#if kids.length && (open || seen)}
<div class="kids" class:open inert={!open}>
<div class="kids-in">
{#each shown as kid (kid.id)}
<TreeNode page={kid} depth={depth + 1} {dropOn} {dragging} />
{/each}
{#if shown.length < kids.length}
<button class="row more" style="--d:{depth + 1}" onclick={() => (limit += STEP)}>
<span class="name">Show {Math.min(STEP, kids.length - shown.length)} more of {kids.length - shown.length}</span>
</button>
{/if}
</div>
</div>
{/if}
</div>
+18
View File
@@ -0,0 +1,18 @@
<script>
import Icon from './Icon.svelte';
import WindowControls from './WindowControls.svelte';
import { store } from '$lib/state.svelte.js';
</script>
<div class="welcome">
<WindowControls />
<div class="welcome-card island">
<img class="welcome-logo" src="/icon.svg" alt="Ink" width="72" height="72" />
<h1>Where should your notes live?</h1>
<p>Every page is a plain Markdown file in a folder you pick. Nothing leaves this computer, and an existing Obsidian vault works too.</p>
<div class="welcome-actions">
<button class="btn primary" onclick={() => store.pick('default')}><Icon name="folder" />Use ~/Notes</button>
<button class="btn" onclick={() => store.pick('choose')}>Choose a folder…</button>
</div>
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { api, isDesktop, platform } from '$lib/api.js';
// macOS keeps its native traffic lights, and the browser build has no window to control.
const show = isDesktop && platform !== 'darwin';
let maximized = $state(false);
onMount(() => {
if (!show) return;
api.isMaximized().then((v) => (maximized = v));
return api.onMaximized((v) => (maximized = v));
});
</script>
{#if show}
<div class="winctl">
<button class="btn-icon" aria-label="Minimize" onclick={() => api.minimize()}><Icon name="minimize" /></button>
<button class="btn-icon" aria-label={maximized ? 'Restore' : 'Maximize'} onclick={() => api.toggleMaximize()}>
<Icon name={maximized ? 'restore' : 'maximize'} />
</button>
<button class="btn-icon close" aria-label="Close" onclick={() => api.close()}><Icon name="x" /></button>
</div>
{/if}
+3
View File
@@ -0,0 +1,3 @@
// Desktop SPA: no server rendering, no prerendering. adapter-static writes index.html as the fallback.
export const ssr = false;
export const prerender = false;
+11
View File
@@ -0,0 +1,11 @@
<script>
import '@fontsource-variable/inter';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import 'katex/dist/katex.min.css';
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+110 -14
View File
@@ -3,6 +3,7 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import Topbar from '$lib/components/Topbar.svelte';
import PageView from '$lib/components/PageView.svelte';
import SplitPane from '$lib/components/SplitPane.svelte';
import Palette from '$lib/components/Palette.svelte';
import ExportDialog from '$lib/components/ExportDialog.svelte';
import Toast from '$lib/components/Toast.svelte';
@@ -10,9 +11,17 @@
import TextMenu from '$lib/components/TextMenu.svelte';
import FindBar from '$lib/components/FindBar.svelte';
import GraphView from '$lib/components/GraphView.svelte';
import CanvasView from '$lib/components/CanvasView.svelte';
import TrashDialog from '$lib/components/TrashDialog.svelte';
import { actionFor } from '$lib/shortcuts.js';
import { installDialogFocus } from '$lib/a11y.js';
import TasksDialog from '$lib/components/TasksDialog.svelte';
import TagsDialog from '$lib/components/TagsDialog.svelte';
import DailyCalendar from '$lib/components/DailyCalendar.svelte';
import ClipDialog from '$lib/components/ClipDialog.svelte';
import HistoryDialog from '$lib/components/HistoryDialog.svelte';
import TemplateDialog from '$lib/components/TemplateDialog.svelte';
import CsvImportDialog from '$lib/components/CsvImportDialog.svelte';
import SettingsDialog from '$lib/components/SettingsDialog.svelte';
import Welcome from '$lib/components/Welcome.svelte';
import { store } from '$lib/state.svelte.js';
@@ -21,46 +30,128 @@
onMount(() => {
store.init();
const offDialogs = installDialogFocus();
// Right-click: remember what was clicked; if nothing in the page handled it, Electron reports it back and
// the app's own menu opens (the browser preview keeps the browser's menu).
let target = null;
const remember = (e) => { target = e.target; };
window.addEventListener('contextmenu', remember, true);
const off = api.onContextMenu?.((info) => { store.textMenu = { ...info, target }; });
return () => { window.removeEventListener('contextmenu', remember, true); off?.(); };
// Mouse back / forward buttons (buttons 3 and 4)
const onMouseNav = (e) => {
if (e.button === 3) {
e.preventDefault();
e.stopPropagation();
store.back();
} else if (e.button === 4) {
e.preventDefault();
e.stopPropagation();
store.forward();
}
};
const onMousePrevent = (e) => {
if (e.button === 3 || e.button === 4) {
e.preventDefault();
e.stopPropagation();
}
};
window.addEventListener('mouseup', onMouseNav, true);
window.addEventListener('mousedown', onMousePrevent, true);
window.addEventListener('pointerdown', onMousePrevent, true);
window.addEventListener('auxclick', onMousePrevent, true);
const offNav = api.onNav?.((dir) => {
if (dir === 'back') store.back();
else if (dir === 'forward') store.forward();
});
return () => {
window.removeEventListener('contextmenu', remember, true);
window.removeEventListener('mouseup', onMouseNav, true);
window.removeEventListener('mousedown', onMousePrevent, true);
window.removeEventListener('pointerdown', onMousePrevent, true);
window.removeEventListener('auxclick', onMousePrevent, true);
off?.();
offNav?.();
offDialogs?.();
};
});
function onkeydown(e) {
if (e.key === 'Escape' && store.peek) store.peekEscape();
// Back / Forward keyboard shortcuts
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key === 'ArrowLeft') {
e.preventDefault();
store.back();
return;
}
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key === 'ArrowRight') {
e.preventDefault();
store.forward();
return;
}
if (platform === 'darwin' && e.metaKey && !e.altKey && !e.ctrlKey && e.key === '[') {
e.preventDefault();
store.back();
return;
}
if (platform === 'darwin' && e.metaKey && !e.altKey && !e.ctrlKey && e.key === ']') {
e.preventDefault();
store.forward();
return;
}
const mod = e.ctrlKey || e.metaKey;
if (!mod || !store.vault) return;
const k = e.key.toLowerCase();
if (k === 'k') { e.preventDefault(); store.paletteOpen = !store.paletteOpen; }
else if (k === 'o' && e.shiftKey) { e.preventDefault(); if (store.doc && !store.isBoard && store.view === 'page') store.toggleOutline(); }
else if (k === 'g' && !e.shiftKey) { e.preventDefault(); store.view === 'graph' ? store.closeGraph() : store.openGraph(); }
else if (k === ',') { e.preventDefault(); store.settingsOpen = !store.settingsOpen; }
else if (k === 'd' && e.shiftKey) { e.preventDefault(); store.openDaily(); }
else if (k === 'n') { e.preventDefault(); store.templatesOpen = { parent: null }; }
else if (k === '\\') { e.preventDefault(); store.toggleSidebar(); }
else if (k === 'f' && store.doc && store.view === 'page') { e.preventDefault(); store.findOpen = (store.findOpen || 0) + 1; }
else if (k === 's' && !e.shiftKey) { e.preventDefault(); store.flush().then(() => toast('Saved to disk')); }
switch (actionFor(e, store.prefs.shortcuts)) {
case 'palette': e.preventDefault(); store.paletteOpen = !store.paletteOpen; break;
case 'outline': e.preventDefault(); if (store.doc && !store.isBoard && !store.isDatabase && !store.isCanvas && store.view === 'page') store.toggleOutline(); break;
case 'tasks': e.preventDefault(); store.tasksOpen = !store.tasksOpen; break;
case 'graph': e.preventDefault(); store.view === 'graph' ? store.closeGraph() : store.openGraph(); break;
case 'settings': e.preventDefault(); store.settingsOpen = !store.settingsOpen; break;
case 'daily': e.preventDefault(); store.openDaily(); break;
case 'newPage': e.preventDefault(); store.templatesOpen = { parent: null }; break;
case 'split': e.preventDefault(); if (store.doc && store.view === 'page') store.toggleSplit(); break;
case 'sidebar': e.preventDefault(); store.toggleSidebar(); break;
case 'find': if (store.doc && store.view === 'page' && !store.isCanvas) { e.preventDefault(); store.findOpen = (store.findOpen || 0) + 1; } break;
case 'save': e.preventDefault(); store.flush().then(() => toast('Saved to disk')); break;
}
}
</script>
<svelte:window {onkeydown} onresize={() => window.innerWidth > 760 && (store.drawer = false)} />
<svelte:window {onkeydown} onmouseout={(e) => !e.relatedTarget && store.peekWindowLeft()} onblur={() => store.peekWindowLeft()} onpointermove={(e) => store.collapsed && store.peekMoved(e.clientX, e.clientY)} onresize={() => window.innerWidth > 760 && (store.drawer = false)} />
{#if store.vault}
<div class="dragstrip"></div>
<div class="app" class:collapsed={store.collapsed} class:focus-mode={store.focusMode} class:drawer={store.drawer} class:mac={platform === 'darwin'}>
<div class="app" class:collapsed={store.collapsed} class:peek={store.peek} class:focus-mode={store.focusMode} class:drawer={store.drawer} class:mac={platform === 'darwin'}>
{#if store.collapsed && !store.focusMode}
<!-- an invisible strip at the left edge: the hidden sidebar floats in while the mouse is on it -->
<div class="edge-hot" role="presentation" onpointerenter={() => store.peekOn()}></div>
{/if}
<Sidebar />
<div class="scrim" role="presentation" onclick={() => (store.drawer = false)}></div>
<main class="main island">
{#if store.splitHint && store.view === 'page'}
<div class="drop-halves" aria-hidden="true">
<div class:on={store.splitHint === 'left'}><span>Open here</span></div>
<div class:on={store.splitHint === 'right'}><span>Open in split</span></div>
</div>
{/if}
{#if store.view === 'graph'}
<GraphView />
{:else}
<Topbar />
{#if store.findOpen}<FindBar />{/if}
{#if store.findOpen && !store.isCanvas}<FindBar />{/if}
<div class="panes">
{#if store.isCanvas}
<div class="pane-main">{#key store.doc.version}<CanvasView id={store.doc.id} body={store.doc.body} />{/key}</div>
{:else}
<PageView />
{/if}
{#if store.split}{#key store.split}<SplitPane id={store.split} />{/key}{/if}
</div>
{/if}
</main>
</div>
{:else if store.ready}
@@ -72,7 +163,12 @@
{#if store.menu}<ContextMenu />{/if}
{#if store.textMenu}{#key store.textMenu}<TextMenu />{/key}{/if}
{#if store.trashOpen}<TrashDialog />{/if}
{#if store.tasksOpen}<TasksDialog />{/if}
{#if store.tagManagerOpen}<TagsDialog />{/if}
{#if store.calendarOpen}<DailyCalendar />{/if}
{#if store.clipOpen}<ClipDialog />{/if}
{#if store.historyOpen}<HistoryDialog />{/if}
{#if store.templatesOpen}<TemplateDialog />{/if}
{#if store.csvImport}<CsvImportDialog />{/if}
{#if store.settingsOpen}<SettingsDialog />{/if}
<Toast />