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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ jobs:

- name: Run browser regressions
working-directory: apps/desktop
run: npm run test:e2e -- e2e/tests/editorTabRendering.spec.ts e2e/tests/livePreviewLists.spec.ts
run: npm run test:e2e -- e2e/tests/editorTabRendering.spec.ts e2e/tests/livePreviewLists.spec.ts e2e/tests/mathLivePreview.spec.ts

- name: Upload browser failure diagnostics
if: failure()
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ viewport, scroll, selection, and DOM snapshots before and after clicking the bla
area. CodeMirror coordinate helpers are deliberately avoided in the assertions:
they can force a measure and hide the regression.

The editor tab and live-preview list suites run in CI's **Editor browser
The editor tab, live-preview list, and math suites run in CI's **Editor browser
regressions** job. It uploads Playwright traces and failure snapshots as artifacts.

Run the dedicated chat pane smoke tests from `apps/desktop`:
Expand All @@ -28,3 +28,13 @@ npx playwright test --config e2e/playwright.config.ts e2e/tests/chatPane.spec.ts
`harness/chat.html` mounts the production sidebar, chat pane, composer and history components with an in-memory conversation and a simulated runtime bridge. The tests check composer identity and drafts across movement/hiding, Archive/Undo, document preservation, and narrow history navigation. Light, dark and narrow screenshots are saved in Playwright's test output directory.

This harness does not exercise native windows or a live provider. Session migration, vault isolation, runtime events and editor integration are covered by Vitest; live streaming and native window restoration still require a desktop smoke test.

Run the math live-preview regressions from `apps/desktop`:

```sh
npx playwright test --config e2e/playwright.config.ts e2e/tests/mathLivePreview.spec.ts
```

These use the production editor fixture to check formula editing, source/preview
switching, tab restoration, error recovery, horizontal overflow and long notes.
See [Markdown Math](../../../docs/markdown-math.md) for syntax and limitations.
195 changes: 195 additions & 0 deletions apps/desktop/e2e/tests/mathLivePreview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test";
import type {} from "../harness/editorFixture";

const formulaDoc = String.raw`# Formulas

Inline $x^2$ and $\frac{a}{b}$; prices $20 and $30.

$$E=mc^2$$

$$
\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}
$$

End.`;

// Desktop builds use classic scrollbars; hidden ones would mask stray overflow.
test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } });

async function mount(page: Page, content = formulaDoc) {
await page.evaluate((doc) => window.editorFixture.mount([
{ id: "math", noteId: "math", title: "Formulas", content: doc },
{ id: "other", noteId: "other", title: "Other", content: "# Other\n\nPlain text." },
]), content);
await expect(page.locator(".cm-editor")).toBeVisible();
}

test.beforeEach(async ({ page }) => {
await page.goto("/editor.html");
await page.waitForFunction(() => Boolean(window.editorFixture));
});

test("renders inline and display math without converting prices", async ({ page }) => {
await mount(page);
await expect(page.locator(".cm-katex-inline")).toHaveCount(2);
await expect(page.locator(".cm-katex-block .katex-display")).toHaveCount(2);
await expect(page.locator(".cm-content")).toContainText("prices $20 and $30");
expect(await page.evaluate(() => window.editorFixture.getView().state.doc.toString())).toBe(formulaDoc);
});

test("aligns inline formulas with the text baseline without stray scrollbars", async ({ page }) => {
await mount(page, String.raw`# Formulas

Inline $y_1$ and $\frac{p}{q}$, $\sum_{j=1}^n j$, $\sqrt{\frac{a}{b}}$, $x_{gy}$, $\overbrace{a+b}^{n}$ and $$x_1^2$$ gy.

Bad $\badcmd{x}$ end.`);
await expect(page.locator(".cm-katex-inline")).toHaveCount(8);
const formulas = await page.locator(".cm-katex-inline").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();
return {
source: element.querySelector("annotation")?.textContent ?? element.textContent,
offset: Math.round(offset * 10) / 10,
scrollbar: element.offsetHeight - element.clientHeight,
};
}));
for (const formula of formulas) {
expect(Math.abs(formula.offset), formula.source!).toBeLessThanOrEqual(0.5);
expect(formula.scrollbar, formula.source!).toBe(0);
}
});

test("clicks to edit formulas, types, and reveals them with arrow navigation", async ({ page }) => {
await mount(page);
await page.locator(".cm-katex-inline").first().click();
await expect(page.locator(".cm-katex-inline")).toHaveCount(1);
await page.keyboard.insertText("2");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await expect(page.locator(".cm-katex-inline")).toHaveCount(2);
await expect(page.locator(".cm-katex-inline annotation").first()).toHaveText("2x^2");
await page.keyboard.press("ArrowLeft");
await expect(page.locator(".cm-katex-inline")).toHaveCount(1);

await page.locator(".cm-katex-block").first().click();
await expect(page.locator(".cm-katex-block")).toHaveCount(1);
await expect(page.locator(".cm-content")).toContainText("$$E=mc^2$$");
});

test("preserves math through tab switches and source/preview toggles", async ({ page }) => {
await mount(page);
await expect(page.locator(".katex")).toHaveCount(4);
await page.getByRole("button", { name: "Other", exact: true }).click();
await expect(page.locator(".katex")).toHaveCount(0);
await page.getByRole("button", { name: "Formulas", exact: true }).click();
await expect(page.locator(".katex")).toHaveCount(4);
await page.evaluate(() => window.editorFixture.setPreview(false));
await expect(page.locator(".katex")).toHaveCount(0);
await expect(page.locator(".cm-content")).toContainText("$$E=mc^2$$");
await page.evaluate(() => window.editorFixture.setPreview(true));
await expect(page.locator(".katex")).toHaveCount(4);
expect(await page.evaluate(() => window.editorFixture.getView().state.doc.toString())).toBe(formulaDoc);
});

for (const theme of ["light", "dark"]) {
test(`keeps wide formulas scrollable and errors editable 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\n\nInline $${wide}$.\n\n$$${wide}$$\n\n$\\badcommand{x}$\n\nEnd`);
await expect(page.locator(".cm-katex-block")).toHaveCount(1);
for (const selector of [".cm-katex-inline:not(.cm-katex-error)", ".cm-katex-block"]) {
const dimensions = await page.locator(selector).evaluate((element) => ({
scroll: element.scrollWidth, client: element.clientWidth,
width: element.getBoundingClientRect().width,
editor: document.querySelector(".cm-content")!.getBoundingClientRect().width,
}));
expect(dimensions.scroll).toBeGreaterThan(dimensions.client);
expect(dimensions.width).toBeLessThanOrEqual(dimensions.editor);
await page.locator(selector).evaluate((element) => { element.scrollLeft = 100; });
expect(await page.locator(selector).evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
}
await expect(page.locator(".cm-katex-error")).toHaveText(String.raw`\badcommand{x}`);
await page.locator(".cm-katex-error").click();
await expect(page.locator(".cm-katex-error")).toHaveCount(0);
await expect(page.locator(".cm-content")).toContainText(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 });
const wide = String.raw`\underbrace{${Array(80).fill("a").join("+")}}_{n}`;
await mount(page, `# Formulas\n\nInline $${wide}$.\n\n$$${wide}$$\n\nEnd`);
const math = page.locator(selector);
const box = await math.evaluate((element: HTMLElement) => {
const rect = element.getBoundingClientRect();
return { right: rect.right, bottom: rect.bottom, bar: element.offsetHeight - element.clientHeight };
});
expect(box.bar).toBeGreaterThan(0);
// Pressing the track near its end pages the formula horizontally.
await page.mouse.click(box.right - 30, box.bottom - box.bar / 2);
await expect.poll(() => math.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
await expect(math).toHaveCount(1);
expect(await page.evaluate(() => window.editorFixture.getView().state.selection.main.head)).toBe(0);
});
}

test("types delimiters incrementally and undoes/redoes the source", async ({ page }) => {
const original = "# Formulas\n\nFormula ";
await mount(page, original);
await page.evaluate(() => {
const view = window.editorFixture.getView();
view.dispatch({ selection: { anchor: view.state.doc.length } });
view.focus();
});
await page.keyboard.type("$x");
await expect(page.locator(".katex")).toHaveCount(0);
await page.keyboard.type("$");
await expect(page.locator(".katex")).toHaveCount(1);
await page.keyboard.press("ControlOrMeta+z");
await expect(page.locator(".katex")).toHaveCount(0);
expect(await page.evaluate(() => window.editorFixture.getView().state.doc.toString())).toBe(original);
await page.keyboard.press("ControlOrMeta+Shift+z");
await expect(page.locator(".katex")).toHaveCount(1);
expect(await page.evaluate(() => window.editorFixture.getView().state.doc.toString())).toBe(`${original}$x$`);
});

test("renders late formulas in long notes and restores their scrolled tab", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const content = "# Formulas\n\n" + Array.from({ length: 300 }, (_, i) =>
`Paragraph ${i}: $x_{${i}}$ and **bold** text.\n\n$$\\frac{${i}}{2}$$\n\n`,
).join("") + "End.";
await mount(page, content);
// Formulas are recognized in parsed Markdown only. Jumping before background
// parsing finishes would let late display blocks grow the note under the
// scroll target.
await page.waitForFunction(() => {
const state = window.editorFixture.snapshot();
return state.parsedLength === state.docLength;
});
await page.evaluate(() => {
const view = window.editorFixture.getView();
view.dispatch({ selection: { anchor: view.state.doc.length }, scrollIntoView: true });
});
await expect(page.locator(".cm-katex-inline annotation").last()).toHaveText("x_{299}");
await expect(page.locator(".cm-katex-block annotation").last()).toHaveText(String.raw`\frac{299}{2}`);
await page.getByRole("button", { name: "Other", exact: true }).click();
await page.getByRole("button", { name: "Formulas", exact: true }).click();
await expect(page.locator(".cm-katex-inline annotation").last()).toHaveText("x_{299}");
expect(await page.evaluate(() => window.editorFixture.snapshot().scrollTop)).toBeGreaterThan(1000);
expect(errors).toEqual([]);
});
22 changes: 22 additions & 0 deletions apps/desktop/src/features/editor/extensions/livePreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { dispatchOpenYouTubeModal } from "../youtube";
import { openVaultEmbedTarget } from "../embedNavigation";
import {
createBlockMathLivePreviewExtension,
createCodeBlockLivePreviewExtension,
createImageLivePreviewExtension,
createImageResizeExtension,
Expand All @@ -30,6 +31,8 @@ import {
} from "./livePreviewListMetrics";
import { livePreviewTheme } from "./livePreviewTheme";

import { getMathRanges, mathRangesField } from "./mathRanges";

const TASK_TOGGLE_HOVER_CLASS = "cm-lp-task-toggle-hover";

const POINTER_INTERACTIVE_PREVIEW_SELECTOR = [
Expand Down Expand Up @@ -462,6 +465,23 @@ export function livePreviewExtension(
const clickHandler = EditorView.domEventHandlers({
mousedown(event: MouseEvent, view: EditorView) {
const target = event.target as HTMLElement;
const math = target.closest(".cm-katex-inline, .cm-katex-block");
if (math && event.button === 0) {
// Resolve against the current document: reused widgets may have moved.
const position = view.posAtDOM(math);
const range = getMathRanges(view.state).find((candidate) =>
candidate.from <= position && position < candidate.to,
);
if (range) {
event.preventDefault();
view.dispatch({ selection: {
anchor: event.shiftKey ? view.state.selection.main.anchor : range.contentFrom,
head: range.contentFrom,
} });
view.focus();
return true;
}
}

// A footnote reference renders as a tiny raised superscript number;
// a plain mousedown would drop the caret inside the token (revealing
Expand Down Expand Up @@ -608,6 +628,8 @@ export function livePreviewExtension(
linkReferenceField,
footnoteNumberField,
lineFlashField,
mathRangesField,
createBlockMathLivePreviewExtension(),
createInlineLivePreviewPlugin(),
createLeadingContentCollapseField(),
createCodeBlockLivePreviewExtension(),
Expand Down
Loading
Loading