@tiptap/extension-mathematics 3.0.0-beta.16 → 3.0.0-beta.18

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.
package/src/utils.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import type { Transaction } from '@tiptap/pm/state'
3
+
4
+ /**
5
+ * Regular expression to match LaTeX math strings wrapped in single dollar signs.
6
+ * This should not catch dollar signs which are not part of a math expression,
7
+ * like those used for currency or other purposes.
8
+ * It ensures that the dollar signs are not preceded or followed by digits,
9
+ * allowing for proper identification of inline math expressions.
10
+ *
11
+ * - `$x^2 + y^2 = z^2$` will match
12
+ * - `This is $inline math$ in text.` will match
13
+ * - `This is $100$ dollars.` will not match (as it is not a math expression)
14
+ * - `This is $x^2 + y^2 = z^2$ and $100$ dollars.` will match both math expressions
15
+ */
16
+ export const mathMigrationRegex = /(?<!\d)\$(?!\$)(?:[^$\n]|\\\$)*?(?<!\\)\$(?!\d)/g
17
+
18
+ /**
19
+ * Creates a transaction that migrates existing math strings in the document to new math nodes.
20
+ * This function traverses the document and replaces LaTeX math syntax (wrapped in single dollar signs)
21
+ * with proper inline math nodes, preserving the mathematical content.
22
+ *
23
+ * @param editor - The editor instance containing the schema and configuration
24
+ * @param tr - The transaction to modify with the migration operations
25
+ * @returns The modified transaction with math string replacements
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const editor = new Editor({ ... })
30
+ * const tr = editor.state.tr
31
+ * const updatedTr = createMathMigrateTransaction(editor, tr)
32
+ * editor.view.dispatch(updatedTr)
33
+ * ```
34
+ */
35
+ export function createMathMigrateTransaction(editor: Editor, tr: Transaction, regex: RegExp = mathMigrationRegex) {
36
+ // we traverse the document and replace all math nodes with the new math nodes
37
+ tr.doc.descendants((node, pos) => {
38
+ if (!node.isText || !node.text || !node.text.includes('$')) {
39
+ return
40
+ }
41
+
42
+ const { text } = node
43
+
44
+ const match = node.text.match(regex)
45
+ if (!match) {
46
+ return
47
+ }
48
+
49
+ match.forEach(mathMatch => {
50
+ const start = text.indexOf(mathMatch)
51
+ const end = start + mathMatch.length
52
+
53
+ const from = tr.mapping.map(pos + start)
54
+
55
+ const $from = tr.doc.resolve(from)
56
+ const parent = $from.parent
57
+ const index = $from.index()
58
+
59
+ const { inlineMath } = editor.schema.nodes
60
+
61
+ if (!parent.canReplaceWith(index, index + 1, inlineMath)) {
62
+ return
63
+ }
64
+
65
+ // Replace the math syntax with a new math node
66
+ tr.replaceWith(
67
+ tr.mapping.map(pos + start),
68
+ tr.mapping.map(pos + end),
69
+ inlineMath.create({ latex: mathMatch.slice(1, -1) }),
70
+ )
71
+ })
72
+ })
73
+
74
+ // don't add to history
75
+ tr.setMeta('addToHistory', false)
76
+ return tr
77
+ }
78
+
79
+ /**
80
+ * Migrates existing math strings in the editor document to math nodes.
81
+ * This function creates and dispatches a transaction that converts LaTeX math syntax
82
+ * (text wrapped in single dollar signs) into proper inline math nodes. The migration
83
+ * happens immediately and is not added to the editor's history.
84
+ *
85
+ * @param editor - The editor instance to perform the migration on
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * const editor = new Editor({
90
+ * extensions: [Mathematics],
91
+ * content: 'This is inline math: $x^2 + y^2 = z^2$ in text.'
92
+ * })
93
+ *
94
+ * // Math strings will be automatically migrated to math nodes
95
+ * migrateMathStrings(editor)
96
+ * ```
97
+ */
98
+ export function migrateMathStrings(editor: Editor, regex: RegExp = mathMigrationRegex) {
99
+ const tr = createMathMigrateTransaction(editor, editor.state.tr, regex)
100
+ editor.view.dispatch(tr)
101
+ }
@@ -1,205 +0,0 @@
1
- import { getChangedRanges } from '@tiptap/core'
2
- import type { EditorState, Transaction } from '@tiptap/pm/state'
3
- import { Plugin, PluginKey } from '@tiptap/pm/state'
4
- import { Decoration, DecorationSet } from '@tiptap/pm/view'
5
- import katex from 'katex'
6
-
7
- import type { MathematicsOptionsWithEditor } from './types.js'
8
-
9
- type DecoSpec = {
10
- isEditable: boolean
11
- isEditing: boolean
12
- katexOptions: MathematicsOptionsWithEditor['katexOptions']
13
- content: string
14
- }
15
-
16
- type Deco = Omit<Decoration, 'spec'> & { spec: DecoSpec }
17
-
18
- type PluginState =
19
- | { decorations: DecorationSet; isEditable: boolean }
20
- | { decorations: undefined; isEditable: undefined }
21
-
22
- /**
23
- * Get the range of positions that have been affected by a transaction
24
- */
25
- function getAffectedRange(
26
- newState: EditorState,
27
- previousPluginState: PluginState,
28
- isEditable: boolean,
29
- tr: Transaction,
30
- state: EditorState,
31
- ) {
32
- const docSize = newState.doc.nodeSize - 2
33
- let minFrom = 0
34
- let maxTo = docSize
35
-
36
- if (previousPluginState.isEditable !== isEditable) {
37
- // When the editable state changes, run on all nodes just to be safe
38
- minFrom = 0
39
- maxTo = docSize
40
- } else if (tr.docChanged) {
41
- // When the document changes, only run on the nodes that have changed
42
- minFrom = docSize
43
- maxTo = 0
44
-
45
- getChangedRanges(tr).forEach(range => {
46
- // Purposefully over scan the range to ensure we catch all decorations
47
- minFrom = Math.min(minFrom, range.newRange.from - 1, range.oldRange.from - 1)
48
- maxTo = Math.max(maxTo, range.newRange.to + 1, range.oldRange.to + 1)
49
- })
50
- } else if (tr.selectionSet) {
51
- const { $from, $to } = state.selection
52
- const { $from: $newFrom, $to: $newTo } = newState.selection
53
-
54
- // When the selection changes, run on all the nodes between the old and new selection
55
- minFrom = Math.min(
56
- // Purposefully over scan the range to ensure we catch all decorations
57
- $from.depth === 0 ? 0 : $from.before(),
58
- $newFrom.depth === 0 ? 0 : $newFrom.before(),
59
- )
60
- maxTo = Math.max($to.depth === 0 ? maxTo : $to.after(), $newTo.depth === 0 ? maxTo : $newTo.after())
61
- }
62
-
63
- return {
64
- minFrom: Math.max(minFrom, 0),
65
- maxTo: Math.min(maxTo, docSize),
66
- }
67
- }
68
-
69
- export const MathematicsPlugin = (options: MathematicsOptionsWithEditor) => {
70
- const { regex, katexOptions = {}, editor, shouldRender } = options
71
-
72
- return new Plugin<PluginState>({
73
- key: new PluginKey('mathematics'),
74
-
75
- state: {
76
- init() {
77
- return { decorations: undefined, isEditable: undefined }
78
- },
79
- apply(tr, previousPluginState, state, newState) {
80
- if (!tr.docChanged && !tr.selectionSet && previousPluginState.decorations) {
81
- // Just reuse the existing decorations, since nothing should have changed
82
- return previousPluginState
83
- }
84
-
85
- const nextDecorationSet = (previousPluginState.decorations || DecorationSet.empty).map(tr.mapping, tr.doc)
86
- const { selection } = newState
87
- const isEditable = editor.isEditable
88
- const decorationsToAdd = [] as Deco[]
89
- const { minFrom, maxTo } = getAffectedRange(newState, previousPluginState, isEditable, tr, state)
90
-
91
- newState.doc.nodesBetween(minFrom, maxTo, (node, pos) => {
92
- const enabled = shouldRender(newState, pos, node)
93
-
94
- if (node.isText && node.text && enabled) {
95
- let match: RegExpExecArray | null
96
-
97
- // eslint-disable-next-line no-cond-assign
98
- while ((match = regex.exec(node.text))) {
99
- const from = pos + match.index
100
- const to = from + match[0].length
101
- const content = match.slice(1).find(Boolean)
102
-
103
- if (content) {
104
- const selectionSize = selection.from - selection.to
105
- const anchorIsInside = selection.anchor >= from && selection.anchor <= to
106
- const rangeIsInside = selection.from >= from && selection.to <= to
107
- const isEditing = (selectionSize === 0 && anchorIsInside) || rangeIsInside
108
-
109
- if (
110
- // Are the decorations already present?
111
- nextDecorationSet.find(
112
- from,
113
- to,
114
- (deco: DecoSpec) =>
115
- isEditing === deco.isEditing &&
116
- content === deco.content &&
117
- isEditable === deco.isEditable &&
118
- katexOptions === deco.katexOptions,
119
- ).length
120
- ) {
121
- // Decoration exists in set, no need to add it again
122
- continue
123
- }
124
- // Use an inline decoration to either hide original (preview is showing) or show it (editing "mode")
125
- decorationsToAdd.push(
126
- Decoration.inline(
127
- from,
128
- to,
129
- {
130
- class:
131
- isEditing && isEditable
132
- ? 'Tiptap-mathematics-editor'
133
- : 'Tiptap-mathematics-editor Tiptap-mathematics-editor--hidden',
134
- style:
135
- !isEditing || !isEditable
136
- ? 'display: inline-block; height: 0; opacity: 0; overflow: hidden; position: absolute; width: 0;'
137
- : undefined,
138
- },
139
- {
140
- content,
141
- isEditable,
142
- isEditing,
143
- katexOptions,
144
- } satisfies DecoSpec,
145
- ),
146
- )
147
-
148
- if (!isEditable || !isEditing) {
149
- // Create decoration widget and add KaTeX preview if selection is not within the math-editor
150
- decorationsToAdd.push(
151
- Decoration.widget(
152
- from,
153
- () => {
154
- const element = document.createElement('span')
155
-
156
- // TODO: changeable class names
157
- element.classList.add('Tiptap-mathematics-render')
158
-
159
- if (isEditable) {
160
- element.classList.add('Tiptap-mathematics-render--editable')
161
- }
162
-
163
- try {
164
- katex.render(content!, element, katexOptions)
165
- } catch {
166
- element.innerHTML = content!
167
- }
168
-
169
- return element
170
- },
171
- {
172
- content,
173
- isEditable,
174
- isEditing,
175
- katexOptions,
176
- } satisfies DecoSpec,
177
- ),
178
- )
179
- }
180
- }
181
- }
182
- }
183
- })
184
-
185
- // Remove any decorations that exist at the same position, they will be replaced by the new decorations
186
- const decorationsToRemove = decorationsToAdd.flatMap(deco => nextDecorationSet.find(deco.from, deco.to))
187
-
188
- return {
189
- decorations: nextDecorationSet
190
- // Remove existing decorations that are going to be replaced
191
- .remove(decorationsToRemove)
192
- // Add any new decorations
193
- .add(tr.doc, decorationsToAdd),
194
- isEditable,
195
- }
196
- },
197
- },
198
-
199
- props: {
200
- decorations(state) {
201
- return this.getState(state)?.decorations ?? DecorationSet.empty
202
- },
203
- },
204
- })
205
- }