// App state. One store, shared by every component. // // api.read(id) ─> parseMarkdown ─> doc.blocks ─> ─> edit(blocks) ─> (600ms) ─> toMarkdown ─> api.write(id) // import { api, errorText } from './api.js'; import { parseMarkdown, toMarkdown, blockText, toHtmlDocument } from './markdown.js'; import { PAGE_ICONS, COVERS, PATHS, isEmoji } from './icons.js'; import { countWords, htmlText } from './text.js'; import { toast } from './toast.svelte.js'; import { 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 , not reactive canvasView = null; // set by (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 : 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: /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();