@prosekit/extensions 0.12.2 → 0.13.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 (55) hide show
  1. package/dist/list/style.css +5 -5
  2. package/dist/list/style.css.map +1 -1
  3. package/dist/prosekit-extensions-autocomplete.d.ts +11 -3
  4. package/dist/prosekit-extensions-autocomplete.d.ts.map +1 -1
  5. package/dist/prosekit-extensions-autocomplete.js +171 -60
  6. package/dist/prosekit-extensions-autocomplete.js.map +1 -1
  7. package/dist/prosekit-extensions-blockquote.js +1 -1
  8. package/dist/prosekit-extensions-blockquote.js.map +1 -1
  9. package/dist/prosekit-extensions-code.d.ts.map +1 -1
  10. package/dist/prosekit-extensions-commit.js +1 -1
  11. package/dist/prosekit-extensions-commit.js.map +1 -1
  12. package/dist/prosekit-extensions-heading.d.ts.map +1 -1
  13. package/dist/prosekit-extensions-heading.js +6 -6
  14. package/dist/prosekit-extensions-heading.js.map +1 -1
  15. package/dist/prosekit-extensions-loro.d.ts +16 -17
  16. package/dist/prosekit-extensions-loro.d.ts.map +1 -1
  17. package/dist/prosekit-extensions-loro.js +13 -6
  18. package/dist/prosekit-extensions-loro.js.map +1 -1
  19. package/dist/prosekit-extensions-paragraph.js +1 -1
  20. package/dist/prosekit-extensions-paragraph.js.map +1 -1
  21. package/dist/prosekit-extensions-placeholder.d.ts.map +1 -1
  22. package/dist/prosekit-extensions-placeholder.js +2 -3
  23. package/dist/prosekit-extensions-placeholder.js.map +1 -1
  24. package/dist/prosekit-extensions-strike.js +2 -2
  25. package/dist/prosekit-extensions-strike.js.map +1 -1
  26. package/dist/prosekit-extensions-table.js +0 -1
  27. package/dist/prosekit-extensions-text-align.js +4 -4
  28. package/dist/prosekit-extensions-text-align.js.map +1 -1
  29. package/dist/prosekit-extensions-yjs.js +1 -1
  30. package/dist/prosekit-extensions-yjs.js.map +1 -1
  31. package/package.json +15 -14
  32. package/src/autocomplete/autocomplete-helpers.ts +18 -9
  33. package/src/autocomplete/autocomplete-plugin.ts +261 -117
  34. package/src/autocomplete/autocomplete-rule.ts +3 -3
  35. package/src/autocomplete/autocomplete.spec.ts +239 -20
  36. package/src/autocomplete/autocomplete.ts +8 -0
  37. package/src/blockquote/blockquote-keymap.spec.ts +4 -4
  38. package/src/blockquote/blockquote-keymap.ts +1 -1
  39. package/src/commit/index.ts +1 -1
  40. package/src/hard-break/hard-break-keymap.spec.ts +5 -7
  41. package/src/heading/heading-keymap.spec.ts +7 -7
  42. package/src/heading/heading-keymap.ts +6 -6
  43. package/src/link/index.spec.ts +9 -8
  44. package/src/list/list-keymap.spec.ts +5 -5
  45. package/src/list/style.css +5 -5
  46. package/src/loro/loro-cursor-plugin.ts +23 -13
  47. package/src/loro/loro-keymap.ts +1 -1
  48. package/src/loro/loro.ts +14 -10
  49. package/src/paragraph/paragraph-keymap.ts +1 -1
  50. package/src/placeholder/index.ts +2 -1
  51. package/src/strike/index.ts +2 -2
  52. package/src/testing/index.ts +2 -2
  53. package/src/testing/keyboard.ts +0 -30
  54. package/src/text-align/index.ts +4 -4
  55. package/src/yjs/yjs-keymap.ts +1 -1
@@ -1,9 +1,15 @@
1
1
  import { OBJECT_REPLACEMENT_CHARACTER } from '@prosekit/core'
2
+ import type {
3
+ ProseMirrorNode,
4
+ ResolvedPos,
5
+ } from '@prosekit/pm/model'
2
6
  import {
3
7
  Plugin,
4
8
  type EditorState,
5
9
  type Transaction,
6
10
  } from '@prosekit/pm/state'
11
+ import type { Mapping } from '@prosekit/pm/transform'
12
+ import type { EditorView } from '@prosekit/pm/view'
7
13
  import {
8
14
  Decoration,
9
15
  DecorationSet,
@@ -16,9 +22,27 @@ import {
16
22
  setTrMeta,
17
23
  type PredictionPluginMatching,
18
24
  type PredictionPluginState,
25
+ type PredictionTransactionMeta,
19
26
  } from './autocomplete-helpers'
20
27
  import type { AutocompleteRule } from './autocomplete-rule'
21
28
 
29
+ /**
30
+ * Creates a plugin that handles autocomplete functionality.
31
+ *
32
+ * Workflow:
33
+ *
34
+ * 1. {@link handleTextInput}: called when text is going to be input, but the
35
+ * transaction is not yet created. Injects a new matching as a transaction
36
+ * meta if applicable. This is the only place to create a new matching if
37
+ * there is no existing matching.
38
+ * 2. {@link handleTransaction}: called when a transaction is going to be
39
+ * applied. Updates the plugin state based on the transaction. This step
40
+ * determines if a matching should be created, updated or removed.
41
+ * 3. {@link handleUpdate}: called when the editor state is updated. This is the
42
+ * place to call `onMatch` and register `deleteMatch` and `ignoreMatch`
43
+ * callbacks.
44
+ * 4. {@link getDecorations}: creates the decorations for the current matching.
45
+ */
22
46
  export function createAutocompletePlugin({
23
47
  getRules,
24
48
  }: {
@@ -31,139 +55,252 @@ export function createAutocompletePlugin({
31
55
  init: (): PredictionPluginState => {
32
56
  return { ignores: [], matching: null }
33
57
  },
34
- apply: (
35
- tr: Transaction,
36
- prevValue: PredictionPluginState,
37
- oldState: EditorState,
38
- newState: EditorState,
39
- ): PredictionPluginState => {
40
- const meta = getTrMeta(tr)
41
-
42
- // No changes
43
- if (
44
- !tr.docChanged
45
- && oldState.selection.eq(newState.selection)
46
- && !meta
47
- ) {
48
- return prevValue
49
- }
50
-
51
- // Receiving a meta means that we are ignoring a match
52
- if (meta) {
53
- let ignores = prevValue.ignores
54
- if (!ignores.includes(meta.ignore)) {
55
- ignores = [...ignores, meta.ignore]
56
- }
57
- return { matching: null, ignores }
58
- }
59
-
60
- // Calculate the new ignores
61
- const ignoreSet = new Set(prevValue.ignores.map(pos => tr.mapping.map(pos)))
62
-
63
- // Calculate the new matching
64
- let matching = calcPluginStateMatching(newState, getRules())
65
-
66
- // Check if the matching should be ignored
67
- if (matching && ignoreSet.has(matching.from)) {
68
- matching = null
69
- }
70
-
71
- // Return the new matching and ignores
72
- return { matching, ignores: Array.from(ignoreSet) }
58
+ apply: (tr, prevValue, oldState, newState): PredictionPluginState => {
59
+ return handleTransaction(tr, prevValue, oldState, newState, getRules)
73
60
  },
74
61
  },
75
62
 
76
63
  view: () => ({
77
- update: (view, prevState) => {
78
- const prevValue = getPluginState(prevState)
79
- const currValue = getPluginState(view.state)
80
-
81
- if (
82
- prevValue?.matching
83
- && prevValue.matching.rule !== currValue?.matching?.rule
84
- ) {
85
- // Deactivate the previous rule
86
- prevValue.matching.rule.onLeave?.()
87
- }
88
-
89
- if (
90
- currValue?.matching
91
- && !currValue.ignores.includes(currValue.matching.from)
92
- ) {
93
- // Activate the current rule
94
-
95
- const { from, to, match, rule } = currValue.matching
96
-
97
- const textContent = view.state.doc.textBetween(
98
- from,
99
- to,
100
- null,
101
- OBJECT_REPLACEMENT_CHARACTER,
102
- )
103
-
104
- const deleteMatch = () => {
105
- if (
106
- view.state.doc.textBetween(
107
- from,
108
- to,
109
- null,
110
- OBJECT_REPLACEMENT_CHARACTER,
111
- ) === textContent
112
- ) {
113
- view.dispatch(view.state.tr.delete(from, to))
114
- }
115
- }
116
-
117
- const ignoreMatch = () => {
118
- view.dispatch(
119
- setTrMeta(view.state.tr, { ignore: from }),
120
- )
121
- }
122
-
123
- rule.onMatch({
124
- state: view.state,
125
- match,
126
- from,
127
- to,
128
- deleteMatch,
129
- ignoreMatch,
130
- })
131
- }
132
- },
64
+ update: handleUpdate,
133
65
  }),
134
66
 
135
67
  props: {
136
- decorations: (state: EditorState) => {
137
- const pluginState = getPluginState(state)
138
- if (pluginState?.matching) {
139
- const { from, to } = pluginState.matching
140
- const deco = Decoration.inline(from, to, {
141
- class: 'prosemirror-prediction-match',
142
- })
143
- return DecorationSet.create(state.doc, [deco])
68
+ handleTextInput: (view, from, to, textAdded, getTr) => {
69
+ const meta = handleTextInput(view, from, to, textAdded, getRules)
70
+ if (meta) {
71
+ const tr = getTr()
72
+ setTrMeta(tr, meta)
73
+ view.dispatch(tr)
74
+ return true
144
75
  }
145
- return null
76
+ return false
146
77
  },
78
+ decorations: getDecorations,
147
79
  },
148
80
  })
149
81
  }
150
82
 
151
- const MAX_MATCH = 200
83
+ function handleTextInput(
84
+ view: EditorView,
85
+ from: number,
86
+ to: number,
87
+ textAdded: string,
88
+ getRules: () => AutocompleteRule[],
89
+ ): PredictionTransactionMeta | undefined {
90
+ // Only handle insertions
91
+ if (from !== to) {
92
+ return
93
+ }
152
94
 
153
- function calcPluginStateMatching(
154
- state: EditorState,
155
- rules: AutocompleteRule[],
156
- ): PredictionPluginMatching | null {
157
- const $pos = state.selection.$from
95
+ const textBackward = getTextBackward(view.state.doc.resolve(from))
96
+ const textFull = textBackward + textAdded
97
+ const textTo = to + textAdded.length
98
+ const textFrom = textTo - textFull.length
158
99
 
159
- const parentOffset: number = $pos.parentOffset
100
+ const pluginState = getPluginState(view.state)
101
+ const ignores = pluginState?.ignores ?? []
102
+
103
+ const currMatching = matchRule(
104
+ view.state,
105
+ getRules(),
106
+ textFull,
107
+ textFrom,
108
+ textTo,
109
+ ignores,
110
+ )
111
+
112
+ if (currMatching) {
113
+ return { type: 'enter', matching: currMatching }
114
+ }
115
+ }
116
+
117
+ function handleTransaction(
118
+ tr: Transaction,
119
+ prevValue: PredictionPluginState,
120
+ oldState: EditorState,
121
+ newState: EditorState,
122
+ getRules: () => AutocompleteRule[],
123
+ ): PredictionPluginState {
124
+ const meta = getTrMeta(tr)
125
+
126
+ if (
127
+ !meta
128
+ && !tr.docChanged
129
+ && oldState.selection.eq(newState.selection)
130
+ ) {
131
+ // No changes
132
+ return prevValue
133
+ }
134
+
135
+ // Handle position mapping changes
136
+ const ignoreSet = new Set<number>()
137
+ for (const ignore of prevValue.ignores) {
138
+ const result = tr.mapping.mapResult(ignore)
139
+ if (!result.deletedBefore && !result.deletedAfter) {
140
+ ignoreSet.add(result.pos)
141
+ }
142
+ }
143
+ const ignores = Array.from(ignoreSet)
144
+
145
+ const prevMatching = prevValue.matching && mapMatching(prevValue.matching, tr.mapping)
146
+
147
+ // If there is no new matching from `handleTextInput`
148
+ if (!meta) {
149
+ if (!prevMatching) {
150
+ return { matching: null, ignores }
151
+ }
152
+
153
+ const { selection } = newState
154
+ // If the text selection is before the matching or after the matching,
155
+ // we leave the matching
156
+ if (selection.to < prevMatching.from || selection.from > prevMatching.to) {
157
+ ignores.push(prevMatching.from)
158
+ return { matching: null, ignores }
159
+ }
160
+
161
+ // Get the text between the existing matching
162
+ const text = getTextBetween(newState.doc, prevMatching.from, prevMatching.to)
163
+ // Check the text again to see if it still matches the rule
164
+ const currMatching = matchRule(
165
+ newState,
166
+ getRules(),
167
+ text,
168
+ prevMatching.from,
169
+ prevMatching.to,
170
+ ignores,
171
+ )
172
+ return { matching: currMatching ?? null, ignores }
173
+ }
174
+
175
+ // If a new matching is being entered from `handleTextInput`
176
+ if (meta.type === 'enter') {
177
+ // Ignore the previous matching if it is not the same as the new matching
178
+ if (prevMatching && prevMatching.from !== meta.matching.from) {
179
+ ignores.push(prevMatching.from)
180
+ }
181
+
182
+ // Return the new matching
183
+ return { matching: meta.matching, ignores }
184
+ }
185
+
186
+ // If a matching is being exited
187
+ if (meta.type === 'leave') {
188
+ if (prevMatching) {
189
+ ignores.push(prevMatching.from)
190
+ }
191
+ return { matching: null, ignores }
192
+ }
193
+
194
+ throw new Error(`Invalid transaction meta: ${meta satisfies never}`)
195
+ }
196
+
197
+ function handleUpdate(view: EditorView, prevState: EditorState): void {
198
+ const prevValue = getPluginState(prevState)
199
+ const currValue = getPluginState(view.state)
200
+
201
+ if (!prevValue || !currValue) {
202
+ // Should not happen
203
+ return
204
+ }
205
+
206
+ const prevMatching = prevValue.matching
207
+ const currMatching = currValue.matching
208
+
209
+ // Deactivate the previous rule
210
+ if (prevMatching && prevMatching.rule !== currMatching?.rule) {
211
+ prevMatching.rule.onLeave?.()
212
+ }
213
+
214
+ // Activate the current rule
215
+ if (currMatching) {
216
+ const { from, to, match, rule } = currMatching
217
+
218
+ const textSnapshot = getTextBetween(view.state.doc, from, to)
219
+
220
+ const deleteMatch = () => {
221
+ if (getTextBetween(view.state.doc, from, to) === textSnapshot) {
222
+ view.dispatch(view.state.tr.delete(from, to))
223
+ }
224
+ }
225
+
226
+ const ignoreMatch = () => {
227
+ view.dispatch(
228
+ setTrMeta(view.state.tr, { type: 'leave' }),
229
+ )
230
+ }
231
+
232
+ rule.onMatch({
233
+ state: view.state,
234
+ match,
235
+ from,
236
+ to,
237
+ deleteMatch,
238
+ ignoreMatch,
239
+ })
240
+ }
241
+ }
242
+
243
+ function getDecorations(state: EditorState): DecorationSet | null {
244
+ const pluginState = getPluginState(state)
245
+ if (pluginState?.matching) {
246
+ const { from, to, match } = pluginState.matching
247
+ const deco = Decoration.inline(from, to, {
248
+ 'class': 'prosekit-autocomplete-match',
249
+ 'data-autocomplete-match-text': match[0],
250
+ })
251
+ return DecorationSet.create(state.doc, [deco])
252
+ }
253
+ return null
254
+ }
255
+
256
+ const MAX_MATCH = 200
160
257
 
161
- const textBefore: string = $pos.parent.textBetween(
258
+ /** Get the text before the given position at the current block. */
259
+ function getTextBackward($pos: ResolvedPos): string {
260
+ const parentOffset: number = $pos.parentOffset
261
+ return getTextBetween(
262
+ $pos.parent,
162
263
  Math.max(0, parentOffset - MAX_MATCH),
163
264
  parentOffset,
265
+ )
266
+ }
267
+
268
+ function getTextBetween(node: ProseMirrorNode, from: number, to: number): string {
269
+ return node.textBetween(
270
+ from,
271
+ to,
164
272
  null,
165
273
  OBJECT_REPLACEMENT_CHARACTER,
166
274
  )
275
+ }
276
+
277
+ function matchRule(
278
+ state: EditorState,
279
+ rules: AutocompleteRule[],
280
+ text: string,
281
+ textFrom: number,
282
+ textTo: number,
283
+ ignores: Array<number>,
284
+ ): PredictionPluginMatching | undefined {
285
+ // Find the rightmost ignore point within the text range
286
+ let maxIgnore = -1
287
+ for (const ignore of ignores) {
288
+ if (ignore >= textFrom && ignore < textTo && ignore > maxIgnore) {
289
+ maxIgnore = ignore
290
+ }
291
+ }
292
+
293
+ // If an ignore point is within the text range, we ignore the text to the left
294
+ // of the ignore point (including the character right after the ignore point).
295
+ if (maxIgnore >= 0) {
296
+ const cut = maxIgnore + 1 - textFrom
297
+ text = text.slice(cut)
298
+ textFrom += cut
299
+ }
300
+
301
+ if (textFrom >= textTo || !text) {
302
+ return
303
+ }
167
304
 
168
305
  for (const rule of rules) {
169
306
  if (!rule.canMatch({ state })) {
@@ -171,16 +308,23 @@ function calcPluginStateMatching(
171
308
  }
172
309
 
173
310
  rule.regex.lastIndex = 0
174
- const match = rule.regex.exec(textBefore)
311
+ const match = rule.regex.exec(text)
175
312
  if (!match) {
176
313
  continue
177
314
  }
178
315
 
179
- const to = $pos.pos
180
- const from = to - textBefore.length + match.index
316
+ const matchTo = textTo
317
+ const matchFrom = textFrom + match.index
181
318
 
182
- return { rule, match, from, to }
319
+ return { rule, match, from: matchFrom, to: matchTo }
183
320
  }
321
+ }
184
322
 
185
- return null
323
+ function mapMatching(matching: PredictionPluginMatching, mapping: Mapping): PredictionPluginMatching {
324
+ return {
325
+ rule: matching.rule,
326
+ match: matching.match,
327
+ from: mapping.map(matching.from),
328
+ to: mapping.map(matching.to, -1),
329
+ }
186
330
  }
@@ -69,7 +69,7 @@ export interface AutocompleteRuleOptions {
69
69
  * The regular expression to match against the text before the cursor. The
70
70
  * last match before the cursor is used.
71
71
  *
72
- * For a slash menu, you might use `/(?<!\S)\/(|\S.*)$/u`.
72
+ * For a slash menu, you might use `/(?<!\S)\/(\S.*)?$/u`.
73
73
  * For a mention, you might use `/@\w*$/`
74
74
  */
75
75
  regex: RegExp
@@ -87,8 +87,8 @@ export interface AutocompleteRuleOptions {
87
87
 
88
88
  /**
89
89
  * A predicate to determine if the rule can be applied in the current editor
90
- * state. If not provided, it defaults to only allowing matches in empty
91
- * selections that are not inside a code block or code mark.
90
+ * state. If not provided, it defaults to only allowing matches that are not
91
+ * inside a code block or code mark.
92
92
  */
93
93
  canMatch?: CanMatchPredicate
94
94
  }