@puredesktop/platform-editor 1.0.0-beta.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.
Files changed (45) hide show
  1. package/package.json +63 -0
  2. package/src/CollapsePanel.tsx +35 -0
  3. package/src/DocumentDiffView.tsx +130 -0
  4. package/src/DocumentEditor.test.ts +118 -0
  5. package/src/DocumentEditor.tsx +2631 -0
  6. package/src/alignedLineDiff.test.ts +52 -0
  7. package/src/alignedLineDiff.ts +67 -0
  8. package/src/collectionAssetSrc.ts +2 -0
  9. package/src/commentUtils.test.ts +350 -0
  10. package/src/commentUtils.ts +546 -0
  11. package/src/constants/toolKeys.ts +14 -0
  12. package/src/contentFormat.test.ts +27 -0
  13. package/src/contentFormat.ts +18 -0
  14. package/src/editorExtensions.ts +155 -0
  15. package/src/extensions/appliedChangeMark.ts +115 -0
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -0
  17. package/src/extensions/autoReviewPrompts.ts +1442 -0
  18. package/src/extensions/collectionImage.ts +26 -0
  19. package/src/extensions/collectionImagePaste.ts +215 -0
  20. package/src/extensions/commentMark.ts +223 -0
  21. package/src/extensions/documentAssetIdentity.ts +54 -0
  22. package/src/extensions/figure.ts +92 -0
  23. package/src/extensions/footnote.ts +186 -0
  24. package/src/extensions/indexMarker.ts +88 -0
  25. package/src/extensions/mathEditing.ts +239 -0
  26. package/src/extensions/mermaidBlock.tsx +297 -0
  27. package/src/extensions/slashCommands.test.ts +170 -0
  28. package/src/extensions/slashCommands.ts +746 -0
  29. package/src/extensions/smartTypography.test.ts +74 -0
  30. package/src/extensions/smartTypography.ts +120 -0
  31. package/src/index.ts +137 -0
  32. package/src/insertCollectionImage.test.ts +60 -0
  33. package/src/insertCollectionImage.ts +63 -0
  34. package/src/insertInlineAssetKind.test.ts +67 -0
  35. package/src/insertInlineAssetKind.ts +49 -0
  36. package/src/mermaidPreview.test.ts +54 -0
  37. package/src/mermaidPreview.ts +115 -0
  38. package/src/styled.ts +676 -0
  39. package/src/toolbar/ToolBtn.tsx +63 -0
  40. package/src/toolbar/Toolbar.test.tsx +325 -0
  41. package/src/toolbar/Toolbar.tsx +624 -0
  42. package/src/toolbar/groups/HeadingGroup.tsx +153 -0
  43. package/src/toolbar/overlayTypes.ts +28 -0
  44. package/src/useEditorExtensions.ts +22 -0
  45. package/src/utils/markdownUtils.ts +331 -0
@@ -0,0 +1,186 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core'
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ footnote: {
6
+ /** Insert an empty footnote marker at the cursor and open it to edit. */
7
+ insertFootnote: () => ReturnType
8
+ }
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Footnote — an inline, atomic reference marker.
14
+ *
15
+ * The body shows only a small superscript number; the note text is embedded on
16
+ * the node (the `text` attribute) and edited in a popover when the marker is
17
+ * clicked. Numbering is by document order, recomputed live. Serializes to
18
+ * `<sup data-footnote data-text="…">` for a clean HTML round-trip; the export
19
+ * pipeline (prepareFootnotes) turns markers into numbered links plus a
20
+ * footnotes section.
21
+ */
22
+ export const Footnote = Node.create({
23
+ name: 'footnote',
24
+ group: 'inline',
25
+ inline: true,
26
+ atom: true,
27
+ selectable: true,
28
+
29
+ addAttributes() {
30
+ return {
31
+ text: {
32
+ default: '',
33
+ parseHTML: el => el.getAttribute('data-text') ?? '',
34
+ renderHTML: (attrs: { text?: string }) => ({
35
+ 'data-text': attrs.text ?? '',
36
+ }),
37
+ },
38
+ }
39
+ },
40
+
41
+ parseHTML() {
42
+ return [{ tag: 'sup[data-footnote]' }]
43
+ },
44
+
45
+ renderHTML({ HTMLAttributes }) {
46
+ return [
47
+ 'sup',
48
+ mergeAttributes(HTMLAttributes, {
49
+ 'data-footnote': '',
50
+ class: 'footnote-ref',
51
+ }),
52
+ ]
53
+ },
54
+
55
+ addCommands() {
56
+ return {
57
+ insertFootnote:
58
+ () =>
59
+ ({ commands }) =>
60
+ commands.insertContent({ type: this.name, attrs: { text: '' } }),
61
+ }
62
+ },
63
+
64
+ addNodeView() {
65
+ return ({ node, getPos, editor }) => {
66
+ let current = node
67
+ const dom = document.createElement('sup')
68
+ dom.className = 'footnote-ref'
69
+ dom.setAttribute('data-footnote', '')
70
+ dom.contentEditable = 'false'
71
+
72
+ const renderNumber = (): void => {
73
+ let count = 0
74
+ let mine = 0
75
+ const here = typeof getPos === 'function' ? getPos() : null
76
+ editor.state.doc.descendants((child, pos) => {
77
+ if (child.type.name === 'footnote') {
78
+ count += 1
79
+ if (here !== null && pos === here) mine = count
80
+ }
81
+ return true
82
+ })
83
+ dom.textContent = String(mine || count || 1)
84
+ }
85
+ renderNumber()
86
+
87
+ let popover: HTMLDivElement | null = null
88
+ const closePopover = (): void => {
89
+ popover?.remove()
90
+ popover = null
91
+ document.removeEventListener('mousedown', onDocPointerDown, true)
92
+ }
93
+ const onDocPointerDown = (event: MouseEvent): void => {
94
+ if (
95
+ popover &&
96
+ event.target !== dom &&
97
+ !popover.contains(event.target as globalThis.Node)
98
+ ) {
99
+ closePopover()
100
+ }
101
+ }
102
+ const openPopover = (): void => {
103
+ if (popover) return
104
+ popover = document.createElement('div')
105
+ popover.className = 'footnote-popover'
106
+ Object.assign(popover.style, {
107
+ position: 'fixed',
108
+ zIndex: '1002',
109
+ width: 'min(360px, calc(100vw - 24px))',
110
+ padding: '8px',
111
+ border: '1px solid var(--platform-colors-border, #d7dbe0)',
112
+ borderRadius: '8px',
113
+ background: 'var(--platform-colors-surface, #fff)',
114
+ boxShadow: '0 18px 42px rgb(23 29 36 / 0.18)',
115
+ })
116
+ const textarea = document.createElement('textarea')
117
+ textarea.value = current.attrs.text ?? ''
118
+ textarea.placeholder = 'Footnote text…'
119
+ textarea.rows = 3
120
+ Object.assign(textarea.style, {
121
+ width: '100%',
122
+ boxSizing: 'border-box',
123
+ resize: 'vertical',
124
+ border: '1px solid var(--platform-colors-border, #d7dbe0)',
125
+ borderRadius: '6px',
126
+ padding: '6px 8px',
127
+ font: 'inherit',
128
+ fontSize: '13px',
129
+ color: 'inherit',
130
+ background: 'var(--platform-colors-surface, #fff)',
131
+ })
132
+ popover.appendChild(textarea)
133
+
134
+ const rect = dom.getBoundingClientRect()
135
+ popover.style.left = `${Math.max(12, rect.left)}px`
136
+ popover.style.top = `${rect.bottom + 6}px`
137
+ document.body.appendChild(popover)
138
+ textarea.focus()
139
+
140
+ const commit = (): void => {
141
+ if (typeof getPos !== 'function') return
142
+ const pos = getPos()
143
+ if (typeof pos !== 'number') return
144
+ const next = textarea.value
145
+ if (next === (current.attrs.text ?? '')) return
146
+ editor.view.dispatch(
147
+ editor.state.tr.setNodeMarkup(pos, undefined, {
148
+ ...current.attrs,
149
+ text: next,
150
+ }),
151
+ )
152
+ }
153
+ textarea.addEventListener('blur', () => {
154
+ commit()
155
+ closePopover()
156
+ })
157
+ textarea.addEventListener('keydown', event => {
158
+ if (event.key === 'Escape') {
159
+ event.preventDefault()
160
+ commit()
161
+ closePopover()
162
+ editor.commands.focus()
163
+ }
164
+ })
165
+ document.addEventListener('mousedown', onDocPointerDown, true)
166
+ }
167
+
168
+ dom.addEventListener('mousedown', event => {
169
+ event.preventDefault()
170
+ openPopover()
171
+ })
172
+
173
+ return {
174
+ dom,
175
+ update: updated => {
176
+ if (updated.type.name !== 'footnote') return false
177
+ current = updated
178
+ renderNumber()
179
+ return true
180
+ },
181
+ ignoreMutation: () => true,
182
+ destroy: closePopover,
183
+ }
184
+ }
185
+ },
186
+ })
@@ -0,0 +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
+ }
@@ -0,0 +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
+ })