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
49 changes: 49 additions & 0 deletions apps/desktop/e2e/tests/mathLivePreview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
151 changes: 135 additions & 16 deletions apps/desktop/src/features/editor/extensions/livePreviewBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand All @@ -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);

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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 |");
});
});
27 changes: 26 additions & 1 deletion apps/desktop/src/features/editor/extensions/mathRanges.test.ts
Original file line number Diff line number Diff line change
@@ -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] });
Expand Down Expand Up @@ -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");
});
});
Loading
Loading