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 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 12:25:45 +02:00
1 parent fcad23afa9
commit d5d25eaf46
10 files changed
+147 -64

No files matched your search

+5
View File
@@ -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}
+2 -2
View File
@@ -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(/<code class="language-math">([\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
+10 -4
View File
@@ -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 =
`<button class="tbl-add tbl-add-col" tabindex="-1" aria-label="Add column" title="Add column">${ic('plus')}</button>` +
`<button class="tbl-add tbl-add-row" tabindex="-1" aria-label="Add row" title="Add row">${ic('plus')}</button></div>`;
}
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 = '<span class="preview-note">The formula appears here as you type it below.</span>'; return; }
try {
const katex = await loadKatex();
box.innerHTML = katex.renderToString(text, { throwOnError: false, displayMode: true, errorColor: cssVar('--danger') || '#ea9188' });
} catch (e) {
box.innerHTML = `<div class="preview-error">${esc(e.message)}</div>`;
@@ -238,6 +241,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange =
if (!String(text || '').trim()) { box.innerHTML = '<span class="preview-note">The diagram appears here as you type it below.</span>'; 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');
+2 -2
View File
@@ -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(`<div class="code"><div class="code-bar"><span class="lang">mermaid</span></div><pre class="c code-c">${highlight(b.text || '')}</pre></div>`);
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(`<div class="math"><div class="math-preview">${out}</div></div>`);
}
case 'divider': return row('<div class="hr"></div>');
+23
View File
@@ -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 `<span class="math-pending" data-display="${displayMode ? 1 : 0}" data-tex="${esc(tex)}">${esc(tex)}</span>`;
}
+24 -5
View File
@@ -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.
+6 -2
View File
@@ -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');