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:
1 parent
fcad23afa9
commit
d5d25eaf46
10 files changed
+120
-37
No files matched your search
+9
-3
@@ -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();
|
||||||
|
|||||||
+29
-18
@@ -74,12 +74,9 @@ 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
|
||||||
const walk = async (dir, parent) => {
|
// parallel batches and subfolders are walked together; the order of the result stays the same.
|
||||||
let entries;
|
const readPage = async (dir, parent, e) => {
|
||||||
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 canvas = /\.canvas$/i.test(e.name);
|
||||||
const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3);
|
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 id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title;
|
||||||
@@ -112,19 +109,33 @@ class Vault {
|
|||||||
};
|
};
|
||||||
this.cache.set(full, { mtime: st.mtimeMs, page });
|
this.cache.set(full, { mtime: st.mtimeMs, page });
|
||||||
}
|
}
|
||||||
pages.set(id, page);
|
return page;
|
||||||
} catch { /* unreadable file: skip */ }
|
} catch { return null; /* unreadable file: skip */ }
|
||||||
}
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.name.startsWith('.') || !e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
|
|
||||||
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: '' });
|
|
||||||
}
|
|
||||||
await walk(path.join(dir, e.name), id);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
await walk(this.root, '');
|
const walk = async (dir, parent) => {
|
||||||
|
let entries;
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
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 (!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);
|
||||||
|
}
|
||||||
|
for (const p of subs[i]) out.push(p);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
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
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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
@@ -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>');
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user