Files
ink-note-app/src/lib/state.svelte.js
T
Your NameandClaude Sonnet 5.5 c0587cb267 feat(app): editor engine, markdown, store and domain modules
Block editor, markdown round trip, formulas/databases/kanban/canvas
models, graph simulation (time-budgeted ticks for big vaults), shortcut
registry, dialog focus handling, HTML to Markdown for paste and clipping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00

1594 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { cleanFolder, autoFolder, dailyId, dailyBody, formatDate } from './daily.js';
import { articleOf } from './htmlDoc.js';
import { DEFAULTS, cleanPrefs, wikiToMarkdownLinks } from './prefs.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';
import { isCanvasId } from './canvas.js';
import { DB_KEY, isDatabase, parseDatabase, databaseToMd, defaultDatabase, view as newView, withFormulas } from './database.js';
import { databaseCsv, parseCsv } from './csv.js';
const CORE_KEYS = ['icon', 'cover', 'tags', 'created'];
// A canvas, board or database keeps its file text as is (#raw); other pages are blocks.
const rawOf = (id, r) => (isCanvasId(id) || isBoard(r.meta) || isDatabase(r.meta) ? r.body : null);
// A page's content as one comparable string: front matter without empty values and in a fixed order, then the text.
// (Used to tell whether the file on disk is still what this window last read or wrote.)
const canon = (meta, body) =>
JSON.stringify([
Object.keys(meta || {})
.filter((k) => { const v = meta[k]; return !(v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length)); })
.sort()
.map((k) => [k, Array.isArray(meta[k]) ? meta[k].map(String) : String(meta[k])]),
String(body ?? '').replace(/\r\n/g, '\n')
]);
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 : p && isCanvasId(p.id) ? 'canvas' : 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'
quickLayout = $state('vertical'); // the sidebar's Search / New page / Today / Graph view: 'vertical' list or 'horizontal' icons
pageFont = $state('sans'); // 'sans' | 'serif' | 'mono'
lineSpacing = $state('default'); // 'compact' | 'default' | 'relaxed'
palette = $state('default'); // color theme: 'default' or a name from Store.PALETTES
accent = $state('blush'); // 'blush' (default) or a name from Store.ACCENTS
reduceMotion = $state(false);
startPage = $state('last'); // which page opens at launch: 'last' | 'today'
prefs = $state({ ...DEFAULTS }); // see prefs.js
settingsOpen = $state(false);
view = $state('page'); // 'page' | 'graph'
graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, tags: false, spacing: 1 });
collapsed = $state(false);
peek = $state(false); // the hidden sidebar is floated in while the mouse is at the window's left edge
tagsOpen = $state(true); // the sidebar's tag list is folded away when false
focusMode = $state(false);
slashRecent = []; // block kinds last picked from the slash menu, newest first
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
tagColors = $state({}); // tag -> colour name (see tagColors.js), per notes folder
dailyFolder = $state(null); // where daily notes go (a page path); null = find it (see daily.js), per notes folder
recent = $state([]); // recently opened page ids, newest first
trashOpen = $state(false);
tasksOpen = $state(false);
tagManagerOpen = $state(false); // the tag manager dialog
calendarOpen = $state(false);
clipOpen = $state(false);
searches = $state([]); // saved searches: [{ q }], per notes folder
historyOpen = $state(null);
templatesOpen = $state(null); // null, or { parent }
csvImport = $state(null); // the CSV import dialog: { target: database id or null (a new database), name, text }
findOpen = $state(0); // find-in-page bar: 0 closed, otherwise bumped on every Ctrl+F to refocus it
navHistory = $state([]); // [{ id, view }]
navIndex = $state(-1);
#isNavigating = false;
get canGoBack() {
return this.navIndex > 0;
}
get canGoForward() {
return this.navIndex >= 0 && this.navIndex < this.navHistory.length - 1;
}
editor = null; // set by <Editor>, not reactive
canvasView = null; // set by <CanvasView> (what the menus need from the open canvas), 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);
split = $state(null); // id of the page shown in the side pane (an ordinary page, never the one on the left)
splitHint = $state(null); // 'left' | 'right' while a sidebar drag is over that half of the page
splitSave = null; // set by <SplitPane>: writes its pending edit, not reactive
#raw = null; // a board's Markdown body; null for ordinary pages, which save from #blocks
#base = null; // canon() of the open page as this window last read or wrote it, to notice edits made elsewhere
#dirty = false;
#timer = null;
#chain = Promise.resolve();
#version = 0;
get current() { return this.pages.find((p) => p.id === this.currentId) || null; }
// parent id -> its sub-pages by title, built once per page list (the sidebar asks for every folder's children)
#childMap = $derived.by(() => {
const map = new Map();
for (const p of this.pages) {
const k = p.parent || null;
const list = map.get(k);
if (list) list.push(p); else map.set(k, [p]);
}
for (const list of map.values()) list.sort(byTitle);
return map;
});
children(parent) { return this.#childMap.get(parent || null) || []; }
#byId = $derived.by(() => new Map(this.pages.map((p) => [p.id, p])));
ancestors(id) {
const out = [];
let p = this.#byId.get(id);
let parent = p && p.parent;
while (parent) {
const a = this.#byId.get(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);
this.quickLayout = s.quickLayout === 'horizontal' ? 'horizontal' : 'vertical';
this.setPageFont(s.pageFont, false);
this.setLineSpacing(s.lineSpacing, false);
this.setPalette(s.palette, false);
this.setAccent(s.accent, false);
this.setReduceMotion(!!s.reduceMotion, false);
this.prefs = cleanPrefs(s.prefs);
this.#applyPrefs();
this.startPage = s.startPage === 'today' ? 'today' : 'last';
if (s.graph && typeof s.graph === 'object') this.graph = { ...this.graph, ...s.graph };
this.collapsed = !!s.sidebarCollapsed;
this.tagsOpen = s.tagsOpen !== false;
this.focusMode = !!s.focusMode;
this.slashRecent = Array.isArray(s.slashRecent) ? s.slashRecent.filter((t) => typeof t === 'string').slice(0, 5) : [];
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);
if (this.startPage === 'today') await this.openDaily();
}
} catch (err) {
toast(errorText(err), 'x');
}
this.ready = true;
api.onChanged((ids) => this.#external(ids));
api.onCapture?.(() => this.takeCaptures());
this.takeCaptures();
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.searches = Array.isArray(saved.searches) ? saved.searches.filter((x) => x && typeof x.q === 'string' && x.q.trim()).map((x) => ({ q: x.q.trim() })) : [];
this.dailyFolder = typeof saved.dailyFolder === 'string' && saved.dailyFolder ? saved.dailyFolder : null;
this.tagColors = saved.tagColors && typeof saved.tagColors === 'object' && !Array.isArray(saved.tagColors) ? { ...saved.tagColors } : {};
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) return;
// The open page may have edits that aren't on disk yet (a cover just chosen, a property just set): the list
// read from disk would take them back out, so what the page itself holds wins until it is saved.
if (this.doc && this.#dirty) {
const p = v.pages.find((x) => x.id === this.doc.id);
if (p) this.#syncListEntry(p, this.doc.meta);
}
this.pages = v.pages;
}
// The fields of a page's list entry that come from its front matter, taken from `meta`.
#syncListEntry(p, meta) {
p.icon = meta.icon || null;
p.cover = meta.cover || null;
p.tags = Array.isArray(meta.tags) ? meta.tags : [];
const props = {};
for (const [k, v] of Object.entries(meta)) if (!CORE_KEYS.includes(k)) props[k] = v;
p.props = props;
}
/* navigation history */
#pushNav(entry) {
const cur = this.navHistory[this.navIndex];
if (cur && cur.view === entry.view && cur.id === entry.id) return;
if (this.navIndex < this.navHistory.length - 1) {
this.navHistory = this.navHistory.slice(0, this.navIndex + 1);
}
this.navHistory.push(entry);
if (this.navHistory.length > 80) {
this.navHistory.shift();
}
this.navIndex = this.navHistory.length - 1;
}
back() { return this.#go(-1); }
forward() { return this.#go(1); }
// Moves through the history one step at a time; presses made while a move is still loading are queued
// rather than dropped, so rapid mouse-button clicks walk the history exactly as many steps.
#goQueue = Promise.resolve();
#go(delta) {
this.#goQueue = this.#goQueue.then(() => this.#step(delta)).catch(() => {});
return this.#goQueue;
}
async #step(delta) {
let i = this.navIndex + delta;
while (i >= 0 && i < this.navHistory.length) {
const entry = this.navHistory[i];
this.#isNavigating = true;
try {
if (entry.view === 'graph') {
if (this.view !== 'graph') this.openGraph();
this.navIndex = i;
return;
}
if (this.view === 'graph') this.closeGraph();
await this.openPage(entry.id, { push: false });
if (this.currentId === entry.id) { this.navIndex = i; return; }
} finally {
this.#isNavigating = false;
}
// The page could not be opened (deleted?): drop the entry and try the next one in that direction.
this.navHistory.splice(i, 1);
if (delta < 0) { i--; this.navIndex--; }
}
}
/* pages */
async openPage(id, { push = true } = {}) {
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;
if (this.split === id) this.split = null; // the side pane would show the same file twice
this.titleDraft = null;
this.#raw = rawOf(id, r);
this.#base = canon(r.meta, r.body);
this.#blocks = isCanvasId(id) ? [] : parseMarkdown(r.body);
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();
if (push && !this.#isNavigating) {
this.#pushNav({ id, view: 'page' });
}
}
async newPage(parent = null, templateName = null) {
await this.flush();
const covers = Object.keys(COVERS);
try {
let meta = {};
if (this.prefs.newPageIcon === 'random') meta.icon = PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)];
if (this.prefs.newPageCover === 'random') meta.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);
if (this.prefs.confirmTrash && !confirm(`Move “${page ? page.title : id}” to the trash?`)) return;
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; }
}
// Several pages at once (a selection of database records), as one step: one toast, one Undo.
async removePages(ids) {
ids = [...new Set(ids)].filter((id) => !this.currentId || id !== this.currentId);
if (!ids.length) return;
if (this.prefs.confirmTrash && !confirm(ids.length === 1 ? 'Move this page to the trash?' : `Move ${ids.length} pages to the trash?`)) return;
await this.flush();
const entries = [];
try {
for (const id of ids) { const e = await api.remove(id); if (e) entries.push(e); }
} catch (err) {
toast(errorText(err), 'x');
}
await this.refresh();
if (!entries.length) return;
const undo = {
label: 'Undo',
run: async () => {
try { for (const e of entries) await api.restore(e); await this.refresh(); toast('Restored', 'check'); } catch (err) { toast('Could not restore: ' + errorText(err), 'x'); }
}
};
toast(entries.length === 1 ? 'Moved to the trash' : `Moved ${entries.length} pages to the trash`, 'trash', undo);
}
// The colour of a tag (null removes it).
setTagColor(tag, color) {
const next = { ...this.tagColors };
if (color) next[tag] = color;
else delete next[tag];
this.tagColors = next;
this.#saveLists();
}
// Rename a tag in every page; into a tag that exists, it merges. An empty new name removes the tag.
async renameTag(oldTag, newTag = '') {
const to = String(newTag || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
if (!oldTag || to === oldTag) return;
await this.flush();
try {
const n = await api.renameTag(oldTag, to);
if (this.filterTag === oldTag) this.filterTag = to || null;
if (this.tagColors[oldTag]) {
if (to && !this.tagColors[to]) this.setTagColor(to, this.tagColors[oldTag]);
this.setTagColor(oldTag, null);
}
await this.refresh();
if (this.currentId) {
const r = await api.read(this.currentId);
this.#base = canon(r.meta, r.body);
this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version };
}
toast(`${to ? 'Renamed' : 'Removed'} #${oldTag} in ${n} page${n === 1 ? '' : 's'}`);
} catch (err) {
toast('Could not change the tag: ' + errorText(err), 'x');
}
}
deleteTag(tag) { return this.renameTag(tag, ''); }
/* bringing notes in and out */
async importNotes(kind) {
try {
await this.flush();
const r = await api.importNotes(kind);
if (!r) return;
await this.refresh();
this.open[r.folder] = true;
toast(`Imported ${r.notes} note${r.notes === 1 ? '' : 's'}${r.files ? ` and ${r.files} file${r.files === 1 ? '' : 's'}` : ''} into “${r.folder}”`, 'download');
if (this.pages.some((p) => p.id === r.folder)) await this.openPage(r.folder);
} catch (err) {
toast('Could not import: ' + errorText(err), 'x');
}
}
async exportVaultZip() {
try {
await this.flush();
const r = await api.exportVaultZip();
if (r) toast(`Saved ${r.count} files to a zip`, 'save');
} catch (err) {
toast('Could not export: ' + errorText(err), 'x');
}
}
// A web page saved as a note: its main text as Markdown, with the address in front matter.
async clipWeb(address) {
const url = String(address || '').trim();
if (!url) return;
try {
await this.flush();
const page = await api.clipPage(/^[a-z]+:\/\//i.test(url) ? url : 'https://' + url);
const { title, markdown } = articleOf(page.html, { base: page.url });
const name = (title || new URL(page.url).hostname).slice(0, 120);
const id = await api.create(null, name, { icon: 'globe', source: page.url }, markdown);
await this.refresh();
await this.openPage(id);
toast('Clipped “' + name + '”', 'check');
} catch (err) {
toast('Could not clip that page: ' + errorText(err), 'x');
}
}
// A copy of a page, board or canvas beside it (with its sub-pages), opened.
async duplicatePage(id) {
await this.flush();
try {
const next = await api.duplicate(id);
await this.refresh();
this.fresh = next;
await this.openPage(next);
setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500);
toast('Duplicated', 'copy', {
label: 'Undo',
run: async () => { await this.removePage(next); await this.openPage(id); }
});
} catch (err) {
toast('Could not duplicate: ' + 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 {
const next = await api.rename(id, title);
await this.#relocated(id, next);
return next; // the new id (the file name may differ from `title`)
} catch (err) {
toast('Could not rename: ' + errorText(err), 'x');
return null;
}
}
// 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), tagColors: $state.snapshot(this.tagColors), dailyFolder: this.dailyFolder, searches: $state.snapshot(this.searches) } };
this.#persist({ vaults: this.#vaults });
}
/* saved searches: a query kept in the sidebar; opening one runs it in the search palette */
isSearchSaved(q) { const t = String(q || '').trim().toLowerCase(); return this.searches.some((s) => s.q.toLowerCase() === t); }
saveSearch(q) {
const t = String(q || '').trim();
if (!t || this.isSearchSaved(t)) return;
this.searches = [...this.searches, { q: t }];
this.#saveLists();
toast('Search saved to the sidebar', 'search');
}
removeSearch(q) {
this.searches = this.searches.filter((s) => s.q !== q);
this.#saveLists();
}
runSearch(q) {
this.paletteQuery = q;
this.paletteOpen = true;
}
/* quick capture (see electron/main.cjs): text from the capture window, added to today's note or to Inbox */
async takeCaptures() {
try {
for (const c of (await api.takeCaptures()) || []) await this.#capture(c);
} catch (err) {
toast('Could not capture: ' + errorText(err), 'x');
}
}
async #capture({ text, to }) {
await this.flush();
await this.refresh();
let id, label;
if (to === 'inbox') {
id = 'Inbox';
label = 'Inbox';
if (!this.pages.some((p) => p.id === id && p.exists)) await api.write(id, { icon: 'inbox', created: dailyId('', new Date()).split('/').pop() }, '');
} else {
id = (await this.#ensureDaily()).id;
label = "today's note";
}
const r = await api.read(id);
const line = text.includes('\n') ? text : '- ' + text;
const base = String(r.body || '').replace(/\s+$/, '');
const tight = !text.includes('\n') && /^\s*[-*+] /.test(base.slice(base.lastIndexOf('\n') + 1));
await api.write(id, r.meta, (base ? base + (tight ? '\n' : '\n\n') : '') + line + '\n');
await this.refresh();
if (this.doc && this.doc.id === id) await this.#external([id]);
toast('Added to ' + label, 'check');
}
/* daily note: <daily folder>/YYYY-MM-DD */
// The folder daily notes go in: the one chosen in Settings, else found (see daily.js).
get dailyFolderNow() { return this.dailyFolder || autoFolder(this.pages); }
setDailyFolder(text) {
const folder = cleanFolder(text);
this.dailyFolder = folder || null;
this.#saveLists();
}
// Today's note, made (with its folder) if it isn't there yet: { id, folder, fresh }.
async #ensureDaily(date = null) {
const folder = this.dailyFolderNow;
const now = date || new Date();
const id = dailyId(folder, now, this.prefs.dateFormat, this.prefs.locale);
const day = dailyId('', now).slice(dailyId('', now).lastIndexOf('/') + 1); // always YYYY-MM-DD: it is the page's created date
if (!this.pages.some((p) => p.id === folder && p.exists)) {
await api.write(folder, { icon: 'book', cover: 'sand', created: day }, '');
}
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: day }, dailyBody(this.prefs.dailyTemplate, now, this.prefs.dateFormat, this.prefs.locale));
await this.refresh();
}
return { id, folder, fresh };
}
// The daily notes that exist: { 'YYYY-MM-DD' -> page id }, for the calendar and the previous / next buttons.
get dailyDays() {
const folder = this.dailyFolderNow;
const out = new Map();
for (const p of this.pages) {
if (!p.exists || p.parent !== folder || !/^\d{4}-\d{2}-\d{2}$/.test(p.created || '')) continue;
const d = new Date(p.created + 'T12:00:00');
if (formatDate(d, this.prefs.dateFormat, this.prefs.locale) === p.title) out.set(p.created, p.id);
}
return out;
}
get currentDay() {
for (const [day, id] of this.dailyDays) if (id === this.currentId) return day;
return null;
}
// The nearest daily note before (-1) or after (+1) the one open, or today's when you go past the last one.
stepDaily(dir) {
const days = [...this.dailyDays.keys()].sort();
const cur = this.currentDay;
if (!cur) return;
const next = dir < 0 ? days.filter((d) => d < cur).pop() : days.find((d) => d > cur);
if (next) return this.openPage(this.dailyDays.get(next));
if (dir > 0) return this.openDaily();
toast('That is the first daily note', 'calendar');
}
async openDaily(date = null) {
try {
await this.flush();
await this.refresh();
const { id, folder, fresh } = await this.#ensureDaily(date);
this.open[folder] = 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();
// A record that leaves its database keeps its property values in the file: say so, and offer to clean up.
const from = page.parent && this.pages.find((p) => p.id === page.parent && p.db);
const into = parent && this.pages.find((p) => p.id === parent && p.db);
let left = []; // names and keys of the old database's properties that this record has values for
if (from) {
try {
const has = new Set(Object.keys(page.props || {}));
const old = await this.readSchema(from.id);
const now = into ? await this.readSchema(into.id) : null;
const kept = new Set(((now && now.properties) || []).map((p) => p.key));
left = ((old && old.properties) || []).filter((p) => has.has(p.key) && !kept.has(p.key)).map((p) => ({ key: p.key, name: p.name }));
} catch { /* not a database after all */ }
}
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);
const base = to ? `Moved into ${to.title}` : 'Moved to the top level';
const undo = { label: 'Undo', run: () => this.movePage(next, page.parent) };
if (!left.length) { toast(base, 'folder', undo); return; }
const n = left.length;
const what = `${n} propert${n === 1 ? 'y' : 'ies'} from ${from.title} (${left.map((p) => p.name).slice(0, 3).join(', ')}${n > 3 ? '…' : ''})`;
toast(`${base}. ${into ? `${what} ${n === 1 ? 'isn’t' : 'aren’t'} in ${into.title}` : `${what} stayed in the file`}`, 'folder', {
label: 'Remove',
run: () => this.dropProps(next, left.map((p) => p.key))
});
} catch (err) {
toast('Could not move: ' + errorText(err), 'x');
}
}
// Take property values (front matter keys) off a page.
async dropProps(id, keys) {
try {
if (this.currentId === id) await this.flush();
const r = await api.read(id);
if (!r.exists) return;
const meta = { ...(r.meta || {}) };
for (const k of keys) delete meta[k];
await api.write(id, meta, r.body);
await this.refresh();
if (this.currentId === id) await this.openPage(id);
toast(`Removed ${keys.length} propert${keys.length === 1 ? 'y' : 'ies'}`, 'check');
} catch (err) {
toast('Could not remove: ' + 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.navHistory = this.navHistory.map((e) => ({ ...e, id: move(e.id) }));
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 (isDatabase(r.meta)) return { state: 'database', id, title, view: heading, body: r.body };
if (isBoard(r.meta)) return { state: 'board', id, title };
if (isCanvasId(id)) return { state: 'canvas', 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.
createLinked(title) { return this.createNote(null, title); }
// Make a page (inside `parent`, or at the top) with some text in it, without opening it. Returns its id.
async createNote(parent, title, body = '') {
const covers = Object.keys(COVERS);
try {
const id = await api.create(parent || null, title || 'Untitled', {
icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
cover: covers[Math.floor(Math.random() * covers.length)]
}, body);
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);
}
/* canvases */
get isCanvas() { return isCanvasId(this.doc?.id); }
// Canvas `id` changed: save its file text. (Ignored if another page is open by now.)
editCanvas(id, text) {
if (!this.isCanvas || this.doc.id !== id) return;
this.#raw = text;
this.#dirty = true;
this.#schedule();
}
async newCanvas(parent = null) {
await this.settle();
try {
const id = await api.createCanvas(parent, 'Untitled canvas');
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');
}
}
/* 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');
}
/* databases
A database is a page whose sub-pages are its records; the schema (properties, views) is in its body and each
record keeps its property values in its own front matter. See database.js. */
get isDatabase() { return isDatabase(this.doc?.meta); }
// The schema changed: save the database page's body.
editDatabase(body) {
this.#raw = body;
this.#dirty = true;
this.#schedule();
}
async newDatabase(parent = null) {
await this.flush();
try {
const id = await api.create(parent, 'Database', { icon: 'table', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [DB_KEY]: 'basic' }, databaseToMd(defaultDatabase()));
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');
}
}
// The records of database `id`: its sub-pages that are files (not canvases, not folders without a page).
records(id) { return this.pages.filter((p) => p.parent === id && p.exists && p.kind !== 'canvas'); }
// A new record in database `id`; `props` are stored values (see database.write). Returns the record's id.
async addRecord(id, title = 'Untitled', props = {}) {
try {
await this.flush();
const rid = await api.create(id, title, { ...props }, '');
await this.refresh();
return rid;
} catch (err) {
toast(errorText(err), 'x');
return null;
}
}
#recordChain = Promise.resolve();
// Set property values on a record (a patch of key -> stored value; '' or [] removes the key). Edits are
// applied one after another so two quick changes to the same record don't undo each other.
setRecordProps(id, patch) {
const p = this.pages.find((x) => x.id === id);
if (p) {
const props = { ...(p.props || {}) };
for (const [k, v] of Object.entries(patch)) {
if (v === '' || v === undefined || (Array.isArray(v) && !v.length)) delete props[k];
else props[k] = v;
}
p.props = props;
}
this.#recordChain = this.#recordChain.then(async () => {
try {
const r = await api.read(id);
if (!r.exists) return;
const meta = { ...(r.meta || {}), ...patch };
for (const k of Object.keys(patch)) if (patch[k] === '' || patch[k] === undefined) delete meta[k];
await api.write(id, meta, r.body);
} catch (err) {
toast('Could not save: ' + errorText(err), 'x');
}
});
return this.#recordChain;
}
// Another page's database schema (read from disk): for a record's page, which shows its parent's properties.
async readSchema(id) {
const r = await api.read(id);
return isDatabase(r.meta) ? parseDatabase(r.body) : null;
}
async saveSchema(id, db) {
const r = await api.read(id);
await api.write(id, r.meta || {}, databaseToMd(db));
}
/* CSV in and out */
// The open database's records as a CSV file (all of them, every property).
async exportCsv() {
const d = this.doc;
if (!d || !this.isDatabase) return;
await this.flush();
try {
const schema = parseDatabase(this.#raw ?? d.body);
const csv = databaseCsv(schema, withFormulas(this.records(d.id), schema.properties));
const where = await api.saveExport(`${d.title}.csv`, csv);
if (where) toast('Exported ' + this.records(d.id).length + ' records', 'export');
} catch (err) {
toast('Could not export: ' + errorText(err), 'x');
}
}
// Choose a CSV file and open the import dialog for it. target: a database id, or null for a new database.
pickCsv(target = null) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.csv,.tsv,.txt,text/csv,text/tab-separated-values';
input.onchange = async () => {
const f = input.files && input.files[0];
if (!f) return;
try {
const text = await f.text();
if (!parseCsv(text).rows.length) { toast('That file has no rows', 'x'); return; }
this.csvImport = { target, name: f.name.replace(/\.[^.]+$/, ''), text };
} catch (err) {
toast('Could not read that file: ' + errorText(err), 'x');
}
};
input.click();
}
// Create what a planned import describes (see csv.planImport). Returns the database's id.
async importCsv(plan, { target, name }) {
await this.flush();
try {
let dbId = target;
if (target) {
const s = await this.readSchema(target);
if (!s) throw new Error('That page is not a database.');
for (const p of plan.properties) s.properties.push(p);
for (const [key, add] of Object.entries(plan.options)) {
const p = s.properties.find((x) => x.key === key);
if (p) p.options = [...new Set([...(p.options || []), ...add])];
}
await this.saveSchema(target, s);
} else {
const views = [newView('table', 'Table')];
const date = plan.properties.find((p) => p.type === 'date');
if (date) views.push({ ...newView('calendar', 'Calendar'), dateProp: date.key });
dbId = await api.create(null, name || 'Imported', { icon: 'table', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [DB_KEY]: 'basic' }, databaseToMd({ properties: plan.properties, views, rest: '' }));
}
await api.createMany(dbId, plan.items);
await this.refresh();
if (this.currentId === dbId || !target) await this.openPage(dbId);
toast(`Imported ${plan.items.length} record${plan.items.length === 1 ? '' : 's'}`, 'check');
return dbId;
} catch (err) {
toast('Could not import: ' + errorText(err), 'x');
return null;
}
}
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.splitSave?.();
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');
}
}
/* split view */
openSplit(id) {
if (!id) return;
if (id === this.currentId) { toast('That page is already open on the left', 'columns'); return; }
this.view = 'page';
this.split = id;
}
async closeSplit() {
await this.splitSave?.();
this.split = null;
}
// The side page moves to the main pane and the main page takes its place.
async swapSplit() {
const side = this.split, main = this.currentId;
if (!side || !main) return;
await this.closeSplit();
await this.openPage(side);
this.split = main;
}
// A page dragged from the sidebar onto the right half goes to the side pane; onto the left half it takes the
// main pane and the page that was there moves to the side.
async dropToSide(id, side) {
if (id === this.currentId) return;
if (side === 'right') { this.openSplit(id); return; }
const was = this.currentId;
await this.splitSave?.();
this.split = null;
await this.openPage(id);
if (was) this.split = was;
}
// Ctrl/⌘ \ with a side pane open closes it; otherwise the page you visited before this one opens there.
toggleSplit() {
if (this.split) { this.closeSplit(); return; }
const other = this.recent.find((id) => id !== this.currentId && this.pages.find((p) => p.id === id && p.exists));
if (other) this.openSplit(other);
else toast('Right-click a page in the sidebar and choose “Open in split”', 'columns');
}
rememberSlash(kind) {
this.slashRecent = [kind, ...this.slashRecent.filter((t) => t !== kind)].slice(0, 5);
this.#persist({ slashRecent: this.slashRecent });
}
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];
// other front matter keys are a record's property values, as the database shows them
const rest = Object.keys(patch).filter((k) => !CORE_KEYS.includes(k));
if (rest.length) {
const props = { ...(p.props || {}) };
for (const k of rest) {
const v = patch[k];
if (v === '' || v === undefined || (Array.isArray(v) && !v.length)) delete props[k];
else props[k] = v;
}
p.props = props;
}
}
this.#dirty = true;
this.#schedule();
}
#schedule() {
this.saving = true;
clearTimeout(this.#timer);
this.#timer = setTimeout(() => this.flush(), this.prefs.autosaveDelay);
}
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 {
// Someone else (another editor, a sync tool) may have changed the file since this window read it. Saving
// would silently replace their text, so their version is put in version history first and you are told.
const mine = canon(d.meta, body);
if (this.#base !== null) {
const disk = await api.read(d.id).catch(() => null);
if (disk && disk.exists) {
const there = canon(disk.meta, disk.body);
if (there !== this.#base && there !== mine) {
await api.keepVersion(d.id).catch(() => {});
toast('This note was also changed outside Ink. Your version was saved; the other one is in version history.', 'clock', {
label: 'Show versions',
run: () => { this.historyOpen = d.id; }
});
}
}
}
await api.write(d.id, { ...d.meta }, body);
this.#base = mine;
const p = this.pages.find((x) => x.id === d.id);
if (p && !p.exists) p.exists = true;
// what was just saved is what the list should show (a refresh that raced the save may have read the old file)
if (p) this.#syncListEntry(p, d.meta);
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 && isDatabase(r.meta) === this.isDatabase) return;
this.#raw = rawOf(d.id, r);
this.#base = canon(r.meta, r.body);
this.#blocks = isCanvasId(d.id) ? [] : parseMarkdown(r.body);
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 '';
if (format === 'html') return toHtmlDocument(d.title, this.#blocks);
const md = `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`;
return this.prefs.exportLinks === 'markdown' ? wikiToMarkdownLinks(md) : md;
}
/* ui */
toggleFold(id) {
this.open[id] = !this.open[id];
this.#persist({ open: $state.snapshot(this.open) });
}
setQuickLayout(layout) {
this.quickLayout = layout === 'horizontal' ? 'horizontal' : 'vertical';
this.#persist({ quickLayout: this.quickLayout });
}
setTagsOpen(on) {
this.tagsOpen = on;
this.#persist({ tagsOpen: on });
}
// Hover reveal for a hidden sidebar, driven by where the mouse is:
// - touching the thin edge strip opens it at once;
// - it stays while the mouse is over it (and a few pixels around it, so slipping off its border or into the gap
// beside it is fine);
// - once the mouse is clearly away it closes: quickly if it never got into the sidebar (just brushed the edge),
// after a short grace if it did. Coming back before that cancels it.
// It also stays while something of the sidebar is in use (a menu, a drag, a rename).
static PEEK_EDGE = 7; // trigger strip width (px)
static PEEK_LEFT = 10; // where the floating sidebar starts, and
static PEEK_RIGHT = 10 + 268; // where it ends
static PEEK_SLACK = 14; // a little beyond its right edge still counts as on it
static PEEK_TOP = 44; // below the window's drag bar
#closeTimer = null;
#peekEntered = false; // the mouse has been in the sidebar since it opened
#peekLocked = false; // closed with Escape while the mouse is still on the edge: stays closed until it moves off
#leftWindow = false; // the pointer went out of the window with the sidebar floating in
#canPeek() { return this.collapsed && !this.focusMode && !isMobile(); }
#peekBusy() {
return !!(this.menu || this.textMenu || this.renaming || document.body.classList.contains('page-dragging') ||
document.querySelector('.sidebar input:focus'));
}
#cancelClose() { clearTimeout(this.#closeTimer); this.#closeTimer = null; }
// The pointer is in the trigger strip (pointerenter on it, or a move that lands there): open at once.
peekOn() {
if (this.#peekLocked || !this.#canPeek()) return;
this.#cancelClose();
if (!this.peek) { this.peek = true; this.#peekEntered = false; }
}
// The pointer left the window (or the window lost focus) with the sidebar floating in: no more pointer moves will
// arrive to close it, so it goes after a short wait (long enough to find your way back, not to hang around).
peekWindowLeft() {
if (!this.peek) return;
this.#cancelClose();
this.#leftWindow = true;
const tryClose = () => {
if (this.#peekBusy()) { this.#closeTimer = setTimeout(tryClose, 300); return; }
this.#closeTimer = null;
this.peek = false;
};
this.#closeTimer = setTimeout(tryClose, 1500);
}
// Every pointer move.
peekMoved(x, y) {
if (this.#leftWindow) { this.#leftWindow = false; this.#cancelClose(); } // back in the window: judged by position again
if (this.#peekLocked) { if (x > 48) this.#peekLocked = false; return; }
if (!this.#canPeek()) return;
if (!this.peek) {
if (x <= Store.PEEK_EDGE && y >= Store.PEEK_TOP) this.peekOn();
return;
}
if (x >= Store.PEEK_LEFT && x <= Store.PEEK_RIGHT) this.#peekEntered = true;
if (x <= Store.PEEK_RIGHT + Store.PEEK_SLACK) { this.#cancelClose(); return; }
if (this.#closeTimer) return;
const tryClose = () => {
if (this.#peekBusy()) { this.#closeTimer = setTimeout(tryClose, 300); return; }
this.#closeTimer = null;
this.peek = false;
};
this.#closeTimer = setTimeout(tryClose, this.#peekEntered ? 400 : 150);
}
// Escape: closed now, and not opened again until the mouse has moved off the edge.
peekEscape() {
this.#cancelClose();
this.peek = false;
this.#peekLocked = true;
}
toggleSidebar() {
this.#cancelClose();
this.peek = false;
if (isMobile()) { this.drawer = !this.drawer; return; }
if (this.focusMode) {
this.toggleFocusMode();
this.collapsed = false;
this.#persist({ sidebarCollapsed: this.collapsed });
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;
if (!this.#isNavigating) {
this.#pushNav({ id: this.currentId, view: 'graph' });
}
}
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';
if (!this.#isNavigating && this.currentId) {
this.#pushNav({ id: this.currentId, 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 }); }
this.#rememberGround();
}
// 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 });
}
static FONTS = {
sans: 'var(--f-ui)',
serif: "'Iowan Old Style','Palatino Linotype',Palatino,Georgia,'Noto Serif',serif",
mono: 'var(--f-mono)'
};
static SPACING = { compact: '1.5', default: '1.7', relaxed: '1.95' };
setPageFont(f, persist = true) {
this.pageFont = Store.FONTS[f] ? f : 'sans';
this.#applyFonts();
if (persist) this.#persist({ pageFont: this.pageFont });
}
setLineSpacing(v, persist = true) {
this.lineSpacing = Store.SPACING[v] ? v : 'default';
document.documentElement.style.setProperty('--lh', Store.SPACING[this.lineSpacing]);
if (persist) this.#persist({ lineSpacing: this.lineSpacing });
}
static PALETTES = ['default', 'midnight', 'nord', 'forest', 'sepia', 'catppuccin', 'catppuccin-dark', 'amoled'];
setPalette(name, persist = true) {
this.palette = Store.PALETTES.includes(name) ? name : 'default';
if (this.palette === 'default') document.documentElement.removeAttribute('data-palette');
else document.documentElement.dataset.palette = this.palette;
if (persist) { this.#persist({ palette: this.palette }); this.#rememberGround(); }
}
// The window's background before the page loads, so launch doesn't flash the wrong color.
#rememberGround() {
requestAnimationFrame(() => {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
if (bg) this.#persist({ ground: bg });
});
}
// Change one or more of the settings in prefs.js.
setPrefs(patch) {
this.prefs = cleanPrefs({ ...$state.snapshot(this.prefs), ...patch });
this.#applyPrefs();
this.#persist({ prefs: $state.snapshot(this.prefs) });
}
// Everything in Settings back to how it came (the notes folder and page contents are not touched).
resetSettings() {
this.setThemePref('dark');
this.setFontSize(Store.FONT.base);
this.setPageWidth('default');
this.setQuickLayout('vertical');
this.setPageFont('sans');
this.setLineSpacing('default');
this.setPalette('default');
this.setAccent('blush');
this.setReduceMotion(false);
this.setStartPage('last');
this.setGraph({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, tags: false, spacing: 1 });
this.prefs = { ...DEFAULTS };
this.#applyPrefs();
this.#persist({ prefs: { ...DEFAULTS } });
}
// The prefs that are only CSS: variables and classes on the root element.
// Page and code fonts: the choice in Settings, with an installed font typed by name in front of it.
#applyFonts() {
const root = document.documentElement;
const clean = (t) => String(t || '').replace(/[^\p{L}\p{N} _.-]/gu, '').trim();
const page = clean(this.prefs?.customPageFont);
root.style.setProperty('--f-page', page ? `"${page}", ${Store.FONTS[this.pageFont]}` : Store.FONTS[this.pageFont]);
const code = clean(this.prefs?.customCodeFont);
if (code) root.style.setProperty('--f-mono', `"${code}", 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace`);
else root.style.removeProperty('--f-mono');
}
#applyPrefs() {
const p = this.prefs, root = document.documentElement;
this.#applyFonts();
const fonts = {
inter: "'Inter Variable','Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif",
system: "system-ui,-apple-system,'Segoe UI',Roboto,'Noto Sans',sans-serif",
mono: "'JetBrains Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace"
};
root.style.setProperty('--f-ui', fonts[p.uiFont]);
root.style.setProperty('--side-w', p.sidebarWidth + 'px');
root.style.setProperty('--code-scale', String(p.codeSize / 100));
root.dataset.density = p.density;
root.classList.toggle('hide-cover', !p.showCover);
root.classList.toggle('hide-icon', !p.showIcon);
root.classList.toggle('hide-backlinks', !p.showBacklinks);
root.classList.toggle('code-wrap', p.codeWrap);
root.classList.toggle('typewriter', p.typewriter);
}
static ACCENTS = ['blush', 'amber', 'sage', 'sky', 'violet'];
setAccent(name, persist = true) {
this.accent = Store.ACCENTS.includes(name) ? name : 'blush';
if (this.accent === 'blush') document.documentElement.removeAttribute('data-accent');
else document.documentElement.dataset.accent = this.accent;
if (persist) this.#persist({ accent: this.accent });
}
setReduceMotion(on, persist = true) {
this.reduceMotion = !!on;
document.documentElement.classList.toggle('reduce-motion', this.reduceMotion);
if (persist) this.#persist({ reduceMotion: this.reduceMotion });
}
setStartPage(v) {
this.startPage = v === 'today' ? 'today' : 'last';
this.#persist({ startPage: this.startPage });
}
#persist(patch) {
Promise.resolve(api.setSettings(patch)).catch(() => {});
}
}
export const store = new Store();