@puredesktop/platform-editor 1.0.0-beta.1 → 2.1.0

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,88 +1,88 @@
1
- import { Mark, mergeAttributes } from '@tiptap/core'
2
-
3
- declare module '@tiptap/core' {
4
- interface Commands<ReturnType> {
5
- indexMarker: {
6
- /**
7
- * Wrap the current selection in an `<a class="ix" id="ix-{slug}">`
8
- * marker. The slug is derived from the selected text. Multiple
9
- * markers per term are fine — the render pipeline groups by term.
10
- */
11
- toggleIndexMarker: () => ReturnType
12
- }
13
- }
14
- }
15
-
16
- /**
17
- * Inline index anchor. Renders as a faint dotted underline in the
18
- * editor; invisible in print output (the wrapping `<a>` carries the
19
- * data the index generator collects but is otherwise display-none in
20
- * print.css). Markdown round-trip stores the marker as inline HTML
21
- * since CommonMark has no native concept for this.
22
- */
23
- export const IndexMarker = Mark.create({
24
- name: 'indexMarker',
25
- inclusive: false,
26
- excludes: '',
27
-
28
- addAttributes() {
29
- return {
30
- term: {
31
- default: null,
32
- parseHTML: el => el.getAttribute('data-term'),
33
- renderHTML: (attrs: { term?: string }) =>
34
- attrs.term ? { 'data-term': attrs.term } : {},
35
- },
36
- }
37
- },
38
-
39
- parseHTML() {
40
- return [
41
- {
42
- tag: 'a.ix',
43
- getAttrs: el => ({
44
- term: (el as HTMLElement).getAttribute('data-term'),
45
- }),
46
- },
47
- ]
48
- },
49
-
50
- renderHTML({ HTMLAttributes, mark }) {
51
- const term = (mark.attrs as { term?: string }).term ?? ''
52
- const id = term ? `ix-${slugify(term)}` : undefined
53
- return [
54
- 'a',
55
- mergeAttributes(HTMLAttributes, {
56
- class: 'ix',
57
- ...(id ? { id } : {}),
58
- ...(term ? { 'data-term': term } : {}),
59
- }),
60
- 0,
61
- ]
62
- },
63
-
64
- addCommands() {
65
- return {
66
- toggleIndexMarker:
67
- () =>
68
- ({ state, chain }) => {
69
- const { from, to } = state.selection
70
- if (from === to) return false
71
- const term = state.doc.textBetween(from, to, ' ').trim()
72
- if (!term) return false
73
- if (state.doc.rangeHasMark(from, to, this.type)) {
74
- return chain().focus().unsetMark(this.name).run()
75
- }
76
- return chain().focus().setMark(this.name, { term }).run()
77
- },
78
- }
79
- },
80
- })
81
-
82
- function slugify(text: string): string {
83
- return text
84
- .toLowerCase()
85
- .trim()
86
- .replace(/[^a-z0-9]+/g, '-')
87
- .replace(/^-|-$/g, '')
88
- }
1
+ import { Mark, mergeAttributes } from '@tiptap/core'
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ indexMarker: {
6
+ /**
7
+ * Wrap the current selection in an `<a class="ix" id="ix-{slug}">`
8
+ * marker. The slug is derived from the selected text. Multiple
9
+ * markers per term are fine — the render pipeline groups by term.
10
+ */
11
+ toggleIndexMarker: () => ReturnType
12
+ }
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Inline index anchor. Renders as a faint dotted underline in the
18
+ * editor; invisible in print output (the wrapping `<a>` carries the
19
+ * data the index generator collects but is otherwise display-none in
20
+ * print.css). Markdown round-trip stores the marker as inline HTML
21
+ * since CommonMark has no native concept for this.
22
+ */
23
+ export const IndexMarker = Mark.create({
24
+ name: 'indexMarker',
25
+ inclusive: false,
26
+ excludes: '',
27
+
28
+ addAttributes() {
29
+ return {
30
+ term: {
31
+ default: null,
32
+ parseHTML: el => el.getAttribute('data-term'),
33
+ renderHTML: (attrs: { term?: string }) =>
34
+ attrs.term ? { 'data-term': attrs.term } : {},
35
+ },
36
+ }
37
+ },
38
+
39
+ parseHTML() {
40
+ return [
41
+ {
42
+ tag: 'a.ix',
43
+ getAttrs: el => ({
44
+ term: (el as HTMLElement).getAttribute('data-term'),
45
+ }),
46
+ },
47
+ ]
48
+ },
49
+
50
+ renderHTML({ HTMLAttributes, mark }) {
51
+ const term = (mark.attrs as { term?: string }).term ?? ''
52
+ const id = term ? `ix-${slugify(term)}` : undefined
53
+ return [
54
+ 'a',
55
+ mergeAttributes(HTMLAttributes, {
56
+ class: 'ix',
57
+ ...(id ? { id } : {}),
58
+ ...(term ? { 'data-term': term } : {}),
59
+ }),
60
+ 0,
61
+ ]
62
+ },
63
+
64
+ addCommands() {
65
+ return {
66
+ toggleIndexMarker:
67
+ () =>
68
+ ({ state, chain }) => {
69
+ const { from, to } = state.selection
70
+ if (from === to) return false
71
+ const term = state.doc.textBetween(from, to, ' ').trim()
72
+ if (!term) return false
73
+ if (state.doc.rangeHasMark(from, to, this.type)) {
74
+ return chain().focus().unsetMark(this.name).run()
75
+ }
76
+ return chain().focus().setMark(this.name, { term }).run()
77
+ },
78
+ }
79
+ },
80
+ })
81
+
82
+ function slugify(text: string): string {
83
+ return text
84
+ .toLowerCase()
85
+ .trim()
86
+ .replace(/[^a-z0-9]+/g, '-')
87
+ .replace(/^-|-$/g, '')
88
+ }
@@ -1,239 +1,239 @@
1
- import { Extension } from '@tiptap/core'
2
- import type { Editor } from '@tiptap/core'
3
- import { Plugin, PluginKey } from '@tiptap/pm/state'
4
- import katex from 'katex'
5
-
6
- /**
7
- * Editing UX for the KaTeX math nodes (@tiptap/extension-mathematics only
8
- * renders + fires onClick — it has no editor). This adds:
9
- * - a floating editor with a LIVE preview as you type;
10
- * - click a rendered equation to edit it;
11
- * - insert-and-edit commands so a new equation is editable immediately and
12
- * renders in place when you click out (empty equations are discarded).
13
- */
14
-
15
- const MATH_TYPES = new Set(['inlineMath', 'blockMath'])
16
-
17
- let closeActive: (() => void) | null = null
18
-
19
- function findMathPosNear(editor: Editor, typeName: string): number | null {
20
- const { from, to } = editor.state.selection
21
- const docSize = editor.state.doc.content.size
22
- let found: number | null = null
23
- editor.state.doc.nodesBetween(
24
- Math.max(0, from - 2),
25
- Math.min(docSize, to + 2),
26
- (node, pos) => {
27
- if (node.type.name === typeName) found = pos
28
- },
29
- )
30
- return found
31
- }
32
-
33
- /** Open the floating LaTeX editor for the math node at `pos`. */
34
- export function openMathEditor(editor: Editor, pos: number): void {
35
- closeActive?.()
36
- const node = editor.state.doc.nodeAt(pos)
37
- if (!node || !MATH_TYPES.has(node.type.name)) return
38
- const isInline = node.type.name === 'inlineMath'
39
-
40
- const popover = document.createElement('div')
41
- popover.className = 'math-editor-popover'
42
- Object.assign(popover.style, {
43
- position: 'fixed',
44
- zIndex: '1003',
45
- width: 'min(440px, calc(100vw - 24px))',
46
- display: 'flex',
47
- flexDirection: 'column',
48
- gap: '8px',
49
- padding: '10px',
50
- borderRadius: '8px',
51
- border: '1px solid var(--platform-colors-border, #d7dbe0)',
52
- background: 'var(--platform-colors-surface, #fff)',
53
- boxShadow: '0 18px 42px rgb(23 29 36 / 0.18)',
54
- })
55
-
56
- const preview = document.createElement('div')
57
- Object.assign(preview.style, {
58
- minHeight: '30px',
59
- padding: '6px 8px',
60
- borderRadius: '6px',
61
- background: 'var(--platform-colors-bg, #f5f6f7)',
62
- color: 'var(--platform-colors-text, inherit)',
63
- overflowX: 'auto',
64
- textAlign: isInline ? 'left' : 'center',
65
- })
66
-
67
- const input = document.createElement('textarea')
68
- // ' ' is the empty sentinel used by the insert-and-edit commands.
69
- const initialLatex = (node.attrs.latex as string) ?? ''
70
- input.value = initialLatex === ' ' ? '' : initialLatex
71
- input.rows = 2
72
- input.spellcheck = false
73
- input.placeholder = isInline
74
- ? 'Inline LaTeX — e.g. E = mc^2'
75
- : 'Display LaTeX — e.g. \\frac{a}{b}'
76
- Object.assign(input.style, {
77
- width: '100%',
78
- boxSizing: 'border-box',
79
- resize: 'vertical',
80
- border: '1px solid var(--platform-colors-border, #d7dbe0)',
81
- borderRadius: '6px',
82
- padding: '6px 8px',
83
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
84
- fontSize: '13px',
85
- color: 'inherit',
86
- background: 'var(--platform-colors-surface, #fff)',
87
- })
88
-
89
- const hint = document.createElement('div')
90
- hint.textContent = 'Live preview · Esc to cancel · click away to apply'
91
- Object.assign(hint.style, {
92
- fontSize: '11px',
93
- color: 'var(--platform-colors-text-secondary, #8a8f98)',
94
- })
95
-
96
- popover.append(preview, input, hint)
97
- document.body.appendChild(popover)
98
-
99
- const renderPreview = (): void => {
100
- const latex = input.value.trim()
101
- if (!latex) {
102
- preview.textContent = ''
103
- return
104
- }
105
- try {
106
- katex.render(latex, preview, { displayMode: !isInline, throwOnError: false })
107
- } catch {
108
- preview.textContent = input.value
109
- }
110
- }
111
- input.addEventListener('input', renderPreview)
112
- renderPreview()
113
-
114
- try {
115
- const coords = editor.view.coordsAtPos(pos)
116
- popover.style.left = `${Math.max(12, Math.min(coords.left, window.innerWidth - 460))}px`
117
- popover.style.top = `${Math.min(coords.bottom + 6, window.innerHeight - 160)}px`
118
- } catch {
119
- popover.style.left = '50%'
120
- popover.style.top = '140px'
121
- popover.style.transform = 'translateX(-50%)'
122
- }
123
-
124
- input.focus()
125
- input.select()
126
-
127
- let closed = false
128
- const teardown = (): void => {
129
- if (closed) return
130
- closed = true
131
- popover.remove()
132
- document.removeEventListener('mousedown', onDocPointerDown, true)
133
- closeActive = null
134
- }
135
- const nodeRange = (): { from: number; size: number } | null => {
136
- const current = editor.state.doc.nodeAt(pos)
137
- if (!current || !MATH_TYPES.has(current.type.name)) return null
138
- return { from: pos, size: current.nodeSize }
139
- }
140
- const apply = (): void => {
141
- const range = nodeRange()
142
- if (range) {
143
- const latex = input.value
144
- const current = editor.state.doc.nodeAt(pos)
145
- if (!latex.trim()) {
146
- editor.view.dispatch(editor.state.tr.delete(range.from, range.from + range.size))
147
- } else if (current && latex !== current.attrs.latex) {
148
- editor.view.dispatch(
149
- editor.state.tr.setNodeMarkup(pos, undefined, {
150
- ...current.attrs,
151
- latex,
152
- }),
153
- )
154
- }
155
- }
156
- teardown()
157
- editor.commands.focus()
158
- }
159
- const cancel = (): void => {
160
- const range = nodeRange()
161
- const current = range ? editor.state.doc.nodeAt(pos) : null
162
- // Discard an equation that was never given any LaTeX (just inserted).
163
- if (range && current && !((current.attrs.latex as string) ?? '').trim()) {
164
- editor.view.dispatch(editor.state.tr.delete(range.from, range.from + range.size))
165
- }
166
- teardown()
167
- editor.commands.focus()
168
- }
169
-
170
- input.addEventListener('keydown', event => {
171
- if (event.key === 'Escape') {
172
- event.preventDefault()
173
- cancel()
174
- } else if (
175
- event.key === 'Enter' &&
176
- (isInline ? !event.shiftKey : event.metaKey || event.ctrlKey)
177
- ) {
178
- event.preventDefault()
179
- apply()
180
- }
181
- })
182
- input.addEventListener('blur', () => {
183
- window.setTimeout(apply, 0)
184
- })
185
- const onDocPointerDown = (event: MouseEvent): void => {
186
- if (!popover.contains(event.target as globalThis.Node)) apply()
187
- }
188
- document.addEventListener('mousedown', onDocPointerDown, true)
189
- closeActive = cancel
190
- }
191
-
192
- type MathChain = ReturnType<Editor['chain']> & {
193
- insertInlineMath?: (options: { latex: string }) => ReturnType<Editor['chain']>
194
- insertBlockMath?: (options: { latex: string }) => ReturnType<Editor['chain']>
195
- }
196
-
197
- /** Insert an empty inline equation and open its editor immediately. */
198
- export function insertEditableInlineMath(editor: Editor): void {
199
- const chain = editor.chain().focus() as MathChain
200
- if (typeof chain.insertInlineMath !== 'function') {
201
- chain.run()
202
- return
203
- }
204
- chain.insertInlineMath({ latex: ' ' }).run()
205
- const pos = findMathPosNear(editor, 'inlineMath')
206
- if (pos != null) openMathEditor(editor, pos)
207
- }
208
-
209
- /** Insert an empty display equation and open its editor immediately. */
210
- export function insertEditableBlockMath(editor: Editor): void {
211
- const chain = editor.chain().focus() as MathChain
212
- if (typeof chain.insertBlockMath !== 'function') {
213
- chain.run()
214
- return
215
- }
216
- chain.insertBlockMath({ latex: ' ' }).run()
217
- const pos = findMathPosNear(editor, 'blockMath')
218
- if (pos != null) openMathEditor(editor, pos)
219
- }
220
-
221
- export const MathEditing = Extension.create({
222
- name: 'mathEditing',
223
-
224
- addProseMirrorPlugins() {
225
- const editor = this.editor
226
- return [
227
- new Plugin({
228
- key: new PluginKey('mathEditing'),
229
- props: {
230
- handleClickOn: (_view, _pos, node, nodePos) => {
231
- if (!MATH_TYPES.has(node.type.name)) return false
232
- openMathEditor(editor, nodePos)
233
- return true
234
- },
235
- },
236
- }),
237
- ]
238
- },
239
- })
1
+ import { Extension } from '@tiptap/core'
2
+ import type { Editor } from '@tiptap/core'
3
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
4
+ import katex from 'katex'
5
+
6
+ /**
7
+ * Editing UX for the KaTeX math nodes (@tiptap/extension-mathematics only
8
+ * renders + fires onClick — it has no editor). This adds:
9
+ * - a floating editor with a LIVE preview as you type;
10
+ * - click a rendered equation to edit it;
11
+ * - insert-and-edit commands so a new equation is editable immediately and
12
+ * renders in place when you click out (empty equations are discarded).
13
+ */
14
+
15
+ const MATH_TYPES = new Set(['inlineMath', 'blockMath'])
16
+
17
+ let closeActive: (() => void) | null = null
18
+
19
+ function findMathPosNear(editor: Editor, typeName: string): number | null {
20
+ const { from, to } = editor.state.selection
21
+ const docSize = editor.state.doc.content.size
22
+ let found: number | null = null
23
+ editor.state.doc.nodesBetween(
24
+ Math.max(0, from - 2),
25
+ Math.min(docSize, to + 2),
26
+ (node, pos) => {
27
+ if (node.type.name === typeName) found = pos
28
+ },
29
+ )
30
+ return found
31
+ }
32
+
33
+ /** Open the floating LaTeX editor for the math node at `pos`. */
34
+ export function openMathEditor(editor: Editor, pos: number): void {
35
+ closeActive?.()
36
+ const node = editor.state.doc.nodeAt(pos)
37
+ if (!node || !MATH_TYPES.has(node.type.name)) return
38
+ const isInline = node.type.name === 'inlineMath'
39
+
40
+ const popover = document.createElement('div')
41
+ popover.className = 'math-editor-popover'
42
+ Object.assign(popover.style, {
43
+ position: 'fixed',
44
+ zIndex: '1003',
45
+ width: 'min(440px, calc(100vw - 24px))',
46
+ display: 'flex',
47
+ flexDirection: 'column',
48
+ gap: '8px',
49
+ padding: '10px',
50
+ borderRadius: '8px',
51
+ border: '1px solid var(--platform-colors-border, #d7dbe0)',
52
+ background: 'var(--platform-colors-surface, #fff)',
53
+ boxShadow: '0 18px 42px rgb(23 29 36 / 0.18)',
54
+ })
55
+
56
+ const preview = document.createElement('div')
57
+ Object.assign(preview.style, {
58
+ minHeight: '30px',
59
+ padding: '6px 8px',
60
+ borderRadius: '6px',
61
+ background: 'var(--platform-colors-bg, #f5f6f7)',
62
+ color: 'var(--platform-colors-text, inherit)',
63
+ overflowX: 'auto',
64
+ textAlign: isInline ? 'left' : 'center',
65
+ })
66
+
67
+ const input = document.createElement('textarea')
68
+ // ' ' is the empty sentinel used by the insert-and-edit commands.
69
+ const initialLatex = (node.attrs.latex as string) ?? ''
70
+ input.value = initialLatex === ' ' ? '' : initialLatex
71
+ input.rows = 2
72
+ input.spellcheck = false
73
+ input.placeholder = isInline
74
+ ? 'Inline LaTeX — e.g. E = mc^2'
75
+ : 'Display LaTeX — e.g. \\frac{a}{b}'
76
+ Object.assign(input.style, {
77
+ width: '100%',
78
+ boxSizing: 'border-box',
79
+ resize: 'vertical',
80
+ border: '1px solid var(--platform-colors-border, #d7dbe0)',
81
+ borderRadius: '6px',
82
+ padding: '6px 8px',
83
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
84
+ fontSize: '13px',
85
+ color: 'inherit',
86
+ background: 'var(--platform-colors-surface, #fff)',
87
+ })
88
+
89
+ const hint = document.createElement('div')
90
+ hint.textContent = 'Live preview · Esc to cancel · click away to apply'
91
+ Object.assign(hint.style, {
92
+ fontSize: '11px',
93
+ color: 'var(--platform-colors-text-secondary, #8a8f98)',
94
+ })
95
+
96
+ popover.append(preview, input, hint)
97
+ document.body.appendChild(popover)
98
+
99
+ const renderPreview = (): void => {
100
+ const latex = input.value.trim()
101
+ if (!latex) {
102
+ preview.textContent = ''
103
+ return
104
+ }
105
+ try {
106
+ katex.render(latex, preview, { displayMode: !isInline, throwOnError: false })
107
+ } catch {
108
+ preview.textContent = input.value
109
+ }
110
+ }
111
+ input.addEventListener('input', renderPreview)
112
+ renderPreview()
113
+
114
+ try {
115
+ const coords = editor.view.coordsAtPos(pos)
116
+ popover.style.left = `${Math.max(12, Math.min(coords.left, window.innerWidth - 460))}px`
117
+ popover.style.top = `${Math.min(coords.bottom + 6, window.innerHeight - 160)}px`
118
+ } catch {
119
+ popover.style.left = '50%'
120
+ popover.style.top = '140px'
121
+ popover.style.transform = 'translateX(-50%)'
122
+ }
123
+
124
+ input.focus()
125
+ input.select()
126
+
127
+ let closed = false
128
+ const teardown = (): void => {
129
+ if (closed) return
130
+ closed = true
131
+ popover.remove()
132
+ document.removeEventListener('mousedown', onDocPointerDown, true)
133
+ closeActive = null
134
+ }
135
+ const nodeRange = (): { from: number; size: number } | null => {
136
+ const current = editor.state.doc.nodeAt(pos)
137
+ if (!current || !MATH_TYPES.has(current.type.name)) return null
138
+ return { from: pos, size: current.nodeSize }
139
+ }
140
+ const apply = (): void => {
141
+ const range = nodeRange()
142
+ if (range) {
143
+ const latex = input.value
144
+ const current = editor.state.doc.nodeAt(pos)
145
+ if (!latex.trim()) {
146
+ editor.view.dispatch(editor.state.tr.delete(range.from, range.from + range.size))
147
+ } else if (current && latex !== current.attrs.latex) {
148
+ editor.view.dispatch(
149
+ editor.state.tr.setNodeMarkup(pos, undefined, {
150
+ ...current.attrs,
151
+ latex,
152
+ }),
153
+ )
154
+ }
155
+ }
156
+ teardown()
157
+ editor.commands.focus()
158
+ }
159
+ const cancel = (): void => {
160
+ const range = nodeRange()
161
+ const current = range ? editor.state.doc.nodeAt(pos) : null
162
+ // Discard an equation that was never given any LaTeX (just inserted).
163
+ if (range && current && !((current.attrs.latex as string) ?? '').trim()) {
164
+ editor.view.dispatch(editor.state.tr.delete(range.from, range.from + range.size))
165
+ }
166
+ teardown()
167
+ editor.commands.focus()
168
+ }
169
+
170
+ input.addEventListener('keydown', event => {
171
+ if (event.key === 'Escape') {
172
+ event.preventDefault()
173
+ cancel()
174
+ } else if (
175
+ event.key === 'Enter' &&
176
+ (isInline ? !event.shiftKey : event.metaKey || event.ctrlKey)
177
+ ) {
178
+ event.preventDefault()
179
+ apply()
180
+ }
181
+ })
182
+ input.addEventListener('blur', () => {
183
+ window.setTimeout(apply, 0)
184
+ })
185
+ const onDocPointerDown = (event: MouseEvent): void => {
186
+ if (!popover.contains(event.target as globalThis.Node)) apply()
187
+ }
188
+ document.addEventListener('mousedown', onDocPointerDown, true)
189
+ closeActive = cancel
190
+ }
191
+
192
+ type MathChain = ReturnType<Editor['chain']> & {
193
+ insertInlineMath?: (options: { latex: string }) => ReturnType<Editor['chain']>
194
+ insertBlockMath?: (options: { latex: string }) => ReturnType<Editor['chain']>
195
+ }
196
+
197
+ /** Insert an empty inline equation and open its editor immediately. */
198
+ export function insertEditableInlineMath(editor: Editor): void {
199
+ const chain = editor.chain().focus() as MathChain
200
+ if (typeof chain.insertInlineMath !== 'function') {
201
+ chain.run()
202
+ return
203
+ }
204
+ chain.insertInlineMath({ latex: ' ' }).run()
205
+ const pos = findMathPosNear(editor, 'inlineMath')
206
+ if (pos != null) openMathEditor(editor, pos)
207
+ }
208
+
209
+ /** Insert an empty display equation and open its editor immediately. */
210
+ export function insertEditableBlockMath(editor: Editor): void {
211
+ const chain = editor.chain().focus() as MathChain
212
+ if (typeof chain.insertBlockMath !== 'function') {
213
+ chain.run()
214
+ return
215
+ }
216
+ chain.insertBlockMath({ latex: ' ' }).run()
217
+ const pos = findMathPosNear(editor, 'blockMath')
218
+ if (pos != null) openMathEditor(editor, pos)
219
+ }
220
+
221
+ export const MathEditing = Extension.create({
222
+ name: 'mathEditing',
223
+
224
+ addProseMirrorPlugins() {
225
+ const editor = this.editor
226
+ return [
227
+ new Plugin({
228
+ key: new PluginKey('mathEditing'),
229
+ props: {
230
+ handleClickOn: (_view, _pos, node, nodePos) => {
231
+ if (!MATH_TYPES.has(node.type.name)) return false
232
+ openMathEditor(editor, nodePos)
233
+ return true
234
+ },
235
+ },
236
+ }),
237
+ ]
238
+ },
239
+ })