diff --git a/apps/desktop/e2e/tests/mathLivePreview.spec.ts b/apps/desktop/e2e/tests/mathLivePreview.spec.ts index 3eac6613..84430423 100644 --- a/apps/desktop/e2e/tests/mathLivePreview.spec.ts +++ b/apps/desktop/e2e/tests/mathLivePreview.spec.ts @@ -128,6 +128,55 @@ for (const theme of ["light", "dark"]) { }); } +for (const theme of ["light", "dark"]) { + test(`keeps table formulas inside their cells in ${theme} mode`, async ({ page }) => { + await page.setViewportSize({ width: 760, height: 900 }); + await page.evaluate((value) => { + document.documentElement.classList.toggle("dark", value === "dark"); + }, theme); + const wide = String.raw`\underbrace{${Array(80).fill("a").join("+")}}_{n}`; + await mount(page, [ + "# Formulas", "", + "| Name | $\\sum_{j=1}^n j$ |", "| --- | :---: |", + "| Short | $y_1$ and $\\frac{p}{q}$ gy |", `| Wide | $${wide}$ |`, "| Bad | $\\badcommand{x}$ |", + "", "End", + ].join("\n")); + await expect(page.locator(".cm-lp-table-cell .cm-katex-inline")).toHaveCount(5); + const formulas = await page.locator(".cm-lp-table-cell .cm-katex-inline:not(.cm-katex-error)") + .evaluateAll((elements: HTMLElement[]) => elements.map((element) => { + const probe = () => Object.assign(document.createElement("span"), { + style: "display:inline-block;width:0;height:0;vertical-align:baseline", + }); + const outside = probe(); + const inside = probe(); + element.after(outside); + (element.querySelector(".katex-html .base") ?? element).prepend(inside); + const offset = inside.getBoundingClientRect().top - outside.getBoundingClientRect().top; + outside.remove(); + inside.remove(); + const rect = element.getBoundingClientRect(); + const cell = element.closest(".cm-lp-table-cell")!.getBoundingClientRect(); + return { + source: element.querySelector("annotation")?.textContent ?? "", + offset: Math.round(offset * 10) / 10, + scrollbar: element.offsetHeight - element.clientHeight, + overflows: element.scrollWidth > element.clientWidth, + inside: rect.left >= cell.left - 0.5 && rect.right <= cell.right + 0.5, + }; + })); + expect(formulas.map((formula) => formula.source)).toEqual([ + String.raw`\sum_{j=1}^n j`, "y_1", String.raw`\frac{p}{q}`, wide, + ]); + for (const formula of formulas) { + expect(formula.inside, formula.source).toBe(true); + expect(Math.abs(formula.offset), formula.source).toBeLessThanOrEqual(0.5); + expect(formula.overflows, formula.source).toBe(formula.source === wide); + if (formula.source !== wide) expect(formula.scrollbar, formula.source).toBe(0); + } + await expect(page.locator(".cm-lp-table-cell .cm-katex-error")).toHaveText(String.raw`\badcommand{x}`); + }); +} + for (const selector of [".cm-katex-inline", ".cm-katex-block"]) { test(`scrolls ${selector} from its scrollbar without revealing its source`, async ({ page }) => { await page.setViewportSize({ width: 760, height: 900 }); diff --git a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts index 7a2ef881..df5fa40a 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts @@ -15,7 +15,13 @@ import { import { syntaxTree } from "@codemirror/language"; import type { SyntaxNode } from "@lezer/common"; import katex from "katex"; -import { getMathRanges, mathRenderingChanged } from "./mathRanges"; +import { + findMathRanges, + getMathRanges, + mathRenderingChanged, + type MathRange, + type TextRegion, +} from "./mathRanges"; import { buildVaultPreviewUrlFromAbsolutePath, isAuthorizedVaultPreviewPath, @@ -1835,15 +1841,119 @@ function appendInteractiveTableContent( } } +/** Code spans keep their dollars literal: a run closes on the next run of equal length. */ +function findCodeSpanRegions(content: string): TextRegion[] { + const regions: TextRegion[] = []; + const runs = [...content.matchAll(/`+/g)]; + for (let index = 0; index < runs.length; index++) { + const opening = runs[index]; + const closingIndex = runs.findIndex( + (run, candidate) => + candidate > index && run[0].length === opening[0].length, + ); + if (closingIndex < 0) continue; + const closing = runs[closingIndex]; + regions.push({ + from: opening.index, + to: closing.index + closing[0].length, + }); + index = closingIndex; + } + return regions; +} + +/** Formatting may wrap whole formulas, but never cut through one or sit inside it. */ +function wrapsMathCleanly( + math: readonly MathRange[], + from: number, + to: number, + contentFrom: number, + contentTo: number, +) { + return math.every( + (range) => + range.to <= from || + range.from >= to || + (range.from >= contentFrom && range.to <= contentTo), + ); +} + +function createTableMath({ tex, display }: MathRange) { + const span = document.createElement("span"); + span.className = "cm-katex-inline"; + // As in GFM, `\|` is how a cell holds a literal pipe, also inside a formula. + renderMath(span, tex.replace(/\\\|/g, "|"), display); + // The table widget ignores editor events, so formulas are not click targets. + if (!span.classList.contains("cm-katex-error")) { + span.removeAttribute("title"); + } + return span; +} + +function appendTableText( + parent: HTMLElement, + content: string, + from: number, + to: number, + math: readonly MathRange[], +) { + let index = from; + for (const range of math) { + if (range.from < from || range.to > to) continue; + if (range.from > index) { + parent.appendChild( + document.createTextNode(content.slice(index, range.from)), + ); + } + parent.appendChild(createTableMath(range)); + index = range.to; + } + if (index < to) { + parent.appendChild(document.createTextNode(content.slice(index, to))); + } +} + +function findTableBold( + content: string, + index: number, + math: readonly MathRange[], +) { + TABLE_BOLD_RE.lastIndex = index; + let match = TABLE_BOLD_RE.exec(content); + while ( + match && + !wrapsMathCleanly( + math, + match.index, + match.index + match[0].length, + match.index + 2, + match.index + match[0].length - 2, + ) + ) { + TABLE_BOLD_RE.lastIndex = match.index + 1; + match = TABLE_BOLD_RE.exec(content); + } + return match; +} + function appendInlineTableFormatting(parent: HTMLElement, content: string) { let index = 0; - const highlightRanges = findHighlightRanges(content); + const math = content.includes("$") + ? findMathRanges(content, findCodeSpanRegions(content)) + : []; + const highlightRanges = findHighlightRanges(content).filter((range) => + wrapsMathCleanly( + math, + range.from, + range.to, + range.contentFrom, + range.contentTo, + ), + ); let highlightIndex = 0; while (index < content.length) { - TABLE_BOLD_RE.lastIndex = index; - - const boldMatch = TABLE_BOLD_RE.exec(content); + const boldMatch = findTableBold(content, index, math); const highlightRange = highlightRanges[highlightIndex] ?? null; const nextHighlightIndex = @@ -1856,24 +1966,29 @@ function appendInlineTableFormatting(parent: HTMLElement, content: string) { nextBoldIndex === Number.POSITIVE_INFINITY && nextHighlightIndex === Number.POSITIVE_INFINITY ) { - parent.appendChild(document.createTextNode(content.slice(index))); + appendTableText(parent, content, index, content.length, math); break; } if (nextHighlightIndex < nextBoldIndex) { if (nextHighlightIndex > index) { - parent.appendChild( - document.createTextNode( - content.slice(index, nextHighlightIndex), - ), + appendTableText( + parent, + content, + index, + nextHighlightIndex, + math, ); } const span = document.createElement("span"); span.className = "cm-lp-table-highlight"; - span.textContent = content.slice( + appendTableText( + span, + content, highlightRange!.contentFrom, highlightRange!.contentTo, + math, ); parent.appendChild(span); @@ -1883,19 +1998,23 @@ function appendInlineTableFormatting(parent: HTMLElement, content: string) { } if (!boldMatch) { - parent.appendChild(document.createTextNode(content.slice(index))); + appendTableText(parent, content, index, content.length, math); break; } if (boldMatch.index > index) { - parent.appendChild( - document.createTextNode(content.slice(index, boldMatch.index)), - ); + appendTableText(parent, content, index, boldMatch.index, math); } const span = document.createElement("span"); span.className = "cm-lp-table-bold"; - span.textContent = boldMatch[1]; + appendTableText( + span, + content, + boldMatch.index + 2, + boldMatch.index + boldMatch[0].length - 2, + math, + ); parent.appendChild(span); index = boldMatch.index + boldMatch[0].length; diff --git a/apps/desktop/src/features/editor/extensions/mathLivePreview.test.ts b/apps/desktop/src/features/editor/extensions/mathLivePreview.test.ts index f2345a23..0793900a 100644 --- a/apps/desktop/src/features/editor/extensions/mathLivePreview.test.ts +++ b/apps/desktop/src/features/editor/extensions/mathLivePreview.test.ts @@ -214,3 +214,68 @@ describe("math live preview integration", () => { expect(view.dom.querySelector(".cm-katex-block annotation")?.textContent).toBe("y"); }); }); + +describe("math in table live preview", () => { + const table = (...rows: string[]) => ["Intro", "", "| A | B |", "| --- | --- |", ...rows, "", "End"].join("\n"); + const formulas = (root: ParentNode) => + [...root.querySelectorAll(".cm-katex-inline annotation")].map((node) => node.textContent); + const cell = (view: EditorView, index: number) => + view.dom.querySelectorAll(".cm-lp-table-cell")[index]; + + it("renders formulas in header and body cells with the document delimiter rules", () => { + const view = mount(String.raw`Intro + +| $x^2$ | Price | +| --- | --- | +| $a+b$ and $$y$$ | $20 and $30, \$z\$, $ w$ |`); + expect(formulas(view.dom)).toEqual(["x^2", "a+b", "y"]); + expect(cell(view, 2).querySelector(".cm-katex-inline .katex-display")).not.toBeNull(); + expect(cell(view, 3).textContent).toBe(String.raw`$20 and $30, \$z\$, $ w$`); + }); + + it("keeps code spans, wikilinks and URLs literal", () => { + const view = mount(table("| `$x$` and ``a `$y$` b`` | [[Cost $5 and $z$]] https://example.com/$w$ |")); + expect(view.dom.querySelector(".katex")).toBeNull(); + expect(cell(view, 2).textContent).toBe("`$x$` and ``a `$y$` b``"); + expect(view.dom.querySelector(".cm-lp-table-wikilink")?.textContent).toBe("Cost $5 and $z$"); + expect(view.dom.querySelector(".cm-lp-table-url")?.textContent).toBe("https://example.com/$w$"); + }); + + it("renders formulas next to links and inside bold or highlighted text", () => { + const view = mount(table("| [[Note]] $a$ https://example.com $b$ | **$c$ bold** and ==$d$== |")); + expect(formulas(cell(view, 2))).toEqual(["a", "b"]); + expect(formulas(view.dom.querySelector(".cm-lp-table-bold")!)).toEqual(["c"]); + expect(view.dom.querySelector(".cm-lp-table-bold")?.textContent).toContain("bold"); + expect(formulas(view.dom.querySelector(".cm-lp-table-highlight")!)).toEqual(["d"]); + }); + + it("does not apply formatting that cuts through or sits inside a formula", () => { + const view = mount(table("| **a $b** c$ | $x **y** z$ ==$p== q$ |")); + expect(formulas(view.dom)).toEqual(["b** c", "x **y** z", "p== q"]); + expect(view.dom.querySelector(".cm-lp-table-bold, .cm-lp-table-highlight")).toBeNull(); + }); + + it("reads escaped pipes as literal pipes inside formulas", () => { + const view = mount(table(String.raw`| $\lvert x \rvert = a \| b$ | 2 |`)); + expect(view.dom.querySelectorAll(".cm-lp-table-cell")).toHaveLength(4); + expect(formulas(view.dom)).toEqual([String.raw`\lvert x \rvert = a | b`]); + }); + + it("keeps invalid formulas readable", () => { + const view = mount(table(String.raw`| $\unknowncommand{x}$ | $y$ |`)); + const error = view.dom.querySelector(".cm-lp-table-cell .cm-katex-error")!; + expect(error.textContent).toBe(String.raw`\unknowncommand{x}`); + expect(error.title).toContain("Undefined control sequence"); + expect(cell(view, 3).querySelector(".cm-katex-inline")?.title).toBe(""); + }); + + it("shows the raw table source while the cursor is inside it", () => { + const doc = table("| $x$ | 2 |"); + const view = mount(doc); + expect(formulas(view.dom)).toEqual(["x"]); + view.dispatch({ selection: { anchor: doc.indexOf("$x$") + 1 } }); + expect(view.dom.querySelector(".cm-lp-table-widget")).toBeNull(); + expect(view.dom.querySelector(".katex")).toBeNull(); + expect(view.contentDOM.textContent).toContain("| $x$ | 2 |"); + }); +}); diff --git a/apps/desktop/src/features/editor/extensions/mathRanges.test.ts b/apps/desktop/src/features/editor/extensions/mathRanges.test.ts index a4a6e666..5d6d8a03 100644 --- a/apps/desktop/src/features/editor/extensions/mathRanges.test.ts +++ b/apps/desktop/src/features/editor/extensions/mathRanges.test.ts @@ -1,7 +1,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { EditorState } from "@codemirror/state"; import { describe, expect, it } from "vitest"; -import { crossesMathRange, mathRangesBetween, mathRangesField, parseMathRanges } from "./mathRanges"; +import { crossesMathRange, findMathRanges, mathRangesBetween, mathRangesField, parseMathRanges } from "./mathRanges"; function state(doc: string) { return EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage }), mathRangesField] }); @@ -89,3 +89,28 @@ describe("Markdown math delimiters", () => { expect(edited.field(mathRangesField)[0].tex).toBe("y"); }); }); + +describe("Math in plain text", () => { + it("applies the document delimiter rules outside the editor state", () => { + expect(findMathRanges(String.raw`$x^2$, $$y$$, $20 and $30, \$z\$ and $$$w$$$`).map( + ({ tex, display, block }) => ({ tex, display, block }), + )).toEqual([ + { tex: "x^2", display: false, block: false }, + { tex: "y", display: true, block: false }, + ]); + }); + + it("skips excluded regions and does not match across them", () => { + const text = "`$a$` $b [[Note]] c$ $d$"; + const code = { from: 0, to: 5 }; + const wikilink = { from: text.indexOf("[["), to: text.indexOf("]]") + 2 }; + expect(findMathRanges(text, [wikilink, code]).map((range) => range.tex)).toEqual(["d"]); + }); + + it("reports offsets relative to the text", () => { + const text = "a $x$ b"; + const [range] = findMathRanges(text); + expect(text.slice(range.from, range.to)).toBe("$x$"); + expect(text.slice(range.contentFrom, range.contentTo)).toBe("x"); + }); +}); diff --git a/apps/desktop/src/features/editor/extensions/mathRanges.ts b/apps/desktop/src/features/editor/extensions/mathRanges.ts index c151df98..f59523a0 100644 --- a/apps/desktop/src/features/editor/extensions/mathRanges.ts +++ b/apps/desktop/src/features/editor/extensions/mathRanges.ts @@ -19,7 +19,7 @@ const excludedNodes = new Set([ const WIKILINK_RE = /\[\[([^\]]+)\]\]/g; -interface TextRegion { +export interface TextRegion { from: number; to: number; } @@ -30,6 +30,15 @@ function escaped(text: string, at: number): boolean { return slashes % 2 === 1; } +function lineStart(text: string, at: number): number { + return text.lastIndexOf("\n", at - 1) + 1; +} + +function lineEnd(text: string, at: number): number { + const end = text.indexOf("\n", at); + return end < 0 ? text.length : end; +} + /** Excluded syntax that the Markdown tree does not model, sorted by position. */ function excludedTextRegions(text: string): TextRegion[] { const regions: TextRegion[] = []; @@ -50,9 +59,8 @@ function excludedTextRegions(text: string): TextRegion[] { export function parseMathRanges(state: EditorState): MathRange[] { const tree = syntaxTree(state); const text = state.doc.sliceString(0, tree.length); - const ranges: MathRange[] = []; const firstDollar = text.indexOf("$"); - if (firstDollar < 0) return ranges; + if (firstDollar < 0) return []; const excluded = excludedTextRegions(text); // Only syntax containing a dollar or separating two of them can matter. tree.iterate({ @@ -65,7 +73,19 @@ export function parseMathRanges(state: EditorState): MathRange[] { } }, }); - excluded.sort((a, b) => a.from - b.from); + return findMathRanges(text, excluded); +} + +/** + * Formulas in plain text, skipping `excluded` regions (code, links, ...). + * Offsets are relative to `text`, so callers outside the document can reuse + * the exact delimiter rules, e.g. table cells rendered by a widget. + */ +export function findMathRanges(text: string, excludedRegions: readonly TextRegion[] = []): MathRange[] { + const ranges: MathRange[] = []; + const firstDollar = text.indexOf("$"); + if (firstDollar < 0) return ranges; + const excluded = [...excludedRegions].sort((a, b) => a.from - b.from); let excludedIndex = 0; for (let from = firstDollar; from >= 0; from = text.indexOf("$", from + 1)) { while (excludedIndex < excluded.length && excluded[excludedIndex].to <= from) { @@ -87,15 +107,15 @@ export function parseMathRanges(state: EditorState): MathRange[] { const display = width === 2; const contentFrom = from + width; if (!display && (!text[contentFrom] || /\s/.test(text[contentFrom]))) continue; - const openingLine = state.doc.lineAt(from); - const standaloneOpening = /^ {0,3}$/.test(text.slice(openingLine.from, from)); + const openingLineEnd = lineEnd(text, from); + const standaloneOpening = /^ {0,3}$/.test(text.slice(lineStart(text, from), from)); // Multiline display math must open on its own line. Embedded $$ stays inline. const multiline = display && standaloneOpening && - /^\s*$/.test(text.slice(contentFrom, openingLine.to)); + /^\s*$/.test(text.slice(contentFrom, openingLineEnd)); // Code, HTML or links after the opener end the formula. const limit = Math.min( boundary?.from ?? text.length, - multiline ? text.length : openingLine.to, + multiline ? text.length : openingLineEnd, ); let match: MathRange | null = null; for (let close = text.indexOf("$", contentFrom); close >= 0 && close < limit; close = text.indexOf("$", close + 1)) { @@ -109,16 +129,16 @@ export function parseMathRanges(state: EditorState): MathRange[] { continue; } if (!display && (/\s/.test(text[close - 1]) || /\d/.test(text[end] ?? ""))) break; - const closingLine = state.doc.lineAt(close); + const closingLineEnd = lineEnd(text, close); if (multiline && ( - !/^ {0,3}$/.test(text.slice(closingLine.from, close)) || - !/^\s*$/.test(text.slice(end, closingLine.to)) + !/^ {0,3}$/.test(text.slice(lineStart(text, close), close)) || + !/^\s*$/.test(text.slice(end, closingLineEnd)) )) continue; const tex = text.slice(contentFrom, close).trim(); if (!tex) break; match = { from, to: end, contentFrom, contentTo: close, tex, display, - block: display && standaloneOpening && /^\s*$/.test(text.slice(end, closingLine.to)), + block: display && standaloneOpening && /^\s*$/.test(text.slice(end, closingLineEnd)), }; break; } diff --git a/docs/markdown-math.md b/docs/markdown-math.md index b8446a07..c6654a56 100644 --- a/docs/markdown-math.md +++ b/docs/markdown-math.md @@ -45,9 +45,32 @@ signs such as `\$5\$`. Numeric formulas such as `$2+2$` are supported. Unmatched delimiters and runs of three or more dollars remain literal. Math is excluded from frontmatter, code spans and blocks, raw HTML, images, link -destinations/titles/reference definitions, wikilinks and embeds, and tables. Table math and the +destinations/titles/reference definitions, wikilinks and embeds. The alternative delimiters `\(...\)` and `\[...\]` are outside the initial scope. +## Tables + +Table cells render inline and display formulas with the same rules: + +```markdown +| Quantity | Formula | +| -------- | ------------------ | +| Energy | $E=mc^2$ | +| Norm | $\lVert v \rVert$ | +| Absolute | $\lvert x \rvert$ | +| Pipe | $a \| b$ | +``` + +As in GitHub Flavored Markdown, an unescaped `|` always ends a cell, even inside +a formula. Write `\lvert`/`\rvert` or `\vert` for bars, or `\|`, which reads as a +literal `|` inside a cell; use `\Vert` or `\lVert`/`\rVert` for a double bar. +Bold and highlights may wrap whole formulas, but formatting that cuts through +a formula or sits inside one stays literal. A `$$x$$` in a cell uses +display-style notation within the line. + +A table renders as a whole: clicking a formula inside it does not reveal that +formula alone. Move the caret into the table to edit its Markdown source. + ## Editing and errors Click a rendered formula or move the caret into its source range to reveal its @@ -76,6 +99,10 @@ paragraph, list item or highlight, still renders around it. Inline widgets are b field because they change document layout. Unchanged widgets retain their DOM when positions move. Rendering never rewrites document text. +Tables are excluded from the document ranges because the table widget replaces +their source. The widget runs the same scanner, `findMathRanges`, on each cell +and renders its formulas only when the table source changes. + From `apps/desktop`: ```sh