e.key === 'Escape' && (menuOpen = false)} />
+
+
diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js
new file mode 100644
index 0000000..f5e48c7
--- /dev/null
+++ b/src/lib/state.svelte.js
@@ -0,0 +1,767 @@
+// App state. One store, shared by every component.
+//
+// api.read(id) ─> parseMarkdown ─> doc.blocks ─> ─> edit(blocks) ─> (600ms) ─> toMarkdown ─> api.write(id)
+//
+import { api, errorText } from './api.js';
+import { parseMarkdown, toMarkdown, blockText, toHtmlDocument } from './markdown.js';
+import { PAGE_ICONS, COVERS, PATHS, isEmoji } from './icons.js';
+import { countWords, htmlText } from './text.js';
+import { toast } from './toast.svelte.js';
+import { resolveLink, refFor, refPage, headingOf, normHeading, matchesHeading } from './links.js';
+import { sectionOf } from './embed.js';
+import { scrollToBlock } from './scroll.js';
+import { BOARD_KEY, isBoard, parseBoard, boardToMd, defaultBoard, boardFromMarkdown } from './kanban.js';
+
+const hash = (s) => { let h = 7; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) | 0; return Math.abs(h); };
+const byTitle = (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' });
+const lastSegment = (id) => id.slice(id.lastIndexOf('/') + 1);
+
+// A page's icon: a stroke icon name or an emoji from its front matter, else one picked from its title.
+export const iconOf = (p) => (p && p.icon && (PATHS[p.icon] || isEmoji(p.icon)) ? p.icon : PAGE_ICONS[hash(p ? p.title : '') % PAGE_ICONS.length]);
+export const coverOf = (p) => (p && COVERS[p.cover] ? p.cover : Object.keys(COVERS)[hash(p ? p.title : '') % 4]);
+export const isMobile = () => typeof window !== 'undefined' && window.innerWidth <= 760;
+
+class Store {
+ vault = $state(null);
+ pages = $state([]);
+ ready = $state(false);
+ currentId = $state(null);
+ doc = $state.raw(null); // { id, title, meta, blocks, exists, version }
+ open = $state({});
+ filterTag = $state(null);
+ theme = $state('dark'); // what's showing: 'dark' | 'light'
+ themePref = $state('dark'); // what was chosen: 'dark' | 'light' | 'system'
+ fontSize = $state(16); // page text, px
+ pageWidth = $state('default'); // 'narrow' | 'default' | 'wide' | 'full'
+ settingsOpen = $state(false);
+ view = $state('page'); // 'page' | 'graph'
+ graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, spacing: 1 });
+ collapsed = $state(false);
+ focusMode = $state(false);
+ drawer = $state(false);
+ saving = $state(false);
+ justSaved = $state(false);
+ #justSavedTimer = null;
+ words = $state(0);
+ titleDraft = $state(null);
+ fresh = $state(null);
+ paletteOpen = $state(false);
+ exportFormat = $state(null);
+ menu = $state(null); // right-click menu: { x, y, id } (id null = empty sidebar space)
+ textMenu = $state(null); // right-click menu for text: what Electron reported, plus what was clicked (TextMenu.svelte)
+ paletteQuery = ''; // text to start the search palette with
+ renaming = $state(null); // page id being renamed inline in the sidebar
+ pins = $state([]); // favorite page ids, in the order they were added
+ recent = $state([]); // recently opened page ids, newest first
+ trashOpen = $state(false);
+ historyOpen = $state(null);
+ templatesOpen = $state(null); // null, or { parent }
+ findOpen = $state(0); // find-in-page bar: 0 closed, otherwise bumped on every Ctrl+F to refocus it
+
+ editor = null; // set by , not reactive
+ #vaults = {}; // per notes folder: { [path]: { pins, recent } }, kept in settings
+ #blocks = [];
+ headings = $state.raw([]); // outline of the open page: [{ id (block id), level 1-3, text }]
+ #headingBase = []; // headings as the rest of the vault knows them: [{ id, text }] (what [[Page#Heading]] links use)
+ #headingTimer = null;
+ #headingChain = Promise.resolve();
+ outlineOpen = $state(false);
+ #raw = null; // a board's Markdown body; null for ordinary pages, which save from #blocks
+ #dirty = false;
+ #timer = null;
+ #chain = Promise.resolve();
+ #version = 0;
+
+ get current() { return this.pages.find((p) => p.id === this.currentId) || null; }
+ children(parent) { return this.pages.filter((p) => p.parent === parent).sort(byTitle); }
+ ancestors(id) {
+ const out = [];
+ let p = this.pages.find((x) => x.id === id);
+ let parent = p && p.parent;
+ while (parent) {
+ const a = this.pages.find((x) => x.id === parent);
+ if (!a) break;
+ out.unshift(a);
+ parent = a.parent;
+ }
+ return out;
+ }
+ get tagCounts() {
+ const n = {};
+ for (const p of this.pages) for (const t of p.tags || []) n[t] = (n[t] || 0) + 1;
+ return Object.entries(n).sort((a, b) => a[0].localeCompare(b[0]));
+ }
+ relPath(id) { return `${this.vault ? this.vault.name : 'Notes'}/${id}.md`; }
+
+ /* lifecycle */
+ async init() {
+ try {
+ const s = (await api.getSettings()) || {};
+ this.themePref = ['dark', 'light', 'system'].includes(s.theme) ? s.theme : 'dark';
+ this.applyTheme(this.#resolve(this.themePref), false);
+ this.#systemDark.addEventListener('change', () => { if (this.themePref === 'system') this.#transition(this.#resolve('system')); });
+ this.setFontSize(s.fontSize, false);
+ this.setPageWidth(s.pageWidth, false);
+ if (s.graph && typeof s.graph === 'object') this.graph = { ...this.graph, ...s.graph };
+ this.collapsed = !!s.sidebarCollapsed;
+ this.focusMode = !!s.focusMode;
+ this.outlineOpen = !!s.outline;
+ this.open = s.open && typeof s.open === 'object' ? s.open : {};
+ this.#vaults = s.vaults && typeof s.vaults === 'object' ? s.vaults : {};
+ const v = await api.get();
+ if (v) await this.#load(v, s.lastPage);
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ this.ready = true;
+ api.onChanged((ids) => this.#external(ids));
+ api.onFlush(() => this.settle());
+ window.addEventListener('blur', () => this.settle());
+ }
+
+ async #load(v, last) {
+ this.vault = { path: v.path, name: v.name };
+ this.pages = v.pages;
+ const saved = this.#vaults[v.path] || {};
+ this.pins = Array.isArray(saved.pins) ? saved.pins : [];
+ this.recent = Array.isArray(saved.recent) ? saved.recent : [];
+ const first = this.pages.find((p) => p.id === last) || this.children(null).find((p) => p.exists) || this.pages[0];
+ if (first) await this.openPage(first.id);
+ else { this.doc = null; this.currentId = null; }
+ }
+
+ async pick(kind) {
+ try {
+ const v = await api.pick(kind);
+ if (v) await this.#load(v, null);
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ }
+
+ async refresh() {
+ const v = await api.list();
+ if (v) this.pages = v.pages;
+ }
+
+ /* pages */
+ async openPage(id) {
+ this.view = 'page';
+ await this.settle();
+ let r;
+ try { r = await api.read(id); } catch (err) { toast(errorText(err), 'x'); return; }
+ for (const a of this.ancestors(id)) this.open[a.id] = true;
+ this.currentId = id;
+ this.titleDraft = null;
+ this.#blocks = parseMarkdown(r.body);
+ this.#raw = isBoard(r.meta) ? r.body : null;
+ this.#dirty = false;
+ this.doc = { id, title: r.title, meta: r.meta || {}, blocks: this.#blocks, body: r.body, exists: r.exists, version: ++this.#version };
+ this.#derive(true);
+ this.drawer = false;
+ this.recent = [id, ...this.recent.filter((x) => x !== id)].slice(0, 30);
+ this.#persist({ lastPage: id, open: $state.snapshot(this.open) });
+ this.#saveLists();
+ }
+
+ async newPage(parent = null, templateName = null) {
+ await this.flush();
+ const covers = Object.keys(COVERS);
+ try {
+ let meta = {
+ icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
+ cover: covers[Math.floor(Math.random() * covers.length)]
+ };
+ let body = '';
+ let title = 'Untitled';
+
+ if (templateName) {
+ const tpl = await api.getTemplate(templateName);
+ if (tpl) {
+ meta = { ...meta, ...tpl.meta };
+ body = tpl.body;
+ if (templateName !== 'Default') title = templateName;
+ }
+ }
+
+ const id = await api.create(parent, title, meta, body);
+ if (parent) this.open[parent] = true;
+ this.filterTag = null;
+ await this.refresh();
+ this.fresh = id;
+ await this.openPage(id);
+ setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500);
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ }
+
+ async removePage(id = this.currentId) {
+ if (!id) return;
+ const page = this.pages.find((p) => p.id === id);
+ const parent = page ? page.parent : null;
+ const within = (x) => x === id || x.startsWith(id + '/');
+ const count = this.pages.filter((p) => within(p.id)).length;
+ const hitsCurrent = !!this.currentId && within(this.currentId);
+ if (hitsCurrent) {
+ clearTimeout(this.#timer);
+ this.#dirty = false;
+ } else {
+ await this.flush();
+ }
+ let entry;
+ try { entry = await api.remove(id); } catch (err) { toast(errorText(err), 'x'); return; }
+ await this.refresh();
+ const undo = entry ? { label: 'Undo', run: () => this.restorePage(entry) } : null;
+ toast(count > 1 ? `Moved ${count} pages to the trash` : 'Moved to the trash', 'trash', undo);
+ if (!hitsCurrent) return;
+ const next = (parent && this.pages.find((p) => p.id === parent)) || this.children(null)[0];
+ if (next) await this.openPage(next.id);
+ else { this.doc = null; this.currentId = null; }
+ }
+
+ async renameTag(oldTag, newTag) {
+ if (!oldTag || !newTag || oldTag === newTag) return;
+ await this.flush();
+ let updated = 0;
+ try {
+ for (const page of this.pages) {
+ if (page.tags && page.tags.includes(oldTag)) {
+ const r = await api.read(page.id);
+ const meta = r.meta || {};
+ const tags = meta.tags || [];
+ const idx = tags.indexOf(oldTag);
+ if (idx !== -1) {
+ tags[idx] = newTag;
+ meta.tags = tags;
+ await api.write(page.id, meta, r.body);
+ updated++;
+ }
+ }
+ }
+ if (updated > 0) {
+ if (this.filterTag === oldTag) this.filterTag = newTag;
+ await this.refresh();
+ if (this.currentId) {
+ const r = await api.read(this.currentId);
+ this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version };
+ }
+ toast(`Renamed tag across ${updated} page${updated === 1 ? '' : 's'}`);
+ }
+ } catch (err) {
+ toast('Could not rename tag: ' + errorText(err), 'x');
+ }
+ }
+
+ async renamePage(id, text) {
+ const page = this.pages.find((p) => p.id === id);
+ if (!page) return;
+ const title = String(text || '').replace(/\s+/g, ' ').trim() || 'Untitled';
+ if (title === page.title) return;
+ await this.settle();
+ try {
+ await this.#relocated(id, await api.rename(id, title));
+ } catch (err) {
+ toast('Could not rename: ' + errorText(err), 'x');
+ }
+ }
+
+ // Bring a page back from the trash and open it.
+ async restorePage(entry) {
+ try {
+ const id = await api.restore(entry);
+ await this.refresh();
+ for (const a of this.ancestors(id)) this.open[a.id] = true;
+ await this.openPage(id);
+ toast('Restored', 'check');
+ return id;
+ } catch (err) {
+ toast('Could not restore: ' + errorText(err), 'x');
+ return null;
+ }
+ }
+
+ /* favorites */
+ isPinned(id) { return this.pins.includes(id); }
+ togglePin(id = this.currentId) {
+ if (!id) return;
+ const on = !this.isPinned(id);
+ this.pins = on ? [...this.pins, id] : this.pins.filter((x) => x !== id);
+ this.#saveLists();
+ toast(on ? 'Added to favorites' : 'Removed from favorites', 'star');
+ }
+ get pinned() { return this.pins.map((id) => this.pages.find((p) => p.id === id)).filter(Boolean); }
+ get recentPages() { return this.recent.map((id) => this.pages.find((p) => p.id === id && p.exists)).filter(Boolean); }
+
+ #saveLists() {
+ if (!this.vault) return;
+ this.#vaults = { ...this.#vaults, [this.vault.path]: { pins: $state.snapshot(this.pins), recent: $state.snapshot(this.recent) } };
+ this.#persist({ vaults: this.#vaults });
+ }
+
+ /* daily note: Daily/YYYY-MM-DD */
+ async openDaily() {
+ const d = new Date();
+ const z = (n) => String(n).padStart(2, '0');
+ const id = `Daily/${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
+ try {
+ await this.flush();
+ await this.refresh();
+ if (!this.pages.some((p) => p.id === 'Daily' && p.exists)) {
+ await api.write('Daily', { icon: 'book', cover: 'sand', created: id.slice(6) }, '');
+ }
+ const fresh = !this.pages.some((p) => p.id === id && p.exists);
+ if (fresh) {
+ const covers = Object.keys(COVERS);
+ await api.write(id, { icon: 'cup', cover: covers[Math.floor(Math.random() * covers.length)], created: id.slice(6) }, '');
+ await this.refresh();
+ }
+ this.open.Daily = true;
+ this.filterTag = null;
+ await this.openPage(id);
+ if (fresh) requestAnimationFrame(() => this.editor?.focusFirst());
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ }
+
+ // Move a page under `parent` (null = top level).
+ async movePage(id, parent) {
+ parent = parent || null;
+ const page = this.pages.find((p) => p.id === id);
+ if (!page || page.parent === parent || parent === id || (parent && parent.startsWith(id + '/'))) return;
+ await this.settle();
+ try {
+ const next = await api.move(id, parent);
+ if (parent) this.open[parent] = true;
+ await this.#relocated(id, next);
+ this.fresh = next;
+ setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500);
+ const to = parent && this.pages.find((p) => p.id === parent);
+ toast(to ? `Moved into ${to.title}` : 'Moved to the top level', 'folder');
+ } catch (err) {
+ toast('Could not move: ' + errorText(err), 'x');
+ }
+ }
+
+ // Bookkeeping after page `id` became `next`, together with everything under it.
+ async #relocated(id, next) {
+ if (next === id) return;
+ const move = (k) => (k === id ? next : k.startsWith(id + '/') ? next + k.slice(id.length) : k);
+ const open = {};
+ for (const [k, v] of Object.entries(this.open)) open[move(k)] = v;
+ this.open = open;
+ this.pins = this.pins.map(move);
+ this.recent = this.recent.map(move);
+ this.#saveLists();
+ await this.refresh();
+ this.#persist({ open: $state.snapshot(this.open) });
+ const cur = this.currentId;
+ if (!cur || move(cur) === cur) return;
+ const parentOf = (x) => x.slice(0, Math.max(0, x.lastIndexOf('/')));
+ if (parentOf(move(cur)) !== parentOf(cur)) {
+ // Its folder changed, so image paths resolve differently: load it fresh.
+ this.currentId = null;
+ await this.openPage(move(cur));
+ return;
+ }
+ this.currentId = move(cur);
+ this.doc = { ...this.doc, id: this.currentId, title: lastSegment(this.currentId) };
+ this.#persist({ lastPage: this.currentId });
+ }
+
+ /* links */
+ // [[#Heading]] has no page part: it points into the page you're on.
+ resolveLink(ref) { return refPage(ref) ? resolveLink(ref, this.pages) : this.currentId; }
+ refFor(id) { return refFor(id, this.pages); }
+
+ // Open what a [[link]] points at: its page (made first if it doesn't exist yet), then its heading if it names one.
+ async openLink(ref) {
+ let id = this.resolveLink(ref);
+ if (!id && !refPage(ref)) return; // [[#Heading]] with no page open
+ if (!id) {
+ id = await this.createLinked(refPage(ref).split('/').pop());
+ if (!id) return;
+ }
+ if (id !== this.currentId || this.view !== 'page') await this.openPage(id);
+ const heading = headingOf(ref);
+ if (heading) await this.revealHeading(heading);
+ }
+
+ // Scroll the open page to the heading with this text (matched ignoring case and spacing).
+ async revealHeading(text) {
+ const h = this.headings.find((x) => matchesHeading(text, x.text));
+ if (!h) { toast(`No heading “${text}” on this page`, 'hash'); return false; }
+ // The page may still be drawing after a page switch: wait for the heading's block to exist.
+ for (let i = 0; i < 40; i++) {
+ const scroller = document.querySelector('.main .scroller');
+ const el = scroller && scroller.querySelector(`.block[data-id="${h.id}"]`);
+ if (el) {
+ await new Promise((r) => requestAnimationFrame(() => r()));
+ scrollToBlock(scroller, el);
+ return true;
+ }
+ await new Promise((r) => setTimeout(r, 40));
+ }
+ return false;
+ }
+
+ // What an ![[embed]] shows. state: 'ok' (blocks, imageUrl) | 'missing' | 'self' | 'board' | 'noheading'.
+ async loadEmbed(ref) {
+ const name = refPage(ref), heading = headingOf(ref);
+ const id = name ? resolveLink(ref, this.pages) : this.currentId;
+ if (!id) return { state: 'missing', name };
+ const page = this.pages.find((p) => p.id === id);
+ const title = page ? page.title : id.slice(id.lastIndexOf('/') + 1);
+ if (id === this.currentId) return { state: 'self', id, title };
+ const r = await api.read(id);
+ if (!r.exists) return { state: 'missing', name: title };
+ if (isBoard(r.meta)) return { state: 'board', id, title };
+ let blocks = parseMarkdown(r.body);
+ if (heading) {
+ const section = sectionOf(blocks, heading);
+ if (!section) return { state: 'noheading', id, title, heading };
+ blocks = section;
+ }
+ return { state: 'ok', id, title, heading, blocks, imageUrl: (src) => api.mediaUrl(id, src) };
+ }
+
+ // Make a top-level page for a link to something that doesn't exist yet. Returns its id.
+ async createLinked(title) {
+ const covers = Object.keys(COVERS);
+ try {
+ const id = await api.create(null, title || 'Untitled', {
+ icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
+ cover: covers[Math.floor(Math.random() * covers.length)]
+ });
+ await this.refresh();
+ return id;
+ } catch (err) {
+ toast(errorText(err), 'x');
+ return null;
+ }
+ }
+
+ async commitTitle(text) {
+ const d = this.doc;
+ this.titleDraft = null;
+ if (!d) return;
+ await this.renamePage(d.id, text);
+ }
+
+ /* boards */
+ get isBoard() { return isBoard(this.doc?.meta); }
+
+ // The board changed: save its Markdown.
+ editBoard(body) {
+ this.#raw = body;
+ this.#blocks = parseMarkdown(body);
+ this.#dirty = true;
+ this.#derive();
+ this.#schedule();
+ }
+
+ // Show the open page as a board (columns from its headings) or back as a document.
+ async setBoard(on) {
+ const d = this.doc;
+ if (!d || on === this.isBoard) return;
+ await this.flush();
+ const meta = { ...d.meta };
+ let body;
+ if (on) {
+ meta[BOARD_KEY] = 'basic';
+ body = boardToMd(boardFromMarkdown(toMarkdown(this.#blocks)));
+ this.#raw = body;
+ } else {
+ delete meta[BOARD_KEY];
+ body = this.#raw ?? toMarkdown(this.#blocks);
+ this.#raw = null;
+ }
+ this.#blocks = parseMarkdown(body);
+ this.#derive(true);
+ this.doc = { ...d, meta, blocks: this.#blocks, body, version: ++this.#version };
+ this.#dirty = true;
+ await this.flush();
+ toast(on ? 'Showing as a board' : 'Showing as a document', on ? 'board' : 'file');
+ }
+
+ async newBoard(parent = null) {
+ await this.flush();
+ try {
+ const id = await api.create(parent, 'Board', { icon: 'board', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [BOARD_KEY]: 'basic' });
+ const meta = (await api.read(id)).meta;
+ await api.write(id, meta, boardToMd(defaultBoard()));
+ if (parent) this.open[parent] = true;
+ this.filterTag = null;
+ await this.refresh();
+ this.fresh = id;
+ await this.openPage(id);
+ setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500);
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ }
+
+ /* editing */
+ edit(blocks) {
+ this.#blocks = blocks;
+ this.#dirty = true;
+ this.#derive();
+ this.#schedule();
+ }
+
+ // Word count and heading outline, both read from the blocks.
+ // fresh: the page was just loaded (or replaced from disk), so these headings are the ones links already use.
+ #derive(fresh = false) {
+ this.words = countWords(this.#blocks.map(blockText).join(' '));
+ const next = this.#raw !== null ? [] : this.#blocks
+ .filter((b) => b.type === 'h1' || b.type === 'h2' || b.type === 'h3')
+ .map((b) => ({ id: b.id, level: +b.type[1], text: htmlText(b.html).replace(/\s+/g, ' ').trim() }))
+ .filter((h) => h.text);
+ // Typing inside a heading changes its text on every key, but most keystrokes change nothing here.
+ const changed = JSON.stringify(next) !== JSON.stringify(this.headings);
+ if (changed) this.headings = next;
+ if (fresh) {
+ clearTimeout(this.#headingTimer);
+ this.#headingBase = next.map(({ id, text }) => ({ id, text }));
+ } else if (changed) {
+ // A renamed heading updates the links to it once you stop typing (not after every keystroke).
+ clearTimeout(this.#headingTimer);
+ this.#headingTimer = setTimeout(() => this.#commitHeadingRenames(), 2000);
+ }
+ }
+
+ // Save, and bring links to renamed headings up to date. Used when you leave the page or the window.
+ async settle() {
+ await this.flush();
+ await this.#commitHeadingRenames();
+ }
+ #commitHeadingRenames() {
+ clearTimeout(this.#headingTimer);
+ this.#headingChain = this.#headingChain.then(() => this.#applyHeadingRenames()).catch((err) => toast(errorText(err), 'x'));
+ return this.#headingChain;
+ }
+ // Compare the headings now with the ones links know about; for each heading whose text changed, re-point the
+ // links to it: in the open page (through the editor), and in every other note (on disk).
+ async #applyHeadingRenames() {
+ const id = this.currentId;
+ if (!id || this.#raw !== null) return;
+ const alive = new Set(this.#blocks.map((b) => b.id));
+ const base = this.#headingBase.filter((b) => alive.has(b.id));
+ const was = base.map((b) => b.text);
+ const now = new Map(this.headings.map((h) => [h.id, h]));
+ const renames = [];
+ base.forEach((b, i) => {
+ const h = now.get(b.id);
+ if (!h || h.text === b.text) return;
+ // Links point at the first heading of a name, and a change of case alone leaves them working.
+ const first = !was.slice(0, i).some((t) => normHeading(t) === normHeading(b.text));
+ if (first && normHeading(h.text) !== normHeading(b.text)) renames.push({ from: b.text, to: h.text });
+ b.text = h.text;
+ });
+ const known = new Set(base.map((b) => b.id));
+ for (const h of this.headings) if (!known.has(h.id)) base.push({ id: h.id, text: h.text }); // tracked from now on
+ this.#headingBase = base;
+ if (!renames.length) return;
+
+ // The open page first, before anything else can change it.
+ let links = 0, notes = new Set();
+ for (const r of renames) {
+ const n = this.editor ? this.editor.renameHeadingRefs(r.from, r.to) : 0;
+ if (n) { links += n; notes.add(id); }
+ }
+ for (const r of renames) {
+ try {
+ const out = await api.renameHeading(id, r.from, r.to);
+ links += out.count;
+ out.pages.forEach((p) => notes.add(p));
+ } catch (err) {
+ toast('Could not update links to the renamed heading: ' + errorText(err), 'x');
+ }
+ }
+ if (links) {
+ const to = renames.length === 1 ? `“${renames[0].to}”` : 'the renamed headings';
+ toast(`Updated ${links} link${links === 1 ? '' : 's'} to ${to} in ${notes.size} note${notes.size === 1 ? '' : 's'}`, 'link');
+ }
+ }
+
+ toggleFocusMode() {
+ this.focusMode = !this.focusMode;
+ this.#persist({ focusMode: this.focusMode });
+ }
+
+ toggleOutline(on = !this.outlineOpen) {
+ this.outlineOpen = on;
+ this.#persist({ outline: on });
+ }
+
+ setMeta(patch) {
+ const d = this.doc;
+ if (!d) return;
+ this.doc = { ...d, meta: { ...d.meta, ...patch } };
+ const p = this.current;
+ if (p) for (const k of ['icon', 'cover', 'tags']) if (k in patch) p[k] = patch[k];
+ this.#dirty = true;
+ this.#schedule();
+ }
+
+ #schedule() {
+ this.saving = true;
+ clearTimeout(this.#timer);
+ this.#timer = setTimeout(() => this.flush(), 600);
+ }
+
+ flush() {
+ clearTimeout(this.#timer);
+ this.#chain = this.#chain.then(() => this.#save());
+ return this.#chain;
+ }
+
+ async #save() {
+ const d = this.doc;
+ if (!d || !this.#dirty) { this.saving = false; return; }
+ this.#dirty = false;
+ const body = this.#raw ?? toMarkdown(this.#blocks);
+ try {
+ await api.write(d.id, { ...d.meta }, body);
+ const p = this.pages.find((x) => x.id === d.id);
+ if (p && !p.exists) p.exists = true;
+ this.justSaved = true;
+ clearTimeout(this.#justSavedTimer);
+ this.#justSavedTimer = setTimeout(() => { this.justSaved = false; }, 3000);
+ } catch (err) {
+ this.#dirty = true;
+ toast('Could not save: ' + errorText(err), 'x');
+ }
+ this.saving = this.#dirty;
+ }
+
+ async #external(ids) {
+ await this.refresh();
+ const d = this.doc;
+ if (!d || !ids.includes(d.id) || this.#dirty) return; // a pending local edit wins and overwrites on save
+ const r = await api.read(d.id);
+ if (!r.exists) {
+ const first = this.children(null)[0];
+ if (first) await this.openPage(first.id);
+ else { this.doc = null; this.currentId = null; }
+ return;
+ }
+ if (r.body === (this.#raw ?? toMarkdown(this.#blocks)) && isBoard(r.meta) === this.isBoard) return;
+ this.#blocks = parseMarkdown(r.body);
+ this.#raw = isBoard(r.meta) ? r.body : null;
+ this.#derive(true);
+ this.doc = { ...d, meta: r.meta || {}, blocks: this.#blocks, body: r.body, version: ++this.#version };
+ toast('Updated from disk', 'file');
+ }
+
+ exportText(format) {
+ const d = this.doc;
+ if (!d) return '';
+ return format === 'html' ? toHtmlDocument(d.title, this.#blocks) : `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`;
+ }
+
+ /* ui */
+ toggleFold(id) {
+ this.open[id] = !this.open[id];
+ this.#persist({ open: $state.snapshot(this.open) });
+ }
+
+ toggleSidebar() {
+ if (isMobile()) { this.drawer = !this.drawer; return; }
+ this.collapsed = !this.collapsed;
+ this.#persist({ sidebarCollapsed: this.collapsed });
+ }
+
+ /* version history */
+ // Put back an earlier version of a page and reload it, so the editor doesn't save the old text over it.
+ async restoreVersion(id, ts) {
+ try {
+ await this.flush();
+ await api.restoreHistory(id, ts);
+ await this.refresh();
+ if (this.currentId === id) { this.currentId = null; await this.openPage(id); }
+ toast('Version restored. The one before is kept in history.', 'clock');
+ } catch (err) {
+ toast('Could not restore: ' + errorText(err), 'x');
+ }
+ }
+
+ /* graph view */
+ openGraph() {
+ this.settle();
+ this.view = 'graph';
+ this.paletteOpen = false;
+ this.findOpen = 0;
+ this.drawer = false;
+ }
+ closeGraph() {
+ // The page editor was unmounted meanwhile: remount it from the latest content, not what was first loaded.
+ if (this.doc) this.doc = { ...this.doc, blocks: this.#blocks, body: this.#raw ?? this.doc.body, version: ++this.#version };
+ this.view = 'page';
+ }
+ setGraph(patch) {
+ this.graph = { ...this.graph, ...patch };
+ this.#persist({ graph: $state.snapshot(this.graph) });
+ }
+
+ /* appearance */
+ #systemDark = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : { matches: true, addEventListener() {} };
+ #resolve(pref) { return pref === 'system' ? (this.#systemDark.matches ? 'dark' : 'light') : pref; }
+
+ applyTheme(t, persist = true) {
+ this.theme = t;
+ document.documentElement.dataset.theme = t;
+ if (persist) { this.themePref = t; this.#persist({ theme: t }); }
+ }
+
+ // Choose 'dark', 'light' or 'system' (follows the desktop). `origin`: element the theme spreads out from.
+ setThemePref(pref, origin) {
+ this.themePref = pref;
+ this.#persist({ theme: pref });
+ this.#transition(this.#resolve(pref), origin);
+ }
+
+ toggleTheme(origin) {
+ this.setThemePref(this.theme === 'dark' ? 'light' : 'dark', origin);
+ }
+
+ #transition(next, origin) {
+ if (next === this.theme) return;
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (!document.startViewTransition || reduce || !origin) return this.applyTheme(next, false);
+ const r = origin.getBoundingClientRect();
+ const x = r.left + r.width / 2, y = r.top + r.height / 2;
+ const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
+ const vt = document.startViewTransition(() => this.applyTheme(next, false));
+ vt.ready
+ .then(() => document.documentElement.animate(
+ { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
+ { duration: 650, easing: 'cubic-bezier(.2,.8,.2,1)', pseudoElement: '::view-transition-new(root)' }
+ ))
+ .catch(() => {});
+ }
+
+ static FONT = { min: 13, max: 22, base: 16 };
+ static WIDTHS = { narrow: '620px', default: '740px', wide: '920px', full: 'none' };
+
+ setFontSize(px, persist = true) {
+ const { min, max, base } = Store.FONT;
+ const n = Math.round(Number(px));
+ this.fontSize = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : base;
+ document.documentElement.style.setProperty('--fs', this.fontSize + 'px');
+ if (persist) this.#persist({ fontSize: this.fontSize });
+ }
+
+ setPageWidth(w, persist = true) {
+ this.pageWidth = Store.WIDTHS[w] ? w : 'default';
+ document.documentElement.style.setProperty('--page-w', Store.WIDTHS[this.pageWidth]);
+ if (persist) this.#persist({ pageWidth: this.pageWidth });
+ }
+
+ #persist(patch) {
+ Promise.resolve(api.setSettings(patch)).catch(() => {});
+ }
+}
+
+export const store = new Store();
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..b89c7fe
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,78 @@
+
+
+ window.innerWidth > 760 && (store.drawer = false)} />
+
+{#if store.vault}
+
+
+
+
(store.drawer = false)}>
+
+ {#if store.view === 'graph'}
+
+ {:else}
+
+ {#if store.findOpen}{/if}
+
+ {/if}
+
+
+{:else if store.ready}
+
+{/if}
+
+{#if store.paletteOpen}{/if}
+{#if store.exportFormat}{/if}
+{#if store.menu}{/if}
+{#if store.textMenu}{#key store.textMenu}{/key}{/if}
+{#if store.trashOpen}{/if}
+{#if store.historyOpen}{/if}
+{#if store.templatesOpen}{/if}
+{#if store.settingsOpen}{/if}
+