From 5fd8f8fb40a9d6bb9808fe12e881f0df1308451c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 26 Apr 2026 03:52:10 +0200 Subject: [PATCH] feat: add markdown math support --- docs/ABSTRACTIONS.md | 22 +- docs/ARCHITECTURE.md | 2 +- docs/adr/0082-markdown-durable-math-notes.md | 40 +++ docs/adr/README.md | 1 + src/App.test.tsx | 1 + src/components/Editor.test.tsx | 48 +++ src/components/Editor.tsx | 1 + src/components/EditorTheme.css | 28 ++ src/components/editorModePosition.ts | 5 +- src/components/editorRawModeSync.ts | 3 +- src/components/editorSchema.tsx | 52 ++- src/hooks/useEditorTabSwap.ts | 13 +- src/utils/mathMarkdown.test.ts | 110 ++++++ src/utils/mathMarkdown.ts | 341 +++++++++++++++++++ tests/smoke/latex-math-notes.spec.ts | 139 ++++++++ 15 files changed, 790 insertions(+), 16 deletions(-) create mode 100644 docs/adr/0082-markdown-durable-math-notes.md create mode 100644 src/utils/mathMarkdown.test.ts create mode 100644 src/utils/mathMarkdown.ts create mode 100644 tests/smoke/latex-math-notes.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index f65e13bc..93d962a9 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -473,6 +473,15 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito - Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts` → `typescript`. - Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface. +### Markdown Math + +Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`: + +- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes. +- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note. +- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots. +- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria. + ### Formatting Surface Policy Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`: @@ -490,22 +499,23 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola flowchart LR A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"] B --> C["preProcessWikilinks(body)\n[[target]] → ‹token›"] - C --> D["tryParseMarkdownToBlocks()\n→ BlockNote block tree"] - D --> E["injectWikilinks(blocks)\n‹token› → WikiLink node"] - E --> F["editor.replaceBlocks()\n→ rendered editor"] + C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"] + D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"] + E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"] + F --> G["editor.replaceBlocks()\n→ rendered editor"] style A fill:#f8f9fa,stroke:#6c757d,color:#000 - style F fill:#d4edda,stroke:#28a745,color:#000 + style G fill:#d4edda,stroke:#28a745,color:#000 ``` -> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax. +> Wikilink placeholder tokens use `\u2039` and `\u203A`; math placeholder tokens use ASCII sentinels with URI-encoded LaTeX payloads. ### BlockNote-to-Markdown Pipeline (Save) ```mermaid flowchart LR A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"] - B --> C["postProcessWikilinks()\nWikiLink node → [[target]]"] + B --> C["restoreWikilinks + serializeMathAwareBlocks()\nschema nodes → Markdown source"] C --> D["prepend frontmatter yaml"] D --> E["invoke('save_note_content')\n→ disk write"] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5107cfeb..5f18e85e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ flowchart TD - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. - **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. -- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. +- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). Panels are separated by `ResizeHandle` components that support drag-to-resize. diff --git a/docs/adr/0082-markdown-durable-math-notes.md b/docs/adr/0082-markdown-durable-math-notes.md new file mode 100644 index 00000000..bd556caf --- /dev/null +++ b/docs/adr/0082-markdown-durable-math-notes.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0082" +title: "Markdown-durable math in notes" +status: active +date: 2026-04-26 +--- + +## Context + +Tolaria notes are durable Markdown files, while the main editor uses BlockNote and raw mode uses CodeMirror. Users coming from technical note-taking tools expect inline math such as `$E=mc^2$` and display math such as `$$ ... $$` to render inside notes without turning the note into an app-only document format. + +BlockNote does not currently ship a first-party math block in the local editor package. Tiptap now offers an official Mathematics extension that renders KaTeX nodes, but Tolaria's save path still depends on BlockNote's Markdown parser and `blocksToMarkdownLossy()` serializer. Adding opaque ProseMirror math nodes without an explicit Tolaria serializer would risk losing or rewriting the original Markdown source. + +## Decision + +**Tolaria will support note math through a Markdown placeholder round-trip owned by the editor pipeline.** + +The initial implementation: + +- Treats `$...$` as inline math and line-owned `$$...$$` / multiline `$$` blocks as display math. +- Converts math source to temporary placeholders before BlockNote parses Markdown. +- Replaces placeholders with Tolaria schema nodes that render via the existing `katex` dependency. +- Serializes those schema nodes back to the original Markdown delimiters before saving or entering raw mode. +- Uses KaTeX with `throwOnError: false` and `trust: false` so malformed or untrusted formulas remain visible rather than breaking the note. + +## Options considered + +- **Tolaria-owned placeholder round-trip with KaTeX rendering** (chosen): matches the existing wikilink architecture, preserves plain-text source, and avoids depending on BlockNote support for non-default ProseMirror math nodes. +- **Tiptap Mathematics extension directly in BlockNote**: attractive because it is official Tiptap and KaTeX-backed, but it does not by itself solve Tolaria's BlockNote Markdown serializer contract. +- **Raw-mode-only math support**: preserves source but fails the rich editor reading experience users expect. +- **Store formulas as custom JSON/frontmatter metadata**: richer structured editing later, but violates the Markdown-first durability requirement. + +## Consequences + +- `src/utils/mathMarkdown.ts` is the canonical parser/serializer bridge for note math. +- The rich editor renders math as schema nodes; raw mode remains the most direct way to edit exact math source. +- CodeMirror raw editing keeps the literal Markdown delimiters, so imported Obsidian-style notes remain understandable outside Tolaria. +- Future equation editing helpers can be added on top of the same Markdown source contract instead of changing the storage model. +- Re-evaluate direct Tiptap Mathematics integration only if it can be proven to preserve Tolaria's Markdown save path without custom lossy behavior. diff --git a/docs/adr/README.md b/docs/adr/README.md index 32afb778..c3baf684 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -137,3 +137,4 @@ proposed → active → superseded | [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active | | [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | active | | [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active | +| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active | diff --git a/src/App.test.tsx b/src/App.test.tsx index e76c0a8d..c429cf2c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -354,6 +354,7 @@ vi.mock('@blocknote/core/extensions', () => ({ })) vi.mock('@blocknote/react', () => ({ + createReactBlockSpec: () => () => ({}), createReactInlineContentSpec: () => ({ render: () => null }), BlockNoteViewRaw: ({ children }: { children?: ReactNode }) => (
diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index c7049665..1e50ad21 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -59,6 +59,7 @@ const capturedGetItemsByTrigger: Record Promise Promise) | null = null vi.mock('@blocknote/react', () => ({ + createReactBlockSpec: () => () => ({}), createReactInlineContentSpec: () => ({ render: () => null }), useCreateBlockNote: () => mockEditor, FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}, @@ -584,6 +585,53 @@ describe('raw-mode sync content guards', () => { expect(rawLatestContentRef.current).toBe(result) }) + it('serializes rich math nodes back to Markdown source when entering raw mode', () => { + const rawLatestContentRef = { current: null as string | null } + const originalDocument = mockEditor.document + const originalSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation() + + try { + mockEditor.document = [ + { + id: 'math-inline', + type: 'paragraph', + content: [ + { type: 'text', text: 'Inline ', styles: {} }, + { type: 'mathInline', props: { latex: 'E=mc^2' } }, + ], + props: {}, + children: [], + }, + { + id: 'math-block', + type: 'mathBlock', + props: { latex: '\\int_0^1 x\\,dx' }, + children: [], + }, + ] + mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => ( + (blocks as Array<{ content?: Array<{ text?: string }> }>) + .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '') + .join('\n\n') + )) + + const result = syncActiveTabIntoRawBuffer({ + editor: mockEditor as never, + activeTabPath: mockEntry.path, + activeTabContent: mockContent, + rawLatestContentRef, + }) + + expect(result).toBe( + '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\nInline $E=mc^2$\n\n$$\n\\int_0^1 x\\,dx\n$$\n', + ) + expect(rawLatestContentRef.current).toBe(result) + } finally { + mockEditor.document = originalDocument + mockEditor.blocksToMarkdownLossy.mockImplementation(originalSerializer) + } + }) + it('does not emit a content change when leaving raw mode without user edits', () => { const onContentChange = vi.fn() const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n' diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a0fe8400..034b4187 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect, useCallback, memo } from 'react' import { useEditorTabSwap } from '../hooks/useEditorTabSwap' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' +import 'katex/dist/katex.min.css' import { uploadImageFile } from '../hooks/useImageDrop' import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents' import type { VaultEntry, GitCommit, NoteStatus } from '../types' diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index deae6b04..62c39297 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -240,6 +240,34 @@ cursor: text; } +.editor__blocknote-container .math { + color: var(--colors-text); + cursor: text; +} + +.editor__blocknote-container .math--inline { + display: inline-flex; + max-width: 100%; + vertical-align: -0.15em; +} + +.editor__blocknote-container .math-block-shell { + width: 100%; + overflow-x: auto; + padding: 6px 0; +} + +.editor__blocknote-container .math--block { + display: block; + min-width: max-content; + text-align: center; +} + +.editor__blocknote-container .math .katex-error { + color: var(--destructive) !important; + white-space: pre-wrap; +} + .editor__blocknote-container[data-follow-links] .bn-editor a, .editor__blocknote-container[data-follow-links] .wikilink { cursor: pointer; diff --git a/src/components/editorModePosition.ts b/src/components/editorModePosition.ts index 0c77cffa..659ce3d9 100644 --- a/src/components/editorModePosition.ts +++ b/src/components/editorModePosition.ts @@ -1,5 +1,6 @@ import { compactMarkdown } from '../utils/compact-markdown' import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks' +import { serializeMathAwareBlocks } from '../utils/mathMarkdown' import { findNearestTextCursorBlockById } from './blockNoteCursorTarget' interface BlockLike { @@ -128,11 +129,11 @@ function getLineIndexFromRatio({ totalLines, ratio }: { totalLines: number; rati } function serializeBlock(editor: BlockNotePositionEditor, block: BlockLike): string { - return compactMarkdown(editor.blocksToMarkdownLossy(restoreWikilinksInBlocks([block]))) + return compactMarkdown(serializeMathAwareBlocks(editor, restoreWikilinksInBlocks([block]))) } function serializeEditorBody(editor: BlockNotePositionEditor): string { - return compactMarkdown(editor.blocksToMarkdownLossy(restoreWikilinksInBlocks(editor.document))) + return compactMarkdown(serializeMathAwareBlocks(editor, restoreWikilinksInBlocks(editor.document))) } function buildBlockLineRanges({ diff --git a/src/components/editorRawModeSync.ts b/src/components/editorRawModeSync.ts index e1f15c66..45c9a7ea 100644 --- a/src/components/editorRawModeSync.ts +++ b/src/components/editorRawModeSync.ts @@ -2,6 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks' import { compactMarkdown } from '../utils/compact-markdown' +import { serializeMathAwareBlocks } from '../utils/mathMarkdown' import { portableImageUrls } from '../utils/vaultImages' interface Tab { @@ -29,7 +30,7 @@ export function serializeEditorDocumentToMarkdown( ): string { const blocks = editor.document const restored = restoreWikilinksInBlocks(blocks) - const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) + const rawBodyMarkdown = compactMarkdown(serializeMathAwareBlocks(editor, restored)) const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown const [frontmatter] = splitFrontmatter(tabContent) return `${frontmatter}${bodyMarkdown}` diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index e6ece749..93976b24 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -1,9 +1,10 @@ /* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */ import { createCodeBlockSpec, BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core' import { codeBlockOptions } from '@blocknote/code-block' -import { createReactInlineContentSpec } from '@blocknote/react' +import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/react' import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import { resolveEntry } from '../utils/wikilink' +import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown' import type { VaultEntry } from '../types' import { NoteTitleIcon } from './NoteTitleIcon' @@ -57,18 +58,67 @@ export const WikiLink = createReactInlineContentSpec( } ) +function MathRender({ latex, displayMode }: { latex: string; displayMode: boolean }) { + const source = displayMode ? `$$\n${latex}\n$$` : `$${latex}$` + return ( + + ) +} + +export const MathInline = createReactInlineContentSpec( + { + type: MATH_INLINE_TYPE, + propSchema: { + latex: { default: '' }, + }, + content: 'none', + }, + { + render: (props) => ( + + ), + }, +) + +const MathBlock = createReactBlockSpec( + { + type: MATH_BLOCK_TYPE, + propSchema: { + latex: { default: '' }, + }, + content: 'none', + }, + { + render: (props) => ( +
+ +
+ ), + }, +) + const codeBlock = createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: 'text', }) +const mathBlock = MathBlock() export const schema = BlockNoteSchema.create({ inlineContentSpecs: { ...defaultInlineContentSpecs, wikilink: WikiLink, + mathInline: MathInline, }, }).extend({ blockSpecs: { codeBlock, + mathBlock, }, }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 5afe5ede..39cd9793 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -3,6 +3,7 @@ import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks' import { compactMarkdown } from '../utils/compact-markdown' +import { injectMathInBlocks, preProcessMathMarkdown, serializeMathAwareBlocks } from '../utils/mathMarkdown' import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance' import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages' import { @@ -148,7 +149,7 @@ async function resolveBlocksForTarget( const body = extractEditorBody(content) const withImages = vaultPath ? resolveImageUrls(body, vaultPath) : body - const preprocessed = preProcessWikilinks(withImages) + const preprocessed = preProcessMathMarkdown({ markdown: preProcessWikilinks(withImages) }) const fastPathBlocks = buildFastPathBlocks({ preprocessed }) if (fastPathBlocks) { const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content } @@ -158,7 +159,8 @@ async function resolveBlocksForTarget( const parsed = normalizeParsedImageBlocks(await parseMarkdownBlocks(editor, preprocessed)) as EditorBlocks const withWikilinks = injectWikilinks(parsed) - const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content } + const withMath = injectMathInBlocks(withWikilinks) + const nextState = { blocks: withMath, scrollTop: 0, sourceContent: content } cacheEditorState(cache, targetPath, nextState) return nextState } @@ -247,10 +249,11 @@ async function resolveEmptyHeadingHtml( const withImages = vaultPath ? resolveImageUrls(remainder, vaultPath) : remainder const parsed = normalizeParsedImageBlocks( - await parseMarkdownBlocks(editor, preProcessWikilinks(withImages)), + await parseMarkdownBlocks(editor, preProcessMathMarkdown({ markdown: preProcessWikilinks(withImages) })), ) as EditorBlocks const withWikilinks = injectWikilinks(parsed) - return `

${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}` + const withMath = injectMathInBlocks(withWikilinks) + return `

${editor.blocksToHTMLLossy(withMath as typeof parsed)}` } function findActiveTab(options: { @@ -265,7 +268,7 @@ function findActiveTab(options: { function serializeEditorBody(editor: ReturnType): string { const restored = restoreWikilinksInBlocks(editor.document) - return compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof editor.document)) + return compactMarkdown(serializeMathAwareBlocks(editor, restored)) } function normalizeTabBody(options: { content: string }): string { diff --git a/src/utils/mathMarkdown.test.ts b/src/utils/mathMarkdown.test.ts new file mode 100644 index 00000000..a01df89f --- /dev/null +++ b/src/utils/mathMarkdown.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MATH_BLOCK_TYPE, + MATH_INLINE_TYPE, + injectMathInBlocks, + preProcessMathMarkdown, + serializeMathAwareBlocks, +} from './mathMarkdown' + +describe('math markdown round-trip', () => { + it('injects inline math placeholders into BlockNote inline content', () => { + const preprocessed = preProcessMathMarkdown({ markdown: 'Energy is $E=mc^2$ in prose.' }) + const blocks = [{ + type: 'paragraph', + content: [{ type: 'text', text: preprocessed, styles: {} }], + children: [], + }] + + const [block] = injectMathInBlocks(blocks) as Array<{ content: unknown[] }> + + expect(block.content).toEqual([ + { type: 'text', text: 'Energy is ', styles: {} }, + { type: MATH_INLINE_TYPE, props: { latex: 'E=mc^2' }, content: undefined }, + { type: 'text', text: ' in prose.', styles: {} }, + ]) + }) + + it('injects display math placeholders into dedicated math blocks', () => { + const preprocessed = preProcessMathMarkdown({ markdown: '$$\n\\int_0^1 x\\,dx\n$$' }) + const blocks = [{ + type: 'paragraph', + content: [{ type: 'text', text: preprocessed, styles: {} }], + children: [], + }] + + const [block] = injectMathInBlocks(blocks) as Array<{ type: string; props: { latex: string } }> + + expect(block.type).toBe(MATH_BLOCK_TYPE) + expect(block.props.latex).toBe('\\int_0^1 x\\,dx') + }) + + it('preprocesses single-line display math without rewinding later lines', () => { + const preprocessed = preProcessMathMarkdown({ + markdown: 'Intro\n\n$$x^2$$\n\nDone', + }) + const blocks = preprocessed.split('\n\n').map((text) => ({ + type: 'paragraph', + content: [{ type: 'text', text, styles: {} }], + children: [], + })) + + const [, block, afterBlock] = injectMathInBlocks(blocks) as Array<{ + type: string + props?: { latex: string } + content?: Array<{ text: string }> + }> + + expect(block.type).toBe(MATH_BLOCK_TYPE) + expect(block.props?.latex).toBe('x^2') + expect(afterBlock.content?.[0]?.text).toBe('Done') + }) + + it('serializes math nodes back to Markdown-compatible source', () => { + const editor = { + blocksToMarkdownLossy: vi.fn((blocks: unknown[]) => { + return (blocks as Array<{ content?: Array<{ text?: string }> }>) + .map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '') + .join('\n\n') + }), + } + const blocks = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Inline ', styles: {} }, + { type: MATH_INLINE_TYPE, props: { latex: 'a^2+b^2=c^2' } }, + ], + children: [], + }, + { + type: MATH_BLOCK_TYPE, + props: { latex: '\\frac{1}{2}' }, + children: [], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Done', styles: {} }], + children: [], + }, + ] + + expect(serializeMathAwareBlocks(editor, blocks)).toBe( + 'Inline $a^2+b^2=c^2$\n\n$$\n\\frac{1}{2}\n$$\n\nDone', + ) + }) + + it('leaves inline code and fenced code math-looking text untouched', () => { + const markdown = [ + 'Keep `$not_math$` literal.', + '', + '```', + '$$', + 'x^2', + '$$', + '```', + ].join('\n') + + expect(preProcessMathMarkdown({ markdown })).toBe(markdown) + }) +}) diff --git a/src/utils/mathMarkdown.ts b/src/utils/mathMarkdown.ts new file mode 100644 index 00000000..59c84024 --- /dev/null +++ b/src/utils/mathMarkdown.ts @@ -0,0 +1,341 @@ +import katex from 'katex' + +export const MATH_INLINE_TYPE = 'mathInline' +export const MATH_BLOCK_TYPE = 'mathBlock' + +const INLINE_TOKEN_PREFIX = '@@TOLARIA_MATH_INLINE:' +const BLOCK_TOKEN_PREFIX = '@@TOLARIA_MATH_BLOCK:' +const TOKEN_SUFFIX = '@@' +const INLINE_TOKEN_RE = /@@TOLARIA_MATH_INLINE:([^@]+)@@/g +const CODE_FENCE_PREFIXES = ['```', '~~~'] + +interface InlineItem { + type: string + text?: string + props?: Record + content?: unknown + [key: string]: unknown +} + +interface BlockLike { + type?: string + content?: InlineItem[] + props?: Record + children?: BlockLike[] + [key: string]: unknown +} + +interface MarkdownSerializer { + blocksToMarkdownLossy: (blocks: unknown[]) => string +} + +interface LatexPayload { + latex: string +} + +interface EncodedPayload { + encoded: string +} + +interface TokenRequest extends LatexPayload { + prefix: string +} + +interface TokenReadRequest { + text: string + prefix: string +} + +interface TextPosition { + text: string + index: number +} + +interface InlineMathMatch extends LatexPayload { + end: number +} + +interface MarkdownSource { + markdown: string +} + +interface MarkdownLine { + line: string +} + +interface MarkdownLines { + lines: string[] + start: number +} + +interface MathRenderRequest extends LatexPayload { + displayMode: boolean +} + +function encodeLatex({ latex }: LatexPayload): string { + return encodeURIComponent(latex) +} + +function decodeLatex({ encoded }: EncodedPayload): string { + try { + return decodeURIComponent(encoded) + } catch { + return encoded + } +} + +function mathToken({ prefix, latex }: TokenRequest): string { + return `${prefix}${encodeLatex({ latex })}${TOKEN_SUFFIX}` +} + +function readMathToken({ text, prefix }: TokenReadRequest): string | null { + if (!text.startsWith(prefix) || !text.endsWith(TOKEN_SUFFIX)) return null + return decodeLatex({ encoded: text.slice(prefix.length, -TOKEN_SUFFIX.length) }) +} + +function isEscaped({ text, index }: TextPosition): boolean { + let slashCount = 0 + for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) { + slashCount++ + } + return slashCount % 2 === 1 +} + +function isCodeFence({ text: line }: { text: string }): boolean { + const trimmed = line.trimStart() + return CODE_FENCE_PREFIXES.some((prefix) => trimmed.startsWith(prefix)) +} + +function isSingleDollar({ text, index }: TextPosition): boolean { + return text[index] === '$' && text[index + 1] !== '$' +} + +function isInlineMathEnd(position: TextPosition): boolean { + return isSingleDollar(position) && !isEscaped(position) +} + +function findInlineMathEnd({ text, index: start }: TextPosition): number { + for (let i = start + 1; i < text.length; i++) { + if (isInlineMathEnd({ text, index: i })) { + return i + } + } + return -1 +} + +function isValidInlineLatex({ latex }: LatexPayload): boolean { + return Boolean(latex.trim()) && !/^\s|\s$/.test(latex) +} + +function readInlineMath({ text, index }: TextPosition): InlineMathMatch | null { + if (!isSingleDollar({ text, index }) || isEscaped({ text, index })) return null + + const end = findInlineMathEnd({ text, index }) + if (end === -1) return null + + const latex = text.slice(index + 1, end) + return isValidInlineLatex({ latex }) ? { latex, end } : null +} + +function replaceInlineMath({ line }: MarkdownLine): string { + let result = '' + let index = 0 + let inCodeSpan = false + + while (index < line.length) { + const char = line[index] + if (char === '`') { + inCodeSpan = !inCodeSpan + result += char + index++ + continue + } + + const inlineMath = inCodeSpan ? null : readInlineMath({ text: line, index }) + if (inlineMath) { + result += mathToken({ prefix: INLINE_TOKEN_PREFIX, latex: inlineMath.latex }) + index = inlineMath.end + 1 + } else { + result += char + index++ + } + } + + return result +} + +function readSingleLineDisplayMath({ line }: MarkdownLine): InlineMathMatch | null { + const match = line.trim().match(/^\$\$(.+)\$\$$/) + const latex = match?.[1]?.trim() + return latex ? { latex, end: 0 } : null +} + +function readMultilineDisplayMath({ lines, start }: MarkdownLines): InlineMathMatch | null { + if (lines[start].trim() !== '$$') return null + const end = lines.findIndex((line, index) => index > start && line.trim() === '$$') + return end === -1 ? null : { latex: lines.slice(start + 1, end).join('\n'), end } +} + +function readDisplayMath({ lines, start }: MarkdownLines): InlineMathMatch | null { + const trimmed = lines[start].trim() + const displayMath = trimmed === '$$' + ? readMultilineDisplayMath({ lines, start }) + : readSingleLineDisplayMath({ line: lines[start] }) + return displayMath && displayMath.end === 0 + ? { ...displayMath, end: start } + : displayMath +} + +export function preProcessMathMarkdown({ markdown }: MarkdownSource): string { + const lines = markdown.split('\n') + const result: string[] = [] + let inFence = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (isCodeFence({ text: line })) { + inFence = !inFence + result.push(line) + continue + } + + if (inFence) { + result.push(line) + continue + } + + const displayMath = readDisplayMath({ lines, start: i }) + if (displayMath) { + result.push(mathToken({ prefix: BLOCK_TOKEN_PREFIX, latex: displayMath.latex })) + i = displayMath.end + continue + } + + result.push(replaceInlineMath({ line })) + } + + return result.join('\n') +} + +function expandInlineMath(content: InlineItem[]): InlineItem[] { + return content.flatMap(expandInlineMathItem) +} + +function expandInlineMathItem(item: InlineItem): InlineItem[] { + if (item.type !== 'text' || typeof item.text !== 'string') return [item] + + return item.text + .split(INLINE_TOKEN_RE) + .flatMap((part, index) => inlineMathPartToItem({ source: item, part, index })) +} + +function inlineMathPartToItem({ source, part, index }: { source: InlineItem; part: string; index: number }): InlineItem[] { + if (!part) return [] + if (index % 2 === 0) return [{ ...source, text: part }] + return [{ + type: MATH_INLINE_TYPE, + props: { latex: decodeLatex({ encoded: part }) }, + content: undefined, + }] +} + +function restoreInlineMath(content: InlineItem[]): InlineItem[] { + return content.map((item) => { + if (item.type !== MATH_INLINE_TYPE || !item.props?.latex) return item + return { type: 'text', text: `$${item.props.latex}$` } + }) +} + +function injectMathInBlock(block: BlockLike): BlockLike { + const content = Array.isArray(block.content) ? expandInlineMath(block.content) : block.content + const children = Array.isArray(block.children) ? block.children.map(injectMathInBlock) : block.children + const latex = readDisplayMathToken(content) + + if (latex !== null) { + return buildMathBlock({ block, latex }) + } + + return { ...block, content, children } +} + +function readDisplayMathToken(content: InlineItem[] | undefined): string | null { + const onlyItem = content?.length === 1 ? content[0] : null + if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null + return readMathToken({ text: onlyItem.text, prefix: BLOCK_TOKEN_PREFIX }) +} + +function buildMathBlock({ block, latex }: { block: BlockLike } & LatexPayload): BlockLike { + return { + ...block, + type: MATH_BLOCK_TYPE, + props: { ...(block.props ?? {}), latex }, + content: undefined, + children: [], + } +} + +function restoreInlineMathInBlock(block: BlockLike): BlockLike { + const content = Array.isArray(block.content) ? restoreInlineMath(block.content) : block.content + const children = Array.isArray(block.children) ? block.children.map(restoreInlineMathInBlock) : block.children + return { ...block, content, children } +} + +function isMathBlock(block: BlockLike): boolean { + return block.type === MATH_BLOCK_TYPE && typeof block.props?.latex === 'string' +} + +function displayMathMarkdown({ latex }: LatexPayload): string { + return `$$\n${latex}\n$$` +} + +export function injectMathInBlocks(blocks: unknown[]): unknown[] { + return (blocks as BlockLike[]).map(injectMathInBlock) +} + +export function restoreMathInBlocks(blocks: unknown[]): unknown[] { + return (blocks as BlockLike[]).map(restoreInlineMathInBlock) +} + +export function serializeMathAwareBlocks(editor: MarkdownSerializer, blocks: unknown[]): string { + const chunks: string[] = [] + let pending: unknown[] = [] + + const flushPending = () => { + if (pending.length === 0) return + const markdown = editor.blocksToMarkdownLossy(restoreMathInBlocks(pending)).trimEnd() + if (markdown) chunks.push(markdown) + pending = [] + } + + for (const block of blocks as BlockLike[]) { + if (isMathBlock(block)) { + flushPending() + chunks.push(displayMathMarkdown({ latex: block.props!.latex })) + } else { + pending.push(block) + } + } + flushPending() + + return chunks.join('\n\n') +} + +function escapeHtml({ text }: { text: string }): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export function renderMathToHtml({ latex, displayMode }: MathRenderRequest): string { + try { + return katex.renderToString(latex, { + displayMode, + throwOnError: false, + trust: false, + }) + } catch { + return escapeHtml({ text: latex }) + } +} diff --git a/tests/smoke/latex-math-notes.spec.ts b/tests/smoke/latex-math-notes.spec.ts new file mode 100644 index 00000000..c8b12edf --- /dev/null +++ b/tests/smoke/latex-math-notes.spec.ts @@ -0,0 +1,139 @@ +import { expect, test, type Page } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { executeCommand, openCommandPalette } from './helpers' + +let tempVaultDir: string + +const INLINE_LATEX = 'E=mc^2' +const DISPLAY_LATEX = '\\int_0^1 x^2 \\, dx = \\frac{1}{3}' +const MALFORMED_LATEX = '\\frac{' + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(90_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +async function openNote(page: Page, title: string): Promise { + await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +async function toggleRawMode(page: Page, visibleSelector: '.bn-editor' | '.cm-content'): Promise { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator(visibleSelector)).toBeVisible({ timeout: 5_000 }) +} + +async function getRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + type CodeMirrorHost = Element & { + cmTile?: { + view?: { + state: { + doc: { + toString(): string + } + } + } + } + } + + const host = document.querySelector('.cm-content') as CodeMirrorHost | null + return host?.cmTile?.view?.state.doc.toString() ?? host?.textContent ?? '' + }) +} + +async function setRawEditorContent(page: Page, content: string): Promise { + await page.evaluate((nextContent) => { + type CodeMirrorHost = Element & { + cmTile?: { + view?: { + state: { + doc: { + length: number + } + } + dispatch(update: { + changes: { + from: number + to: number + insert: string + } + }): void + } + } + } + + const host = document.querySelector('.cm-content') as CodeMirrorHost | null + const view = host?.cmTile?.view + if (!view) return + + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: nextContent }, + }) + }, content) +} + +async function expectMathNode(page: Page, selector: string, latex: string): Promise { + await expect.poll(async () => + page.locator(selector).evaluateAll((nodes, expectedLatex) => + nodes.some((node) => node.getAttribute('data-latex') === expectedLatex), + latex), + ).toBe(true) +} + +function readNoteBFile(): string { + return fs.readFileSync(path.join(tempVaultDir, 'note', 'note-b.md'), 'utf8') +} + +test('LaTeX math source round-trips through rich mode, save, and reopen @smoke', async ({ page }) => { + await openNote(page, 'Note B') + await toggleRawMode(page, '.cm-content') + + const originalContent = await getRawEditorContent(page) + const nextContent = `${originalContent.trimEnd()} + +Inline math $${INLINE_LATEX}$ stays in prose. + +$$ +${DISPLAY_LATEX} +$$ + +Malformed math $${MALFORMED_LATEX}$ stays visible. +` + + await setRawEditorContent(page, nextContent) + + await expect.poll(readNoteBFile).toContain(`$${INLINE_LATEX}$`) + await expect.poll(readNoteBFile).toContain(`$$\n${DISPLAY_LATEX}\n$$`) + + await toggleRawMode(page, '.bn-editor') + + await expectMathNode(page, '.math--inline', INLINE_LATEX) + await expectMathNode(page, '.math--block', DISPLAY_LATEX) + await expectMathNode(page, '.math--inline', MALFORMED_LATEX) + await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(3) + + await toggleRawMode(page, '.cm-content') + const rawAfterRichMode = await getRawEditorContent(page) + expect(rawAfterRichMode).toContain(`$${INLINE_LATEX}$`) + expect(rawAfterRichMode).toContain(`$$\n${DISPLAY_LATEX}\n$$`) + expect(rawAfterRichMode).toContain(`$${MALFORMED_LATEX}$`) + + await toggleRawMode(page, '.bn-editor') + await openNote(page, 'Note C') + await openNote(page, 'Note B') + await toggleRawMode(page, '.cm-content') + + const reopenedRaw = await getRawEditorContent(page) + expect(reopenedRaw).toContain(`$${INLINE_LATEX}$`) + expect(reopenedRaw).toContain(`$$\n${DISPLAY_LATEX}\n$$`) + expect(reopenedRaw).toContain(`$${MALFORMED_LATEX}$`) +})