Skip to content

Commit 88a9093

Browse files
PttCodingManclaude
andcommitted
fix: preserve math syntax through the Milkdown editor.
Pasting `$...$` / `$$...$$` into the editor previously fell through to the CommonMark parser, which treated underscores inside math as emphasis and re-escaped them on save (`\int_{...}` → `\int\_{...}`), breaking subscripts on render. Add a dedicated math plugin built on remark-math with node schemas for inline/block math and KaTeX-rendering node views that let the user click to edit the source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31eea05 commit 88a9093

5 files changed

Lines changed: 286 additions & 3 deletions

File tree

frontend/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"react-force-graph-2d": "^1.29.1",
3636
"react-force-graph-3d": "^1.29.1",
3737
"react-router-dom": "^7.14.0",
38+
"remark-math": "^6.0.0",
3839
"three": "^0.184.0",
3940
"zustand": "^5.0.12"
4041
},

frontend/src/components/Editor/Editor.jsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TextSelection, Plugin, PluginKey } from '@milkdown/kit/prose/state'
1111
import { $prose } from '@milkdown/kit/utils'
1212
import api from '../../api/client'
1313
import { wikilink } from './wikilink'
14+
import { math, mathBlockSchema } from './math'
1415

1516
const SLASH_ITEMS = [
1617
{ id: 'h1', label: 'Heading 1', icon: 'H1', desc: 'Big section heading' },
@@ -56,9 +57,9 @@ function executeSlashCommand(ctx, id, view, drawioHandlerRef, mediaHandlerRef) {
5657

5758
if (id === 'math' && view) {
5859
const { state, dispatch } = view
59-
const { from } = state.selection
60-
const text = '\n$$\nE = mc^2\n$$\n'
61-
dispatch(state.tr.insertText(text, from))
60+
const mathType = mathBlockSchema.type(ctx)
61+
const node = mathType.create({ value: 'E = mc^2' })
62+
dispatch(state.tr.replaceSelectionWith(node))
6263
return
6364
}
6465

@@ -478,6 +479,7 @@ const Editor = forwardRef(function Editor({ defaultValue = '', onChange, onDrawi
478479
})
479480
.use(commonmark)
480481
.use(gfm)
482+
.use(math)
481483
.use(listener)
482484
.use(clipboard)
483485
.use(history)
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { $nodeSchema, $remark, $view } from '@milkdown/kit/utils'
2+
import katex from 'katex'
3+
import 'katex/dist/katex.min.css'
4+
import remarkMath from 'remark-math'
5+
6+
const MATH_BLOCK_ID = 'math_block'
7+
const MATH_INLINE_ID = 'math_inline'
8+
9+
const renderInto = (dom, value, displayMode) => {
10+
try {
11+
katex.render(value, dom, { throwOnError: false, displayMode })
12+
} catch {
13+
dom.textContent = value || ''
14+
}
15+
}
16+
17+
export const remarkMathPlugin = $remark('remarkMath', () => remarkMath)
18+
19+
export const mathBlockSchema = $nodeSchema(MATH_BLOCK_ID, () => ({
20+
group: 'block',
21+
atom: true,
22+
defining: true,
23+
draggable: false,
24+
selectable: true,
25+
attrs: {
26+
value: { default: '' },
27+
},
28+
parseDOM: [
29+
{
30+
tag: `div[data-type="${MATH_BLOCK_ID}"]`,
31+
getAttrs: (dom) => ({ value: dom.dataset.value ?? '' }),
32+
},
33+
],
34+
toDOM: (node) => {
35+
const dom = document.createElement('div')
36+
dom.dataset.type = MATH_BLOCK_ID
37+
dom.dataset.value = node.attrs.value ?? ''
38+
dom.className = 'math-block-node'
39+
renderInto(dom, node.attrs.value ?? '', true)
40+
return dom
41+
},
42+
parseMarkdown: {
43+
match: (node) => node.type === 'math',
44+
runner: (state, node, type) => {
45+
state.addNode(type, { value: node.value ?? '' })
46+
},
47+
},
48+
toMarkdown: {
49+
match: (node) => node.type.name === MATH_BLOCK_ID,
50+
runner: (state, node) => {
51+
state.addNode('math', undefined, node.attrs.value ?? '')
52+
},
53+
},
54+
}))
55+
56+
export const mathInlineSchema = $nodeSchema(MATH_INLINE_ID, () => ({
57+
group: 'inline',
58+
inline: true,
59+
atom: true,
60+
draggable: false,
61+
selectable: true,
62+
attrs: {
63+
value: { default: '' },
64+
},
65+
parseDOM: [
66+
{
67+
tag: `span[data-type="${MATH_INLINE_ID}"]`,
68+
getAttrs: (dom) => ({ value: dom.dataset.value ?? '' }),
69+
},
70+
],
71+
toDOM: (node) => {
72+
const dom = document.createElement('span')
73+
dom.dataset.type = MATH_INLINE_ID
74+
dom.dataset.value = node.attrs.value ?? ''
75+
dom.className = 'math-inline-node'
76+
renderInto(dom, node.attrs.value ?? '', false)
77+
return dom
78+
},
79+
parseMarkdown: {
80+
match: (node) => node.type === 'inlineMath',
81+
runner: (state, node, type) => {
82+
state.addNode(type, { value: node.value ?? '' })
83+
},
84+
},
85+
toMarkdown: {
86+
match: (node) => node.type.name === MATH_INLINE_ID,
87+
runner: (state, node) => {
88+
state.addNode('inlineMath', undefined, node.attrs.value ?? '')
89+
},
90+
},
91+
}))
92+
93+
// Shared editable node view factory. `displayMode` controls KaTeX display mode.
94+
function createMathNodeView(displayMode, tag) {
95+
return () => (node, view, getPos) => {
96+
const dom = document.createElement(tag)
97+
dom.className = displayMode ? 'math-block-node' : 'math-inline-node'
98+
dom.dataset.type = displayMode ? MATH_BLOCK_ID : MATH_INLINE_ID
99+
dom.contentEditable = 'false'
100+
101+
let currentValue = node.attrs.value ?? ''
102+
let editing = false
103+
let editor = null
104+
105+
const paintMath = () => {
106+
dom.innerHTML = ''
107+
const inner = document.createElement(displayMode ? 'div' : 'span')
108+
inner.className = 'math-render'
109+
renderInto(inner, currentValue, displayMode)
110+
if (!currentValue) {
111+
inner.textContent = displayMode ? '(empty math block — click to edit)' : '(empty math)'
112+
inner.classList.add('math-empty')
113+
}
114+
dom.appendChild(inner)
115+
dom.dataset.value = currentValue
116+
}
117+
118+
const commit = (next) => {
119+
editing = false
120+
const pos = typeof getPos === 'function' ? getPos() : null
121+
if (pos != null && next !== currentValue) {
122+
const tr = view.state.tr.setNodeMarkup(pos, undefined, { value: next })
123+
view.dispatch(tr)
124+
} else {
125+
currentValue = next
126+
paintMath()
127+
}
128+
}
129+
130+
const cancelEdit = () => {
131+
editing = false
132+
paintMath()
133+
view.focus()
134+
}
135+
136+
const startEdit = () => {
137+
if (editing) return
138+
editing = true
139+
dom.innerHTML = ''
140+
editor = document.createElement('textarea')
141+
editor.className = displayMode ? 'math-block-editor' : 'math-inline-editor'
142+
editor.value = currentValue
143+
editor.spellcheck = false
144+
editor.autocapitalize = 'off'
145+
editor.autocorrect = 'off'
146+
if (displayMode) {
147+
editor.rows = Math.max(2, (currentValue.match(/\n/g)?.length ?? 0) + 1)
148+
}
149+
editor.addEventListener('input', () => {
150+
if (displayMode) {
151+
editor.rows = Math.max(2, (editor.value.match(/\n/g)?.length ?? 0) + 1)
152+
}
153+
})
154+
editor.addEventListener('keydown', (e) => {
155+
if (e.key === 'Escape') {
156+
e.preventDefault()
157+
cancelEdit()
158+
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
159+
e.preventDefault()
160+
commit(editor.value)
161+
view.focus()
162+
} else if (!displayMode && e.key === 'Enter') {
163+
// Inline math: Enter commits (no newlines in inline math).
164+
e.preventDefault()
165+
commit(editor.value)
166+
view.focus()
167+
}
168+
})
169+
editor.addEventListener('blur', () => {
170+
if (editing) commit(editor.value)
171+
})
172+
dom.appendChild(editor)
173+
requestAnimationFrame(() => {
174+
editor.focus()
175+
editor.select()
176+
})
177+
}
178+
179+
dom.addEventListener('mousedown', (e) => {
180+
if (editing) return
181+
e.preventDefault()
182+
startEdit()
183+
})
184+
185+
paintMath()
186+
187+
return {
188+
dom,
189+
update(updated) {
190+
const expected = displayMode ? MATH_BLOCK_ID : MATH_INLINE_ID
191+
if (updated.type.name !== expected) return false
192+
const nextValue = updated.attrs.value ?? ''
193+
if (nextValue !== currentValue) {
194+
currentValue = nextValue
195+
if (!editing) paintMath()
196+
else if (editor) editor.value = nextValue
197+
}
198+
return true
199+
},
200+
stopEvent: () => editing,
201+
ignoreMutations: () => true,
202+
destroy() {
203+
editor = null
204+
},
205+
}
206+
}
207+
}
208+
209+
export const mathBlockView = $view(mathBlockSchema, createMathNodeView(true, 'div'))
210+
export const mathInlineView = $view(mathInlineSchema, createMathNodeView(false, 'span'))
211+
212+
export const math = [
213+
remarkMathPlugin,
214+
mathBlockSchema,
215+
mathInlineSchema,
216+
mathBlockView,
217+
mathInlineView,
218+
].flat()

frontend/src/index.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,67 @@ mark {
511511
font-size: 0.85em;
512512
}
513513

514+
/* Editor math node views */
515+
.math-block-node {
516+
display: block;
517+
margin: 0.75em 0;
518+
padding: 0.5em 0.75em;
519+
border-radius: 6px;
520+
background: rgba(0, 0, 0, 0.02);
521+
cursor: pointer;
522+
text-align: center;
523+
overflow-x: auto;
524+
}
525+
.math-block-node:hover {
526+
background: rgba(0, 0, 0, 0.05);
527+
}
528+
.math-inline-node {
529+
cursor: pointer;
530+
padding: 0 0.1em;
531+
border-radius: 3px;
532+
}
533+
.math-inline-node:hover {
534+
background: rgba(0, 0, 0, 0.05);
535+
}
536+
.math-empty {
537+
color: #888;
538+
font-style: italic;
539+
}
540+
.math-block-editor {
541+
display: block;
542+
width: 100%;
543+
min-height: 3em;
544+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
545+
font-size: 0.9em;
546+
line-height: 1.45;
547+
padding: 0.5em 0.75em;
548+
border: 1px solid #d0d7de;
549+
border-radius: 6px;
550+
background: #f6f8fa;
551+
color: inherit;
552+
resize: vertical;
553+
outline: none;
554+
}
555+
.math-block-editor:focus {
556+
border-color: #0969da;
557+
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.2);
558+
}
559+
.math-inline-editor {
560+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
561+
font-size: 0.9em;
562+
padding: 0 0.35em;
563+
border: 1px solid #d0d7de;
564+
border-radius: 3px;
565+
background: #f6f8fa;
566+
color: inherit;
567+
min-width: 6em;
568+
outline: none;
569+
}
570+
.math-inline-editor:focus {
571+
border-color: #0969da;
572+
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.2);
573+
}
574+
514575
/* Draw.io embeds */
515576
.drawio-embed {
516577
margin: 1em 0;

0 commit comments

Comments
 (0)