@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.2
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/package.json +1 -1
- package/src/CollapsePanel.tsx +35 -35
- package/src/DocumentDiffView.tsx +130 -130
- package/src/DocumentEditor.test.ts +118 -118
- package/src/DocumentEditor.tsx +2651 -2631
- package/src/alignedLineDiff.test.ts +52 -52
- package/src/alignedLineDiff.ts +67 -67
- package/src/collectionAssetSrc.ts +2 -2
- package/src/commentUtils.test.ts +350 -350
- package/src/commentUtils.ts +546 -546
- package/src/constants/toolKeys.ts +14 -14
- package/src/contentFormat.test.ts +27 -27
- package/src/contentFormat.ts +18 -18
- package/src/editorExtensions.ts +155 -155
- package/src/extensions/appliedChangeMark.ts +115 -115
- package/src/extensions/autoReviewPrompts.test.ts +529 -529
- package/src/extensions/autoReviewPrompts.ts +1442 -1442
- package/src/extensions/collectionImage.ts +26 -26
- package/src/extensions/collectionImagePaste.ts +215 -215
- package/src/extensions/commentMark.ts +223 -223
- package/src/extensions/documentAssetIdentity.ts +54 -54
- package/src/extensions/figure.ts +92 -92
- package/src/extensions/footnote.ts +186 -186
- package/src/extensions/indexMarker.ts +88 -88
- package/src/extensions/mathEditing.ts +239 -239
- package/src/extensions/mermaidBlock.tsx +297 -297
- package/src/extensions/slashCommands.test.ts +170 -170
- package/src/extensions/slashCommands.ts +746 -746
- package/src/extensions/smartTypography.test.ts +74 -74
- package/src/extensions/smartTypography.ts +120 -120
- package/src/index.ts +138 -137
- package/src/insertCollectionImage.test.ts +60 -60
- package/src/insertCollectionImage.ts +63 -63
- package/src/insertInlineAssetKind.test.ts +67 -67
- package/src/insertInlineAssetKind.ts +49 -49
- package/src/mermaidPreview.test.ts +54 -54
- package/src/mermaidPreview.ts +115 -115
- package/src/styled.ts +697 -676
- package/src/toolbar/ToolBtn.tsx +77 -63
- package/src/toolbar/Toolbar.test.tsx +325 -325
- package/src/toolbar/Toolbar.tsx +662 -624
- package/src/toolbar/groups/HeadingGroup.tsx +175 -153
- package/src/toolbar/overlayTypes.ts +28 -28
- package/src/useEditorExtensions.ts +22 -22
- 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
|
+
})
|