From d5d25eaf46dddd757d77ed0d777da9a117d894a7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 15 Oct 2026 12:25:45 +0200 Subject: [PATCH] perf: faster startup and long pages - open the window while the notes folder loads; rebuild the search index after the page list is sent - load mermaid and katex on demand - vault.list reads files in parallel batches - resolveLink uses a per-page-list lookup table - very long pages (>300 blocks) skip layout of off-screen blocks - perf-large: add a long-page case, fix the tasks poll Co-Authored-By: Claude Sonnet 5.5 --- electron/main.cjs | 12 +++- electron/vault.cjs | 101 ++++++++++++++++++-------------- scripts/perf-large.cjs | 11 +++- src/app.css | 5 ++ src/lib/components/Board.svelte | 4 +- src/lib/editor.js | 14 +++-- src/lib/embed.js | 4 +- src/lib/katex.js | 23 ++++++++ src/lib/links.js | 29 +++++++-- src/lib/scroll.js | 8 ++- 10 files changed, 147 insertions(+), 64 deletions(-) create mode 100644 src/lib/katex.js diff --git a/electron/main.cjs b/electron/main.cjs index 92045c3..44edad5 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -67,7 +67,11 @@ async function openVault(dir) { vault = new Vault(dir, { trash: (p) => shell.trashItem(p) }); db = openDb(path.join(dir, '.notes', 'index.sqlite')); const pages = await vault.list(); - db.rebuild(pages); + // The index is only a cache of the files, so refresh it once the window has its page list. + const opened = vault; + setTimeout(async () => { + try { if (vault === opened) db.rebuild(await vault.list()); } catch (err) { console.error('Could not rebuild the search index', err); } + }, 300).unref(); const trashDays = prefs().trashDays; if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err)); stopWatch = vault.watch(async (ids) => { @@ -162,7 +166,9 @@ function need() { const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args)); -handle('vault:get', () => snapshot()); +// The window is created while the folder is still opening; the page's first question waits for it. +let opening = null; +handle('vault:get', async () => { if (opening) await opening.catch(() => {}); return snapshot(); }); handle('vault:list', () => snapshot()); handle('vault:pick', async (kind) => { let dir; @@ -757,7 +763,7 @@ app.whenReady().then(async () => { const s = settings.get(); if (s.vault && fs.existsSync(s.vault)) { - try { await openVault(s.vault); } catch (err) { console.error('Could not open notes folder', err); } + opening = openVault(s.vault).catch((err) => { console.error('Could not open notes folder', err); }); } createWindow(); applyPrefs(); diff --git a/electron/vault.cjs b/electron/vault.cjs index be33142..cad010c 100644 --- a/electron/vault.cjs +++ b/electron/vault.cjs @@ -74,57 +74,68 @@ class Vault { isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; } async list() { - const pages = new Map(); + // Each folder yields its pages (files first, then its subfolders, in directory order). Files are read in + // parallel batches and subfolders are walked together; the order of the result stays the same. + const readPage = async (dir, parent, e) => { + const canvas = /\.canvas$/i.test(e.name); + const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3); + const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title; + const full = path.join(dir, e.name); + try { + const st = await fsp.stat(full); + const cached = this.cache.get(full); + let page; + if (cached && cached.mtime === st.mtimeMs) { + page = cached.page; + // update ID and parent in case it moved/renamed but mtime stayed the same + page.id = id; + page.parent = parent || null; + page.title = title; + } else { + const raw = await fsp.readFile(full, 'utf8'); + // A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph. + const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw); + // Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js). + const props = {}; + for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k]; + page = { + ...(canvas ? { kind: 'canvas' } : {}), + props, + ...(meta['ink-database'] ? { db: true } : {}), + id, parent: parent || null, title, exists: true, mtime: st.mtimeMs, + icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags), + created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime), + body + }; + this.cache.set(full, { mtime: st.mtimeMs, page }); + } + return page; + } catch { return null; /* unreadable file: skip */ } + }; const walk = async (dir, parent) => { let entries; - try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } - for (const e of entries) { - if (e.name.startsWith('.') || !e.isFile() || !/\.(md|canvas)$/i.test(e.name)) continue; - const canvas = /\.canvas$/i.test(e.name); - const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3); - const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title; - const full = path.join(dir, e.name); - try { - const st = await fsp.stat(full); - const cached = this.cache.get(full); - let page; - if (cached && cached.mtime === st.mtimeMs) { - page = cached.page; - // update ID and parent in case it moved/renamed but mtime stayed the same - page.id = id; - page.parent = parent || null; - page.title = title; - } else { - const raw = await fsp.readFile(full, 'utf8'); - // A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph. - const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw); - // Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js). - const props = {}; - for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k]; - page = { - ...(canvas ? { kind: 'canvas' } : {}), - props, - ...(meta['ink-database'] ? { db: true } : {}), - id, parent: parent || null, title, exists: true, mtime: st.mtimeMs, - icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags), - created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime), - body - }; - this.cache.set(full, { mtime: st.mtimeMs, page }); - } - pages.set(id, page); - } catch { /* unreadable file: skip */ } + try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return []; } + const files = entries.filter((e) => !e.name.startsWith('.') && e.isFile() && /\.(md|canvas)$/i.test(e.name)); + const out = []; + const taken = new Set(); + for (let i = 0; i < files.length; i += 64) { + const batch = await Promise.all(files.slice(i, i + 64).map((e) => readPage(dir, parent, e))); + for (const page of batch) if (page) { out.push(page); taken.add(page.id); } } - for (const e of entries) { - if (e.name.startsWith('.') || !e.isDirectory() || SKIP_DIRS.has(e.name)) continue; + const dirs = entries.filter((e) => !e.name.startsWith('.') && e.isDirectory() && !SKIP_DIRS.has(e.name)); + const subs = await Promise.all(dirs.map((e) => walk(path.join(dir, e.name), parent ? `${parent}/${e.name}` : e.name))); + dirs.forEach((e, i) => { const id = parent ? `${parent}/${e.name}` : e.name; - if (!pages.has(id)) { - pages.set(id, { id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' }); + if (!taken.has(id)) { + out.push({ id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' }); + taken.add(id); } - await walk(path.join(dir, e.name), id); - } + for (const p of subs[i]) out.push(p); + }); + return out; }; - await walk(this.root, ''); + const pages = new Map(); + for (const p of await walk(this.root, '')) pages.set(p.id, p); const active = new Set([...pages.values()].filter(p => p.exists).map(p => this.abs(p.id))); for (const k of this.cache.keys()) { if (!active.has(k)) this.cache.delete(k); diff --git a/scripts/perf-large.cjs b/scripts/perf-large.cjs index 7dd6e35..12a0436 100644 --- a/scripts/perf-large.cjs +++ b/scripts/perf-large.cjs @@ -18,6 +18,8 @@ for (let i = 0; i < N; i++) { fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`); } for (let a = 0; a < 40; a++) fs.writeFileSync(path.join(vault, `Area ${a}.md`), `Area ${a}\n`); +// one very long page: headings, paragraphs, bullets, a table +fs.writeFileSync(path.join(vault, 'Long.md'), Array.from({ length: 1500 }, (_, i) => `## Section ${i}\n\n${sentence()} **bold** and a [[Note ${i}]] link. ${sentence()}\n\n- ${sentence()}\n- ${sentence()}\n`).join('\n')); app.setPath('userData', tmp); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Area 1', theme: 'dark', bounds: { width: 1300, height: 850 } })); const started = Date.now(); @@ -49,6 +51,13 @@ app.whenReady().then(async () => { await js(`document.querySelector('.tree .row[data-id="Area 2/Note 2"]')?.click()`); for (let i = 0; i < 100; i++) { if ((await js(`return document.querySelector('.title')?.textContent`)) === 'Note 2') break; await sleep(20); } report('open a page', Date.now() - o); + // a very long page + const L = Date.now(); + await js(`document.querySelector('.tree .row[data-id="Long"]')?.click()`); + for (let i = 0; i < 300; i++) { if ((await js(`return document.querySelectorAll('.block').length`)) > 4000) break; await sleep(20); } + report('open a 4500-block page', Date.now() - L); + report('worst frame gap after that (1s)', await jank(1000)); + report('scroll that page 20 steps', await js(`const sc = [...document.querySelectorAll('*')].find((e) => e.scrollHeight > e.clientHeight + 5000); if (!sc) return -1; const t = performance.now(); for (let i = 0; i < 20; i++) { sc.scrollTop += 1500; await new Promise((r) => requestAnimationFrame(r)); } return performance.now() - t`)); // search const s = Date.now(); await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`); @@ -69,7 +78,7 @@ app.whenReady().then(async () => { // tasks list const k = Date.now(); await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`); - for (let i = 0; i < 100; i++) { if (await js(`return !document.querySelector('.tasks-panel .trash-empty') || false`)) break; await sleep(20); } + for (let i = 0; i < 100; i++) { if (await js(`const p = document.querySelector('.tasks-panel'); return !!p && !/Loading/.test(p.textContent)`)) break; await sleep(20); } report('tasks dialog', Date.now() - k); } catch (err) { console.log('harness', err.stack); diff --git a/src/app.css b/src/app.css index b10ca7b..72ce39b 100644 --- a/src/app.css +++ b/src/app.css @@ -312,6 +312,10 @@ kbd{font:500 10.5px/1 var(--f-mono);padding:3px 5px;border-radius:4px;background /* blocks */ .blocks{position:relative;padding-top:4px} .block{position:relative;display:flex;align-items:flex-start;gap:6px;margin:1px 0;border-radius:6px} +/* very long pages (editor.js marks them): blocks off screen are not laid out or painted. The clip margin keeps the + gutter, which sits outside its block, visible. */ +.long-page>.block{content-visibility:auto;contain-intrinsic-size:auto 32px;overflow-clip-margin:64px} +.long-page>.block.menu-open{content-visibility:visible} .block.stagger{animation:rise .5s var(--ease) both;animation-delay:calc(var(--i,0)*26ms + 90ms)} .block.enter-b{animation:blockIn .3s var(--ease)} .block.morph{animation:morph .36s var(--spring)} @@ -1692,3 +1696,4 @@ tr:hover .db-row-x,.db-lrow:hover .db-row-x,.db-row-x:focus-visible{transform:no .keys.rebind .kd-btn.capturing{box-shadow:inset 0 0 0 1px var(--blush-line);background:var(--blush-soft)} .keys.rebind .kd-btn em{font-style:normal;color:var(--ink-2);font-size:12.5px} .tree:focus-visible{outline-offset:-2px} +.math-pending{opacity:.6} diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 7fec87e..c295561 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -7,7 +7,7 @@ import { staticHtml } from '$lib/embed.js'; import { api } from '$lib/api.js'; import { unesc } from '$lib/text.js'; - import katex from 'katex'; + import { renderTex } from '$lib/katex.js'; import { parseBoard, boardToMd, card, column } from '$lib/kanban.js'; import { optionColor } from '$lib/database.js'; import { TAG_COLORS } from '$lib/tagColors.js'; @@ -187,7 +187,7 @@ // images, tables, callouts, math, bold / italic / highlight / links … exactly as on a page. const withMath = (html) => html.replace(/([\s\S]*?)<\/code>/g, (m, c) => { - try { return katex.renderToString(unesc(c), { throwOnError: false }); } catch { return m; } + try { return renderTex(unesc(c)); } catch { return m; } }); const cardHtml = (t) => withMath(staticHtml(parseMarkdown(t), { imageUrl: (src) => api.mediaUrl(pageId || store.currentId, src) })); // a picture that can't be loaded shows its "not found" note, as in the editor diff --git a/src/lib/editor.js b/src/lib/editor.js index bdeedf6..7d57d9c 100644 --- a/src/lib/editor.js +++ b/src/lib/editor.js @@ -11,9 +11,10 @@ import { paintFootnotes } from './footnotes.js'; import { scrollToBlock } from './scroll.js'; import { staticHtml } from './embed.js'; import { linkLabel, IMAGE_FILE, OTHER_FILE, headingOf, refPage, linkHeading, matchesHeading } from './links.js'; -import katex from 'katex'; -import mermaid from 'mermaid'; -mermaid.initialize({ startOnLoad: false, theme: 'dark' }); +import { loadKatex } from './katex.js'; +// Mermaid is several MB of script: load it the first time a diagram is drawn, not at startup. +let mermaidLib = null; +const loadMermaid = () => (mermaidLib ||= import('mermaid').then((m) => m.default)); @@ -197,6 +198,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = `` + ``; } + const LONG_PAGE = 300; // blocks; above this the page skips drawing what is off screen (.long-page in app.css) function make(b) { const w = document.createElement('div'); w.className = 'block b-' + b.type + (b.type === 'todo' && b.checked ? ' done' : ''); @@ -224,9 +226,10 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = const run = () => (b.type === 'math' ? renderMath(box, b.text) : renderMermaid(box, b.text)); delay ? previewTimers.set(box, setTimeout(run, delay)) : run(); } - function renderMath(box, text) { + async function renderMath(box, text) { if (!String(text || '').trim()) { box.innerHTML = 'The formula appears here as you type it below.'; return; } try { + const katex = await loadKatex(); box.innerHTML = katex.renderToString(text, { throwOnError: false, displayMode: true, errorColor: cssVar('--danger') || '#ea9188' }); } catch (e) { box.innerHTML = `
${esc(e.message)}
`; @@ -238,6 +241,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = if (!String(text || '').trim()) { box.innerHTML = 'The diagram appears here as you type it below.'; return; } const id = 'mermaid-' + mine; try { + const mermaid = await loadMermaid(); // Follow the app's theme each time (it can change between renders). mermaid.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' }); await mermaid.parse(text); @@ -318,6 +322,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = function paintLinks(root) { for (const a of root.querySelectorAll('.wl')) a.classList.toggle('missing', !links.resolve(a.dataset.ref)); } + host.classList.toggle('long-page', blocks.length > LONG_PAGE); blocks.forEach((b, i) => { const el = make(b); if (animate && !reduce) { el.style.setProperty('--i', Math.min(i, 16)); el.classList.add('stagger'); } @@ -505,6 +510,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange = last = { kind: null, id: null, at: 0 }; host.querySelectorAll('.block, .cols').forEach((x) => x.remove()); for (const b of blocks) host.append(make(b)); + host.classList.toggle('long-page', blocks.length > LONG_PAGE); layout(); const el = s.caret && elOf(s.caret.id); const c = el && el.querySelector(s.caret.cell ? `.tcell[data-r="${s.caret.cell[0]}"][data-c="${s.caret.cell[1]}"]` : s.caret.c2 ? '.c2' : '.c'); diff --git a/src/lib/embed.js b/src/lib/embed.js index 3f76d42..3fc9451 100644 --- a/src/lib/embed.js +++ b/src/lib/embed.js @@ -3,7 +3,7 @@ // headingsOf(blocks) the headings of a page, for pickers and the outline // sectionOf(blocks, text) a heading plus everything under it, up to the next heading of the same or a higher level // staticHtml(blocks, opts) read-only HTML for a list of blocks, styled by the same CSS as the editor's blocks -import katex from 'katex'; +import { renderTex } from './katex.js'; import { ic } from './icons.js'; import { esc, htmlText } from './text.js'; import { highlight } from './highlight.js'; @@ -75,7 +75,7 @@ function one(b, imageUrl) { case 'mermaid': return row(`
mermaid
${highlight(b.text || '')}
`); case 'math': { let out; - try { out = katex.renderToString(b.text || '', { throwOnError: false, displayMode: true }); } catch { out = esc(b.text || ''); } + try { out = renderTex(b.text || '', true); } catch { out = esc(b.text || ''); } return row(`
${out}
`); } case 'divider': return row('
'); diff --git a/src/lib/katex.js b/src/lib/katex.js new file mode 100644 index 0000000..d7e5676 --- /dev/null +++ b/src/lib/katex.js @@ -0,0 +1,23 @@ +// KaTeX is loaded the first time a formula is drawn, not at startup. +// renderTex() answers at once: the finished HTML when KaTeX is already loaded, else a placeholder that is +// replaced as soon as it arrives (so callers that build HTML strings need no async plumbing). +import { esc } from './text.js'; + +let lib = null; +let loading = null; + +export const loadKatex = () => (loading ||= import('katex').then((m) => (lib = m.default))); + +function typeset() { + if (typeof document === 'undefined' || !lib) return; + for (const el of document.querySelectorAll('.math-pending')) { + const display = el.dataset.display === '1'; + try { el.outerHTML = lib.renderToString(el.dataset.tex || '', { throwOnError: false, displayMode: display }); } catch { el.classList.remove('math-pending'); } + } +} + +export function renderTex(tex, displayMode = false) { + if (lib) return lib.renderToString(tex, { throwOnError: false, displayMode }); + if (typeof document !== 'undefined') loadKatex().then(() => setTimeout(typeset, 0), () => {}); + return `${esc(tex)}`; +} diff --git a/src/lib/links.js b/src/lib/links.js index bf67b1e..93b6fde 100644 --- a/src/lib/links.js +++ b/src/lib/links.js @@ -39,14 +39,33 @@ export const linkLabel = (ref) => { return name && h ? `${name} › ${h}` : name || h || String(ref || ''); }; +// Lookup tables for one list of pages (the app replaces the list when pages change, so a list's table never +// goes stale): lower-cased full id -> id, and lower-cased file name -> its shortest id. +const indexes = new WeakMap(); +function indexOf(pages) { + let ix = indexes.get(pages); + if (ix && ix.n === pages.length) return ix; + const exact = new Map(); + const byName = new Map(); + for (const p of pages) { + const id = p.id; + const low = id.toLowerCase(); + if (!exact.has(low)) exact.set(low, id); + const name = low.slice(low.lastIndexOf('/') + 1); + const cur = byName.get(name); + if (cur === undefined || id.length < cur.length) byName.set(name, id); + } + ix = { n: pages.length, exact, byName }; + indexes.set(pages, ix); + return ix; +} + export function resolveLink(ref, pages) { const want = refPage(ref).toLowerCase(); if (!want) return null; - const exact = pages.find((p) => p.id.toLowerCase() === want); - if (exact) return exact.id; - // by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas - const byTitle = pages.filter((p) => p.id.slice(p.id.lastIndexOf('/') + 1).toLowerCase() === want).sort((a, b) => a.id.length - b.id.length); - return byTitle.length ? byTitle[0].id : null; + const { exact, byName } = indexOf(pages); + // exact id first; else by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas + return exact.get(want) ?? byName.get(want) ?? null; } // The shortest text that links to `id`: its title when that's unique, else the full path. diff --git a/src/lib/scroll.js b/src/lib/scroll.js index 18a3d7b..6670ae8 100644 --- a/src/lib/scroll.js +++ b/src/lib/scroll.js @@ -2,8 +2,12 @@ // Used by the outline and by [[Page#Heading]] links. Returns how long (ms) smooth scrolling will keep running. export function scrollToBlock(scroller, el) { const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; - const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 24; - scroller.scrollTo({ top: Math.max(0, top), behavior: reduce ? 'auto' : 'smooth' }); + const where = () => Math.max(0, el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 24); + if (el.closest('.long-page')) { + // Blocks off screen only have an estimated height until they are drawn, so jump, then correct once they are. + scroller.scrollTo({ top: where(), behavior: 'auto' }); + for (let i = 0; i < 2; i++) requestAnimationFrame(() => scroller.scrollTo({ top: where(), behavior: 'auto' })); + } else scroller.scrollTo({ top: where(), behavior: reduce ? 'auto' : 'smooth' }); el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');