Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"graphology": "^0.26.0",
"graphology-communities-louvain": "^2.0.2",
"graphology-layout-forceatlas2": "^0.10.1",
"katex": "^0.16.47",
"lucide-react": "^0.441.0",
"marked": "^18.0.13",
"marked-cjk-friendly": "^0.1.2",
Expand Down
35 changes: 33 additions & 2 deletions apps/web/src/components/live-markdown/decorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import { DONE_EMOJI, DUE_EMOJI, findToken, isTaskLine, START_EMOJI, TIMESTAMP_EM
import type { I18nContextValue } from '../../lib/i18n/index.js';
import { LiveMarkdownDirective } from '../LiveMarkdownDirective.js';
import { LiveMarkdownTable, tableUIState } from '../LiveMarkdownTable.js';
import { BulletMarker, DateAdder, ExternalLink, MdxImportWidget, MermaidDiagram, PageBreak, PageFooter, RenderedMarkdown, TaskCheckbox, TokenChip, TokenEditor, YouTubeWidget } from './widgets.js';
import { findMath } from '../../lib/math.js';
import { BulletMarker, DateAdder, ExternalLink, MathFormula, MdxImportWidget, MermaidDiagram, PageBreak, PageFooter, RenderedMarkdown, TaskCheckbox, TokenChip, TokenEditor, YouTubeWidget } from './widgets.js';
import { chipEditState } from './chip-editing.js';
import { mermaidFenceAt } from './mermaid-fence.js';

export function liveDecorations(state: EditorState, focused: boolean, notePath: string, linkLabel: string, tableLabel: string, pageLabel: string, youtubeOwner: string, t: I18nContextValue['t']): DecorationSet {
const marks: Range<Decoration>[] = [];
const mermaidRanges: { from: number; to: number; }[] = [];
// Source the math scan must not read: code keeps its dollars, and tables and directives render their own math.
const nonMath: { from: number; to: number; }[] = [];
let pageNumber = 1;
const references = marked.lexer(state.doc.toString()).links;
const active = (from: number, to: number) => focused && !state.readOnly && state.selection.ranges.some(range => state.doc.lineAt(range.from).from <= to && state.doc.lineAt(range.to).to >= from);
Expand Down Expand Up @@ -71,6 +74,7 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath:
return false;
}
}
if (name === 'FencedCode' || name === 'CodeBlock' || name === 'InlineCode' || name === 'HTMLBlock' || name === 'CommentBlock') nonMath.push({ from, to });
if (name === 'FencedCode' || name === 'CodeBlock') {
for (let line = state.doc.lineAt(from); line.from < to; line = state.doc.line(line.number + 1)) {
marks.push(Decoration.line({ class: 'live-md-codeblock' }).range(line.from));
Expand Down Expand Up @@ -100,6 +104,7 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath:
}
}
if (name === 'Table' && node.node.parent?.name === 'Document') {
nonMath.push({ from, to });
marks.push(Decoration.replace({ widget: new LiveMarkdownTable(state.sliceDoc(from, to), notePath, from, state.readOnly, t, state.field(tableUIState).get(from)), block: true }).range(from, to));
return false;
}
Expand Down Expand Up @@ -170,10 +175,22 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath:
},
});
flushQuoteReveal();
// The prefix hiding and list bullet decorations of a drawn fence's opening line sit inside its widget's range.
const mathRanges: { from: number; to: number; }[] = [];
for (const span of findMath(maskRanges(docText, [...nonMath, ...mermaidRanges, ...collapsedDirectives]))) {
if (active(span.from, span.to)) continue;
const first = state.doc.lineAt(span.from), last = state.doc.lineAt(span.to);
// A display formula alone on its lines is drawn as a block in their place; any other formula stays in the text flow.
const block = span.display && !first.text.slice(0, span.from - first.from).trim() && !last.text.slice(span.to - last.from).trim();
const from = block ? first.from : span.from, to = block ? last.to : span.to;
mathRanges.push({ from, to });
marks.push(Decoration.replace({ widget: new MathFormula(span.tex, span.display, block), block }).range(from, to));
}
// The prefix hiding and list bullet decorations of a drawn fence's opening line sit inside its widget's range, and so
// do the emphasis or escape marks the Markdown parser finds inside a formula's TeX.
for (let i = marks.length - 1; i >= 0; i--) {
const { from, to, value } = marks[i];
if (!(value.spec.widget instanceof MermaidDiagram) && mermaidRanges.some(range => from >= range.from && to <= range.to)) marks.splice(i, 1);
else if (!(value.spec.widget instanceof MathFormula) && mathRanges.some(range => from < to ? from >= range.from && to <= range.to : from > range.from && from < range.to)) marks.splice(i, 1);
}

for (const block of collapsedDirectives) {
Expand Down Expand Up @@ -218,3 +235,17 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath:
marks.push(Decoration.widget({ widget: new PageFooter(pageNumber, lastPageLabel), side: 1, block: true }).range(state.doc.length));
return Decoration.set(marks, true);
}

/** `text` with every character in `ranges` except line breaks turned into a space, so positions stay the same. */
function maskRanges(text: string, ranges: { from: number; to: number; }[]): string {
const parts: string[] = [];
let pos = 0;
for (const { from, to } of [...ranges].sort((a, b) => a.from - b.from)) {
if (to <= pos) continue;
const start = Math.max(from, pos);
parts.push(text.slice(pos, start), text.slice(start, to).replace(/[^\n]/g, ' '));
pos = to;
}
parts.push(text.slice(pos));
return parts.join('');
}
54 changes: 54 additions & 0 deletions apps/web/src/components/live-markdown/math.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { EditorState, StateField } from '@codemirror/state';
import { Decoration, type DecorationSet, EditorView } from '@codemirror/view';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { cjkEmphasis } from './cjk-emphasis.js';
import { chipEditState } from './chip-editing.js';
import { tableUIState } from '../LiveMarkdownTable.js';
import { liveDecorations } from './decorations.js';
import { MathFormula } from './widgets.js';

const t = (key: string) => key;
// Focused, so the line holding the cursor shows its source the way it does while the user edits.
const decorations = StateField.define<DecorationSet>({ create: state => liveDecorations(state, true, 'n.md', 'link', 'table', 'page', 'owner', t as never), update: (_value, tr) => liveDecorations(tr.state, true, 'n.md', 'link', 'table', 'page', 'owner', t as never), provide: field => EditorView.decorations.from(field) });

const views: EditorView[] = [];
function editor(doc: string, cursor = doc.length) {
const view = new EditorView({ parent: document.body, state: EditorState.create({ doc, selection: { anchor: cursor }, extensions: [markdown({ base: markdownLanguage, extensions: [cjkEmphasis] }), chipEditState, tableUIState, decorations] }) });
views.push(view);
return view;
}
afterEach(() => {
while (views.length) views.pop()!.destroy();
});

/** The formulas the live editor draws, with the source range each one replaces. */
function formulas(view: EditorView) {
const found: { tex: string; display: boolean; block: boolean; source: string; }[] = [];
view.state.field(decorations).between(0, view.state.doc.length, (from, to, value: Decoration) => {
if (value.spec.widget instanceof MathFormula) found.push({ tex: value.spec.widget.tex, display: value.spec.widget.display, block: value.spec.widget.block, source: view.state.sliceDoc(from, to) });
});
return found;
}

const note = 'Inline $p(9)$ and $|\\{\\text{Socrates}\\}|$ here.\n\n$$\n\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)\n$$\n\nCode `$x$` and $5 or $10.\n\n```\n$$y$$\n```\n\nend';

describe('math in the live editor', () => {
it('draws inline and display formulas in place of their source', () => {
const view = editor(note);
expect(formulas(view)).toEqual([{ tex: 'p(9)', display: false, block: false, source: '$p(9)$' }, { tex: '|\\{\\text{Socrates}\\}|', display: false, block: false, source: '$|\\{\\text{Socrates}\\}|$' }, { tex: '\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)', display: true, block: true, source: '$$\n\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)\n$$' }]);
expect(view.dom.querySelectorAll('.live-md-math .katex')).toHaveLength(3);
expect(view.dom.querySelector('.live-md-math-block .katex-display')).not.toBeNull();
});

it('shows the source of a formula while the cursor is on its lines', () => {
const inside = note.indexOf('ref}_a(t_1)');
expect(formulas(editor(note, inside)).map(formula => formula.tex)).toEqual(['p(9)', '|\\{\\text{Socrates}\\}|']);
expect(formulas(editor(note, 3)).map(formula => formula.display)).toEqual([true]);
});

it('leaves formulas in a table to the rendered table', () => {
expect(formulas(editor('| a |\n| - |\n| $x$ |\n\nend'))).toEqual([]);
});
});
21 changes: 21 additions & 0 deletions apps/web/src/components/live-markdown/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { setTaskChecked, setTokenValue } from '../../lib/task-tokens.js';
import { TASK_TOKEN_ICON_SVG } from '../../lib/task-icons.js';
import { activateYouTubeEmbed, populateYouTubeEmbed, type YouTubeLabels } from '../../lib/youtube-embed.js';
import { createMermaidBlock, hydrateMermaid } from '../../lib/mermaid.js';
import { drawMath } from '../../lib/math.js';
import { type MermaidEditorLabels, openMermaidEditor } from '../../lib/mermaid-editor.js';
import { chipEditChanged } from './chip-editing.js';
import { countMermaidTwins, findMermaidFences, relocateMermaidFence } from './mermaid-fence.js';
Expand Down Expand Up @@ -68,6 +69,26 @@ export class RenderedMarkdown extends WidgetType {
return this.block ? 100 : 160;
}
}
/** A `$…$` or `$$…$$` formula drawn by KaTeX; the source returns when the cursor enters its lines. */
export class MathFormula extends WidgetType {
constructor(readonly tex: string, readonly display: boolean, readonly block: boolean) {
super();
}
eq(other: MathFormula) {
return this.tex === other.tex && this.display === other.display && this.block === other.block;
}
toDOM(view: EditorView) {
const dom = document.createElement(this.block ? 'div' : 'span');
dom.className = this.block ? 'live-md-math live-md-math-block' : 'live-md-math';
drawMath(dom, this.tex, this.display);
dom.addEventListener('mousedown', event => {
event.preventDefault();
view.dispatch({ selection: { anchor: view.posAtDOM(dom) } });
view.focus();
});
return dom;
}
}
export class PageBreak extends WidgetType {
constructor(readonly from: number, readonly label: string, readonly pageNumber: number) {
super();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { parseR2Reference, r2AssetUrl, r2PreviewType } from '@mygitnotes/core/r2
import { headingSlug, resolveWorkspaceHref } from './workspace-links.js';
import { escapeHtml, stripMdxImports, transformDirectives, transformMdxComponents } from './directives.js';
import { isMermaidInfo } from './mermaid.js';
import { markedMath } from './math.js';
import { DEFAULT_YOUTUBE_LABELS, type YouTubeDisplayMode, type YouTubeLabels } from './youtube-embed.js';

// CommonMark cannot close emphasis when a full-width punctuation mark sits before the delimiter and
// a CJK character after it, so `**二口女(ふたくちおんな)**意象` renders as literal asterisks.
const md = new Marked(markedCjkFriendly());
const md = new Marked(markedCjkFriendly(), markedMath);

// A mermaid fence renders to a placeholder holding its source as text; sanitized HTML cannot carry the SVG, so each
// surface draws the diagram once the HTML is mounted (see hydrateMermaid). The source stays in the <pre> because
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/lib/math.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { renderNote } from './markdown.js';
import { findMath } from './math.js';

function mount(source: string) {
return new DOMParser().parseFromString(renderNote(source, 'notes/n.md'), 'text/html').body;
}

/** The formulas KaTeX drew, with the TeX source their accessible label carries. */
function formulas(root: HTMLElement) {
return [...root.querySelectorAll('.note-math')].filter(node => node.querySelector('.katex')).map(node => ({ display: !!node.querySelector('.katex-display'), tex: node.getAttribute('aria-label') }));
}

describe('findMath', () => {
it('finds inline and display math with their positions', () => {
expect(findMath('a $x$ b')).toEqual([{ from: 2, to: 5, tex: 'x', display: false }]);
expect(findMath('$$\ny = 1\n$$')).toEqual([{ from: 0, to: 11, tex: 'y = 1', display: true }]);
expect(findMath('see $$x$$ here')).toEqual([{ from: 4, to: 9, tex: 'x', display: true }]);
});

it('leaves prices, escaped dollars, and unclosed dollars as text', () => {
expect(findMath('It costs $5 and $10 today.')).toEqual([]);
expect(findMath('Pay $5, not $ 6$.')).toEqual([]);
expect(findMath('literal \\$x\\$ here')).toEqual([]);
expect(findMath('just one $ sign')).toEqual([]);
expect(findMath('$$\na\n\nb\n$$')).toEqual([]);
});

it('keeps an escaped dollar inside a formula', () => {
expect(findMath('$a \\$ b$')).toEqual([{ from: 0, to: 8, tex: 'a \\$ b', display: false }]);
});
});

describe('math in rendered notes', () => {
it('draws inline and display formulas instead of the dollar source', () => {
const root = mount('Inline $x^2$ here.\n\n$$\n\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)\n$$\n\nAfter $$y$$ too.');
expect(root.textContent).not.toContain('$');
expect(formulas(root)).toEqual([{ display: false, tex: 'x^2' }, { display: true, tex: '\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)' }, { display: true, tex: 'y' }]);
});

it('renders the sample note formulas next to CJK punctuation and escapes', () => {
const root = mount('「$p(9)$」由函數符號 $p$ 組成。於是 $|\\{\\text{Socrates}\\}| = \\text{Socrates}$。\n\n- (5\\*) 它是 $\\Box\\exists x Fx$ 與 $\\#(F)$。');
expect(formulas(root).map(formula => formula.tex)).toEqual(['p(9)', 'p', '|\\{\\text{Socrates}\\}| = \\text{Socrates}', '\\Box\\exists x Fx', '\\#(F)']);
expect(root.querySelector('.katex-error')).toBeNull();
});

it('keeps code and prices literal', () => {
const root = mount('Costs $5 and $10.\n\n`$x$` in code\n\n```\n$$y$$\n```');
expect(formulas(root)).toEqual([]);
expect(root.textContent).toContain('Costs $5 and $10.');
expect(root.querySelector('code')?.textContent).toBe('$x$');
expect(root.querySelector('pre')?.textContent).toContain('$$y$$');
});

it('renders formulas inside tables and directive bodies', () => {
expect(formulas(mount('| a | b |\n| - | - |\n| $x$ | 1 |'))).toEqual([{ display: false, tex: 'x' }]);
expect(formulas(mount(':::info\nSee $y$.\n:::'))).toEqual([{ display: false, tex: 'y' }]);
});

it('stays safe through the sanitizer', () => {
const root = mount('$\\href{javascript:alert(1)}{x}$ and $<img src=x onerror=alert(1)>$ and $$\\htmlClass{x}{y}$$');
expect(root.querySelector('a, img, [href], [src], [onerror]')).toBeNull();
expect(root.querySelector('.katex [class~="x"]')).toBeNull();
expect(formulas(root).map(formula => formula.tex)).toEqual(['\\href{javascript:alert(1)}{x}', '<img src=x onerror=alert(1)>', '\\htmlClass{x}{y}']);
});

it('shows a bad formula as an error instead of breaking the note', () => {
const root = mount('Broken $\\frac{1}$ formula, then more text.');
expect(root.textContent).toContain('then more text.');
expect(root.querySelector('.katex-error')?.textContent).toBe('\\frac{1}');
});
});
114 changes: 114 additions & 0 deletions apps/web/src/lib/math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import katex, { type KatexOptions } from 'katex';
import type { MarkedExtension, Tokens } from 'marked';
import { escapeHtml } from './directives.js';

export interface MathSpan {
from: number;
to: number;
tex: string;
display: boolean;
}

// `trust` stays off so \href, \url and \html* cannot emit links or attributes; a bad formula shows as a red error in place.
// HTML output only: DOMPurify drops MathML's <semantics> and <annotation> but keeps their text, which would leak the TeX
// source into the formula, so the wrapper's aria-label carries the source for assistive technology instead.
const KATEX_OPTIONS: KatexOptions = { output: 'html', throwOnError: false, strict: false, trust: false };

/**
* The formula whose opening dollar sits at `start`, following Pandoc's dollar rules so prices stay text: inline `$…$`
* opens before a non-space, closes after a non-space where no digit follows, and stays on one line; display `$$…$$`
* may span lines but not a blank line. A backslash escapes a dollar.
*/
export function mathAt(text: string, start: number): MathSpan | null {
if (text[start] !== '$') return null;
const display = text[start + 1] === '$';
const open = display ? start + 2 : start + 1;
if (!display && (open >= text.length || /\s/.test(text[open]))) return null;
for (let i = open; i < text.length; i++) {
const char = text[i];
if (char === '\\') {
i++;
continue;
}
if (char === '\n' && (!display || /^[ \t]*(?:\n|$)/.test(text.slice(i + 1, i + 200)))) return null;
if (char !== '$') continue;
if (display) {
if (text[i + 1] !== '$') continue;
const tex = text.slice(open, i).trim();
return tex ? { from: start, to: i + 2, tex, display } : null;
}
if (text[i + 1] === '$' || /\s/.test(text[i - 1]) || /\d/.test(text[i + 1] ?? '')) return null;
return { from: start, to: i + 1, tex: text.slice(open, i), display };
}
return null;
}

/** Every formula in `text`, in order; a dollar that opens no formula stays text. */
export function findMath(text: string): MathSpan[] {
const spans: MathSpan[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === '\\') {
i++;
continue;
}
if (text[i] !== '$') continue;
const span = mathAt(text, i);
if (span) {
spans.push(span);
i = span.to - 1;
} else if (text[i + 1] === '$') i++;
}
return spans;
}

export function renderMath(tex: string, display: boolean): string {
return `<span class="note-math" role="math" aria-label="${escapeHtml(tex)}">${katex.renderToString(tex, { ...KATEX_OPTIONS, displayMode: display })}</span>`;
}

/** The same formula as renderMath, drawn into `element`. */
export function drawMath(element: HTMLElement, tex: string, display: boolean): void {
element.classList.add('note-math');
element.setAttribute('role', 'math');
element.setAttribute('aria-label', tex);
katex.render(tex, element, { ...KATEX_OPTIONS, displayMode: display });
}

interface MathToken extends Tokens.Generic {
tex: string;
display: boolean;
}

function firstDollar(src: string): number | undefined {
for (let i = 0; i < src.length; i++) {
if (src[i] === '\\') i++;
else if (src[i] === '$') return i;
}
return undefined;
}

/** Marked tokens for `$$…$$` on their own lines (a display block) and `$…$` or `$$…$$` within text. */
export const markedMath: MarkedExtension = {
extensions: [{
name: 'mathBlock',
level: 'block',
start: src => src.match(/^ {0,3}\$\$/m)?.index,
tokenizer(src): MathToken | undefined {
const indent = /^ {0,3}(?=\$\$)/.exec(src)?.[0];
if (indent === undefined) return undefined;
const span = mathAt(src, indent.length);
const tail = span && /^[ \t]*(?:\n|$)/.exec(src.slice(span.to));
if (!span || !tail) return undefined;
return { type: 'mathBlock', raw: src.slice(0, span.to + tail[0].length), tex: span.tex, display: true };
},
renderer: token => `${renderMath((token as MathToken).tex, true)}\n`,
}, {
name: 'mathInline',
level: 'inline',
start: firstDollar,
tokenizer(src): MathToken | undefined {
const span = mathAt(src, 0);
return span ? { type: 'mathInline', raw: src.slice(0, span.to), tex: span.tex, display: span.display } : undefined;
},
renderer: token => renderMath((token as MathToken).tex, (token as MathToken).display),
}],
};
Loading
Loading