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>
1594 lines
65 KiB
JavaScript
1594 lines
65 KiB
JavaScript
// 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();
|