Quellcode durchsuchen

feat: add LaTeX math support (inline $...$ + block $$...$$)

- Inline math via Lezer extension + decoration rule + KaTeX widget (mirrors ^^highlight^^)
- Block math via math_block schema node + MathBlockView with contentDOM (source) + sibling preview (KaTeX), CSS toggle via .ProseMirror-focused
- Shared lib/katex.ts utility with async/sync render + getMathContent
- Auto-close rules for $ (pair) and $$ at paragraph start (block conversion)
- insertInlineMath / insertBlockMath formatting commands with Mod-M keybinding
- KaTeX CSS import in dev app
- /math test page for manual verification
- 274 tests all pass
Zander Hawke vor 1 Woche
Ursprung
Commit
2f2dbee23e

+ 7 - 0
apps/dev/src/App.vue

@@ -6,6 +6,13 @@
       </template>
 
       <template #right>
+        <UButton to="/" color="neutral" variant="ghost">
+          Editor
+        </UButton>
+        <UButton to="/math" color="neutral" variant="ghost">
+          Math Test
+        </UButton>
+
         <UColorModeButton />
 
         <UButton

+ 5 - 1
apps/dev/src/main.ts

@@ -3,9 +3,13 @@ import { createApp } from "vue"
 import { createRouter, createWebHistory } from "vue-router"
 import App from "~/App.vue"
 import EditorView from "~/pages/EditorView.vue"
+import MathTestView from "~/pages/MathTestView.vue"
 import "~/style.css"
 
-const routes = [{ path: "/", component: EditorView }]
+const routes = [
+  { path: "/", component: EditorView },
+  { path: "/math", component: MathTestView },
+]
 
 const app = createApp(App)
 const router = createRouter({

+ 116 - 0
apps/dev/src/pages/MathTestView.vue

@@ -0,0 +1,116 @@
+<script setup lang="ts">
+import { Block } from "@enesis/editor"
+import { ref } from "vue"
+
+const inlineMath = ref(`This has inline math: $x^2 + y^2 = z^2$ inside a paragraph.
+
+You can also do $\\sum_{i=1}^n i = \\frac{n(n+1)}{2}$ for summations.
+
+And $\\alpha\\beta\\gamma$ for Greek letters.
+
+A bare $5 should not render as math. Neither should $ this$.`)
+
+const blockMath = ref(`Here is some block math:
+
+$$
+\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}
+$$
+
+And another one:
+
+$$
+\\begin{bmatrix}
+a & b \\\\
+c & d
+\\end{bmatrix}
+$$`)
+
+const mixed = ref(`# Math Test Document
+
+You can mix **bold** and $x$ math inline.
+
+> [!NOTE] Math in callouts works: $E = mc^2$
+
+[[Page Ref]] with $\\theta$ math.
+
+TODO Test task with math: $\\Delta$ [#A]
+
+$$
+f(x) = \\begin{cases}
+x^2 & \\text{if } x > 0 \\\\
+0 & \\text{otherwise}
+\\end{cases}
+$$
+
+property:: math expression $\\lambda$ test`)
+
+function log(event: string, payload?: unknown) {
+  console.log(`[Block] ${event}`, payload)
+
+  if (event === "blur") {
+    console.log("Content:", inlineMath.value, blockMath.value, mixed.value)
+  }
+}
+</script>
+
+<template>
+  <div class="p-8 max-w-2xl mx-auto space-y-6">
+    <h1 class="text-2xl font-bold">Math Feature Tests</h1>
+
+    <section class="rounded-lg border border-(--ui-border) p-4 space-y-2">
+      <h2 class="text-lg font-semibold">Inline Math</h2>
+      <p class="text-sm text-(--ui-text-muted)">
+        Type <code class="text-xs">$...$</code> for inline math. Use
+        <kbd class="px-1.5 py-0.5 rounded bg-(--ui-bg-elevated) border border-(--ui-border) text-xs">Mod+M</kbd>
+        to toggle inline math on selection.
+      </p>
+      <Block
+        v-model:content="inlineMath"
+        :focused="true"
+        cursor-position="end"
+        marker-mode="always-visible"
+        @change="log('change', $event)"
+        @focus="log('focus', $event)"
+        @blur="log('blur')"
+        @selection-change="log('selection-change', $event)"
+      />
+    </section>
+
+    <section class="rounded-lg border border-(--ui-border) p-4 space-y-2">
+      <h2 class="text-lg font-semibold">Block Math</h2>
+      <p class="text-sm text-(--ui-text-muted)">
+        Type <code class="text-xs">$$</code> at the start of an empty paragraph for a
+        display math block. Use
+        <kbd class="px-1.5 py-0.5 rounded bg-(--ui-bg-elevated) border border-(--ui-border) text-xs">Mod+Shift+M</kbd>
+        to insert one.
+      </p>
+      <Block
+        v-model:content="blockMath"
+        :focused="true"
+        cursor-position="end"
+        marker-mode="always-visible"
+        @change="log('change', $event)"
+        @focus="log('focus', $event)"
+        @blur="log('blur')"
+        @selection-change="log('selection-change', $event)"
+      />
+    </section>
+
+    <section class="rounded-lg border border-(--ui-border) p-4 space-y-2">
+      <h2 class="text-lg font-semibold">Mixed Syntax</h2>
+      <p class="text-sm text-(--ui-text-muted)">
+        Math combined with headings, bold, callouts, page refs, tasks, and properties.
+      </p>
+      <Block
+        v-model:content="mixed"
+        :focused="true"
+        cursor-position="end"
+        marker-mode="always-visible"
+        @change="log('change', $event)"
+        @focus="log('focus', $event)"
+        @blur="log('blur')"
+        @selection-change="log('selection-change', $event)"
+      />
+    </section>
+  </div>
+</template>

+ 1 - 0
apps/dev/src/style.css

@@ -1,6 +1,7 @@
 @import "tailwindcss";
 @import "@nuxt/ui";
 @import "@enesis/editor";
+@import "katex/dist/katex.min.css";
 
 @theme static {
   --font-sans: "Public Sans", sans-serif;

+ 2 - 0
packages/editor/package.json

@@ -23,6 +23,7 @@
   "devDependencies": {
     "@nuxt/ui": "^4.7.1",
     "@tsconfig/node24": "^24.0.4",
+    "@types/katex": "^0.16.8",
     "@types/node": "^25.9.0",
     "@vitejs/plugin-vue": "^6.0.7",
     "@vue/test-utils": "^2.4.10",
@@ -59,6 +60,7 @@
     "@lezer/markdown": "^1.6.3",
     "@nuxt/ui": "^4.7.1",
     "@tailwindcss/vite": "^4.3.0",
+    "katex": "^0.17.0",
     "prosemirror-commands": "^1.7.1",
     "prosemirror-gapcursor": "^1.4.1",
     "prosemirror-keymap": "^1.2.3",

+ 44 - 0
packages/editor/src/assets/style.css

@@ -256,3 +256,47 @@
 .cm-code-block .cm-fence-marker {
   @apply text-(--ui-text-muted);
 }
+
+/* ── Inline Math ────────────────────────────────────────────────── */
+
+.md-math-inline {
+  @apply bg-(--ui-bg-elevated) rounded px-0.5 text-(--ui-text);
+}
+
+.md-math-hidden {
+  display: none !important;
+}
+
+.md-math-rendered {
+  @apply inline-flex items-center px-1;
+  cursor: pointer;
+}
+
+.md-math-rendered .katex {
+  @apply text-sm;
+}
+
+/* ── Math Block ───────────────────────────────────────────── */
+
+.math-block {
+  @apply my-2 rounded-lg border border-(--ui-border) bg-(--ui-bg-elevated);
+}
+
+.math-block-source {
+  @apply px-4 py-3 font-mono text-sm;
+  white-space: pre-wrap;
+  line-height: 1.5;
+  display: none;
+}
+
+.math-block-preview {
+  @apply px-4 py-3;
+  display: block;
+}
+
+.ProseMirror-focused .math-block-source {
+  display: block;
+}
+.ProseMirror-focused .math-block-preview {
+  display: none;
+}

+ 18 - 0
packages/editor/src/components/Block.vue

@@ -6,6 +6,7 @@ import { EditorView } from "prosemirror-view"
 import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
 import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
 import { CodeBlockView } from "@/composables/useCodeBlockView"
+import { MathBlockView } from "@/composables/useMathBlockView"
 import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
@@ -20,6 +21,7 @@ import { createPatternPlugin } from "@/composables/usePatternPlugin"
 import {
   autoClosePlugin,
   createPairRule,
+  dollarSignRule,
   doubleAsteriskRule,
   doubleTildeRule,
   singleUnderscoreRule,
@@ -27,6 +29,8 @@ import {
 } from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
 import {
+  insertBlockMath,
+  insertInlineMath,
   insertLink,
   isFormatActive,
   setHeading,
@@ -105,6 +109,12 @@ const formattingHandlers = {
   insertLink: (url?: string) => {
     if (view) insertLink(view, url)
   },
+  insertInlineMath: () => {
+    if (view) insertInlineMath(view)
+  },
+  insertBlockMath: () => {
+    if (view) insertBlockMath(view)
+  },
   setHeading: (level: number | null) => {
     if (view) setHeading(view, level)
   },
@@ -184,6 +194,7 @@ onMounted(() => {
       patternPlugin,
       autoClosePlugin([
         tripleBacktickRule,
+        dollarSignRule,
         doubleAsteriskRule,
         doubleTildeRule,
         singleUnderscoreRule,
@@ -224,6 +235,11 @@ onMounted(() => {
           insertLink(view)
           return true
         },
+        "Mod-m": (_state, _dispatch, view) => {
+          if (!view) return false
+          insertInlineMath(view)
+          return true
+        },
       }),
     ],
   })
@@ -236,6 +252,8 @@ onMounted(() => {
     nodeViews: {
       code_block: (node, view, getPos) =>
         new CodeBlockView(node, view, () => getPos() ?? 0, props.debug),
+      math_block: (node, view, getPos) =>
+        new MathBlockView(node, view, () => getPos() ?? 0),
     },
     handleKeyDown,
     handlePaste: createPasteHandler((before, after) =>

+ 56 - 13
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -15,8 +15,9 @@
  */
 
 import type { Node as ProsemirrorNode } from "prosemirror-model"
-import { Plugin, PluginKey } from "prosemirror-state"
+import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
+import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
 import {
   type DecorationRange,
@@ -84,8 +85,8 @@ function buildDecorationsFromCache(
   doc: ProsemirrorNode,
   contentCaches: Map<string, BlockTokenCache>,
   isFocused: boolean,
-  _cursorFrom: number,
-  _cursorTo: number,
+  cursorFrom: number,
+  cursorTo: number,
   markerMode: MarkerVisibilityMode,
 ): DecorationSet {
   const decorationSpecs: Decoration[] = []
@@ -156,8 +157,8 @@ function buildDecorationsFromCache(
           blockStart,
           decorationSpecs,
           isFocused,
-          _cursorFrom,
-          _cursorTo,
+          cursorFrom,
+          cursorTo,
           markerMode,
         )
       } else {
@@ -174,8 +175,8 @@ function buildDecorationsFromCache(
           blockStart,
           decorationSpecs,
           isFocused,
-          _cursorFrom,
-          _cursorTo,
+          cursorFrom,
+          cursorTo,
           markerMode,
         )
       }
@@ -187,8 +188,8 @@ function buildDecorationsFromCache(
         blockStart,
         decorationSpecs,
         isFocused,
-        _cursorFrom,
-        _cursorTo,
+        cursorFrom,
+        cursorTo,
         markerMode,
       )
     }
@@ -198,8 +199,8 @@ function buildDecorationsFromCache(
       blockStart,
       decorationSpecs,
       isFocused,
-      _cursorFrom,
-      _cursorTo,
+      cursorFrom,
+      cursorTo,
       markerMode,
     )
   }
@@ -227,8 +228,8 @@ function buildTokenDecorations(
   offset: number,
   decorationSpecs: Decoration[],
   isFocused: boolean,
-  _cursorFrom: number,
-  _cursorTo: number,
+  cursorFrom: number,
+  cursorTo: number,
   markerMode: MarkerVisibilityMode,
 ) {
   const ranges = token.decorationRanges
@@ -236,6 +237,48 @@ function buildTokenDecorations(
 
   const sorted = [...ranges].sort((a, b) => a.from - b.from)
 
+  // ── Inline math: show KaTeX when blurred; show source when focused ──
+  if (token.type === "inline-math") {
+    if (!isFocused) {
+      const tokenStart = offset + sorted[0].from
+      // Hide all ranges
+      const source = token.wrapperAttrs?.["data-math"] ?? ""
+      for (const range of sorted) {
+        decorationSpecs.push(
+          Decoration.inline(offset + range.from, offset + range.to, {
+            class: "md-math-hidden",
+          }),
+        )
+      }
+      // Add clickable KaTeX widget
+      if (source) {
+        decorationSpecs.push(
+          Decoration.widget(
+            tokenStart,
+            (view) => {
+              const widgetSpan = document.createElement("span")
+              widgetSpan.className = "md-math-rendered"
+              renderKatexSync(source, widgetSpan, false)
+              widgetSpan.addEventListener("mousedown", (e) => {
+                e.preventDefault()
+                e.stopPropagation()
+                view.dispatch(
+                  view.state.tr.setSelection(
+                    TextSelection.near(view.state.doc.resolve(tokenStart)),
+                  ),
+                )
+                view.focus()
+              })
+              return widgetSpan
+            },
+            { side: 1 },
+          ),
+        )
+      }
+      return
+    }
+  }
+
   for (const range of sorted) {
     const rangeFrom = offset + range.from
     const rangeTo = offset + range.to

+ 99 - 0
packages/editor/src/composables/useMathBlockView.ts

@@ -0,0 +1,99 @@
+import type { Node as ProsemirrorNode } from "prosemirror-model"
+import { TextSelection } from "prosemirror-state"
+import type { EditorView, NodeView } from "prosemirror-view"
+import { getMathContent, renderKatex } from "@/lib/katex"
+
+export class MathBlockView implements NodeView {
+  dom: HTMLElement
+  contentDOM: HTMLElement
+  private preview: HTMLElement
+  private pmView: EditorView
+  private getPos: () => number
+
+  constructor(node: ProsemirrorNode, view: EditorView, getPos: () => number) {
+    this.pmView = view
+    this.getPos = getPos
+
+    this.dom = document.createElement("div")
+    this.dom.className = "math-block"
+
+    this.preview = document.createElement("div")
+    this.preview.className = "math-block-preview"
+
+    this.contentDOM = document.createElement("div")
+    this.contentDOM.className = "math-block-source"
+
+    this.dom.append(this.preview, this.contentDOM)
+
+    this.renderPreview(node.textContent)
+
+    this.contentDOM.addEventListener("keydown", (e) => {
+      const $pos = this.pmView.state.selection.$from
+      if ($pos.parent.type.name !== "math_block") return
+
+      const atEnd = $pos.parentOffset >= $pos.parent.content.size
+
+      if (e.key === "ArrowUp" && $pos.parentOffset === 0) {
+        e.preventDefault()
+        e.stopPropagation()
+        this.exitBlock("up")
+        return
+      }
+
+      if (e.key === "ArrowDown" && atEnd) {
+        e.preventDefault()
+        e.stopPropagation()
+        this.exitBlock("down")
+        return
+      }
+
+      if (e.key === "Enter" && atEnd) {
+        const text = $pos.parent.textContent
+        const beforeCursor = text.slice(0, $pos.parentOffset)
+        const lineStart = beforeCursor.lastIndexOf("\n") + 1
+        const currentLine = beforeCursor.slice(lineStart)
+        if (/^\$\$\s*$/.test(currentLine)) {
+          e.preventDefault()
+          e.stopPropagation()
+          this.exitBlock("down")
+          return
+        }
+      }
+    })
+  }
+
+  private renderPreview(source: string) {
+    this.preview.textContent = ""
+    const math = getMathContent(source)
+    if (math) renderKatex(math, this.preview, true)
+  }
+
+  private exitBlock(dir: "up" | "down") {
+    const pos = this.getPos()
+    const node = this.pmView.state.doc.nodeAt(pos)
+    if (!node) return
+    const targetPos = dir === "up" ? pos - 1 : pos + node.nodeSize
+    if (targetPos < 0 || targetPos > this.pmView.state.doc.content.size) return
+    const $pos = this.pmView.state.doc.resolve(targetPos)
+    const sel = TextSelection.near($pos, dir === "up" ? -1 : 1)
+    if (!sel) return
+    this.pmView.dispatch(
+      this.pmView.state.tr.setSelection(sel).scrollIntoView(),
+    )
+    this.pmView.focus()
+  }
+
+  update(node: ProsemirrorNode) {
+    if (node.type.name !== "math_block") return false
+    this.renderPreview(node.textContent)
+    return true
+  }
+
+  destroy() {
+    // PM handles cleanup
+  }
+
+  stopEvent() {
+    return true
+  }
+}

+ 54 - 0
packages/editor/src/lib/__tests__/auto-close-plugin.test.ts

@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import {
   autoClosePlugin,
   createPairRule,
+  dollarSignRule,
   doubleAsteriskRule,
   doubleTildeRule,
   singleUnderscoreRule,
@@ -37,6 +38,7 @@ afterEach(() => {
 function createPlugin() {
   return autoClosePlugin([
     tripleBacktickRule,
+    dollarSignRule,
     doubleAsteriskRule,
     doubleTildeRule,
     singleUnderscoreRule,
@@ -303,3 +305,55 @@ describe("tripleBacktickRule", () => {
     expect(handled).toBe(false)
   })
 })
+
+describe("dollarSignRule", () => {
+  it("auto-closes single $ at word boundary", () => {
+    const view = getView("hello ", 6)
+
+    const handled = typeAtCursor(view, "$")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 8)).toBe("hello $$")
+  })
+
+  it("does not auto-close after word character", () => {
+    const view = getView("hello", 5)
+
+    const handled = typeAtCursor(view, "$")
+
+    expect(handled).toBe(false)
+  })
+
+  it("converts $$ at paragraph start to math_block", () => {
+    const view = getView("", 0)
+
+    const first = typeAtCursor(view, "$")
+    expect(first).toBe(true)
+
+    // After first $ auto-close, text is "$$", cursor between them.
+    // Type second $: should trigger block math conversion.
+    const second = typeAtCursor(view, "$")
+    expect(second).toBe(true)
+
+    const doc = view.state.doc
+    expect(doc.child(0).type.name).toBe("math_block")
+  })
+
+  it("provides skip-over when cursor is before closing $", () => {
+    const view = getView("$x^2$", 4)
+
+    const handled = typeAtCursor(view, "$")
+
+    expect(handled).toBe(true)
+    expect(view.state.selection.head).toBe(1 + 5) // after closing $
+  })
+
+  it("deletes pair on backspace between $$", () => {
+    const view = getView("$$", 1)
+
+    const handled = pressBackspace(view)
+
+    expect(handled).toBe(true)
+    expect(view.state.doc.textBetween(1, 2)).toBe("")
+  })
+})

+ 35 - 0
packages/editor/src/lib/__tests__/content-model.test.ts

@@ -194,3 +194,38 @@ describe("roundtrip", () => {
     expect(docToContent(doc)).toBe(original)
   })
 })
+
+describe("math block (content model)", () => {
+  it("detects $$ fence in contentToDoc", () => {
+    const doc = contentToDoc("before\n$$\n\\int_a^b f(x) dx\n$$\nafter")
+    expect(doc.childCount).toBe(3)
+    expect(doc.child(0).type.name).toBe("paragraph")
+    expect(doc.child(0).textContent).toBe("before")
+    expect(doc.child(1).type.name).toBe("math_block")
+    expect(doc.child(1).textContent).toBe("$$\n\\int_a^b f(x) dx\n$$")
+    expect(doc.child(2).type.name).toBe("paragraph")
+    expect(doc.child(2).textContent).toBe("after")
+  })
+
+  it("serializes math_block in docToContent", () => {
+    const doc = schema.nodes.doc.create(null, [
+      schema.nodes.paragraph.create(null, [schema.text("intro")]),
+      schema.nodes.math_block.create(null, [schema.text("$$\nE = mc^2\n$$")]),
+      schema.nodes.paragraph.create(null, [schema.text("outro")]),
+    ])
+    expect(docToContent(doc)).toBe("intro\n$$\nE = mc^2\n$$\noutro")
+  })
+
+  it("roundtrips block math", () => {
+    const original = "text\n$$\na^2 + b^2 = c^2\n$$\nmore"
+    const doc = contentToDoc(original)
+    expect(docToContent(doc)).toBe(original)
+  })
+
+  it("handles empty math block", () => {
+    const doc = contentToDoc("$$\n$$")
+    expect(doc.childCount).toBe(1)
+    expect(doc.child(0).type.name).toBe("math_block")
+    expect(doc.child(0).textContent).toBe("$$\n$$")
+  })
+})

+ 59 - 0
packages/editor/src/lib/__tests__/formatting.test.ts

@@ -2,6 +2,8 @@ import { EditorState, TextSelection } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import {
+  insertBlockMath,
+  insertInlineMath,
   insertLink,
   isFormatActive,
   setHeading,
@@ -314,3 +316,60 @@ describe("toggleTask", () => {
     expect(doc.textBetween(1, doc.content.size)).toBe("WAITING fix bug")
   })
 })
+
+describe("insertBlockMath", () => {
+  it("replaces empty paragraph with math block", () => {
+    const view = getView("", 0)
+    insertBlockMath(view)
+    const doc = view.state.doc
+    expect(doc.child(0).type.name).toBe("math_block")
+  })
+
+  it("inserts math block after non-empty paragraph", () => {
+    const view = getView("hello", 5)
+    insertBlockMath(view)
+    const doc = view.state.doc
+    expect(doc.childCount).toBe(2)
+    expect(doc.child(0).type.name).toBe("paragraph")
+    expect(doc.child(1).type.name).toBe("math_block")
+  })
+
+  it("preserves content when inserting after non-empty paragraph", () => {
+    const view = getView("hello", 5)
+    insertBlockMath(view)
+    const doc = view.state.doc
+    expect(doc.textContent).toContain("hello")
+    expect(doc.textContent).toContain("$$")
+  })
+
+  it("sets cursor inside the math block after insertion", () => {
+    const view = getView("", 0)
+    insertBlockMath(view)
+    const sel = view.state.selection
+    const $pos = view.state.doc.resolve(sel.from)
+    expect($pos.parent.type.name).toBe("math_block")
+  })
+})
+
+describe("insertInlineMath", () => {
+  it("wraps selection in $...$", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertInlineMath(view)
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello $world$")
+  })
+
+  it("unwraps $...$ when cursor is inside", () => {
+    const view = getView("$x^2$", 3)
+    insertInlineMath(view)
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("x^2")
+  })
+
+  it("inserts empty delimiters at cursor when no selection", () => {
+    const view = getView("hello ", 6)
+    insertInlineMath(view)
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello $$")
+  })
+})

+ 28 - 0
packages/editor/src/lib/__tests__/katex.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest"
+import { getMathContent } from "@/lib/katex"
+
+describe("getMathContent", () => {
+  it("extracts content between $$ fences", () => {
+    expect(getMathContent("$$\nE = mc^2\n$$")).toBe("E = mc^2")
+  })
+
+  it("returns empty string for unclosed opening fence", () => {
+    expect(getMathContent("$$\nE = mc^2")).toBe("")
+  })
+
+  it("returns empty string for text without any fences", () => {
+    expect(getMathContent("just math")).toBe("")
+  })
+
+  it("handles empty block ($$\\n$$) - exactly 2 lines", () => {
+    expect(getMathContent("$$\n$$")).toBe("")
+  })
+
+  it("returns empty string for closing fence without opening", () => {
+    expect(getMathContent("E = mc^2\n$$")).toBe("")
+  })
+
+  it("extracts multi-line content", () => {
+    expect(getMathContent("$$\na\nb\nc\n$$")).toBe("a\nb\nc")
+  })
+})

+ 49 - 0
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
   type BlockRefDecoration,
+  type InlineMathDecoration,
   type PageRefDecoration,
   parseMarkdown,
   type TagDecoration,
@@ -682,3 +683,51 @@ describe("wrapperAttrs output", () => {
     })
   })
 })
+
+describe("InlineMath", () => {
+  it("parses $x^2$ as inline-math", () => {
+    const result = parseMarkdown("$x^2$")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("inline-math")
+    const token = result.content[0] as InlineMathDecoration
+    expect(token.wrapperAttrs?.["data-math"]).toBe("x^2")
+    const ranges = token.decorationRanges
+    expect(ranges).toHaveLength(3)
+    expect(ranges[0]).toEqual({ type: "hidden", from: 0, to: 1 })
+    expect(ranges[1]).toEqual({
+      type: "content",
+      from: 1,
+      to: 4,
+      className: "md-math-inline",
+    })
+    expect(ranges[2]).toEqual({ type: "hidden", from: 4, to: 5 })
+  })
+
+  it("parses inline math in a sentence", () => {
+    const result = parseMarkdown("solve $x^2 + 1$ for x")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("inline-math")
+    const token = result.content[0] as InlineMathDecoration
+    expect(token.wrapperAttrs?.["data-math"]).toBe("x^2 + 1")
+  })
+
+  it("does not match $$ as inline math", () => {
+    const result = parseMarkdown("$$")
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("does not match bare $ without closing", () => {
+    const result = parseMarkdown("$5")
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("does not match $ followed by whitespace", () => {
+    const result = parseMarkdown("$ x^2$")
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("does not match closing $ followed by digit", () => {
+    const result = parseMarkdown("$x^2$3")
+    expect(result.content).toHaveLength(0)
+  })
+})

+ 80 - 0
packages/editor/src/lib/auto-close-plugin.ts

@@ -252,6 +252,86 @@ export const doubleAsteriskRule: AutoCloseRule = createToggleRule("**")
 /** Auto-close ~~ (double tilde → strikethrough) */
 export const doubleTildeRule: AutoCloseRule = createToggleRule("~~")
 
+/**
+ * Auto-close $ for inline math and $$ → math_block conversion.
+ *
+ * - Single $ at word boundary: inserts $$ with cursor between (inline math)
+ * - Second $ at paragraph start on an existing $$: converts paragraph to math_block
+ * - $ at end of inline math content: skip-over (move cursor past closing $)
+ * - Backspace between $$: delete both
+ */
+export const dollarSignRule: AutoCloseRule = {
+  name: "dollar-sign",
+  handler(state, view, from, to, text) {
+    if (text !== "$") return false
+    const $pos = state.doc.resolve(from)
+    if ($pos.parent.type.spec.code) return false
+
+    const prevChar = state.doc.textBetween(Math.max(0, from - 1), from)
+
+    // ── Skip-over: cursor right before a closing $ → jump past it ──
+    if (from === to && prevChar !== "$") {
+      const next = state.doc.textBetween(from, from + 1)
+      if (next === "$") {
+        const tr = state.tr.setSelection(
+          TextSelection.create(state.doc, from + 1),
+        )
+        view.dispatch(tr)
+        return true
+      }
+    }
+
+    // ── $$ at paragraph start → math_block node ──
+    if (prevChar === "$") {
+      const posInPara = from - $pos.before() - 1
+      const prefix = $pos.parent.textContent.slice(0, posInPara - 1)
+      if (prefix.trim() === "") {
+        log.info("double dollar → math block")
+        const paraStart = $pos.before()
+        const paraEnd = paraStart + $pos.parent.nodeSize
+        const init = "$$\n\n$$"
+        const mathBlock = schema.nodes.math_block.create(null, [
+          schema.text(init),
+        ])
+        const tr = state.tr.replaceWith(paraStart, paraEnd, mathBlock)
+        const cursorPos = paraStart + 1 + "$$".length
+        tr.setSelection(new TextSelection(tr.doc.resolve(cursorPos)))
+        view.dispatch(tr)
+        return true
+      }
+      return false
+    }
+
+    // ── Single $: auto-close (inline math) with context check ──
+    const context = /^$|[\s([{:>]/
+    const before = state.doc.textBetween(Math.max(0, from - 1), from)
+    if (before && !context.test(before)) return false
+
+    // Wrap selection
+    if (from !== to) {
+      const selected = state.doc.textBetween(from, to)
+      const tr = state.tr.replaceWith(
+        from,
+        to,
+        state.schema.text(`$${selected}$`),
+      )
+      tr.setSelection(
+        TextSelection.create(tr.doc, from + 1 + selected.length + 1),
+      )
+      view.dispatch(tr)
+      return true
+    }
+
+    // Auto-close
+    log.debug("dollar auto-close", {})
+    const tr = state.tr.replaceWith(from, from, state.schema.text("$$"))
+    tr.setSelection(TextSelection.create(tr.doc, from + 1))
+    view.dispatch(tr)
+    return true
+  },
+  pair: { open: "$", close: "$" },
+}
+
 /** Auto-close _ (single underscore → italic/emphasis) */
 export const singleUnderscoreRule: AutoCloseRule = {
   name: "single-underscore",

+ 28 - 3
packages/editor/src/lib/content-model.ts

@@ -26,8 +26,34 @@ export function contentToDoc(content: string): ProsemirrorNode {
   const lines = content.split("\n")
   let i = 0
   let codeBlockCount = 0
+  let mathBlockCount = 0
   let paraCount = 0
   while (i < lines.length) {
+    // Detect $$...$$ display math blocks ($$ on a line by itself)
+    const line = lines[i]
+    if (line != null && line.trim() === "$$") {
+      mathBlockCount++
+      const mathLines: string[] = [line]
+      i++
+      while (i < lines.length) {
+        const ml = lines[i]
+        if (ml != null && ml.trim() === "$$") {
+          mathLines.push(ml)
+          i++
+          break
+        }
+        mathLines.push(ml ?? "")
+        i++
+      }
+      const mathText = mathLines.join("\n")
+      nodes.push(
+        mathText
+          ? schema.nodes.math_block.create(null, [schema.text(mathText)])
+          : schema.nodes.math_block.create(),
+      )
+      continue
+    }
+
     const fence = lines[i]?.match(/^(```|~~~)\s*(\w*)/)
     if (fence) {
       codeBlockCount++
@@ -67,6 +93,7 @@ export function contentToDoc(content: string): ProsemirrorNode {
   log.debug("contentToDoc result", {
     paragraphs: paraCount,
     codeBlocks: codeBlockCount,
+    mathBlocks: mathBlockCount,
     totalLines: lines.length,
   })
   return schema.nodes.doc.create(null, nodes)
@@ -83,10 +110,8 @@ export function docToContent(doc: ProsemirrorNode): string {
   doc.forEach((node: ProsemirrorNode) => {
     if (node.type.name === "code_block") {
       codeBlockCount++
-      lines.push(node.textContent)
-    } else {
-      lines.push(node.textContent)
     }
+    lines.push(node.textContent)
   })
 
   const result = lines.join("\n")

+ 33 - 0
packages/editor/src/lib/formatting.ts

@@ -7,6 +7,7 @@ import type {
   ParsedDecorations,
   TaskState,
 } from "@/lib/markdown-rules"
+import { schema } from "@/lib/schema"
 
 const engine = new MarkdownRuleEngine()
 
@@ -23,6 +24,7 @@ const FORMAT_CONFIGS = {
   code: { parseType: "code", delim: "`" },
   strikethrough: { parseType: "strike", delim: "~~" },
   highlight: { parseType: "highlight", delim: "^^" },
+  inlineMath: { parseType: "inline-math", delim: "$" },
 } as const
 
 function getFormat(key: string): FormatConfig | undefined {
@@ -165,6 +167,37 @@ export function toggleHighlight(view: EditorView) {
   toggleFormat(view, FORMAT_CONFIGS.highlight)
 }
 
+export function insertInlineMath(view: EditorView) {
+  toggleFormat(view, FORMAT_CONFIGS.inlineMath)
+}
+
+export function insertBlockMath(view: EditorView) {
+  const { $from } = view.state.selection
+  const init = "$$\n\n$$"
+  const mathBlock = schema.nodes.math_block.create(null, [schema.text(init)])
+
+  if ($from.parent.content.size === 0) {
+    const pos = $from.before()
+    const tr = view.state.tr.replaceWith(
+      pos,
+      pos + $from.parent.nodeSize,
+      mathBlock,
+    )
+    const cursorPos = pos + 1 + "$$".length
+    tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+    view.dispatch(tr)
+    view.focus()
+    return
+  }
+
+  const pos = $from.after()
+  const tr = view.state.tr.insert(pos, mathBlock)
+  const cursorPos = pos + 1 + "$$".length
+  tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+  view.dispatch(tr)
+  view.focus()
+}
+
 // ── insertLink ────────────────────────────────────────────────────
 
 export function insertLink(view: EditorView, url?: string) {

+ 47 - 0
packages/editor/src/lib/katex.ts

@@ -0,0 +1,47 @@
+let mod: typeof import("katex") | null = null
+
+export async function renderKatex(
+  source: string,
+  el: HTMLElement,
+  displayMode = false,
+) {
+  if (!mod) mod = (await import("katex")).default
+  try {
+    mod.render(source, el, { throwOnError: false, displayMode })
+  } catch {
+    el.textContent = source
+  }
+}
+
+export function renderKatexSync(
+  source: string,
+  el: HTMLElement,
+  displayMode = false,
+) {
+  if (!mod) {
+    renderKatex(source, el, displayMode)
+    return
+  }
+  try {
+    mod.render(source, el, { throwOnError: false, displayMode })
+  } catch {
+    el.textContent = source
+  }
+}
+
+/**
+ * Extract math content from a math_block's raw text (which includes
+ * optional $$...$$ fences). Returns the content between fences, or
+ * empty string if the text doesn't represent valid fenced math.
+ */
+export function getMathContent(text: string): string {
+  const lines = text.split("\n")
+  if (
+    lines.length >= 2 &&
+    /^\$\$$/.test(lines[0] ?? "") &&
+    /^\$\$$/.test(lines[lines.length - 1] ?? "")
+  ) {
+    return lines.slice(1, -1).join("\n")
+  }
+  return ""
+}

+ 69 - 1
packages/editor/src/lib/markdown-extensions.ts

@@ -118,6 +118,57 @@ function createPatternExtension(patterns: MarkdownPattern[]): MarkdownConfig {
   }
 }
 
+function createInlineMathParser(): MarkdownConfig["parseInline"] {
+  const nodeType = "InlineMath"
+  return {
+    name: nodeType,
+    before: "Link",
+    parse(cx, _next, pos) {
+      if (cx.char(pos) !== 36) return -1 // '$'
+
+      // Don't consume $$ as inline math
+      if (cx.char(pos + 1) === 36) return -1
+
+      // Opening $ must not be followed by whitespace
+      const afterOpen = cx.char(pos + 1)
+      if (
+        afterOpen == null ||
+        afterOpen === 32 ||
+        afterOpen === 9 ||
+        afterOpen === 10 ||
+        afterOpen === 13
+      ) {
+        return -1
+      }
+
+      let i = pos + 1
+      let hasContent = false
+
+      while (i < cx.end) {
+        const ch = cx.char(i)
+        if (ch === 36) {
+          // Closing $ must not be followed by a digit
+          const next = cx.char(i + 1)
+          if (next != null && next >= 48 && next <= 57) return -1
+
+          if (hasContent) {
+            return cx.addElement(cx.elt(nodeType, pos, i + 1))
+          }
+          return -1
+        }
+        if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) {
+          hasContent = true
+        }
+        i++
+      }
+      return -1
+    },
+  }
+}
+
+// Note: BlockMath is intentionally excluded from defaultPatterns.
+// math_block nodes have code: true and are handled by MathBlockView
+// (a PM NodeView with contentDOM + sibling preview), not by inline decorations.
 export const defaultPatterns: MarkdownPattern[] = [
   {
     name: "PageRef",
@@ -225,5 +276,22 @@ export function createMarkdownExtensions(
   customPatterns: MarkdownPattern[] = [],
 ): MarkdownConfig[] {
   const allPatterns = [...defaultPatterns, ...customPatterns]
-  return [createPatternExtension(allPatterns), TaskBlock]
+  const patternExt = createPatternExtension(allPatterns)
+  const inlineMathParser = createInlineMathParser()
+
+  const merged: MarkdownConfig = {
+    defineNodes: [
+      ...((patternExt.defineNodes as Array<{ name: string; block: boolean }>) ??
+        []),
+      { name: "InlineMath", block: false },
+    ],
+    parseInline: [
+      ...((patternExt.parseInline as Array<{
+        name: string
+        parse: (cx: unknown, next: number, pos: number) => number
+      }>) ?? []),
+      inlineMathParser,
+    ],
+  }
+  return [merged, TaskBlock]
 }

+ 2 - 0
packages/editor/src/lib/markdown-parser.ts

@@ -13,6 +13,7 @@ import {
   type CalloutType,
   type DecorationRange,
   type HighlightDecoration,
+  type InlineMathDecoration,
   MarkdownRuleEngine,
   type PageRefDecoration,
   type ParsedDecorations,
@@ -29,6 +30,7 @@ export type {
   CalloutType,
   DecorationRange,
   HighlightDecoration,
+  InlineMathDecoration,
   PageRefDecoration,
   ParsedDecorations,
   PriorityLevel,

+ 2 - 0
packages/editor/src/lib/markdown-rules/index.ts

@@ -18,6 +18,7 @@ export {
   createBlockRefRule,
   createDelimitedRule,
   createHighlightRule,
+  createInlineMathRule,
   createInlineRules,
   createLinkRule,
   createPageRefRule,
@@ -33,6 +34,7 @@ export type {
   CalloutType,
   DecorationRange,
   HighlightDecoration,
+  InlineMathDecoration,
   MarkdownRule,
   PageRefDecoration,
   ParseContext,

+ 30 - 0
packages/editor/src/lib/markdown-rules/inline-rules.ts

@@ -13,6 +13,7 @@ import type { SyntaxNode } from "@lezer/common"
 import type {
   BlockRefDecoration,
   HighlightDecoration,
+  InlineMathDecoration,
   MarkdownRule,
   PageRefDecoration,
   ParseContext,
@@ -213,6 +214,34 @@ export function createTagRule(): MarkdownRule<TagDecoration> {
   }
 }
 
+export function createInlineMathRule(): MarkdownRule<InlineMathDecoration> {
+  return {
+    name: "inline-math",
+    nodeTypes: ["InlineMath"],
+    match(node) {
+      return node.type.name === "InlineMath"
+    },
+    run(node, ctx) {
+      const { from, to } = node
+      const source = ctx.doc.slice(from + 1, to - 1)
+      return {
+        type: "inline-math",
+        wrapperAttrs: { "data-math": source },
+        decorationRanges: [
+          { type: "hidden", from, to: from + 1 },
+          {
+            type: "content",
+            from: from + 1,
+            to: to - 1,
+            className: "md-math-inline",
+          },
+          { type: "hidden", from: to - 1, to },
+        ],
+      }
+    },
+  }
+}
+
 export function createHighlightRule(): MarkdownRule<HighlightDecoration> {
   return {
     name: "highlight",
@@ -273,5 +302,6 @@ export function createInlineRules(): MarkdownRule<unknown>[] {
     createBlockRefRule(),
     createTagRule(),
     createHighlightRule(),
+    createInlineMathRule(),
   ]
 }

+ 5 - 0
packages/editor/src/lib/markdown-rules/types.ts

@@ -66,6 +66,10 @@ export interface HighlightDecoration extends ParsedToken {
   type: "highlight"
 }
 
+export interface InlineMathDecoration extends ParsedToken {
+  type: "inline-math"
+}
+
 // ── Block decoration ─────────────────────────────────────────────────
 
 export type TaskState =
@@ -110,6 +114,7 @@ export interface ParsedDecorations {
     | BlockRefDecoration
     | TagDecoration
     | HighlightDecoration
+    | InlineMathDecoration
   )[]
   properties: PropertyInfo[]
   blocks: BlockDecoration[]

+ 9 - 1
packages/editor/src/lib/schema.ts

@@ -7,7 +7,7 @@
  */
 import { type NodeSpec, Schema } from "prosemirror-model"
 
-type DOMNode = "doc" | "paragraph" | "code_block" | "text"
+type DOMNode = "doc" | "paragraph" | "code_block" | "math_block" | "text"
 
 /** Node definitions */
 const nodes: Record<DOMNode, NodeSpec> = {
@@ -45,6 +45,14 @@ const nodes: Record<DOMNode, NodeSpec> = {
       ["code", 0],
     ],
   },
+  /** Display math block — rendered via KaTeX node view */
+  math_block: {
+    group: "block",
+    content: "text*",
+    code: true,
+    defining: true,
+    toDOM: () => ["div", { "data-math-block": "" }, 0],
+  },
   /** Text content */
   text: {
     group: "inline",

+ 25 - 0
pnpm-lock.yaml

@@ -111,6 +111,9 @@ importers:
       '@tailwindcss/vite':
         specifier: ^4.3.0
         version: 4.3.0([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
+      katex:
+        specifier: ^0.17.0
+        version: 0.17.0
       prosemirror-commands:
         specifier: ^1.7.1
         version: 1.7.1
@@ -136,6 +139,9 @@ importers:
       '@tsconfig/node24':
         specifier: ^24.0.4
         version: 24.0.4
+      '@types/katex':
+        specifier: ^0.16.8
+        version: 0.16.8
       '@types/node':
         specifier: ^25.9.0
         version: 25.9.1
@@ -1247,6 +1253,9 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==}
 
+  '@types/[email protected]':
+    resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+
   '@types/[email protected]':
     resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
 
@@ -1546,6 +1555,10 @@ packages:
     resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
     engines: {node: '>=14'}
 
+  [email protected]:
+    resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+    engines: {node: '>= 12'}
+
   [email protected]:
     resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
 
@@ -1870,6 +1883,10 @@ packages:
     engines: {node: '>=6'}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==}
+    hasBin: true
+
   [email protected]:
     resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
     engines: {node: '>= 8'}
@@ -4181,6 +4198,8 @@ snapshots:
 
   '@types/[email protected]': {}
 
+  '@types/[email protected]': {}
+
   '@types/[email protected]':
     dependencies:
       undici-types: 7.16.0
@@ -4507,6 +4526,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -4899,6 +4920,10 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      commander: 8.3.0
+
   [email protected]: {}
 
   [email protected]: {}