feat: Add Focus Mode toggle for distraction-free writing

This commit is contained in:
Your Name committed 2026-10-10 11:57:11 +02:00
1 parent ac23f67e8a
commit 437887207c
4 files changed
+996 -41

No files matched your search

+767
View File
@@ -0,0 +1,767 @@
// App state. One store, shared by every component.
//
// api.read(id) ─> parseMarkdown ─> doc.blocks ─> <Editor> ─> 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 <Editor>, 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();