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