prosemirror-math 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ import { formatHTML } from 'diffable-html-snapshot'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { setupTest } from './testing'
5
+
6
+ describe('mathBlockSpec', () => {
7
+ it('can serialize to HTML', () => {
8
+ const { editor, n } = setupTest()
9
+ editor.set(n.doc(n.mathBlock('E = mc^2')))
10
+ const html = editor.getDocHTML()
11
+ expect(formatHTML(html)).toMatchInlineSnapshot(
12
+ `
13
+ "
14
+ <div>
15
+ <div class="prosekit-math-block">
16
+ <pre>
17
+ <code>
18
+ E = mc^2
19
+ </code>
20
+ </pre>
21
+ </div>
22
+ </div>
23
+ "
24
+ `,
25
+ )
26
+ })
27
+
28
+ it('can serialize to JSON', () => {
29
+ const { editor, n } = setupTest()
30
+ editor.set(n.doc(n.mathBlock('x^2 + y^2 = z^2')))
31
+ const json = editor.getDocJSON()
32
+ expect(json).toMatchInlineSnapshot(`
33
+ {
34
+ "content": [
35
+ {
36
+ "content": [
37
+ {
38
+ "text": "x^2 + y^2 = z^2",
39
+ "type": "text",
40
+ },
41
+ ],
42
+ "type": "mathBlock",
43
+ },
44
+ ],
45
+ "type": "doc",
46
+ }
47
+ `)
48
+ })
49
+
50
+ it('can handle empty math block', () => {
51
+ const { editor, n } = setupTest()
52
+ editor.set(n.doc(n.mathBlock()))
53
+ const json = editor.getDocJSON()
54
+ expect(json).toMatchInlineSnapshot(`
55
+ {
56
+ "content": [
57
+ {
58
+ "type": "mathBlock",
59
+ },
60
+ ],
61
+ "type": "doc",
62
+ }
63
+ `)
64
+ })
65
+ })
@@ -0,0 +1,22 @@
1
+ import type { NodeSpec } from 'prosemirror-model'
2
+
3
+ export const mathBlockSpec: NodeSpec = {
4
+ atom: false,
5
+ group: 'block',
6
+ content: 'text*',
7
+ code: true,
8
+ toDOM() {
9
+ return [
10
+ 'div',
11
+ {
12
+ class: 'prosekit-math-block',
13
+ },
14
+ ['pre', ['code', 0]],
15
+ ]
16
+ },
17
+ parseDOM: [
18
+ {
19
+ tag: 'div.prosekit-math-block',
20
+ },
21
+ ],
22
+ }
@@ -0,0 +1,59 @@
1
+ import { formatHTML } from 'diffable-html-snapshot'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { setupTest } from './testing'
5
+
6
+ describe('createMathBlockView', () => {
7
+ it('renders the math block DOM structure', () => {
8
+ const { editor, n } = setupTest()
9
+ editor.set(n.doc(n.mathBlock('x^2')))
10
+
11
+ const dom = editor.view.dom
12
+ const mathBlock = dom.querySelector('.prosekit-math-block')
13
+ expect(mathBlock).toBeTruthy()
14
+ expect(mathBlock?.querySelector('.prosekit-math-source')).toBeTruthy()
15
+ expect(mathBlock?.querySelector('.prosekit-math-display')).toBeTruthy()
16
+ })
17
+
18
+ it('renders Temml output in the display element', () => {
19
+ const { editor, n } = setupTest()
20
+ editor.set(n.doc(n.mathBlock('x^2')))
21
+
22
+ const display = editor.view.dom.querySelector('.prosekit-math-display')
23
+ const html = formatHTML(display?.innerHTML || '')
24
+ expect(html).toMatchInlineSnapshot(`
25
+ "
26
+ <math
27
+ class="tml-display"
28
+ display="block"
29
+ style="display: block math;"
30
+ >
31
+ <msup>
32
+ <mi>
33
+ x
34
+ </mi>
35
+ <mn class="tml-sml-pad">
36
+ 2
37
+ </mn>
38
+ </msup>
39
+ </math>
40
+ "
41
+ `)
42
+ })
43
+
44
+ it('updates display when content changes', () => {
45
+ const { editor, n } = setupTest()
46
+ editor.set(n.doc(n.mathBlock('x^2')))
47
+
48
+ const display = editor.view.dom.querySelector('.prosekit-math-display')
49
+ const initialHTML = display?.innerHTML
50
+
51
+ // Dispatch a transaction to change content
52
+ const { state } = editor.view
53
+ const tr = state.tr.insertText(' + y^2', 3)
54
+ editor.view.dispatch(tr)
55
+
56
+ const updatedHTML = display?.innerHTML
57
+ expect(updatedHTML).not.toBe(initialHTML)
58
+ })
59
+ })
@@ -0,0 +1,40 @@
1
+ import type { Node as ProseMirrorNode } from 'prosemirror-model'
2
+ import type { NodeView } from 'prosemirror-view'
3
+
4
+ import { createElement } from './create-element'
5
+
6
+ /**
7
+ * The function to render a math block.
8
+ *
9
+ * @param code - The code of the math block. For example, a TeX expression.
10
+ * @param element - A `<div>` element to render the math block.
11
+ */
12
+ export type RenderMathBlock = (code: string, element: HTMLElement) => void
13
+
14
+ export function createMathBlockView(node: ProseMirrorNode, renderMathBlock: RenderMathBlock): NodeView {
15
+ const code = createElement('code')
16
+ const source = createElement('pre', 'prosekit-math-source', code)
17
+ const display = createElement('div', 'prosekit-math-display', source)
18
+ const dom = createElement('div', 'prosekit-math-block', source, display)
19
+
20
+ let prevText = ''
21
+
22
+ const render = (node: ProseMirrorNode) => {
23
+ const nodeText = node.textContent
24
+ if (prevText !== nodeText) {
25
+ prevText = nodeText
26
+ renderMathBlock(nodeText, display)
27
+ }
28
+ }
29
+
30
+ render(node)
31
+
32
+ return {
33
+ dom,
34
+ contentDOM: code,
35
+ update: (node) => {
36
+ render(node)
37
+ return true
38
+ },
39
+ }
40
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { userEvent } from 'vitest/browser'
3
+
4
+ import { MATH_INPUT_REGEXP } from './math-inline-input-rule'
5
+ import { setupTest } from './testing'
6
+
7
+ describe('MATH_INPUT_REGEXP', () => {
8
+ const regexp = new RegExp(MATH_INPUT_REGEXP)
9
+
10
+ const cases: Array<[input: string, delimiter: string | null, captured: string | null]> = [
11
+ // Single dollar: inline math
12
+ ['$x$', '$', 'x'],
13
+ ['$x^2$', '$', 'x^2'],
14
+ ['$a+b=c$', '$', 'a+b=c'],
15
+ [String.raw`$\alpha$`, '$', String.raw`\alpha`],
16
+ [String.raw`$\frac{a}{b}$`, '$', String.raw`\frac{a}{b}`],
17
+ ['hello $x$', '$', 'x'],
18
+ ['The formula $E=mc^2$', '$', 'E=mc^2'],
19
+ ['$a + b$', '$', 'a + b'],
20
+ ['$a$', '$', 'a'],
21
+
22
+ // Double dollar: block math
23
+ ['$$x$$', '$$', 'x'],
24
+ ['$$x^2$$', '$$', 'x^2'],
25
+ ['$$a+b=c$$', '$$', 'a+b=c'],
26
+ [String.raw`$$\alpha$$`, '$$', String.raw`\alpha`],
27
+ ['$$a + b$$', '$$', 'a + b'],
28
+
29
+ // Empty content — should not match
30
+ ['$$', null, null],
31
+ ['$$$$', null, null],
32
+
33
+ // Whitespace at boundaries — should not match
34
+ ['$ x$', null, null],
35
+ ['$x $', null, null],
36
+ ['$ x $', null, null],
37
+ ['$$ x$$', null, null],
38
+ ['$$x $$', null, null],
39
+
40
+ // Mismatched delimiters — should not match
41
+ ['$x$$', null, null],
42
+ ['$$x$', null, null],
43
+ ['$$$x=2$$', null, null],
44
+
45
+ // No dollar signs
46
+ ['hello', null, null],
47
+ ]
48
+
49
+ it.each(cases)('should handle %s', (input, expectedDelimiter, expectedContent) => {
50
+ const match = regexp.exec(input)
51
+ const delimiter = match?.[1] ?? null
52
+ const captured = match?.[2] ?? null
53
+ expect(delimiter).toEqual(expectedDelimiter)
54
+ expect(captured).toEqual(expectedContent)
55
+ })
56
+ })
57
+
58
+ describe('defineMathInlineInputRule', () => {
59
+ const { editor, n } = setupTest()
60
+
61
+ it('should create mathInline when typing $...$', async () => {
62
+ editor.set(n.doc(n.p('<a>')))
63
+
64
+ await userEvent.keyboard('$x^2')
65
+ expect(editor.view.state.doc.toJSON()).toEqual(
66
+ n.doc(n.p('$x^2')).toJSON(),
67
+ )
68
+
69
+ await userEvent.keyboard('$')
70
+ expect(editor.view.state.doc.toJSON()).toEqual(
71
+ n.doc(n.p(n.mathInline('x^2'))).toJSON(),
72
+ )
73
+ })
74
+
75
+ it('should create mathInline when typing $$...$$', async () => {
76
+ editor.set(n.doc(n.p('<a>')))
77
+
78
+ await userEvent.keyboard('$$x^2$')
79
+ expect(editor.view.state.doc.toJSON()).toEqual(
80
+ n.doc(n.p('$$x^2$')).toJSON(),
81
+ )
82
+
83
+ await userEvent.keyboard('$')
84
+ expect(editor.view.state.doc.toJSON()).toEqual(
85
+ n.doc(n.p(n.mathInline('x^2'))).toJSON(),
86
+ )
87
+ })
88
+
89
+ it('should create mathInline with multi-character content', async () => {
90
+ editor.set(n.doc(n.p('<a>')))
91
+
92
+ await userEvent.keyboard('$a+b=c$')
93
+ expect(editor.view.state.doc.toJSON()).toEqual(
94
+ n.doc(n.p(n.mathInline('a+b=c'))).toJSON(),
95
+ )
96
+ })
97
+
98
+ it('should not trigger with empty content $$', async () => {
99
+ editor.set(n.doc(n.p('<a>')))
100
+
101
+ await userEvent.keyboard('$$')
102
+ expect(editor.view.state.doc.toJSON()).toEqual(
103
+ n.doc(n.p('$$')).toJSON(),
104
+ )
105
+ })
106
+
107
+ it('should not trigger with spaces at boundaries', async () => {
108
+ editor.set(n.doc(n.p('<a>')))
109
+
110
+ await userEvent.keyboard('$ x^2 $')
111
+ expect(editor.view.state.doc.toJSON()).toEqual(
112
+ n.doc(n.p('$ x^2 $')).toJSON(),
113
+ )
114
+ })
115
+
116
+ it('should not trigger inside a code block', async () => {
117
+ editor.set(n.doc(n.codeBlock('<a>')))
118
+
119
+ await userEvent.keyboard('$x^2$')
120
+ expect(editor.view.state.doc.toJSON()).toEqual(
121
+ n.doc(n.codeBlock('$x^2$')).toJSON(),
122
+ )
123
+ })
124
+ })
@@ -0,0 +1,37 @@
1
+ /* eslint-disable unicorn/prefer-string-raw -- Don't use String.raw here for better bundler minification */
2
+
3
+ import { supportsRegexLookbehind } from '@ocavue/utils'
4
+ import { InputRule } from 'prosemirror-inputrules'
5
+
6
+ // Matches text wrapped in `$` or `$$`, e.g. `$x^2$` or `$$x^2$$`.
7
+ export const MATH_INPUT_REGEXP: string = (
8
+ // Don't allow $ before the opening delimiter
9
+ (supportsRegexLookbehind() ? '(?<!\\$)' : '')
10
+ // capture group 1: opening delimiter (`$` or `$$`)
11
+ + '(\\$\\$?)'
12
+ // capture group 2: the math content
13
+ // - a single non-whitespace, non-`$` character
14
+ // - optionally followed by any non-`$` characters and a final non-whitespace, non-`$` character
15
+ + '([^\\s$](?:[^$]*[^\\s$])?)'
16
+ // backreference: closing delimiter must match the opening
17
+ + '\\1'
18
+ // end of input (required by ProseMirror InputRule)
19
+ + '$'
20
+ )
21
+
22
+ export function createMathInlineInputRule(
23
+ nodeType: string,
24
+ ): InputRule {
25
+ return new InputRule(new RegExp(MATH_INPUT_REGEXP), (state, match, start, end) => {
26
+ const { tr, schema } = state
27
+ const mathText = match[2]
28
+ if (!mathText) return null
29
+
30
+ const type = schema.nodes[nodeType]
31
+ if (!type) return null
32
+
33
+ const node = type.create(null, schema.text(mathText))
34
+ tr.replaceWith(start, end, node)
35
+ return tr
36
+ })
37
+ }
@@ -0,0 +1,110 @@
1
+ import { formatHTML } from 'diffable-html-snapshot'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { setupTest } from './testing'
5
+
6
+ describe('mathInlineSpec', () => {
7
+ it('can serialize to HTML', () => {
8
+ const { editor, n } = setupTest()
9
+ editor.set(n.doc(n.paragraph(n.mathInline('x^2'))))
10
+ const html = editor.getDocHTML()
11
+ expect(formatHTML(html)).toMatchInlineSnapshot(
12
+ `
13
+ "
14
+ <div>
15
+ <p>
16
+ <span class="prosekit-math-inline">
17
+ <code>
18
+ x^2
19
+ </code>
20
+ </span>
21
+ </p>
22
+ </div>
23
+ "
24
+ `,
25
+ )
26
+ })
27
+
28
+ it('can serialize to JSON', () => {
29
+ const { editor, n } = setupTest()
30
+ editor.set(n.doc(n.paragraph(n.mathInline(String.raw`\alpha + \beta`))))
31
+ const json = editor.getDocJSON()
32
+ expect(json).toMatchInlineSnapshot(String.raw`
33
+ {
34
+ "content": [
35
+ {
36
+ "content": [
37
+ {
38
+ "content": [
39
+ {
40
+ "text": "\alpha + \beta",
41
+ "type": "text",
42
+ },
43
+ ],
44
+ "type": "mathInline",
45
+ },
46
+ ],
47
+ "type": "paragraph",
48
+ },
49
+ ],
50
+ "type": "doc",
51
+ }
52
+ `)
53
+ })
54
+
55
+ it('can handle empty math inline', () => {
56
+ const { editor, n } = setupTest()
57
+ editor.set(n.doc(n.paragraph(n.mathInline())))
58
+ const json = editor.getDocJSON()
59
+ expect(json).toMatchInlineSnapshot(`
60
+ {
61
+ "content": [
62
+ {
63
+ "content": [
64
+ {
65
+ "type": "mathInline",
66
+ },
67
+ ],
68
+ "type": "paragraph",
69
+ },
70
+ ],
71
+ "type": "doc",
72
+ }
73
+ `)
74
+ })
75
+
76
+ it('can be mixed with regular text', () => {
77
+ const { editor, n } = setupTest()
78
+ editor.set(n.doc(n.paragraph('The formula ', n.mathInline('E=mc^2'), ' is famous.')))
79
+ const json = editor.getDocJSON()
80
+ expect(json).toMatchInlineSnapshot(`
81
+ {
82
+ "content": [
83
+ {
84
+ "content": [
85
+ {
86
+ "text": "The formula ",
87
+ "type": "text",
88
+ },
89
+ {
90
+ "content": [
91
+ {
92
+ "text": "E=mc^2",
93
+ "type": "text",
94
+ },
95
+ ],
96
+ "type": "mathInline",
97
+ },
98
+ {
99
+ "text": " is famous.",
100
+ "type": "text",
101
+ },
102
+ ],
103
+ "type": "paragraph",
104
+ },
105
+ ],
106
+ "type": "doc",
107
+ }
108
+ `)
109
+ })
110
+ })
@@ -0,0 +1,24 @@
1
+ import type { NodeSpec } from 'prosemirror-model'
2
+
3
+ export const mathInlineSpec: NodeSpec = {
4
+ atom: false,
5
+ inline: true,
6
+ group: 'inline',
7
+ content: 'text*',
8
+ selectable: false,
9
+ code: true,
10
+ toDOM() {
11
+ return [
12
+ 'span',
13
+ {
14
+ class: 'prosekit-math-inline',
15
+ },
16
+ ['code', 0],
17
+ ]
18
+ },
19
+ parseDOM: [
20
+ {
21
+ tag: 'span.prosekit-math-inline',
22
+ },
23
+ ],
24
+ }
@@ -0,0 +1,47 @@
1
+ import { formatHTML } from 'diffable-html-snapshot'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { setupTest } from './testing'
5
+
6
+ describe('createMathInlineView', () => {
7
+ it('renders the math inline DOM structure', () => {
8
+ const { editor, n } = setupTest()
9
+ editor.set(n.doc(n.paragraph(n.mathInline('x^2'))))
10
+
11
+ const dom = editor.view.dom
12
+ const mathInline = dom.querySelector('.prosekit-math-inline')
13
+ expect(mathInline).toBeTruthy()
14
+ expect(mathInline?.querySelector('.prosekit-math-source')).toBeTruthy()
15
+ expect(mathInline?.querySelector('.prosekit-math-display')).toBeTruthy()
16
+ })
17
+
18
+ it('renders Temml output in the display element', () => {
19
+ const { editor, n } = setupTest()
20
+ editor.set(n.doc(n.paragraph(n.mathInline('x^2'))))
21
+
22
+ const display = editor.view.dom.querySelector('.prosekit-math-display')
23
+ const html = formatHTML(display?.innerHTML || '')
24
+ expect(html).toMatchInlineSnapshot(`
25
+ "
26
+ <math>
27
+ <msup>
28
+ <mi>
29
+ x
30
+ </mi>
31
+ <mn class="tml-sml-pad">
32
+ 2
33
+ </mn>
34
+ </msup>
35
+ </math>
36
+ "
37
+ `)
38
+ })
39
+
40
+ it('uses span elements for inline math', () => {
41
+ const { editor, n } = setupTest()
42
+ editor.set(n.doc(n.paragraph(n.mathInline('x'))))
43
+
44
+ const mathInline = editor.view.dom.querySelector('.prosekit-math-inline')
45
+ expect(mathInline?.tagName).toBe('SPAN')
46
+ })
47
+ })
@@ -0,0 +1,39 @@
1
+ import type { Node as ProseMirrorNode } from 'prosemirror-model'
2
+ import type { NodeView } from 'prosemirror-view'
3
+
4
+ import { createElement } from './create-element'
5
+
6
+ /**
7
+ * The function to render a math inline.
8
+ *
9
+ * @param code - The code of the math inline. For example, a TeX expression.
10
+ * @param element - A `<span>` element to render the math inline.
11
+ */
12
+ export type RenderMathInline = (code: string, element: HTMLElement) => void
13
+
14
+ export function createMathInlineView(node: ProseMirrorNode, renderMathInline: RenderMathInline): NodeView {
15
+ const source = createElement('code', 'prosekit-math-source')
16
+ const display = createElement('span', 'prosekit-math-display', source)
17
+ const dom = createElement('span', 'prosekit-math-inline', source, display)
18
+
19
+ let prevText = ''
20
+
21
+ const render = (node: ProseMirrorNode) => {
22
+ const nodeText = node.textContent
23
+ if (prevText !== nodeText) {
24
+ prevText = nodeText
25
+ renderMathInline(nodeText, display)
26
+ }
27
+ }
28
+
29
+ render(node)
30
+
31
+ return {
32
+ dom,
33
+ contentDOM: source,
34
+ update: (node) => {
35
+ render(node)
36
+ return true
37
+ },
38
+ }
39
+ }