@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
@@ -8,15 +8,13 @@ import {
8
8
  it,
9
9
  vi,
10
10
  } from 'vitest'
11
+ import { keyboard } from 'vitest-browser-commands/playwright'
11
12
 
12
13
  import {
13
14
  defineTestExtension,
14
15
  setupTestFromExtension,
15
16
  } from '../testing'
16
- import {
17
- inputText,
18
- pressKey,
19
- } from '../testing/keyboard'
17
+ import { inputText } from '../testing/keyboard'
20
18
 
21
19
  import { defineAutocomplete } from './autocomplete'
22
20
  import {
@@ -26,10 +24,20 @@ import {
26
24
  } from './autocomplete-rule'
27
25
 
28
26
  function setupSlashMenu() {
29
- const regex = canUseRegexLookbehind() ? /(?<!\S)\/(|\S.*)$/u : /\/(|\S.*)$/u
27
+ const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
28
+
29
+ let matching: MatchHandlerOptions | null = null
30
30
 
31
- const onEnter = vi.fn<MatchHandler>()
32
- const onLeave = vi.fn<VoidFunction>()
31
+ const onEnter = vi.fn<MatchHandler>((options) => {
32
+ matching = options
33
+ })
34
+ const onLeave = vi.fn<VoidFunction>(() => {
35
+ if (matching) {
36
+ matching = null
37
+ } else {
38
+ throw new Error('onLeave should not be called when there is no matching')
39
+ }
40
+ })
33
41
 
34
42
  const rule = new AutocompleteRule({ regex, onEnter, onLeave })
35
43
  const extension = union(defineTestExtension(), defineAutocomplete(rule))
@@ -38,16 +46,34 @@ function setupSlashMenu() {
38
46
  const doc = n.doc(n.paragraph('<a>'))
39
47
  editor.set(doc)
40
48
 
41
- const getOnEnterOptions = (): MatchHandlerOptions => {
42
- const parameters = onEnter.mock.calls.at(-1)
43
- const options = parameters?.[0]
44
- if (!options) {
45
- throw new Error('No onEnter options found')
49
+ const isMatching = (): boolean => {
50
+ return !!matching
51
+ }
52
+
53
+ const getMatching = (): MatchHandlerOptions => {
54
+ if (!matching) {
55
+ throw new Error('No matching found')
46
56
  }
47
- return options
57
+ return matching
48
58
  }
49
59
 
50
- return { editor, n, m, onEnter, onLeave, getOnEnterOptions }
60
+ const getMatchingText = (): string => {
61
+ return getMatching().match[0]
62
+ }
63
+
64
+ const showSelection = (): string => {
65
+ const { selection, doc } = editor.state
66
+ const textBackward = doc.textBetween(0, selection.from, '\n')
67
+ const textSelected = doc.textBetween(selection.from, selection.to, '\n')
68
+ const textForward = doc.textBetween(selection.to, doc.content.size, '\n')
69
+ if (selection.empty) {
70
+ return textBackward + '<cursor>' + textForward
71
+ } else {
72
+ return textBackward + '<selection>' + textSelected + '<selection>' + textForward
73
+ }
74
+ }
75
+
76
+ return { editor, n, m, onEnter, onLeave, getMatching, isMatching, getMatchingText, showSelection }
51
77
  }
52
78
 
53
79
  describe('defineAutocomplete', () => {
@@ -82,27 +108,27 @@ describe('defineAutocomplete', () => {
82
108
  expect(onLeave).toHaveBeenCalledTimes(0)
83
109
 
84
110
  // Slash menu should not be triggered when typing "/ "
85
- await pressKey('Space')
111
+ await keyboard.press('Space')
86
112
  expect(onEnter).toHaveBeenCalledTimes(1)
87
113
  expect(onLeave).toHaveBeenCalledTimes(1)
88
114
  })
89
115
 
90
116
  it('can delete the matched text', async () => {
91
- const { editor, onEnter, getOnEnterOptions } = setupSlashMenu()
117
+ const { editor, onEnter, getMatching } = setupSlashMenu()
92
118
 
93
119
  expect(onEnter).not.toHaveBeenCalled()
94
120
 
95
121
  await inputText('/')
96
122
  expect(onEnter).toHaveBeenCalledTimes(1)
97
123
 
98
- const options = getOnEnterOptions()
124
+ const options = getMatching()
99
125
  expect(editor.state.doc.textContent).toBe('/')
100
126
  options.deleteMatch()
101
127
  expect(editor.state.doc.textContent).toBe('')
102
128
  })
103
129
 
104
- it('can ignore the match', async () => {
105
- const { editor, onEnter, onLeave, getOnEnterOptions } = setupSlashMenu()
130
+ it('can ignore the match by calling `ignoreMatch`', async () => {
131
+ const { editor, onEnter, onLeave, getMatching } = setupSlashMenu()
106
132
 
107
133
  expect(onEnter).not.toHaveBeenCalled()
108
134
 
@@ -118,7 +144,7 @@ describe('defineAutocomplete', () => {
118
144
  expect(editor.state.doc.textContent).toBe('/a')
119
145
 
120
146
  // Call `ignoreMatch` to dismiss the match
121
- const options = getOnEnterOptions()
147
+ const options = getMatching()
122
148
  options.ignoreMatch()
123
149
  expect(onEnter).toHaveBeenCalledTimes(2)
124
150
  expect(onLeave).toHaveBeenCalledTimes(1)
@@ -129,4 +155,197 @@ describe('defineAutocomplete', () => {
129
155
  expect(onLeave).toHaveBeenCalledTimes(1)
130
156
  expect(editor.state.doc.textContent).toBe('/aa')
131
157
  })
158
+
159
+ it('can dismiss the match by deleting the matched text', async () => {
160
+ const { isMatching, showSelection } = setupSlashMenu()
161
+
162
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
163
+ expect(isMatching()).toBe(false)
164
+
165
+ await inputText('/')
166
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
167
+ expect(isMatching()).toBe(true)
168
+
169
+ await keyboard.press('Backspace')
170
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
171
+ expect(isMatching()).toBe(false)
172
+ })
173
+
174
+ it('can recover the match after dismissing from Backspace', async () => {
175
+ const { isMatching, showSelection } = setupSlashMenu()
176
+
177
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
178
+ expect(isMatching()).toBe(false)
179
+
180
+ await inputText('/')
181
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
182
+ expect(isMatching()).toBe(true)
183
+
184
+ await keyboard.press('Backspace')
185
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
186
+ expect(isMatching()).toBe(false)
187
+
188
+ await inputText('/')
189
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
190
+ expect(isMatching()).toBe(true)
191
+ })
192
+
193
+ it('can recover the match after dismissing from onLeave', async () => {
194
+ const { isMatching, showSelection, getMatching } = setupSlashMenu()
195
+
196
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
197
+ expect(isMatching()).toBe(false)
198
+
199
+ await inputText('/')
200
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
201
+ expect(isMatching()).toBe(true)
202
+
203
+ const matching = getMatching()
204
+ expect(matching).toBeTruthy()
205
+ matching?.ignoreMatch()
206
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
207
+ expect(isMatching()).toBe(false)
208
+
209
+ await keyboard.press('Backspace')
210
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
211
+ expect(isMatching()).toBe(false)
212
+
213
+ await inputText('/')
214
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
215
+ expect(isMatching()).toBe(true)
216
+ })
217
+
218
+ it('can start a new match after dismissing the previous match', async () => {
219
+ const { isMatching, showSelection, getMatching, getMatchingText } = setupSlashMenu()
220
+
221
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
222
+ expect(isMatching()).toBe(false)
223
+
224
+ await inputText('a /b')
225
+ expect(showSelection()).toMatchInlineSnapshot(`"a /b<cursor>"`)
226
+ expect(isMatching()).toBe(true)
227
+
228
+ getMatching().ignoreMatch()
229
+ expect(showSelection()).toMatchInlineSnapshot(`"a /b<cursor>"`)
230
+ expect(isMatching()).toBe(false)
231
+
232
+ await keyboard.press('Space')
233
+ expect(showSelection()).toMatchInlineSnapshot(`"a /b <cursor>"`)
234
+ expect(isMatching()).toBe(false)
235
+
236
+ await inputText('/')
237
+ expect(showSelection()).toMatchInlineSnapshot(`"a /b /<cursor>"`)
238
+ expect(isMatching()).toBe(true)
239
+ expect(getMatchingText()).toBe('/')
240
+
241
+ await inputText('c')
242
+ expect(showSelection()).toMatchInlineSnapshot(`"a /b /c<cursor>"`)
243
+ expect(isMatching()).toBe(true)
244
+ expect(getMatchingText()).toBe('/c')
245
+ })
246
+
247
+ it('can dismiss the match by creating a new paragraph', async () => {
248
+ const { isMatching, showSelection } = setupSlashMenu()
249
+
250
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
251
+ expect(isMatching()).toBe(false)
252
+
253
+ await inputText('/')
254
+ expect(showSelection()).toMatchInlineSnapshot(`"/<cursor>"`)
255
+ expect(isMatching()).toBe(true)
256
+
257
+ await keyboard.press('Enter')
258
+ expect(showSelection()).toMatchInlineSnapshot(`
259
+ "/
260
+ <cursor>"
261
+ `)
262
+ expect(isMatching()).toBe(false)
263
+ })
264
+
265
+ it('can keep the match when selecting the text', async () => {
266
+ const { isMatching, showSelection } = setupSlashMenu()
267
+
268
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
269
+ expect(isMatching()).toBe(false)
270
+
271
+ await inputText('/page')
272
+ expect(showSelection()).toMatchInlineSnapshot(`"/page<cursor>"`)
273
+ expect(isMatching()).toBe(true)
274
+
275
+ await keyboard.down('Shift')
276
+ await keyboard.press('ArrowLeft')
277
+ await keyboard.press('ArrowLeft')
278
+ await keyboard.up('Shift')
279
+ expect(showSelection()).toMatchInlineSnapshot(`"/pa<selection>ge<selection>"`)
280
+ expect(isMatching()).toBe(true)
281
+ })
282
+
283
+ it('can ignore the match by moving the text cursor outside of the match', async () => {
284
+ const { onEnter, isMatching, getMatchingText, showSelection } = setupSlashMenu()
285
+
286
+ expect(onEnter).not.toHaveBeenCalled()
287
+
288
+ expect(showSelection()).toMatchInlineSnapshot(`"<cursor>"`)
289
+ expect(isMatching()).toBe(false)
290
+
291
+ await inputText('a ')
292
+ expect(showSelection()).toMatchInlineSnapshot(`"a <cursor>"`)
293
+ expect(isMatching()).toBe(false)
294
+
295
+ await inputText('/')
296
+ expect(showSelection()).toMatchInlineSnapshot(`"a /<cursor>"`)
297
+ expect(isMatching()).toBe(true)
298
+ expect(getMatchingText()).toBe('/')
299
+
300
+ await inputText('b')
301
+ expect(showSelection()).toMatchInlineSnapshot(`"a /b<cursor>"`)
302
+ expect(isMatching()).toBe(true)
303
+ expect(getMatchingText()).toBe('/b')
304
+
305
+ await keyboard.press('ArrowLeft')
306
+ expect(showSelection()).toMatchInlineSnapshot(`"a /<cursor>b"`)
307
+ expect(isMatching()).toBe(true)
308
+ expect(getMatchingText()).toBe('/b')
309
+
310
+ await keyboard.press('ArrowLeft')
311
+ expect(showSelection()).toMatchInlineSnapshot(`"a <cursor>/b"`)
312
+ expect(isMatching()).toBe(true)
313
+ expect(getMatchingText()).toBe('/b')
314
+
315
+ await keyboard.press('ArrowLeft')
316
+ expect(showSelection()).toMatchInlineSnapshot(`"a<cursor> /b"`)
317
+ expect(isMatching()).toBe(false)
318
+
319
+ await keyboard.press('ArrowRight')
320
+ expect(showSelection()).toMatchInlineSnapshot(`"a <cursor>/b"`)
321
+ expect(isMatching()).toBe(false)
322
+
323
+ await keyboard.press('ArrowRight')
324
+ expect(showSelection()).toMatchInlineSnapshot(`"a /<cursor>b"`)
325
+ expect(isMatching()).toBe(false)
326
+
327
+ await keyboard.press('Backspace')
328
+ await keyboard.press('Backspace')
329
+ expect(showSelection()).toMatchInlineSnapshot(`"a<cursor>b"`)
330
+ expect(isMatching()).toBe(false)
331
+
332
+ await inputText(' /')
333
+ expect(showSelection()).toMatchInlineSnapshot(`"a /<cursor>b"`)
334
+ expect(isMatching()).toBe(true)
335
+ expect(getMatchingText()).toBe('/')
336
+
337
+ await inputText('c')
338
+ expect(showSelection()).toMatchInlineSnapshot(`"a /c<cursor>b"`)
339
+ expect(isMatching()).toBe(true)
340
+ expect(getMatchingText()).toBe('/c')
341
+
342
+ await inputText('d')
343
+ expect(showSelection()).toMatchInlineSnapshot(`"a /cd<cursor>b"`)
344
+ expect(isMatching()).toBe(true)
345
+ expect(getMatchingText()).toBe('/cd')
346
+
347
+ await keyboard.press('ArrowRight')
348
+ expect(showSelection()).toMatchInlineSnapshot(`"a /cdb<cursor>"`)
349
+ expect(isMatching()).toBe(false)
350
+ })
132
351
  })
@@ -9,6 +9,14 @@ import {
9
9
  import { createAutocompletePlugin } from './autocomplete-plugin'
10
10
  import type { AutocompleteRule } from './autocomplete-rule'
11
11
 
12
+ /**
13
+ * Defines an autocomplete extension that executes logic when the text before
14
+ * the cursor matches the given regular expression.
15
+ *
16
+ * When a match is found, an inline decoration is applied to the matched text
17
+ * with the class `prosekit-autocomplete-match` and a `data-autocomplete-match-text`
18
+ * attribute containing the full matched string.
19
+ */
12
20
  export function defineAutocomplete(rule: AutocompleteRule): Extension {
13
21
  return defineFacetPayload(autocompleteFacet, [rule])
14
22
  }
@@ -3,9 +3,9 @@ import {
3
3
  expect,
4
4
  it,
5
5
  } from 'vitest'
6
+ import { keyboard } from 'vitest-browser-commands/playwright'
6
7
 
7
8
  import { setupTest } from '../testing'
8
- import { pressKey } from '../testing/keyboard'
9
9
 
10
10
  describe('blockquote keymap', () => {
11
11
  it('should wrap paragraph into blockquote with shortcut', async () => {
@@ -14,7 +14,7 @@ describe('blockquote keymap', () => {
14
14
  const doc1 = n.doc(n.p('hel<a>lo'))
15
15
  editor.set(doc1)
16
16
 
17
- await pressKey('mod-shift-b')
17
+ await keyboard.press('ControlOrMeta+Shift+B')
18
18
 
19
19
  const doc2 = n.doc(n.blockquote(n.p('hello')))
20
20
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
@@ -25,7 +25,7 @@ describe('blockquote keymap', () => {
25
25
  const doc1 = n.doc(n.blockquote(n.p('hello')))
26
26
  editor.set(doc1)
27
27
 
28
- await pressKey('mod-shift-b')
28
+ await keyboard.press('ControlOrMeta+Shift+B')
29
29
 
30
30
  const doc2 = n.doc(n.p('hello'))
31
31
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
@@ -37,7 +37,7 @@ describe('blockquote keymap', () => {
37
37
 
38
38
  editor.set(doc1)
39
39
 
40
- await pressKey('Backspace')
40
+ await keyboard.press('Backspace')
41
41
 
42
42
  const doc2 = n.doc(n.p('hello'))
43
43
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
@@ -25,7 +25,7 @@ function backspaceUnsetBlockquote(): Command {
25
25
  */
26
26
  export function defineBlockquoteKeymap(): PlainExtension {
27
27
  return defineKeymap({
28
- 'mod-shift-b': toggleBlockquoteKeybinding(),
28
+ 'Mod-B': toggleBlockquoteKeybinding(),
29
29
  'Backspace': backspaceUnsetBlockquote(),
30
30
  })
31
31
  }
@@ -250,7 +250,7 @@ function defineCommitDecoration(commit: Commit): PlainExtension {
250
250
  */
251
251
  function defineCommitViewer(commit: Commit): PlainExtension {
252
252
  return union(
253
- defineDefaultState({ defaultDoc: commit.doc }),
253
+ defineDefaultState({ defaultContent: commit.doc }),
254
254
  defineCommitDecoration(commit),
255
255
  )
256
256
  }
@@ -3,12 +3,10 @@ import {
3
3
  expect,
4
4
  it,
5
5
  } from 'vitest'
6
+ import { keyboard } from 'vitest-browser-commands/playwright'
6
7
 
7
8
  import { setupTest } from '../testing'
8
- import {
9
- inputText,
10
- pressKey,
11
- } from '../testing/keyboard'
9
+ import { inputText } from '../testing/keyboard'
12
10
 
13
11
  describe('defineHardBreakKeymap', () => {
14
12
  it('should insert hard break', async () => {
@@ -19,12 +17,12 @@ describe('defineHardBreakKeymap', () => {
19
17
 
20
18
  editor.set(doc1)
21
19
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
22
- await pressKey('Shift-Enter')
20
+ await keyboard.press('Shift+Enter')
23
21
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
24
22
 
25
23
  editor.set(doc1)
26
24
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
27
- await pressKey('Mod-Enter')
25
+ await keyboard.press('ControlOrMeta+Enter')
28
26
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
29
27
  })
30
28
 
@@ -37,7 +35,7 @@ describe('defineHardBreakKeymap', () => {
37
35
 
38
36
  editor.set(doc1)
39
37
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
40
- await pressKey('Shift-Enter')
38
+ await keyboard.press('Shift+Enter')
41
39
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
42
40
  await inputText('baz')
43
41
  expect(editor.state.doc.toJSON()).toEqual(doc3.toJSON())
@@ -3,9 +3,9 @@ import {
3
3
  expect,
4
4
  it,
5
5
  } from 'vitest'
6
+ import { keyboard } from 'vitest-browser-commands/playwright'
6
7
 
7
8
  import { setupTest } from '../testing'
8
- import { pressKey } from '../testing/keyboard'
9
9
 
10
10
  describe('defineHeadingKeymap', () => {
11
11
  it('should toggle heading', async () => {
@@ -17,13 +17,13 @@ describe('defineHeadingKeymap', () => {
17
17
 
18
18
  editor.set(doc)
19
19
  expect(editor.state.doc.toJSON()).toEqual(doc.toJSON())
20
- await pressKey('mod-alt-1')
20
+ await keyboard.press('ControlOrMeta+Alt+1')
21
21
  expect(editor.state.doc.toJSON()).toEqual(docH1.toJSON())
22
- await pressKey('mod-alt-1')
22
+ await keyboard.press('ControlOrMeta+Alt+1')
23
23
  expect(editor.state.doc.toJSON()).toEqual(doc.toJSON())
24
- await pressKey('mod-alt-3')
24
+ await keyboard.press('ControlOrMeta+Alt+3')
25
25
  expect(editor.state.doc.toJSON()).toEqual(docH3.toJSON())
26
- await pressKey('mod-alt-1')
26
+ await keyboard.press('ControlOrMeta+Alt+1')
27
27
  expect(editor.state.doc.toJSON()).toEqual(docH1.toJSON())
28
28
  })
29
29
 
@@ -35,7 +35,7 @@ describe('defineHeadingKeymap', () => {
35
35
 
36
36
  editor.set(doc1)
37
37
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
38
- await pressKey('Backspace')
38
+ await keyboard.press('Backspace')
39
39
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
40
40
  })
41
41
 
@@ -47,7 +47,7 @@ describe('defineHeadingKeymap', () => {
47
47
 
48
48
  editor.set(doc1)
49
49
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
50
- await pressKey('Backspace')
50
+ await keyboard.press('Backspace')
51
51
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
52
52
  })
53
53
  })
@@ -29,12 +29,12 @@ const backspaceUnsetHeading: Command = (state, dispatch, view) => {
29
29
  */
30
30
  export function defineHeadingKeymap(): PlainExtension {
31
31
  return defineKeymap({
32
- 'mod-alt-1': toggleHeadingKeybinding(1),
33
- 'mod-alt-2': toggleHeadingKeybinding(2),
34
- 'mod-alt-3': toggleHeadingKeybinding(3),
35
- 'mod-alt-4': toggleHeadingKeybinding(4),
36
- 'mod-alt-5': toggleHeadingKeybinding(5),
37
- 'mod-alt-6': toggleHeadingKeybinding(6),
32
+ 'Mod-Alt-1': toggleHeadingKeybinding(1),
33
+ 'Mod-Alt-2': toggleHeadingKeybinding(2),
34
+ 'Mod-Alt-3': toggleHeadingKeybinding(3),
35
+ 'Mod-Alt-4': toggleHeadingKeybinding(4),
36
+ 'Mod-Alt-5': toggleHeadingKeybinding(5),
37
+ 'Mod-Alt-6': toggleHeadingKeybinding(6),
38
38
  'Backspace': backspaceUnsetHeading,
39
39
  })
40
40
  }
@@ -3,9 +3,10 @@ import {
3
3
  expect,
4
4
  it,
5
5
  } from 'vitest'
6
- import { userEvent } from 'vitest/browser'
6
+ import { keyboard } from 'vitest-browser-commands/playwright'
7
7
 
8
8
  import { setupTest } from '../testing'
9
+ import { inputText } from '../testing/keyboard'
9
10
 
10
11
  describe('defineLinkCommands', () => {
11
12
  const { editor, n, m } = setupTest()
@@ -46,11 +47,11 @@ describe('defineLinkCommands', () => {
46
47
  describe('defineLinkInputRule', () => {
47
48
  it('should insert a link after pressing Space', async () => {
48
49
  const { editor } = setupTest()
49
- await userEvent.keyboard('https://example.com')
50
+ await inputText('https://example.com')
50
51
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
51
52
  `"doc(paragraph("https://example.com"))"`,
52
53
  )
53
- await userEvent.keyboard(' ')
54
+ await inputText(' ')
54
55
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
55
56
  `"doc(paragraph(link("https://example.com"), " "))"`,
56
57
  )
@@ -58,15 +59,15 @@ describe('defineLinkInputRule', () => {
58
59
 
59
60
  it('should handle a link before a period', async () => {
60
61
  const { editor } = setupTest()
61
- await userEvent.keyboard('https://example.com')
62
+ await inputText('https://example.com')
62
63
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
63
64
  `"doc(paragraph("https://example.com"))"`,
64
65
  )
65
- await userEvent.keyboard('.')
66
+ await inputText('.')
66
67
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
67
68
  `"doc(paragraph("https://example.com."))"`,
68
69
  )
69
- await userEvent.keyboard(' ')
70
+ await inputText(' ')
70
71
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
71
72
  `"doc(paragraph(link("https://example.com"), ". "))"`,
72
73
  )
@@ -76,11 +77,11 @@ describe('defineLinkInputRule', () => {
76
77
  describe('defineLinkEnterRule', () => {
77
78
  it('should insert a link after pressing Enter', async () => {
78
79
  const { editor } = setupTest()
79
- await userEvent.keyboard('https://example.com')
80
+ await inputText('https://example.com')
80
81
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
81
82
  `"doc(paragraph("https://example.com"))"`,
82
83
  )
83
- await userEvent.keyboard('{Enter}')
84
+ await keyboard.press('Enter')
84
85
  expect(editor.view.state.doc.toString()).toMatchInlineSnapshot(
85
86
  `"doc(paragraph(link("https://example.com")), paragraph)"`,
86
87
  )
@@ -3,9 +3,9 @@ import {
3
3
  expect,
4
4
  it,
5
5
  } from 'vitest'
6
+ import { keyboard } from 'vitest-browser-commands/playwright'
6
7
 
7
8
  import { setupTest } from '../testing'
8
- import { pressKey } from '../testing/keyboard'
9
9
 
10
10
  describe('keymap', () => {
11
11
  const { editor, n } = setupTest()
@@ -26,14 +26,14 @@ describe('keymap', () => {
26
26
  )
27
27
  editor.set(doc1)
28
28
 
29
- await pressKey('mod-]')
29
+ await keyboard.press('ControlOrMeta+]')
30
30
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
31
- await pressKey('mod-[')
31
+ await keyboard.press('ControlOrMeta+[')
32
32
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
33
33
 
34
- await pressKey('Tab')
34
+ await keyboard.press('Tab')
35
35
  expect(editor.state.doc.toJSON()).toEqual(doc2.toJSON())
36
- await pressKey('Shift-Tab')
36
+ await keyboard.press('Shift+Tab')
37
37
  expect(editor.state.doc.toJSON()).toEqual(doc1.toJSON())
38
38
  })
39
39
  })
@@ -14,17 +14,17 @@
14
14
 
15
15
  & > .list-marker {
16
16
  position: absolute;
17
- left: 0;
18
17
  width: 1.5em;
19
18
  width: 1lh;
20
19
  height: 1.5em;
21
20
  height: 1lh;
21
+ inset-inline-start: 0;
22
22
  text-align: center;
23
23
  }
24
24
 
25
25
  & > .list-content {
26
- margin-left: 1.5em;
27
- margin-left: 1lh;
26
+ margin-inline-start: 1.5em;
27
+ margin-inline-start: 1lh;
28
28
  }
29
29
 
30
30
  &[data-list-kind="bullet"] > .list-marker,
@@ -64,8 +64,8 @@
64
64
 
65
65
  &::before {
66
66
  position: absolute;
67
- right: calc(100% - 1.5em);
68
- right: calc(100% - 1lh);
67
+ inset-inline-end: calc(100% - 1.5em);
68
+ inset-inline-end: calc(100% - 1lh);
69
69
  content: counter(prosemirror-flat-list-counter, decimal) ". ";
70
70
  font-variant-numeric: tabular-nums;
71
71
  }
@@ -2,27 +2,37 @@ import {
2
2
  definePlugin,
3
3
  type PlainExtension,
4
4
  } from '@prosekit/core'
5
+ import type { Plugin } from '@prosekit/pm/state'
5
6
  import type {
6
- EditorState,
7
- Selection,
8
- } from '@prosekit/pm/state'
9
- import type { DecorationAttrs } from '@prosekit/pm/view'
10
- import type { PeerID } from 'loro-crdt'
7
+ CursorAwareness,
8
+ CursorEphemeralStore,
9
+ CursorPluginOptions,
10
+ } from 'loro-prosemirror'
11
11
  import {
12
12
  LoroCursorPlugin,
13
- type CursorAwareness,
13
+ LoroEphemeralCursorPlugin,
14
14
  } from 'loro-prosemirror'
15
15
 
16
- export interface LoroCursorOptions {
17
- awareness: CursorAwareness
18
- getSelection?: (state: EditorState) => Selection
19
- createCursor?: (user: PeerID) => Element
20
- createSelection?: (user: PeerID) => DecorationAttrs
16
+ export interface LoroCursorOptions extends CursorPluginOptions {
17
+ awareness?: CursorAwareness
18
+ presence?: CursorEphemeralStore
21
19
  }
22
20
 
23
21
  export function defineLoroCursorPlugin(
24
22
  options: LoroCursorOptions,
25
23
  ): PlainExtension {
26
- const { awareness, ...rest } = options
27
- return definePlugin(LoroCursorPlugin(awareness, rest))
24
+ return definePlugin(createLoroCursorPlugin(options))
25
+ }
26
+
27
+ function createLoroCursorPlugin(options: LoroCursorOptions): Plugin {
28
+ const { awareness, presence, ...rest } = options
29
+ if (awareness && presence) {
30
+ throw new Error('Only one of awareness and presence can be provided')
31
+ } else if (awareness) {
32
+ return LoroCursorPlugin(awareness, rest)
33
+ } else if (presence) {
34
+ return LoroEphemeralCursorPlugin(presence, rest)
35
+ } else {
36
+ throw new Error('Either awareness or presence must be provided')
37
+ }
28
38
  }