feat(ui): components, routes and styles
Sidebar (lazy tree, keyboard navigation, saved searches), page view with typed properties, tasks, tags, calendar and clip dialogs, settings with shortcut rebinding, canvas, database and board views. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0587cb267
commit
27fafa0bcd
39 files changed
+8744
-94
No files matched your search
+947
-41
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import WindowControls from './WindowControls.svelte';
|
import WindowControls from './WindowControls.svelte';
|
||||||
import { store } from '$lib/state.svelte.js';
|
import { store } from '$lib/state.svelte.js';
|
||||||
|
import { nodeColor } from '$lib/graphColors.js';
|
||||||
import { api, errorText } from '$lib/api.js';
|
import { api, errorText } from '$lib/api.js';
|
||||||
import { toast } from '$lib/toast.svelte.js';
|
import { toast } from '$lib/toast.svelte.js';
|
||||||
import { createSimulation, neighbourhood } from '$lib/graph.js';
|
import { createSimulation, neighbourhood } from '$lib/graph.js';
|
||||||
@@ -49,6 +50,18 @@
|
|||||||
const ids = new Set(nodes.map((n) => n.id));
|
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']);
|
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.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)) {
|
if (g.local && cur && ids.has(cur)) {
|
||||||
const keep = neighbourhood(cur, edges, g.depth || 1);
|
const keep = neighbourhood(cur, edges, g.depth || 1);
|
||||||
nodes = nodes.filter((n) => keep.has(n.id));
|
nodes = nodes.filter((n) => keep.has(n.id));
|
||||||
@@ -62,12 +75,14 @@
|
|||||||
sim.params.repel = (store.graph.repel || 420) * g.spacing;
|
sim.params.repel = (store.graph.repel || 420) * g.spacing;
|
||||||
sim.params.linkDistance = (store.graph.linkDistance || 96) * g.spacing;
|
sim.params.linkDistance = (store.graph.linkDistance || 96) * g.spacing;
|
||||||
sim.setData(nodes, edges);
|
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]));
|
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';
|
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 };
|
counts = { nodes: nodes.filter((n) => !n.ghost).length, links: sim.links.filter((l) => l.kind === 'link').length };
|
||||||
if (!fitted) {
|
if (!fitted) {
|
||||||
// A head start so the first frame isn't a tangle; then frame it.
|
// 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);
|
fitted = fit(false);
|
||||||
}
|
}
|
||||||
sim.reheat(0.5);
|
sim.reheat(0.5);
|
||||||
@@ -86,17 +101,12 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
$effect(() => { store.theme; readColors(); draw(); });
|
$effect(() => { store.theme; readColors(); draw(); });
|
||||||
|
// colouring a tag while the graph is open
|
||||||
|
$effect(() => { store.tagColors; redraw(); });
|
||||||
|
|
||||||
|
|
||||||
const colorForNode = (n) => {
|
// A tag with a colour (see the sidebar's tag menu) gives its pages that colour here; the rest are plain.
|
||||||
if (n.tags && n.tags.length > 0) {
|
const colorForNode = (n) => nodeColor(n.tags, store.tagColors, colors.ink3);
|
||||||
// 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
|
|
||||||
};
|
|
||||||
|
|
||||||
const radius = (n) => (n.ghost ? 3.5 : 3.6 + Math.sqrt(n.degree) * 1.7);
|
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 */
|
/* animation: run the simulation while it's warm or something is being dragged */
|
||||||
function frame() {
|
function frame() {
|
||||||
raf = 0;
|
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();
|
draw();
|
||||||
if (sim.running || gesture?.kind === 'drag') start();
|
if (sim.running || gesture?.kind === 'drag') start();
|
||||||
}
|
}
|
||||||
@@ -279,6 +289,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function open(n) {
|
function open(n) {
|
||||||
|
if (n.tag) { store.filterTag = n.tag; store.closeGraph(); return; }
|
||||||
if (n.ghost) store.openLink(n.title);
|
if (n.ghost) store.openLink(n.title);
|
||||||
else store.openPage(n.id);
|
else store.openPage(n.id);
|
||||||
}
|
}
|
||||||
@@ -375,6 +386,7 @@
|
|||||||
{/if}
|
{/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>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>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>
|
<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">
|
<div class="gp-row">
|
||||||
<span>Spacing</span>
|
<span>Spacing</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -6,9 +6,14 @@
|
|||||||
import Board from './Board.svelte';
|
import Board from './Board.svelte';
|
||||||
import Outline from './Outline.svelte';
|
import Outline from './Outline.svelte';
|
||||||
import { store, iconOf, coverOf } from '$lib/state.svelte.js';
|
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 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 doc = $derived(store.doc);
|
||||||
let page = $derived(store.current);
|
let page = $derived(store.current);
|
||||||
@@ -17,6 +22,27 @@
|
|||||||
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
|
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
|
||||||
let created = $derived(doc?.meta.created || page?.created || null);
|
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 scrollerEl = $state();
|
||||||
let titleEl = $state();
|
let titleEl = $state();
|
||||||
let iconEl = $state();
|
let iconEl = $state();
|
||||||
@@ -40,7 +66,7 @@
|
|||||||
const fmtDate = (d) => {
|
const fmtDate = (d) => {
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
const t = new Date(String(d).length === 10 ? d + 'T12:00:00' : d);
|
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) {
|
function restart(el, cls) {
|
||||||
@@ -55,6 +81,37 @@
|
|||||||
restart(iconEl, 'spin');
|
restart(iconEl, 'spin');
|
||||||
iconEl?.focus();
|
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) {
|
function setCover(v) {
|
||||||
pickingCover = false;
|
pickingCover = false;
|
||||||
store.setMeta({ cover: v });
|
store.setMeta({ cover: v });
|
||||||
@@ -113,6 +170,52 @@
|
|||||||
let addingProp = $state(false);
|
let addingProp = $state(false);
|
||||||
let newPropEl = $state();
|
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) {
|
function updateMeta(k, v) {
|
||||||
if (Array.isArray(doc.meta[k])) {
|
if (Array.isArray(doc.meta[k])) {
|
||||||
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
|
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
|
||||||
@@ -141,13 +244,15 @@
|
|||||||
|
|
||||||
function commitNewProp(k) {
|
function commitNewProp(k) {
|
||||||
k = k.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
|
k = k.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
if (k && !doc.meta[k] && !['icon', 'cover', 'tags', 'created'].includes(k)) {
|
if (k && doc.meta[k] === undefined && !HIDDEN_KEYS.includes(k)) {
|
||||||
store.setMeta({ [k]: '' });
|
store.setMeta({ [k]: startValue(newType) });
|
||||||
}
|
}
|
||||||
addingProp = false;
|
addingProp = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={(e) => { if (typeMenu && !e.target.closest?.('.ptype, .pk-type')) typeMenu = null; }} />
|
||||||
|
|
||||||
<div class="pagewrap">
|
<div class="pagewrap">
|
||||||
<div class="scroller" bind:this={scrollerEl}>
|
<div class="scroller" bind:this={scrollerEl}>
|
||||||
{#if doc}
|
{#if doc}
|
||||||
@@ -157,12 +262,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</datalist>
|
</datalist>
|
||||||
{#key doc.version}
|
{#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>
|
<button class="cover-btn" bind:this={coverBtnEl} onclick={() => (pickingCover = !pickingCover)}><Icon name="image" />Change cover</button>
|
||||||
</div>
|
</div>
|
||||||
<article class="page enter" class:wide={store.isBoard}>
|
<article class="page enter" class:wide={store.isBoard || store.isDatabase}>
|
||||||
<div class="head">
|
<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} />
|
<Icon name={icon} />
|
||||||
</button>
|
</button>
|
||||||
<h1
|
<h1
|
||||||
@@ -176,12 +295,39 @@
|
|||||||
onblur={() => store.commitTitle(titleEl.textContent)}
|
onblur={() => store.commitTitle(titleEl.textContent)}
|
||||||
onpaste={onTitlePaste}
|
onpaste={onTitlePaste}
|
||||||
></h1>
|
></h1>
|
||||||
|
{#if !store.isDatabase}
|
||||||
<div class="props">
|
<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">
|
<div class="prop">
|
||||||
<span class="pk"><Icon name="hash" />Tags</span>
|
<span class="pk"><Icon name="hash" />Tags</span>
|
||||||
<div class="pv tagv">
|
<div class="pv tagv">
|
||||||
{#each tags as t (t)}
|
{#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>
|
{t}<button class="chip-x" aria-label="Remove tag {t}" onclick={() => removeTag(t)}><Icon name="x" /></button>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -212,44 +358,75 @@
|
|||||||
<span class="pk"><Icon name="file" />File</span>
|
<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>
|
<button class="pv mono pv-link" title="Show in folder" onclick={() => api.reveal(doc.id)}>{store.relPath(doc.id)}</button>
|
||||||
</div>
|
</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">
|
<div class="prop">
|
||||||
<span class="pk">
|
<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)} />
|
<input class="custom-pk" value={pk} onchange={(e) => renameMeta(pk, e.target.value)} />
|
||||||
</span>
|
</span>
|
||||||
<div style="display:flex; gap: 4px; align-items: center; width: 100%;">
|
<div style="display:flex; gap: 4px; align-items: center; width: 100%;">
|
||||||
<input
|
{#if cp.type === 'text'}
|
||||||
class="pv custom-pv"
|
<input class="pv custom-pv" value={doc.meta[pk]} onchange={(e) => updateMeta(pk, e.target.value)} placeholder="Empty" />
|
||||||
value={Array.isArray(doc.meta[pk]) ? doc.meta[pk].join(', ') : doc.meta[pk]}
|
{:else}
|
||||||
onchange={(e) => updateMeta(pk, e.target.value)}
|
<div class="pv db-pv">
|
||||||
placeholder="Empty"
|
<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>
|
<button class="chip-x" aria-label="Remove property" onclick={() => removeMeta(pk)}><Icon name="x" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if addingProp}
|
{#if addingProp}
|
||||||
<div class="prop">
|
<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>
|
<span class="pv custom-pv"></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="prop">
|
<div class="prop">
|
||||||
<span class="pk"></span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !store.isBoard}
|
{#if !store.isBoard && !store.isDatabase}
|
||||||
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
|
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
|
||||||
<Backlinks id={doc.id} />
|
<Backlinks id={doc.id} />
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</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}
|
{#if pickingCover}
|
||||||
<CoverPicker
|
<CoverPicker
|
||||||
anchor={coverBtnEl}
|
anchor={coverBtnEl}
|
||||||
current={cover}
|
current={coverImage ? '' : cover}
|
||||||
|
hasImage={!!coverImage}
|
||||||
|
onupload={uploadCover}
|
||||||
|
onurl={coverFromUrl}
|
||||||
onpick={setCover}
|
onpick={setCover}
|
||||||
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
|
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
|
||||||
/>
|
/>
|
||||||
@@ -263,7 +440,10 @@
|
|||||||
onclose={() => { picking = false; iconEl?.focus(); }}
|
onclose={() => { picking = false; iconEl?.focus(); }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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} />
|
<Board body={doc.body} />
|
||||||
<div class="page wide"><Backlinks id={doc.id} /></div>
|
<div class="page wide"><Backlinks id={doc.id} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -275,5 +455,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if store.outlineOpen && doc && !store.isBoard}<Outline scroller={scrollerEl} />{/if}
|
{#if store.outlineOpen && doc && !store.isBoard && !store.isDatabase}<Outline scroller={scrollerEl} />{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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 & 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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
let trail = $derived(store.currentId ? [...store.ancestors(store.currentId), store.current].filter(Boolean) : []);
|
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() {
|
function toggleMenu() {
|
||||||
menuOpen = !menuOpen;
|
menuOpen = !menuOpen;
|
||||||
armed = false;
|
armed = false;
|
||||||
@@ -25,7 +28,11 @@
|
|||||||
}
|
}
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
if (kind === 'md' || kind === 'html' || kind === 'pdf') store.exportFormat = kind;
|
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 === '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 === 'reveal') api.reveal(store.currentId);
|
||||||
if (kind === 'history') { store.historyOpen = store.currentId; }
|
if (kind === 'history') { store.historyOpen = store.currentId; }
|
||||||
if (kind === 'path') {
|
if (kind === 'path') {
|
||||||
@@ -43,29 +50,64 @@
|
|||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="btn-icon menu-btn" aria-label="Show sidebar" onclick={() => store.toggleSidebar()}><Icon name="panel" /></button>
|
<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">
|
<nav class="crumbs" aria-label="Breadcrumb">
|
||||||
{#each trail as p, i (p.id)}
|
{#each trail as p, i (p.id)}
|
||||||
{#if i > 0}<span class="sep">/</span>{/if}
|
{#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>
|
<Icon name={iconOf(p)} /><span>{p.id === store.currentId && store.titleDraft !== null ? store.titleDraft || 'Untitled' : p.title}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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}
|
{#if store.saving || store.justSaved}
|
||||||
<div class="status" class:saving={store.saving} class:visible={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>
|
<i class="dot"></i><span>{store.saving ? 'Saving…' : 'Saved to disk'}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
class:on={store.outlineOpen && !store.isBoard}
|
class:on={store.outlineOpen && !store.isBoard}
|
||||||
aria-label="Outline"
|
aria-label="Outline"
|
||||||
aria-pressed={store.outlineOpen && !store.isBoard}
|
aria-pressed={store.outlineOpen && !store.isBoard}
|
||||||
title={store.isBoard ? 'Boards have no outline' : `Outline (${platform === 'darwin' ? '⌘⇧O' : 'Ctrl+Shift+O'})`}
|
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()}
|
onclick={() => store.toggleOutline()}
|
||||||
><Icon name="outline" /></button>
|
><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
|
<button
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
class:on={store.focusMode}
|
class:on={store.focusMode}
|
||||||
@@ -88,14 +130,30 @@
|
|||||||
<WindowControls />
|
<WindowControls />
|
||||||
{#if menuOpen}
|
{#if menuOpen}
|
||||||
<div class="pop menu" bind:this={menuEl} role="menu">
|
<div class="pop menu" bind:this={menuEl} role="menu">
|
||||||
|
{#if !store.isCanvas}
|
||||||
|
{#if !store.isDatabase}
|
||||||
<button class="mi" role="menuitem" onclick={() => run('board')}>
|
<button class="mi" role="menuitem" onclick={() => run('board')}>
|
||||||
<Icon name={store.isBoard ? 'file' : 'board'} />{store.isBoard ? 'Turn into document' : 'Turn into board'}
|
<Icon name={store.isBoard ? 'file' : 'board'} />{store.isBoard ? 'Turn into document' : 'Turn into board'}
|
||||||
</button>
|
</button>
|
||||||
<div class="sepline"></div>
|
<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('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('html')}><Icon name="export" />Export as HTML</button>
|
||||||
<button class="mi" role="menuitem" onclick={() => run('pdf')}><Icon name="export" />Export as PDF</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}
|
{#if isDesktop}
|
||||||
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
|
<button class="mi" role="menuitem" onclick={() => run('reveal')}><Icon name="folder" />Show in folder</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
@@ -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()}
|
||||||
+111
-15
@@ -3,6 +3,7 @@
|
|||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import Topbar from '$lib/components/Topbar.svelte';
|
import Topbar from '$lib/components/Topbar.svelte';
|
||||||
import PageView from '$lib/components/PageView.svelte';
|
import PageView from '$lib/components/PageView.svelte';
|
||||||
|
import SplitPane from '$lib/components/SplitPane.svelte';
|
||||||
import Palette from '$lib/components/Palette.svelte';
|
import Palette from '$lib/components/Palette.svelte';
|
||||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
@@ -10,9 +11,17 @@
|
|||||||
import TextMenu from '$lib/components/TextMenu.svelte';
|
import TextMenu from '$lib/components/TextMenu.svelte';
|
||||||
import FindBar from '$lib/components/FindBar.svelte';
|
import FindBar from '$lib/components/FindBar.svelte';
|
||||||
import GraphView from '$lib/components/GraphView.svelte';
|
import GraphView from '$lib/components/GraphView.svelte';
|
||||||
|
import CanvasView from '$lib/components/CanvasView.svelte';
|
||||||
import TrashDialog from '$lib/components/TrashDialog.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 HistoryDialog from '$lib/components/HistoryDialog.svelte';
|
||||||
import TemplateDialog from '$lib/components/TemplateDialog.svelte';
|
import TemplateDialog from '$lib/components/TemplateDialog.svelte';
|
||||||
|
import CsvImportDialog from '$lib/components/CsvImportDialog.svelte';
|
||||||
import SettingsDialog from '$lib/components/SettingsDialog.svelte';
|
import SettingsDialog from '$lib/components/SettingsDialog.svelte';
|
||||||
import Welcome from '$lib/components/Welcome.svelte';
|
import Welcome from '$lib/components/Welcome.svelte';
|
||||||
import { store } from '$lib/state.svelte.js';
|
import { store } from '$lib/state.svelte.js';
|
||||||
@@ -21,45 +30,127 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
store.init();
|
store.init();
|
||||||
|
const offDialogs = installDialogFocus();
|
||||||
// Right-click: remember what was clicked; if nothing in the page handled it, Electron reports it back and
|
// 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).
|
// the app's own menu opens (the browser preview keeps the browser's menu).
|
||||||
let target = null;
|
let target = null;
|
||||||
const remember = (e) => { target = e.target; };
|
const remember = (e) => { target = e.target; };
|
||||||
window.addEventListener('contextmenu', remember, true);
|
window.addEventListener('contextmenu', remember, true);
|
||||||
const off = api.onContextMenu?.((info) => { store.textMenu = { ...info, target }; });
|
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) {
|
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;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
if (!mod || !store.vault) return;
|
if (!mod || !store.vault) return;
|
||||||
const k = e.key.toLowerCase();
|
switch (actionFor(e, store.prefs.shortcuts)) {
|
||||||
if (k === 'k') { e.preventDefault(); store.paletteOpen = !store.paletteOpen; }
|
case 'palette': e.preventDefault(); store.paletteOpen = !store.paletteOpen; break;
|
||||||
else if (k === 'o' && e.shiftKey) { e.preventDefault(); if (store.doc && !store.isBoard && store.view === 'page') store.toggleOutline(); }
|
case 'outline': e.preventDefault(); if (store.doc && !store.isBoard && !store.isDatabase && !store.isCanvas && store.view === 'page') store.toggleOutline(); break;
|
||||||
else if (k === 'g' && !e.shiftKey) { e.preventDefault(); store.view === 'graph' ? store.closeGraph() : store.openGraph(); }
|
case 'tasks': e.preventDefault(); store.tasksOpen = !store.tasksOpen; break;
|
||||||
else if (k === ',') { e.preventDefault(); store.settingsOpen = !store.settingsOpen; }
|
case 'graph': e.preventDefault(); store.view === 'graph' ? store.closeGraph() : store.openGraph(); break;
|
||||||
else if (k === 'd' && e.shiftKey) { e.preventDefault(); store.openDaily(); }
|
case 'settings': e.preventDefault(); store.settingsOpen = !store.settingsOpen; break;
|
||||||
else if (k === 'n') { e.preventDefault(); store.templatesOpen = { parent: null }; }
|
case 'daily': e.preventDefault(); store.openDaily(); break;
|
||||||
else if (k === '\\') { e.preventDefault(); store.toggleSidebar(); }
|
case 'newPage': e.preventDefault(); store.templatesOpen = { parent: null }; break;
|
||||||
else if (k === 'f' && store.doc && store.view === 'page') { e.preventDefault(); store.findOpen = (store.findOpen || 0) + 1; }
|
case 'split': e.preventDefault(); if (store.doc && store.view === 'page') store.toggleSplit(); break;
|
||||||
else if (k === 's' && !e.shiftKey) { e.preventDefault(); store.flush().then(() => toast('Saved to disk')); }
|
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>
|
</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}
|
{#if store.vault}
|
||||||
<div class="dragstrip"></div>
|
<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 />
|
<Sidebar />
|
||||||
<div class="scrim" role="presentation" onclick={() => (store.drawer = false)}></div>
|
<div class="scrim" role="presentation" onclick={() => (store.drawer = false)}></div>
|
||||||
<main class="main island">
|
<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'}
|
{#if store.view === 'graph'}
|
||||||
<GraphView />
|
<GraphView />
|
||||||
{:else}
|
{:else}
|
||||||
<Topbar />
|
<Topbar />
|
||||||
{#if store.findOpen}<FindBar />{/if}
|
{#if store.findOpen && !store.isCanvas}<FindBar />{/if}
|
||||||
<PageView />
|
<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}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +163,12 @@
|
|||||||
{#if store.menu}<ContextMenu />{/if}
|
{#if store.menu}<ContextMenu />{/if}
|
||||||
{#if store.textMenu}{#key store.textMenu}<TextMenu />{/key}{/if}
|
{#if store.textMenu}{#key store.textMenu}<TextMenu />{/key}{/if}
|
||||||
{#if store.trashOpen}<TrashDialog />{/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.historyOpen}<HistoryDialog />{/if}
|
||||||
{#if store.templatesOpen}<TemplateDialog />{/if}
|
{#if store.templatesOpen}<TemplateDialog />{/if}
|
||||||
|
{#if store.csvImport}<CsvImportDialog />{/if}
|
||||||
{#if store.settingsOpen}<SettingsDialog />{/if}
|
{#if store.settingsOpen}<SettingsDialog />{/if}
|
||||||
<Toast />
|
<Toast />
|
||||||
Reference in new issue
Block a user