frappe-ui 1.0.0-beta.49 → 1.0.0-beta.50
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/molecules/editor/extensions/link/exit-link-on-space-plugin.test.ts +237 -0
- package/src/molecules/editor/extensions/link/exit-link-on-space-plugin.ts +130 -0
- package/src/molecules/editor/extensions/link/link-extension.ts +4 -1
- package/src/molecules/editor/extensions/list/list-join.ts +2 -37
- package/src/molecules/editor/extensions/shared/collaboration.ts +42 -0
package/package.json
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { Editor, Extension, type Extensions } from '@tiptap/core'
|
|
6
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
7
|
+
import Document from '@tiptap/extension-document'
|
|
8
|
+
import Paragraph from '@tiptap/extension-paragraph'
|
|
9
|
+
import Text from '@tiptap/extension-text'
|
|
10
|
+
import { UndoRedo } from '@tiptap/extensions'
|
|
11
|
+
import { LinkExtension } from './link-extension'
|
|
12
|
+
|
|
13
|
+
const openEditors: Editor[] = []
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
// An undestroyed view keeps a DOM observer timer alive past teardown.
|
|
17
|
+
while (openEditors.length) openEditors.pop()?.destroy()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function editorWith(content: string, extensions: Extensions = []) {
|
|
21
|
+
const editor = new Editor({
|
|
22
|
+
extensions: [Document, Paragraph, Text, LinkExtension, ...extensions],
|
|
23
|
+
content,
|
|
24
|
+
})
|
|
25
|
+
openEditors.push(editor)
|
|
26
|
+
return editor
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The document's inline HTML, with the link's boilerplate attributes dropped. */
|
|
30
|
+
function inlineHTML(editor: Editor): string {
|
|
31
|
+
return editor
|
|
32
|
+
.getHTML()
|
|
33
|
+
.replace(/ target="_blank" rel="noopener noreferrer nofollow"/g, '')
|
|
34
|
+
.replace(/^<p>|<\/p>$/g, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Type `text` at the cursor the way the view does, one character at a time. */
|
|
38
|
+
function type(editor: Editor, text: string) {
|
|
39
|
+
for (const char of text) {
|
|
40
|
+
editor.view.dispatch(editor.state.tr.insertText(char))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Put the cursor at the end of the link in `<p>…<a>link</a>…</p>`. */
|
|
45
|
+
function cursorAtLinkEnd(editor: Editor, linkText: string) {
|
|
46
|
+
const text = editor.state.doc.textContent
|
|
47
|
+
editor.commands.setTextSelection(
|
|
48
|
+
text.indexOf(linkText) + linkText.length + 1, // +1 for the paragraph open token
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('exitLinkOnSpacePlugin', () => {
|
|
53
|
+
it('leaves a space typed at the end of a link outside the link', () => {
|
|
54
|
+
const editor = editorWith(
|
|
55
|
+
'<p>see <a href="https://example.com">docs</a></p>',
|
|
56
|
+
)
|
|
57
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
58
|
+
|
|
59
|
+
type(editor, ' ')
|
|
60
|
+
|
|
61
|
+
expect(inlineHTML(editor)).toBe(
|
|
62
|
+
'see <a href="https://example.com">docs</a> ',
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('ends the link, so text typed after the space is not linked', () => {
|
|
67
|
+
const editor = editorWith(
|
|
68
|
+
'<p>see <a href="https://example.com">docs</a></p>',
|
|
69
|
+
)
|
|
70
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
71
|
+
|
|
72
|
+
type(editor, ' and more')
|
|
73
|
+
|
|
74
|
+
expect(inlineHTML(editor)).toBe(
|
|
75
|
+
'see <a href="https://example.com">docs</a> and more',
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles a run of spaces', () => {
|
|
80
|
+
const editor = editorWith(
|
|
81
|
+
'<p>see <a href="https://example.com">docs</a></p>',
|
|
82
|
+
)
|
|
83
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
84
|
+
|
|
85
|
+
type(editor, ' x')
|
|
86
|
+
|
|
87
|
+
expect(inlineHTML(editor)).toBe(
|
|
88
|
+
'see <a href="https://example.com">docs</a> x',
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('ends the link when the space is typed between the link and later text', () => {
|
|
93
|
+
const editor = editorWith(
|
|
94
|
+
'<p><a href="https://example.com">docs</a>tail</p>',
|
|
95
|
+
)
|
|
96
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
97
|
+
|
|
98
|
+
type(editor, ' ')
|
|
99
|
+
|
|
100
|
+
expect(inlineHTML(editor)).toBe(
|
|
101
|
+
'<a href="https://example.com">docs</a> tail',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('keeps a space typed inside link text linked', () => {
|
|
106
|
+
const editor = editorWith(
|
|
107
|
+
'<p><a href="https://example.com">clickme</a></p>',
|
|
108
|
+
)
|
|
109
|
+
editor.commands.setTextSelection(1 + 'click'.length)
|
|
110
|
+
|
|
111
|
+
type(editor, ' ')
|
|
112
|
+
|
|
113
|
+
expect(inlineHTML(editor)).toBe(
|
|
114
|
+
'<a href="https://example.com">click me</a>',
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('leaves non-whitespace typed at the end of a link alone', () => {
|
|
119
|
+
// The mark is inclusive on purpose: a URL still being typed keeps growing.
|
|
120
|
+
const editor = editorWith('<p><a href="https://example.com">docs</a></p>')
|
|
121
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
122
|
+
|
|
123
|
+
type(editor, '/api')
|
|
124
|
+
|
|
125
|
+
expect(inlineHTML(editor)).toBe(
|
|
126
|
+
'<a href="https://example.com">docs/api</a>',
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not relink an autolinked URL onto the space that triggered it', () => {
|
|
131
|
+
const editor = editorWith('<p></p>')
|
|
132
|
+
|
|
133
|
+
type(editor, 'see https://example.com/a then')
|
|
134
|
+
|
|
135
|
+
expect(inlineHTML(editor)).toBe(
|
|
136
|
+
'see <a href="https://example.com/a">https://example.com/a</a> then',
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('leaves a trailing space outside a link applied over it', () => {
|
|
141
|
+
// A mark-only change: no text moves, so the plugin has to read the changed
|
|
142
|
+
// range off the AddMarkStep itself.
|
|
143
|
+
const editor = editorWith('<p>see docs tail</p>')
|
|
144
|
+
|
|
145
|
+
editor
|
|
146
|
+
.chain()
|
|
147
|
+
.setTextSelection({ from: 5, to: 10 }) // "docs "
|
|
148
|
+
.setLink({ href: 'https://example.com' })
|
|
149
|
+
.setTextSelection(10)
|
|
150
|
+
.run()
|
|
151
|
+
|
|
152
|
+
expect(inlineHTML(editor)).toBe(
|
|
153
|
+
'see <a href="https://example.com">docs</a> tail',
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('keeps a link whose text is only whitespace', () => {
|
|
158
|
+
// Ending the link would delete it outright, which is not this plugin's job.
|
|
159
|
+
const editor = editorWith('<p>a b</p>')
|
|
160
|
+
|
|
161
|
+
editor
|
|
162
|
+
.chain()
|
|
163
|
+
.setTextSelection({ from: 2, to: 3 })
|
|
164
|
+
.setLink({ href: 'https://example.com' })
|
|
165
|
+
.setTextSelection(3)
|
|
166
|
+
.run()
|
|
167
|
+
|
|
168
|
+
expect(inlineHTML(editor)).toBe('a<a href="https://example.com"> </a>b')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('ignores a change made elsewhere in the document', () => {
|
|
172
|
+
// A collaborator's keystroke (or an undo) must not rewrite marks the user
|
|
173
|
+
// never touched, even with the cursor resting after an already-linked space.
|
|
174
|
+
const editor = editorWith(
|
|
175
|
+
'<p>see <a href="https://example.com">docs </a>tail</p>',
|
|
176
|
+
)
|
|
177
|
+
editor.commands.setTextSelection(10) // just after the linked space
|
|
178
|
+
|
|
179
|
+
const tr = editor.state.tr.insertText('Z', 1, 1)
|
|
180
|
+
tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping))
|
|
181
|
+
editor.view.dispatch(tr)
|
|
182
|
+
|
|
183
|
+
expect(inlineHTML(editor)).toBe(
|
|
184
|
+
'Zsee <a href="https://example.com">docs </a>tail',
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('leaves a remote collaboration change to the peer that made it', () => {
|
|
189
|
+
// Stand-in for y-prosemirror's plugin: the real one is pulled in by a
|
|
190
|
+
// consumer's Collaboration extension, so it cannot be imported here.
|
|
191
|
+
const ySyncKey = new PluginKey('y-sync')
|
|
192
|
+
const YSync = Extension.create({
|
|
193
|
+
name: 'ySyncStandIn',
|
|
194
|
+
addProseMirrorPlugins: () => [new Plugin({ key: ySyncKey })],
|
|
195
|
+
})
|
|
196
|
+
const editor = editorWith(
|
|
197
|
+
'<p>see <a href="https://example.com">docs</a></p>',
|
|
198
|
+
[YSync],
|
|
199
|
+
)
|
|
200
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
201
|
+
|
|
202
|
+
// That peer runs this same plugin; its result replicates on its own.
|
|
203
|
+
editor.view.dispatch(editor.state.tr.insertText(' ').setMeta(ySyncKey, {}))
|
|
204
|
+
|
|
205
|
+
expect(inlineHTML(editor)).toBe(
|
|
206
|
+
'see <a href="https://example.com">docs </a>',
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('undoes the space and the mark change as one step', () => {
|
|
211
|
+
const editor = editorWith(
|
|
212
|
+
'<p>see <a href="https://example.com">docs</a></p>',
|
|
213
|
+
[UndoRedo],
|
|
214
|
+
)
|
|
215
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
216
|
+
type(editor, ' ')
|
|
217
|
+
|
|
218
|
+
editor.commands.undo()
|
|
219
|
+
|
|
220
|
+
expect(inlineHTML(editor)).toBe(
|
|
221
|
+
'see <a href="https://example.com">docs</a>',
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('stays out of the way when the editor is not editable', () => {
|
|
226
|
+
// A read-only user cannot type, so this drives the guard through
|
|
227
|
+
// `view.dispatch` instead: it pins the guard rather than the user path,
|
|
228
|
+
// which matters for a programmatic write into a read-only editor.
|
|
229
|
+
const editor = editorWith('<p><a href="https://example.com">docs</a></p>')
|
|
230
|
+
editor.setEditable(false)
|
|
231
|
+
cursorAtLinkEnd(editor, 'docs')
|
|
232
|
+
|
|
233
|
+
type(editor, ' x')
|
|
234
|
+
|
|
235
|
+
expect(inlineHTML(editor)).toBe('<a href="https://example.com">docs x</a>')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
combineTransactionSteps,
|
|
3
|
+
getChangedRanges,
|
|
4
|
+
getMarkRange,
|
|
5
|
+
type Editor,
|
|
6
|
+
} from '@tiptap/core'
|
|
7
|
+
import type { MarkType, ResolvedPos } from '@tiptap/pm/model'
|
|
8
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
9
|
+
import { isRemoteChange } from '#molecules/editor/extensions/shared/collaboration'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options for {@link exitLinkOnSpacePlugin}.
|
|
13
|
+
*/
|
|
14
|
+
export interface ExitLinkOnSpacePluginOptions {
|
|
15
|
+
editor: Editor
|
|
16
|
+
type: MarkType
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* End the link when whitespace is typed at the end of one.
|
|
21
|
+
*
|
|
22
|
+
* The Link mark is *inclusive* whenever `autolink` is on (tiptap ties the two
|
|
23
|
+
* together, so the mark can grow while a URL is still being typed). The side
|
|
24
|
+
* effect is that a space typed right after a link is pulled inside the `<a>`,
|
|
25
|
+
* and everything typed after it stays linked too. So `<a>docs</a>` becomes
|
|
26
|
+
* `<a>docs and more</a>` one keystroke at a time.
|
|
27
|
+
*
|
|
28
|
+
* Whitespace is where a URL unambiguously ends, so it is the boundary we act
|
|
29
|
+
* on: strip the link mark off a whitespace run the user just typed at the end
|
|
30
|
+
* of a link. Text typed after it then inherits the (unlinked) space's marks,
|
|
31
|
+
* which ends the link without touching the inclusive-mark behaviour that
|
|
32
|
+
* autolink relies on while a URL is mid-typing.
|
|
33
|
+
*
|
|
34
|
+
* Runs as `appendTransaction` rather than `handleTextInput` so input rules keep
|
|
35
|
+
* their first crack at the keystroke, and so pasted or IME-composed whitespace
|
|
36
|
+
* is covered as well.
|
|
37
|
+
*/
|
|
38
|
+
export function exitLinkOnSpacePlugin(
|
|
39
|
+
options: ExitLinkOnSpacePluginOptions,
|
|
40
|
+
): Plugin {
|
|
41
|
+
return new Plugin({
|
|
42
|
+
key: new PluginKey('exitLinkOnSpace'),
|
|
43
|
+
appendTransaction: (transactions, oldState, newState) => {
|
|
44
|
+
if (!options.editor.isEditable) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!transactions.some((transaction) => transaction.docChanged)) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A remote peer's space is that peer's link to end: it runs this same
|
|
53
|
+
// plugin and the result replicates. Ending it here too would push a local
|
|
54
|
+
// rewrite into their typing.
|
|
55
|
+
if (isRemoteChange(transactions, newState)) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { selection } = newState
|
|
60
|
+
if (!selection.empty) {
|
|
61
|
+
// Only a collapsed cursor means "the user is typing here".
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const range = linkedTrailingSpace(selection.$from, options.type)
|
|
66
|
+
if (!range) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The whitespace has to be part of what just changed. Otherwise an edit
|
|
71
|
+
// anywhere else — a collaborator's keystroke, an undo — would rewrite
|
|
72
|
+
// marks the user never touched, just because the cursor happened to be
|
|
73
|
+
// resting after an already-linked space.
|
|
74
|
+
const changes = getChangedRanges(
|
|
75
|
+
combineTransactionSteps(oldState.doc, [...transactions]),
|
|
76
|
+
)
|
|
77
|
+
const justWritten = changes.some(
|
|
78
|
+
({ newRange }) =>
|
|
79
|
+
newRange.to >= range.from && newRange.from <= range.to,
|
|
80
|
+
)
|
|
81
|
+
if (!justWritten) {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return newState.tr.removeMark(range.from, range.to, options.type)
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The run of linked whitespace immediately before `$pos`, when `$pos` is the
|
|
92
|
+
* end of that link. Returns `null` when there is no such run, or when the link
|
|
93
|
+
* continues past the cursor — a space typed *inside* link text stays linked,
|
|
94
|
+
* because splitting a link in two is never what the keystroke meant.
|
|
95
|
+
*/
|
|
96
|
+
function linkedTrailingSpace(
|
|
97
|
+
$pos: ResolvedPos,
|
|
98
|
+
type: MarkType,
|
|
99
|
+
): { from: number; to: number } | null {
|
|
100
|
+
const before = $pos.nodeBefore
|
|
101
|
+
if (!before?.isText || !before.text) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const link = before.marks.find((mark) => mark.type === type)
|
|
106
|
+
if (!link) {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const after = $pos.nodeAfter
|
|
111
|
+
if (after && link.isInSet(after.marks)) {
|
|
112
|
+
// Mid-link: the whitespace separates link text rather than ending it.
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const trailing = before.text.length - before.text.trimEnd().length
|
|
117
|
+
if (trailing === 0) {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const from = $pos.pos - trailing
|
|
122
|
+
const linkRange = getMarkRange($pos, type)
|
|
123
|
+
if (linkRange && from <= linkRange.from) {
|
|
124
|
+
// The link is whitespace all the way down, so ending it here would delete
|
|
125
|
+
// it outright. Ending a link is this plugin's job; removing one is not.
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { from, to: $pos.pos }
|
|
130
|
+
}
|
|
@@ -2,6 +2,7 @@ import Link from '@tiptap/extension-link'
|
|
|
2
2
|
import { buildOpenLinkEditor, type OpenLinkEditorOptions } from './link-commands'
|
|
3
3
|
import { linkPastePlugin } from './link-paste-plugin'
|
|
4
4
|
import { clearLinkOnBoundaryPlugin } from './clear-link-on-boundary-plugin'
|
|
5
|
+
import { exitLinkOnSpacePlugin } from './exit-link-on-space-plugin'
|
|
5
6
|
import { linkClickPlugin } from './link-click-plugin'
|
|
6
7
|
import { linkShortcutPlugin } from './link-shortcut-plugin'
|
|
7
8
|
|
|
@@ -21,7 +22,8 @@ declare module '@tiptap/core' {
|
|
|
21
22
|
* from `@tiptap/extension-link` via `...this.parent?.()` and deliberately NOT
|
|
22
23
|
* weakened. This extension only adds:
|
|
23
24
|
* - the `openLinkEditor` command (bubble popup) — see `link-commands.ts`;
|
|
24
|
-
* -
|
|
25
|
+
* - five ProseMirror plugins (paste / boundary / space-exit / click / shortcut) —
|
|
26
|
+
* one per file.
|
|
25
27
|
* `Mod-k` lives in the shortcut plugin (not `addKeyboardShortcuts`) so it can
|
|
26
28
|
* stop the keystroke propagating to app-level listeners; see that file.
|
|
27
29
|
*/
|
|
@@ -57,6 +59,7 @@ export const LinkExtension = Link.extend({
|
|
|
57
59
|
plugins.push(
|
|
58
60
|
linkPastePlugin({ editor: this.editor, type: this.type }),
|
|
59
61
|
clearLinkOnBoundaryPlugin({ editor: this.editor, type: this.type }),
|
|
62
|
+
exitLinkOnSpacePlugin({ editor: this.editor, type: this.type }),
|
|
60
63
|
linkClickPlugin({ editor: this.editor, type: this.type }),
|
|
61
64
|
linkShortcutPlugin({ editor: this.editor }),
|
|
62
65
|
)
|
|
@@ -4,13 +4,9 @@ import type {
|
|
|
4
4
|
NodeType,
|
|
5
5
|
Schema,
|
|
6
6
|
} from '@tiptap/pm/model'
|
|
7
|
-
import {
|
|
8
|
-
Plugin,
|
|
9
|
-
PluginKey,
|
|
10
|
-
type EditorState,
|
|
11
|
-
type Transaction,
|
|
12
|
-
} from '@tiptap/pm/state'
|
|
7
|
+
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
|
|
13
8
|
import { canJoin } from '@tiptap/pm/transform'
|
|
9
|
+
import { isRemoteChange } from '#molecules/editor/extensions/shared/collaboration'
|
|
14
10
|
|
|
15
11
|
const listTypeCache = new WeakMap<Schema, Set<NodeType>>()
|
|
16
12
|
|
|
@@ -92,37 +88,6 @@ function joinAdjacentListsIn(tr: Transaction, schema: Schema): boolean {
|
|
|
92
88
|
return positions.length > 0
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
/**
|
|
96
|
-
* Whether these transactions carry a remote collaboration update.
|
|
97
|
-
*
|
|
98
|
-
* Collaboration is a consumer-supplied extension (`@tiptap/extension-collaboration`
|
|
99
|
-
* brings y-prosemirror), so its plugin key cannot be imported here. Find it on
|
|
100
|
-
* the live state instead: y-prosemirror tags every remote application with the
|
|
101
|
-
* meta of its `y-sync` plugin.
|
|
102
|
-
*/
|
|
103
|
-
function isRemoteChange(
|
|
104
|
-
transactions: readonly Transaction[],
|
|
105
|
-
state: EditorState,
|
|
106
|
-
): boolean {
|
|
107
|
-
const syncKey = state.plugins.find((plugin) =>
|
|
108
|
-
((plugin.spec.key as PluginKey | undefined)?.key ?? '').startsWith(
|
|
109
|
-
'y-sync',
|
|
110
|
-
),
|
|
111
|
-
)?.spec.key as PluginKey | undefined
|
|
112
|
-
if (!syncKey) return false
|
|
113
|
-
return transactions.some((transaction) => {
|
|
114
|
-
if (transaction.getMeta(syncKey)) return true
|
|
115
|
-
// Another plugin appending to the same dispatch (TrailingNode does, on any
|
|
116
|
-
// structural change) produces a transaction that carries no sync meta of
|
|
117
|
-
// its own. ProseMirror tags it with the root transaction that caused it,
|
|
118
|
-
// so ask that one — otherwise the guard would depend on plugin order.
|
|
119
|
-
const root = transaction.getMeta('appendedTransaction') as
|
|
120
|
-
| Transaction
|
|
121
|
-
| undefined
|
|
122
|
-
return Boolean(root?.getMeta(syncKey))
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
91
|
declare module '@tiptap/core' {
|
|
127
92
|
interface Commands<ReturnType> {
|
|
128
93
|
listJoin: {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { EditorState, PluginKey, Transaction } from '@tiptap/pm/state'
|
|
2
|
+
|
|
3
|
+
/** `PluginKey`'s runtime `key` string, which its public type does not declare. */
|
|
4
|
+
type KeyedPluginKey = PluginKey & { key?: string }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether these transactions carry a remote collaboration update.
|
|
8
|
+
*
|
|
9
|
+
* A remote peer's edit arrives as an ordinary local transaction, so a plugin
|
|
10
|
+
* that rewrites the document in `appendTransaction` cannot tell the two apart
|
|
11
|
+
* without asking. It needs to: that peer runs the same plugin and its own
|
|
12
|
+
* rewrite replicates on its own, so acting here too means two peers editing the
|
|
13
|
+
* same range concurrently — and, worse, silently pushing a local
|
|
14
|
+
* normalisation into someone else's typing.
|
|
15
|
+
*
|
|
16
|
+
* Collaboration is a consumer-supplied extension (`@tiptap/extension-collaboration`
|
|
17
|
+
* brings y-prosemirror), so its plugin key cannot be imported here. Find it on
|
|
18
|
+
* the live state instead: y-prosemirror tags every remote application with the
|
|
19
|
+
* meta of its `y-sync` plugin.
|
|
20
|
+
*/
|
|
21
|
+
export function isRemoteChange(
|
|
22
|
+
transactions: readonly Transaction[],
|
|
23
|
+
state: EditorState,
|
|
24
|
+
): boolean {
|
|
25
|
+
const syncKey = state.plugins.find((plugin) =>
|
|
26
|
+
((plugin.spec.key as KeyedPluginKey | undefined)?.key ?? '').startsWith(
|
|
27
|
+
'y-sync',
|
|
28
|
+
),
|
|
29
|
+
)?.spec.key as PluginKey | undefined
|
|
30
|
+
if (!syncKey) return false
|
|
31
|
+
return transactions.some((transaction) => {
|
|
32
|
+
if (transaction.getMeta(syncKey)) return true
|
|
33
|
+
// Another plugin appending to the same dispatch (TrailingNode does, on any
|
|
34
|
+
// structural change) produces a transaction that carries no sync meta of
|
|
35
|
+
// its own. ProseMirror tags it with the root transaction that caused it,
|
|
36
|
+
// so ask that one — otherwise the guard would depend on plugin order.
|
|
37
|
+
const root = transaction.getMeta('appendedTransaction') as
|
|
38
|
+
| Transaction
|
|
39
|
+
| undefined
|
|
40
|
+
return Boolean(root?.getMeta(syncKey))
|
|
41
|
+
})
|
|
42
|
+
}
|