feat: add markdown math support

This commit is contained in:
lucaronin
2026-04-26 03:52:10 +02:00
parent 694419d442
commit 5fd8f8fb40
15 changed files with 790 additions and 16 deletions

View File

@@ -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)\ntoken → 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 nodesMarkdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -354,6 +354,7 @@ vi.mock('@blocknote/core/extensions', () => ({
}))
vi.mock('@blocknote/react', () => ({
createReactBlockSpec: () => () => ({}),
createReactInlineContentSpec: () => ({ render: () => null }),
BlockNoteViewRaw: ({ children }: { children?: ReactNode }) => (
<div data-testid="blocknote-view">

View File

@@ -59,6 +59,7 @@ const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
let capturedGetItems: ((query: string) => Promise<any[]>) | 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'

View File

@@ -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'

View File

@@ -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;

View File

@@ -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({

View File

@@ -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}`

View File

@@ -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 (
<span
aria-label={`Math: ${latex}`}
className={displayMode ? 'math math--block' : 'math math--inline'}
data-latex={latex}
role="img"
title={source}
dangerouslySetInnerHTML={{ __html: renderMathToHtml({ latex, displayMode }) }}
/>
)
}
export const MathInline = createReactInlineContentSpec(
{
type: MATH_INLINE_TYPE,
propSchema: {
latex: { default: '' },
},
content: 'none',
},
{
render: (props) => (
<MathRender latex={props.inlineContent.props.latex} displayMode={false} />
),
},
)
const MathBlock = createReactBlockSpec(
{
type: MATH_BLOCK_TYPE,
propSchema: {
latex: { default: '' },
},
content: 'none',
},
{
render: (props) => (
<div className="math-block-shell">
<MathRender latex={props.block.props.latex} displayMode />
</div>
),
},
)
const codeBlock = createCodeBlockSpec({
...codeBlockOptions,
defaultLanguage: 'text',
})
const mathBlock = MathBlock()
export const schema = BlockNoteSchema.create({
inlineContentSpecs: {
...defaultInlineContentSpecs,
wikilink: WikiLink,
mathInline: MathInline,
},
}).extend({
blockSpecs: {
codeBlock,
mathBlock,
},
})

View File

@@ -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 `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
const withMath = injectMathInBlocks(withWikilinks)
return `<h1></h1>${editor.blocksToHTMLLossy(withMath as typeof parsed)}`
}
function findActiveTab(options: {
@@ -265,7 +268,7 @@ function findActiveTab(options: {
function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): 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 {

View File

@@ -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)
})
})

341
src/utils/mathMarkdown.ts Normal file
View File

@@ -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<string, string>
content?: unknown
[key: string]: unknown
}
interface BlockLike {
type?: string
content?: InlineItem[]
props?: Record<string, string>
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function renderMathToHtml({ latex, displayMode }: MathRenderRequest): string {
try {
return katex.renderToString(latex, {
displayMode,
throwOnError: false,
trust: false,
})
} catch {
return escapeHtml({ text: latex })
}
}

View File

@@ -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<void> {
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<void> {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator(visibleSelector)).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
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<void> {
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<void> {
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}$`)
})