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

+9 -3
View File
@@ -67,7 +67,11 @@ async function openVault(dir) {
vault = new Vault(dir, { trash: (p) => shell.trashItem(p) }); vault = new Vault(dir, { trash: (p) => shell.trashItem(p) });
db = openDb(path.join(dir, '.notes', 'index.sqlite')); db = openDb(path.join(dir, '.notes', 'index.sqlite'));
const pages = await vault.list(); 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; const trashDays = prefs().trashDays;
if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err)); if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err));
stopWatch = vault.watch(async (ids) => { stopWatch = vault.watch(async (ids) => {
@@ -162,7 +166,9 @@ function need() {
const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args)); 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:list', () => snapshot());
handle('vault:pick', async (kind) => { handle('vault:pick', async (kind) => {
let dir; let dir;
@@ -757,7 +763,7 @@ app.whenReady().then(async () => {
const s = settings.get(); const s = settings.get();
if (s.vault && fs.existsSync(s.vault)) { 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(); createWindow();
applyPrefs(); applyPrefs();
+56 -45
View File
@@ -74,57 +74,68 @@ class Vault {
isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; } isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; }
async list() { 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) => { const walk = async (dir, parent) => {
let entries; let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return []; }
for (const e of entries) { const files = entries.filter((e) => !e.name.startsWith('.') && e.isFile() && /\.(md|canvas)$/i.test(e.name));
if (e.name.startsWith('.') || !e.isFile() || !/\.(md|canvas)$/i.test(e.name)) continue; const out = [];
const canvas = /\.canvas$/i.test(e.name); const taken = new Set();
const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3); for (let i = 0; i < files.length; i += 64) {
const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title; const batch = await Promise.all(files.slice(i, i + 64).map((e) => readPage(dir, parent, e)));
const full = path.join(dir, e.name); for (const page of batch) if (page) { out.push(page); taken.add(page.id); }
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 */ }
} }
for (const e of entries) { const dirs = entries.filter((e) => !e.name.startsWith('.') && e.isDirectory() && !SKIP_DIRS.has(e.name));
if (e.name.startsWith('.') || !e.isDirectory() || SKIP_DIRS.has(e.name)) continue; 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; const id = parent ? `${parent}/${e.name}` : e.name;
if (!pages.has(id)) { if (!taken.has(id)) {
pages.set(id, { id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' }); 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))); const active = new Set([...pages.values()].filter(p => p.exists).map(p => this.abs(p.id)));
for (const k of this.cache.keys()) { for (const k of this.cache.keys()) {
if (!active.has(k)) this.cache.delete(k); if (!active.has(k)) this.cache.delete(k);
+10 -1
View File
@@ -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}`); 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`); 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); app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Area 1', theme: 'dark', bounds: { width: 1300, height: 850 } })); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Area 1', theme: 'dark', bounds: { width: 1300, height: 850 } }));
const started = Date.now(); 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()`); 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); } 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); 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 // search
const s = Date.now(); const s = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`); await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`);
@@ -69,7 +78,7 @@ app.whenReady().then(async () => {
// tasks list // tasks list
const k = Date.now(); const k = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`); 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); report('tasks dialog', Date.now() - k);
} catch (err) { } catch (err) {
console.log('harness', err.stack); console.log('harness', err.stack);
+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 */
.blocks{position:relative;padding-top:4px} .blocks{position:relative;padding-top:4px}
.block{position:relative;display:flex;align-items:flex-start;gap:6px;margin:1px 0;border-radius:6px} .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.stagger{animation:rise .5s var(--ease) both;animation-delay:calc(var(--i,0)*26ms + 90ms)}
.block.enter-b{animation:blockIn .3s var(--ease)} .block.enter-b{animation:blockIn .3s var(--ease)}
.block.morph{animation:morph .36s var(--spring)} .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.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} .keys.rebind .kd-btn em{font-style:normal;color:var(--ink-2);font-size:12.5px}
.tree:focus-visible{outline-offset:-2px} .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 { staticHtml } from '$lib/embed.js';
import { api } from '$lib/api.js'; import { api } from '$lib/api.js';
import { unesc } from '$lib/text.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 { parseBoard, boardToMd, card, column } from '$lib/kanban.js';
import { optionColor } from '$lib/database.js'; import { optionColor } from '$lib/database.js';
import { TAG_COLORS } from '$lib/tagColors.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. // images, tables, callouts, math, bold / italic / highlight / links … exactly as on a page.
const withMath = (html) => const withMath = (html) =>
html.replace(/<code class="language-math">([\s\S]*?)<\/code>/g, (m, c) => { 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) })); 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 // 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 { scrollToBlock } from './scroll.js';
import { staticHtml } from './embed.js'; import { staticHtml } from './embed.js';
import { linkLabel, IMAGE_FILE, OTHER_FILE, headingOf, refPage, linkHeading, matchesHeading } from './links.js'; import { linkLabel, IMAGE_FILE, OTHER_FILE, headingOf, refPage, linkHeading, matchesHeading } from './links.js';
import katex from 'katex'; import { loadKatex } from './katex.js';
import mermaid from 'mermaid'; // Mermaid is several MB of script: load it the first time a diagram is drawn, not at startup.
mermaid.initialize({ startOnLoad: false, theme: 'dark' }); 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-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>`; `<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) { function make(b) {
const w = document.createElement('div'); const w = document.createElement('div');
w.className = 'block b-' + b.type + (b.type === 'todo' && b.checked ? ' done' : ''); 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)); const run = () => (b.type === 'math' ? renderMath(box, b.text) : renderMermaid(box, b.text));
delay ? previewTimers.set(box, setTimeout(run, delay)) : run(); 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; } if (!String(text || '').trim()) { box.innerHTML = '<span class="preview-note">The formula appears here as you type it below.</span>'; return; }
try { try {
const katex = await loadKatex();
box.innerHTML = katex.renderToString(text, { throwOnError: false, displayMode: true, errorColor: cssVar('--danger') || '#ea9188' }); box.innerHTML = katex.renderToString(text, { throwOnError: false, displayMode: true, errorColor: cssVar('--danger') || '#ea9188' });
} catch (e) { } catch (e) {
box.innerHTML = `<div class="preview-error">${esc(e.message)}</div>`; 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; } 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; const id = 'mermaid-' + mine;
try { try {
const mermaid = await loadMermaid();
// Follow the app's theme each time (it can change between renders). // Follow the app's theme each time (it can change between renders).
mermaid.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' }); mermaid.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' });
await mermaid.parse(text); await mermaid.parse(text);
@@ -318,6 +322,7 @@ export function createEditor(host, { blocks: initial, animate = true, onChange =
function paintLinks(root) { function paintLinks(root) {
for (const a of root.querySelectorAll('.wl')) a.classList.toggle('missing', !links.resolve(a.dataset.ref)); 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) => { blocks.forEach((b, i) => {
const el = make(b); const el = make(b);
if (animate && !reduce) { el.style.setProperty('--i', Math.min(i, 16)); el.classList.add('stagger'); } 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 }; last = { kind: null, id: null, at: 0 };
host.querySelectorAll('.block, .cols').forEach((x) => x.remove()); host.querySelectorAll('.block, .cols').forEach((x) => x.remove());
for (const b of blocks) host.append(make(b)); for (const b of blocks) host.append(make(b));
host.classList.toggle('long-page', blocks.length > LONG_PAGE);
layout(); layout();
const el = s.caret && elOf(s.caret.id); 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'); 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 // 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 // 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 // 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 { ic } from './icons.js';
import { esc, htmlText } from './text.js'; import { esc, htmlText } from './text.js';
import { highlight } from './highlight.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 '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': { case 'math': {
let out; 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>`); return row(`<div class="math"><div class="math-preview">${out}</div></div>`);
} }
case 'divider': return row('<div class="hr"></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 || ''); 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) { export function resolveLink(ref, pages) {
const want = refPage(ref).toLowerCase(); const want = refPage(ref).toLowerCase();
if (!want) return null; if (!want) return null;
const exact = pages.find((p) => p.id.toLowerCase() === want); const { exact, byName } = indexOf(pages);
if (exact) return exact.id; // exact id first; else by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas
// 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;
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;
} }
// The shortest text that links to `id`: its title when that's unique, else the full path. // 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. // Used by the outline and by [[Page#Heading]] links. Returns how long (ms) smooth scrolling will keep running.
export function scrollToBlock(scroller, el) { export function scrollToBlock(scroller, el) {
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 24; const where = () => Math.max(0, el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 24);
scroller.scrollTo({ top: Math.max(0, top), behavior: reduce ? 'auto' : 'smooth' }); 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'); el.classList.remove('flash');
void el.offsetWidth; void el.offsetWidth;
el.classList.add('flash'); el.classList.add('flash');