From 437887207cf31188876f4674c28cdcccf24c7da8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 10 Oct 2026 11:57:11 +0200 Subject: [PATCH] feat: Add Focus Mode toggle for distraction-free writing --- src/app.css | 82 ++-- src/lib/components/Topbar.svelte | 110 +++++ src/lib/state.svelte.js | 767 +++++++++++++++++++++++++++++++ src/routes/+page.svelte | 78 ++++ 4 files changed, 996 insertions(+), 41 deletions(-) create mode 100644 src/lib/components/Topbar.svelte create mode 100644 src/lib/state.svelte.js create mode 100644 src/routes/+page.svelte diff --git a/src/app.css b/src/app.css index cd2bd6a..dc1f3d9 100644 --- a/src/app.css +++ b/src/app.css @@ -55,9 +55,34 @@ kbd{font:500 10.5px/1 var(--f-mono);padding:3px 5px;border-radius:4px;background /* shell */ .app{height:100%;display:grid;grid-template-columns:268px minmax(0,1fr);gap:10px;padding:10px;transition:grid-template-columns .5s var(--ease),gap .5s var(--ease)} .app.collapsed{grid-template-columns:0px minmax(0,1fr);gap:0} +.app.focus-mode{grid-template-columns:0px minmax(0,1fr);gap:0} .island{background:var(--island);border-radius:12px;box-shadow:0 0 0 1px var(--line);min-width:0;min-height:0} .sidebar{overflow:hidden;transition:opacity .35s var(--ease),transform .45s var(--ease)} .app.collapsed .sidebar{opacity:0;pointer-events:none} +.app.focus-mode .sidebar{opacity:0;pointer-events:none} +.app.focus-mode .topbar { + opacity: 0; + pointer-events: none; + transition: opacity 0.4s ease; +} +.app.focus-mode .topbar:hover, .app.focus-mode .topbar:focus-within { + opacity: 1; + pointer-events: auto; +} +.app.focus-mode .main { + background: var(--island); +} +.app.focus-mode .scroller { + padding-top: 10px; +} +.app.focus-mode .topbar { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 20; +} + .sb-inner{width:268px;height:100%;display:flex;flex-direction:column;padding:10px;gap:2px} .btn-icon{width:30px;height:30px;display:grid;place-items:center;border-radius:6px;color:var(--ink-2);transition:background .15s,color .15s,transform .2s var(--spring);flex:none} .btn-icon:hover{background:var(--hover);color:var(--ink)} @@ -336,7 +361,9 @@ pre.c{margin:0;padding:6px 18px 16px;font:calc(var(--fs) * .84)/1.7 var(--f-mono .kaddcol:hover{background:var(--hover);color:var(--ink)} .kdrop{position:absolute;left:4px;right:4px;height:3px;border-radius:3px;background:var(--blush);pointer-events:none} .kdrop-col{position:absolute;top:0;bottom:0;width:3px;border-radius:3px;background:var(--blush);pointer-events:none} -.kghost{position:fixed;left:0;top:0;z-index:90;pointer-events:none;opacity:.95;box-shadow:0 0 0 1px var(--line-2),var(--shadow)!important;overflow:hidden} +/* The floating copy is a clone of a card or column, which carry hover and move transitions: none of those may apply, + or it would slide in from the corner and trail behind the pointer. */ +.kghost{position:fixed;left:0;top:0;z-index:90;pointer-events:none;opacity:.95;box-shadow:0 0 0 1px var(--line-2),var(--shadow)!important;overflow:hidden;transition:none!important;animation:none!important;will-change:transform} .kghost .kmore,.kghost .kh-btn{display:none} body.board-dragging,body.board-dragging *{cursor:grabbing!important;user-select:none!important} .pop.kmenu{width:220px;transform-origin:top left;z-index:75} @@ -663,7 +690,7 @@ body.page-dragging,body.page-dragging *{cursor:grabbing!important;user-select:no ::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal} @media (max-width:760px){ - .app,.app.collapsed{grid-template-columns:minmax(0,1fr);gap:0;padding:8px} + .app,.app.collapsed,.app.focus-mode{grid-template-columns:minmax(0,1fr);gap:0;padding:8px} .sidebar{position:fixed;top:8px;bottom:8px;left:8px;z-index:60;width:min(284px,calc(100% - 56px));transform:translateX(calc(-100% - 20px));box-shadow:0 0 0 1px var(--line-2),var(--shadow)} .app.collapsed .sidebar{opacity:1;pointer-events:auto} .app.drawer .sidebar{transform:none} @@ -713,45 +740,18 @@ body.page-dragging,body.page-dragging *{cursor:grabbing!important;user-select:no .welcome-logo{width:72px;height:72px;margin:-8px 0 -4px -8px} .ws-mark.big{width:44px;height:44px;border-radius:9px;font-size:19px} -/* Math and Mermaid */ -.math, .mermaid-wrap { - position: relative; - margin: 12px 0; - border-radius: 12px; - background: var(--ink-bg-2); - border: 1px solid var(--ink-border); - overflow: hidden; -} -.math-preview, .mermaid-preview { - padding: 16px; - background: var(--ink-bg); - border-bottom: 1px solid var(--ink-border); - text-align: center; - overflow-x: auto; -} -.mermaid-preview svg { - max-width: 100%; - height: auto; -} -.math .code-bar, .mermaid-wrap .code-bar { - position: absolute; - top: 100%; - right: 0; - transform: translateY(-100%); - display: flex; - opacity: 0.5; -} -.math:focus-within .code-bar, .mermaid-wrap:focus-within .code-bar { - opacity: 1; -} -.math .code-c, .mermaid-wrap .code-c { - padding: 12px 16px; - background: var(--ink-bg-2); - font: 0.85em var(--font-mono); - outline: none; - white-space: pre-wrap; - word-break: break-all; -} +/* Math and Mermaid: the rendered result on top, its source underneath */ +.math,.mermaid-wrap{flex:1;min-width:0;margin:6px 0;border-radius:9px;background:var(--code-bg);box-shadow:0 0 0 1px var(--line);overflow:hidden} +.b-math,.b-mermaid{--gt:14px} +.math-preview,.mermaid-preview{padding:18px 16px;background:var(--island-2);text-align:center;overflow-x:auto;min-height:60px} +.math-preview .katex-display{margin:0} +.mermaid-preview svg{max-width:100%;height:auto} +.preview-note{color:var(--ink-3);font-size:13px} +.preview-error{color:var(--danger);font:12.5px/1.5 var(--f-mono);text-align:left;white-space:pre-wrap;word-break:break-word} +.math .code-bar,.mermaid-wrap .code-bar{justify-content:flex-start;border-top:1px solid var(--line);padding:8px 14px 0} +.math .lang,.mermaid-wrap .lang{font:500 11px var(--f-mono);color:var(--ink-3)} +.math pre.c,.mermaid-wrap pre.c{font-variant-ligatures:none} /* "-->" must stay what you typed */ +.math pre.c:empty::before,.mermaid-wrap pre.c:empty::before{content:attr(data-ph);color:var(--ink-3)} /* custom properties */ .custom-pk, .custom-pv { diff --git a/src/lib/components/Topbar.svelte b/src/lib/components/Topbar.svelte new file mode 100644 index 0000000..ab4fbc6 --- /dev/null +++ b/src/lib/components/Topbar.svelte @@ -0,0 +1,110 @@ + + + e.key === 'Escape' && (menuOpen = false)} /> + +
+ + + {#if store.saving || store.justSaved} +
+ {store.saving ? 'Saving…' : 'Saved to disk'} +
+ {/if} + {store.words} {store.words === 1 ? 'word' : 'words'} + + + + + + {#if menuOpen} + + {/if} +
diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js new file mode 100644 index 0000000..f5e48c7 --- /dev/null +++ b/src/lib/state.svelte.js @@ -0,0 +1,767 @@ +// 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 { 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 , 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(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..b89c7fe --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,78 @@ + + + window.innerWidth > 760 && (store.drawer = false)} /> + +{#if store.vault} +
+
+ + +
+ {#if store.view === 'graph'} + + {:else} + + {#if store.findOpen}{/if} + + {/if} +
+
+{:else if store.ready} + +{/if} + +{#if store.paletteOpen}{/if} +{#if store.exportFormat}{/if} +{#if store.menu}{/if} +{#if store.textMenu}{#key store.textMenu}{/key}{/if} +{#if store.trashOpen}{/if} +{#if store.historyOpen}{/if} +{#if store.templatesOpen}{/if} +{#if store.settingsOpen}{/if} +