@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/dist/index.cjs +320 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +286 -16
- package/dist/index.d.ts +286 -16
- package/dist/index.js +314 -143
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/extensions/BlockMath.ts +251 -0
- package/src/extensions/InlineMath.ts +252 -0
- package/src/extensions/index.ts +2 -0
- package/src/index.ts +2 -1
- package/src/mathematics.ts +54 -14
- package/src/types.ts +16 -4
- package/src/utils.ts +101 -0
- package/src/MathematicsPlugin.ts +0 -205
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
|
+
}
|
package/src/MathematicsPlugin.ts
DELETED
|
@@ -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
|
-
}
|