@portabletext/plugin-emoji-picker 1.0.3 → 1.0.5

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.
@@ -1,8 +1,9 @@
1
1
  import type {
2
- BlockOffset,
2
+ ChildPath,
3
3
  Editor,
4
- EditorSelectionPoint,
4
+ EditorSelector,
5
5
  EditorSnapshot,
6
+ PortableTextSpan,
6
7
  } from '@portabletext/editor'
7
8
  import {
8
9
  defineBehavior,
@@ -10,19 +11,30 @@ import {
10
11
  forward,
11
12
  raise,
12
13
  } from '@portabletext/editor/behaviors'
13
- import * as selectors from '@portabletext/editor/selectors'
14
- import * as utils from '@portabletext/editor/utils'
14
+ import {
15
+ getFocusSpan,
16
+ getMarkState,
17
+ getNextSpan,
18
+ getPreviousSpan,
19
+ isPointAfterSelection,
20
+ isPointBeforeSelection,
21
+ type MarkState,
22
+ } from '@portabletext/editor/selectors'
23
+ import {
24
+ isEqualSelectionPoints,
25
+ isSelectionCollapsed,
26
+ } from '@portabletext/editor/utils'
15
27
  import {createKeyboardShortcut} from '@portabletext/keyboard-shortcuts'
16
28
  import {
17
29
  defineInputRule,
18
30
  defineInputRuleBehavior,
31
+ type InputRuleMatch,
19
32
  } from '@portabletext/plugin-input-rule'
20
33
  import {
21
34
  assertEvent,
22
35
  assign,
23
36
  fromCallback,
24
37
  not,
25
- or,
26
38
  sendTo,
27
39
  setup,
28
40
  type AnyEventObject,
@@ -49,6 +61,165 @@ const escapeShortcut = createKeyboardShortcut({
49
61
  default: [{key: 'Escape'}],
50
62
  })
51
63
 
64
+ const getTriggerState: EditorSelector<
65
+ | {
66
+ focusSpan: {
67
+ node: PortableTextSpan
68
+ path: ChildPath
69
+ }
70
+ markState: MarkState
71
+ focusSpanTextBefore: string
72
+ focusSpanTextAfter: string
73
+ previousSpan:
74
+ | {
75
+ node: PortableTextSpan
76
+ path: ChildPath
77
+ }
78
+ | undefined
79
+ nextSpan:
80
+ | {
81
+ node: PortableTextSpan
82
+ path: ChildPath
83
+ }
84
+ | undefined
85
+ }
86
+ | undefined
87
+ > = (snapshot) => {
88
+ const focusSpan = getFocusSpan(snapshot)
89
+ const markState = getMarkState(snapshot)
90
+
91
+ if (!focusSpan || !markState || !snapshot.context.selection) {
92
+ return undefined
93
+ }
94
+
95
+ const focusSpanTextBefore = focusSpan.node.text.slice(
96
+ 0,
97
+ snapshot.context.selection.focus.offset,
98
+ )
99
+ const focusSpanTextAfter = focusSpan.node.text.slice(
100
+ snapshot.context.selection.focus.offset,
101
+ )
102
+ const previousSpan = getPreviousSpan(snapshot)
103
+ const nextSpan = getNextSpan(snapshot)
104
+
105
+ return {
106
+ focusSpan,
107
+ markState,
108
+ focusSpanTextBefore,
109
+ focusSpanTextAfter,
110
+ previousSpan,
111
+ nextSpan,
112
+ }
113
+ }
114
+
115
+ function createTriggerActions({
116
+ snapshot,
117
+ payload,
118
+ keywordState,
119
+ }: {
120
+ snapshot: EditorSnapshot
121
+ payload: ReturnType<typeof getTriggerState> & {lastMatch: InputRuleMatch}
122
+ keywordState: 'partial' | 'complete'
123
+ }) {
124
+ if (payload.markState.state === 'unchanged') {
125
+ const focusSpan = {
126
+ node: {
127
+ _key: payload.focusSpan.node._key,
128
+ _type: payload.focusSpan.node._type,
129
+ text: `${payload.focusSpanTextBefore}${payload.lastMatch.text}${payload.focusSpanTextAfter}`,
130
+ marks: payload.markState.marks,
131
+ },
132
+ path: payload.focusSpan.path,
133
+ textBefore: payload.focusSpanTextBefore,
134
+ textAfter: payload.focusSpanTextAfter,
135
+ }
136
+
137
+ if (keywordState === 'complete') {
138
+ return [
139
+ raise(
140
+ createKeywordFoundEvent({
141
+ focusSpan,
142
+ }),
143
+ ),
144
+ ]
145
+ }
146
+
147
+ return [
148
+ raise(
149
+ createTriggerFoundEvent({
150
+ focusSpan,
151
+ }),
152
+ ),
153
+ ]
154
+ }
155
+
156
+ const newSpan = {
157
+ _key: snapshot.context.keyGenerator(),
158
+ _type: payload.focusSpan.node._type,
159
+ text: payload.lastMatch.text,
160
+ marks: payload.markState.marks,
161
+ }
162
+
163
+ let focusSpan = {
164
+ node: {
165
+ _key: newSpan._key,
166
+ _type: newSpan._type,
167
+ text: `${newSpan.text}${payload.nextSpan?.node.text ?? payload.focusSpanTextAfter}`,
168
+ marks: payload.markState.marks,
169
+ },
170
+ path: [
171
+ {_key: payload.focusSpan.path[0]._key},
172
+ 'children',
173
+ {_key: newSpan._key},
174
+ ] satisfies ChildPath,
175
+ textBefore: '',
176
+ textAfter: payload.nextSpan?.node.text ?? payload.focusSpanTextAfter,
177
+ }
178
+
179
+ if (
180
+ payload.previousSpan &&
181
+ payload.focusSpanTextBefore.length === 0 &&
182
+ JSON.stringify(payload.previousSpan.node.marks ?? []) ===
183
+ JSON.stringify(payload.markState.marks)
184
+ ) {
185
+ // The text will be inserted into the previous span, so we'll treat that
186
+ // as the focus span
187
+
188
+ focusSpan = {
189
+ node: {
190
+ _key: payload.previousSpan.node._key,
191
+ _type: newSpan._type,
192
+ text: `${payload.previousSpan.node.text}${newSpan.text}`,
193
+ marks: newSpan.marks,
194
+ },
195
+ path: payload.previousSpan.path,
196
+ textBefore: payload.previousSpan.node.text,
197
+ textAfter: '',
198
+ }
199
+ }
200
+
201
+ return [
202
+ raise({type: 'select', at: payload.lastMatch.targetOffsets}),
203
+ raise({type: 'delete', at: payload.lastMatch.targetOffsets}),
204
+ raise({type: 'insert.child', child: newSpan}),
205
+ ...(keywordState === 'complete'
206
+ ? [
207
+ raise(
208
+ createKeywordFoundEvent({
209
+ focusSpan,
210
+ }),
211
+ ),
212
+ ]
213
+ : [
214
+ raise(
215
+ createTriggerFoundEvent({
216
+ focusSpan,
217
+ }),
218
+ ),
219
+ ]),
220
+ ]
221
+ }
222
+
52
223
  /*******************
53
224
  * Input Rules
54
225
  *******************/
@@ -58,34 +229,39 @@ const escapeShortcut = createKeyboardShortcut({
58
229
  */
59
230
  const triggerRule = defineInputRule({
60
231
  on: /:/,
61
- guard: ({event}) => {
232
+ guard: ({snapshot, event}) => {
62
233
  const lastMatch = event.matches.at(-1)
63
234
 
64
235
  if (lastMatch === undefined) {
65
236
  return false
66
237
  }
67
238
 
239
+ const triggerState = getTriggerState(snapshot)
240
+
241
+ if (!triggerState) {
242
+ return false
243
+ }
244
+
68
245
  return {
69
- keyword: lastMatch.text,
70
- keywordAnchor: {
71
- point: lastMatch.selection.anchor,
72
- blockOffset: lastMatch.targetOffsets.anchor,
73
- },
74
- keywordFocus: lastMatch.targetOffsets.focus,
246
+ lastMatch,
247
+ ...triggerState,
75
248
  }
76
249
  },
77
- actions: [(_, payload) => [raise(createTriggerFoundEvent(payload))]],
250
+ actions: [
251
+ ({snapshot}, payload) =>
252
+ createTriggerActions({snapshot, payload, keywordState: 'partial'}),
253
+ ],
78
254
  })
79
255
 
80
256
  type TriggerFoundEvent = ReturnType<typeof createTriggerFoundEvent>
81
257
 
82
258
  function createTriggerFoundEvent(payload: {
83
- keyword: string
84
- keywordAnchor: {
85
- point: EditorSelectionPoint
86
- blockOffset: BlockOffset
259
+ focusSpan: {
260
+ node: PortableTextSpan
261
+ path: ChildPath
262
+ textBefore: string
263
+ textAfter: string
87
264
  }
88
- keywordFocus: BlockOffset
89
265
  }) {
90
266
  return {
91
267
  type: 'custom.trigger found',
@@ -98,76 +274,76 @@ function createTriggerFoundEvent(payload: {
98
274
  */
99
275
  const partialKeywordRule = defineInputRule({
100
276
  on: /:[\S]+/,
101
- guard: ({event}) => {
277
+ guard: ({snapshot, event}) => {
102
278
  const lastMatch = event.matches.at(-1)
103
279
 
104
280
  if (lastMatch === undefined) {
105
281
  return false
106
282
  }
107
283
 
108
- const keyword = lastMatch.text
109
- const keywordAnchor = {
110
- point: lastMatch.selection.anchor,
111
- blockOffset: lastMatch.targetOffsets.anchor,
284
+ if (lastMatch.targetOffsets.anchor.offset < event.textBefore.length) {
285
+ return false
112
286
  }
113
- const keywordFocus = lastMatch.targetOffsets.focus
114
287
 
115
- return {keyword, keywordAnchor, keywordFocus}
116
- },
117
- actions: [(_, payload) => [raise(createPartialKeywordFoundEvent(payload))]],
118
- })
288
+ const triggerState = getTriggerState(snapshot)
119
289
 
120
- type PartialKeywordFoundEvent = ReturnType<
121
- typeof createPartialKeywordFoundEvent
122
- >
290
+ if (!triggerState) {
291
+ return false
292
+ }
123
293
 
124
- function createPartialKeywordFoundEvent(payload: {
125
- keyword: string
126
- keywordAnchor: {
127
- point: EditorSelectionPoint
128
- blockOffset: BlockOffset
129
- }
130
- keywordFocus: BlockOffset
131
- }) {
132
- return {
133
- type: 'custom.partial keyword found',
134
- ...payload,
135
- } as const
136
- }
294
+ return {
295
+ ...triggerState,
296
+ lastMatch,
297
+ }
298
+ },
299
+ actions: [
300
+ ({snapshot}, payload) =>
301
+ createTriggerActions({snapshot, payload, keywordState: 'partial'}),
302
+ ],
303
+ })
137
304
 
138
305
  /**
139
306
  * Listen for a complete keyword like ":joy:"
140
307
  */
141
308
  const keywordRule = defineInputRule({
142
309
  on: /:[\S]+:/,
143
- guard: ({event}) => {
310
+ guard: ({snapshot, event}) => {
144
311
  const lastMatch = event.matches.at(-1)
145
312
 
146
313
  if (lastMatch === undefined) {
147
314
  return false
148
315
  }
149
316
 
150
- const keyword = lastMatch.text
151
- const keywordAnchor = {
152
- point: lastMatch.selection.anchor,
153
- blockOffset: lastMatch.targetOffsets.anchor,
317
+ if (lastMatch.targetOffsets.anchor.offset < event.textBefore.length) {
318
+ return false
319
+ }
320
+
321
+ const triggerState = getTriggerState(snapshot)
322
+
323
+ if (!triggerState) {
324
+ return false
154
325
  }
155
- const keywordFocus = lastMatch.targetOffsets.focus
156
326
 
157
- return {keyword, keywordAnchor, keywordFocus}
327
+ return {
328
+ ...triggerState,
329
+ lastMatch,
330
+ }
158
331
  },
159
- actions: [(_, payload) => [raise(createKeywordFoundEvent(payload))]],
332
+ actions: [
333
+ ({snapshot}, payload) =>
334
+ createTriggerActions({snapshot, payload, keywordState: 'complete'}),
335
+ ],
160
336
  })
161
337
 
162
338
  type KeywordFoundEvent = ReturnType<typeof createKeywordFoundEvent>
163
339
 
164
340
  function createKeywordFoundEvent(payload: {
165
- keyword: string
166
- keywordAnchor: {
167
- point: EditorSelectionPoint
168
- blockOffset: BlockOffset
341
+ focusSpan: {
342
+ node: PortableTextSpan
343
+ path: ChildPath
344
+ textBefore: string
345
+ textAfter: string
169
346
  }
170
- keywordFocus: BlockOffset
171
347
  }) {
172
348
  return {
173
349
  type: 'custom.keyword found',
@@ -180,37 +356,23 @@ type EmojiPickerContext = {
180
356
  matches: ReadonlyArray<BaseEmojiMatch>
181
357
  matchEmojis: MatchEmojis<BaseEmojiMatch>
182
358
  selectedIndex: number
183
- keywordAnchor:
359
+ focusSpan:
184
360
  | {
185
- point: EditorSelectionPoint
186
- blockOffset: BlockOffset
361
+ node: PortableTextSpan
362
+ path: ChildPath
363
+ textBefore: string
364
+ textAfter: string
187
365
  }
188
366
  | undefined
189
- keywordFocus: BlockOffset | undefined
190
367
  incompleteKeywordRegex: RegExp
191
368
  keyword: string
192
369
  }
193
370
 
194
371
  type EmojiPickerEvent =
195
372
  | TriggerFoundEvent
196
- | PartialKeywordFoundEvent
197
373
  | KeywordFoundEvent
198
374
  | {
199
375
  type: 'selection changed'
200
- snapshot: EditorSnapshot
201
- }
202
- | {
203
- type: 'insert.text'
204
- focus: EditorSelectionPoint
205
- text: string
206
- }
207
- | {
208
- type: 'delete.backward'
209
- focus: EditorSelectionPoint
210
- }
211
- | {
212
- type: 'delete.forward'
213
- focus: EditorSelectionPoint
214
376
  }
215
377
  | {
216
378
  type: 'dismiss'
@@ -252,21 +414,6 @@ const triggerListenerCallback: CallbackLogicFunction<
252
414
  ],
253
415
  }),
254
416
  }),
255
- input.editor.registerBehavior({
256
- behavior: defineBehavior<
257
- PartialKeywordFoundEvent,
258
- PartialKeywordFoundEvent['type']
259
- >({
260
- on: 'custom.partial keyword found',
261
- actions: [
262
- ({event}) => [
263
- effect(() => {
264
- sendBack(event)
265
- }),
266
- ],
267
- ],
268
- }),
269
- }),
270
417
  input.editor.registerBehavior({
271
418
  behavior: defineBehavior<TriggerFoundEvent, TriggerFoundEvent['type']>({
272
419
  on: 'custom.trigger found',
@@ -357,8 +504,12 @@ const emojiInsertListener: CallbackLogicFunction<
357
504
  return input.context.editor.registerBehavior({
358
505
  behavior: defineBehavior<{
359
506
  emoji: string
360
- anchor: BlockOffset
361
- focus: BlockOffset
507
+ focusSpan: {
508
+ node: PortableTextSpan
509
+ path: ChildPath
510
+ textBefore: string
511
+ textAfter: string
512
+ }
362
513
  }>({
363
514
  on: 'custom.insert emoji',
364
515
  actions: [
@@ -367,8 +518,19 @@ const emojiInsertListener: CallbackLogicFunction<
367
518
  sendBack({type: 'dismiss'})
368
519
  }),
369
520
  raise({
370
- type: 'delete.text',
371
- at: {anchor: event.anchor, focus: event.focus},
521
+ type: 'delete',
522
+ at: {
523
+ anchor: {
524
+ path: event.focusSpan.path,
525
+ offset: event.focusSpan.textBefore.length,
526
+ },
527
+ focus: {
528
+ path: event.focusSpan.path,
529
+ offset:
530
+ event.focusSpan.node.text.length -
531
+ event.focusSpan.textAfter.length,
532
+ },
533
+ },
372
534
  }),
373
535
  raise({
374
536
  type: 'insert.text',
@@ -403,21 +565,17 @@ const submitListenerCallback: CallbackLogicFunction<
403
565
  return false
404
566
  }
405
567
 
406
- const anchor = context.keywordAnchor?.blockOffset
407
- const focus = context.keywordFocus
568
+ const focusSpan = context.focusSpan
408
569
  const match = context.matches[context.selectedIndex]
409
570
 
410
- return match && anchor && focus
411
- ? {anchor, focus, emoji: match.emoji}
412
- : false
571
+ return match && focusSpan ? {focusSpan, emoji: match.emoji} : false
413
572
  },
414
573
  actions: [
415
- (_, {anchor, focus, emoji}) => [
574
+ (_, {focusSpan, emoji}) => [
416
575
  raise({
417
576
  type: 'custom.insert emoji',
418
577
  emoji,
419
- anchor,
420
- focus,
578
+ focusSpan,
421
579
  }),
422
580
  ],
423
581
  ],
@@ -438,31 +596,6 @@ const submitListenerCallback: CallbackLogicFunction<
438
596
  ],
439
597
  }),
440
598
  }),
441
- input.context.editor.registerBehavior({
442
- behavior: defineInputRuleBehavior({
443
- rules: [keywordRule],
444
- }),
445
- }),
446
- input.context.editor.registerBehavior({
447
- behavior: defineBehavior<
448
- KeywordFoundEvent,
449
- KeywordFoundEvent['type'],
450
- {
451
- anchor: BlockOffset
452
- focus: BlockOffset
453
- emoji: string
454
- }
455
- >({
456
- on: 'custom.keyword found',
457
- actions: [
458
- ({event}) => [
459
- effect(() => {
460
- sendBack(event)
461
- }),
462
- ],
463
- ],
464
- }),
465
- }),
466
599
  ]
467
600
 
468
601
  return () => {
@@ -478,86 +611,55 @@ const selectionListenerCallback: CallbackLogicFunction<
478
611
  {editor: Editor}
479
612
  > = ({sendBack, input}) => {
480
613
  const subscription = input.editor.on('selection', () => {
481
- const snapshot = input.editor.getSnapshot()
482
- sendBack({type: 'selection changed', snapshot})
614
+ sendBack({type: 'selection changed'})
483
615
  })
484
616
 
485
617
  return subscription.unsubscribe
486
618
  }
487
619
 
488
- const textChangeListener: CallbackLogicFunction<
489
- AnyEventObject,
620
+ const textInsertionListenerCallback: CallbackLogicFunction<
621
+ {type: 'context changed'; context: EmojiPickerContext},
490
622
  EmojiPickerEvent,
491
- {editor: Editor}
492
- > = ({sendBack, input}) => {
493
- const unregisterBehaviors = [
494
- input.editor.registerBehavior({
495
- behavior: defineBehavior({
496
- on: 'insert.text',
497
- guard: ({snapshot}) =>
498
- snapshot.context.selection
499
- ? {focus: snapshot.context.selection.focus}
500
- : false,
501
- actions: [
502
- ({event}, {focus}) => [
503
- effect(() => {
504
- sendBack({
505
- ...event,
506
- focus,
507
- })
508
- }),
509
- forward(event),
510
- ],
511
- ],
512
- }),
513
- }),
514
- input.editor.registerBehavior({
515
- behavior: defineBehavior({
516
- on: 'delete.backward',
517
- guard: ({snapshot, event}) =>
518
- event.unit === 'character' && snapshot.context.selection
519
- ? {focus: snapshot.context.selection.focus}
520
- : false,
521
- actions: [
522
- ({event}, {focus}) => [
523
- effect(() => {
524
- sendBack({
525
- type: 'delete.backward',
526
- focus,
527
- })
528
- }),
529
- forward(event),
530
- ],
531
- ],
532
- }),
533
- }),
534
- input.editor.registerBehavior({
535
- behavior: defineBehavior({
536
- on: 'delete.forward',
537
- guard: ({snapshot, event}) =>
538
- event.unit === 'character' && snapshot.context.selection
539
- ? {focus: snapshot.context.selection.focus}
540
- : false,
541
- actions: [
542
- ({event}, {focus}) => [
543
- effect(() => {
544
- sendBack({
545
- type: 'delete.forward',
546
- focus,
547
- })
548
- }),
549
- forward(event),
550
- ],
623
+ {context: EmojiPickerContext}
624
+ > = ({sendBack, input, receive}) => {
625
+ let context = input.context
626
+
627
+ receive((event) => {
628
+ context = event.context
629
+ })
630
+
631
+ return input.context.editor.registerBehavior({
632
+ behavior: defineBehavior({
633
+ on: 'insert.text',
634
+ guard: ({snapshot}) => {
635
+ if (!context.focusSpan) {
636
+ return false
637
+ }
638
+
639
+ if (!snapshot.context.selection) {
640
+ return false
641
+ }
642
+
643
+ const keywordAnchor = {
644
+ path: context.focusSpan.path,
645
+ offset: context.focusSpan.textBefore.length,
646
+ }
647
+
648
+ return isEqualSelectionPoints(
649
+ snapshot.context.selection.focus,
650
+ keywordAnchor,
651
+ )
652
+ },
653
+ actions: [
654
+ ({event}) => [
655
+ forward(event),
656
+ effect(() => {
657
+ sendBack({type: 'dismiss'})
658
+ }),
551
659
  ],
552
- }),
660
+ ],
553
661
  }),
554
- ]
555
-
556
- return () => {
557
- for (const unregister of unregisterBehaviors) {
558
- unregister()
559
- }
560
- }
662
+ })
561
663
  }
562
664
 
563
665
  export const emojiPickerMachine = setup({
@@ -576,96 +678,139 @@ export const emojiPickerMachine = setup({
576
678
  'trigger listener': fromCallback(triggerListenerCallback),
577
679
  'escape listener': fromCallback(escapeListenerCallback),
578
680
  'selection listener': fromCallback(selectionListenerCallback),
579
- 'text change listener': fromCallback(textChangeListener),
681
+ 'text insertion listener': fromCallback(textInsertionListenerCallback),
580
682
  },
581
683
  actions: {
582
- 'init keyword': assign({
583
- keyword: ({context, event}) => {
684
+ 'set focus span': assign({
685
+ focusSpan: ({context, event}) => {
584
686
  if (
585
687
  event.type !== 'custom.trigger found' &&
586
- event.type !== 'custom.partial keyword found' &&
587
688
  event.type !== 'custom.keyword found'
588
689
  ) {
589
- return context.keyword
690
+ return context.focusSpan
590
691
  }
591
692
 
592
- return event.keyword
693
+ return event.focusSpan
593
694
  },
594
695
  }),
595
- 'set keyword anchor': assign({
596
- keywordAnchor: ({context, event}) => {
597
- if (
598
- event.type !== 'custom.trigger found' &&
599
- event.type !== 'custom.partial keyword found' &&
600
- event.type !== 'custom.keyword found'
601
- ) {
602
- return context.keywordAnchor
696
+ 'update focus span': assign({
697
+ focusSpan: ({context}) => {
698
+ if (!context.focusSpan) {
699
+ return undefined
603
700
  }
604
701
 
605
- return event.keywordAnchor
606
- },
607
- }),
608
- 'set keyword focus': assign({
609
- keywordFocus: ({context, event}) => {
702
+ const snapshot = context.editor.getSnapshot()
703
+ const focusSpan = getFocusSpan(snapshot)
704
+
705
+ if (!snapshot.context.selection) {
706
+ return undefined
707
+ }
708
+
709
+ if (!focusSpan) {
710
+ return undefined
711
+ }
712
+
713
+ const nextSpan = getNextSpan({
714
+ ...snapshot,
715
+ context: {
716
+ ...snapshot.context,
717
+ selection: {
718
+ anchor: {
719
+ path: context.focusSpan.path,
720
+ offset: 0,
721
+ },
722
+ focus: {
723
+ path: context.focusSpan.path,
724
+ offset: 0,
725
+ },
726
+ },
727
+ },
728
+ })
729
+
610
730
  if (
611
- event.type !== 'custom.trigger found' &&
612
- event.type !== 'custom.partial keyword found' &&
613
- event.type !== 'custom.keyword found'
731
+ JSON.stringify(focusSpan.path) !==
732
+ JSON.stringify(context.focusSpan.path)
614
733
  ) {
615
- return context.keywordFocus
734
+ if (
735
+ nextSpan &&
736
+ context.focusSpan.textAfter.length === 0 &&
737
+ snapshot.context.selection.focus.offset === 0 &&
738
+ isSelectionCollapsed(snapshot.context.selection)
739
+ ) {
740
+ // This is an edge case where the caret is moved from the end of
741
+ // the focus span to the start of the next span.
742
+ return context.focusSpan
743
+ }
744
+
745
+ return undefined
616
746
  }
617
747
 
618
- return event.keywordFocus
619
- },
620
- }),
621
- 'update keyword focus': assign({
622
- keywordFocus: ({context, event}) => {
623
- assertEvent(event, ['insert.text', 'delete.backward', 'delete.forward'])
748
+ if (!focusSpan.node.text.startsWith(context.focusSpan.textBefore)) {
749
+ return undefined
750
+ }
624
751
 
625
- if (!context.keywordFocus) {
626
- return context.keywordFocus
752
+ if (!focusSpan.node.text.endsWith(context.focusSpan.textAfter)) {
753
+ return undefined
627
754
  }
628
755
 
629
- return {
630
- path: context.keywordFocus.path,
756
+ const keywordAnchor = {
757
+ path: focusSpan.path,
758
+ offset: context.focusSpan.textBefore.length,
759
+ }
760
+ const keywordFocus = {
761
+ path: focusSpan.path,
631
762
  offset:
632
- event.type === 'insert.text'
633
- ? context.keywordFocus.offset + event.text.length
634
- : event.type === 'delete.backward' ||
635
- event.type === 'delete.forward'
636
- ? context.keywordFocus.offset - 1
637
- : event.focus.offset,
763
+ focusSpan.node.text.length - context.focusSpan.textAfter.length,
764
+ }
765
+
766
+ const selectionIsBeforeKeyword =
767
+ isPointAfterSelection(keywordAnchor)(snapshot)
768
+
769
+ const selectionIsAfterKeyword =
770
+ isPointBeforeSelection(keywordFocus)(snapshot)
771
+
772
+ if (selectionIsBeforeKeyword || selectionIsAfterKeyword) {
773
+ return undefined
774
+ }
775
+
776
+ return {
777
+ node: focusSpan.node,
778
+ path: focusSpan.path,
779
+ textBefore: context.focusSpan.textBefore,
780
+ textAfter: context.focusSpan.textAfter,
638
781
  }
639
782
  },
640
783
  }),
641
784
  'update keyword': assign({
642
- keyword: ({context, event}) => {
643
- assertEvent(event, 'selection changed')
644
-
645
- if (!context.keywordAnchor || !context.keywordFocus) {
785
+ keyword: ({context}) => {
786
+ if (!context.focusSpan) {
646
787
  return ''
647
788
  }
648
789
 
649
- const keywordFocusPoint = utils.blockOffsetToSpanSelectionPoint({
650
- context: event.snapshot.context,
651
- blockOffset: context.keywordFocus,
652
- direction: 'forward',
653
- })
790
+ if (
791
+ context.focusSpan.textBefore.length > 0 &&
792
+ context.focusSpan.textAfter.length > 0
793
+ ) {
794
+ return context.focusSpan.node.text.slice(
795
+ context.focusSpan.textBefore.length,
796
+ -context.focusSpan.textAfter.length,
797
+ )
798
+ }
654
799
 
655
- if (!keywordFocusPoint) {
656
- return ''
800
+ if (context.focusSpan.textBefore.length > 0) {
801
+ return context.focusSpan.node.text.slice(
802
+ context.focusSpan.textBefore.length,
803
+ )
657
804
  }
658
805
 
659
- return selectors.getSelectionText({
660
- ...event.snapshot,
661
- context: {
662
- ...event.snapshot.context,
663
- selection: {
664
- anchor: context.keywordAnchor.point,
665
- focus: keywordFocusPoint,
666
- },
667
- },
668
- })
806
+ if (context.focusSpan.textAfter.length > 0) {
807
+ return context.focusSpan.node.text.slice(
808
+ 0,
809
+ -context.focusSpan.textAfter.length,
810
+ )
811
+ }
812
+
813
+ return context.focusSpan.node.text
669
814
  },
670
815
  }),
671
816
  'update matches': assign({
@@ -713,132 +858,75 @@ export const emojiPickerMachine = setup({
713
858
  return event.index
714
859
  },
715
860
  }),
716
- 'update emoji insert listener context': sendTo(
717
- 'emoji insert listener',
861
+ 'update submit listener context': sendTo(
862
+ 'submit listener',
718
863
  ({context}) => ({
719
864
  type: 'context changed',
720
865
  context,
721
866
  }),
722
867
  ),
723
- 'update submit listener context': sendTo(
724
- 'submit listener',
868
+ 'update text insertion listener context': sendTo(
869
+ 'text insertion listener',
725
870
  ({context}) => ({
726
871
  type: 'context changed',
727
872
  context,
728
873
  }),
729
874
  ),
730
- 'insert selected match': ({context, event}) => {
875
+ 'insert selected match': ({context}) => {
731
876
  const match = context.matches[context.selectedIndex]
732
877
 
733
- if (!match || !context.keywordAnchor || !context.keywordFocus) {
734
- return
735
- }
736
-
737
- if (event.type === 'custom.keyword found' && match.type !== 'exact') {
878
+ if (!match || !context.focusSpan) {
738
879
  return
739
880
  }
740
881
 
741
882
  context.editor.send({
742
883
  type: 'custom.insert emoji',
743
884
  emoji: match.emoji,
744
- anchor: context.keywordAnchor.blockOffset,
745
- focus: context.keywordFocus,
885
+ focusSpan: context.focusSpan,
746
886
  })
747
887
  },
748
888
  'reset': assign({
749
- keywordAnchor: undefined,
750
- keywordFocus: undefined,
889
+ focusSpan: undefined,
751
890
  keyword: '',
752
891
  matches: [],
753
892
  selectedIndex: 0,
754
893
  }),
755
894
  },
756
895
  guards: {
896
+ 'no focus span': ({context}) => {
897
+ return !context.focusSpan
898
+ },
757
899
  'has matches': ({context}) => {
758
900
  return context.matches.length > 0
759
901
  },
760
902
  'no matches': not('has matches'),
761
- 'keyword is wel-formed': ({context}) => {
762
- return context.incompleteKeywordRegex.test(context.keyword)
903
+ 'keyword is malformed': ({context}) => {
904
+ return !context.incompleteKeywordRegex.test(context.keyword)
763
905
  },
764
- 'keyword is malformed': not('keyword is wel-formed'),
765
- 'selection is before keyword': ({context, event}) => {
766
- assertEvent(event, 'selection changed')
906
+ 'keyword is direct match': ({context}) => {
907
+ const fullKeywordRegex = /^:[\S]+:$/
767
908
 
768
- if (!context.keywordAnchor) {
769
- return true
770
- }
771
-
772
- return selectors.isPointAfterSelection(context.keywordAnchor.point)(
773
- event.snapshot,
774
- )
775
- },
776
- 'selection is after keyword': ({context, event}) => {
777
- assertEvent(event, 'selection changed')
778
-
779
- if (context.keywordFocus === undefined) {
780
- return true
781
- }
782
-
783
- const keywordFocusPoint = utils.blockOffsetToSpanSelectionPoint({
784
- context: event.snapshot.context,
785
- blockOffset: context.keywordFocus,
786
- direction: 'forward',
787
- })
788
-
789
- if (!keywordFocusPoint) {
790
- return true
791
- }
792
-
793
- return selectors.isPointBeforeSelection(keywordFocusPoint)(event.snapshot)
794
- },
795
- 'selection is expanded': ({event}) => {
796
- assertEvent(event, 'selection changed')
797
-
798
- return selectors.isSelectionExpanded(event.snapshot)
799
- },
800
- 'selection moved unexpectedly': or([
801
- 'selection is before keyword',
802
- 'selection is after keyword',
803
- 'selection is expanded',
804
- ]),
805
- 'unexpected text insertion': ({context, event}) => {
806
- if (event.type !== 'insert.text') {
909
+ if (!fullKeywordRegex.test(context.keyword)) {
807
910
  return false
808
911
  }
809
912
 
810
- if (!context.keywordAnchor) {
913
+ const match = context.matches.at(context.selectedIndex)
914
+
915
+ if (!match || match.type !== 'exact') {
811
916
  return false
812
917
  }
813
918
 
814
- const snapshot = context.editor.getSnapshot()
815
-
816
- const textInsertedBeforeKeyword =
817
- selectors.isPointBeforeSelection(event.focus)({
818
- ...snapshot,
819
- context: {
820
- ...snapshot.context,
821
- selection: {
822
- anchor: context.keywordAnchor.point,
823
- focus: context.keywordAnchor.point,
824
- },
825
- },
826
- }) ||
827
- utils.isEqualSelectionPoints(event.focus, context.keywordAnchor.point)
828
-
829
- return textInsertedBeforeKeyword
919
+ return true
830
920
  },
831
921
  },
832
922
  }).createMachine({
833
- /** @xstate-layout N4IgpgJg5mDOIC5RgLYHsBWBLABABywGMBrMAJwDosIAbMAYkLRrQDsctXZyAXSAbQAMAXUSg8aWFh5Y2YkAA9EARmUB2AJwUAzADYNADgCs65YO1q1ygDQgAnojUG1O5c+W7tAJmdfdRgF8A21RMXAIScgpuAEMyQgALTih6Tm4yHgo+BR4hUSQQCSkZOQKlBABaZW0DHSMAFksNQUF6oyNzL1sHBC9vCnrGtS8GtQaNNt0gkPRsfCJSSlj4pNYUiDA6PgoAMzQyAHc4iDz5IulZVnlyr3MKE30LA31DZoNuxD6vAaHdP29vOo1NNwLNwgsostEsl6BstmAKAAjGIkI5kE4iM6SC6lUDlerabT3arDfS3eoaPQfXr9QaWEaNcaTEGhOYRRbRMBxaFrWFYWAofmwU4Fc4lK5lFTqWqDAwUjReeoGbwaNTU1TPCh-QxNNS6XQGHwssHzSJLLkrGHcOiEcU4RIxNYCTGi7Hi66IfxE1oeZX1EbeZXUhUUZSKjxOOV6bTmY1hU0cqGrFLWsC2y72hKOmAnZT5cRuy4ehDVXQUVXDIyWGpmAzveyfLRKwQaVRVwR1ixeONsiHm7nJ+gigvFIuSkvKVuhgwaEwKmMKwbqkaCAaverqVuvKbBUHx9mQi08qAUVhoHAoGI8RJwHCwBJoA4w4eFQu4xSfaoDA3KOmCVSTn06r-i4-hGH0ZiEoI4H1D24JmpyA7JNED5PmsF5XjesD0KwMQAG5YFAV5gDgECPqwL5imOeKIIM9ShsoRh1i2erPB41L6C40GqISUb6n8cEJoeSFrChj7JBh14JHAOH4YRxE4AArnglFvhKNEIGoq4+E0yraPULa6PUHGqhQ3HVDUBL8dogkHv2lqife4noZeUkybhBFEXwOA8Ggqmju+5RGPqFBqP6ujDD4v4tjYDYIMqLjVKo5gdM4TGBLurLwYmR7JmJaFQJJWGpFwvB3psaZ8BARUJP5OLqR+CD1Loq5ymYfyeP4spGOqv70fpv7NBSBKtBlMz7n2iEOSeTkFTVMl1e645eB49wGP+K0UpBbjaMBzQUF4IzBYq7TNa2QS7meGzwAUWVCWQWIBQ15RVJq2ijJoLRtB03jUhUVYUHKtz-gqeoxkYGi2ZN1B0I99XFqobTlh4-5-Ot4H1j0ZirbcENhR4RmKrBmUmnZU3HnDS0aVUjHlh2cqtkqfTBdSSr0RMGieBS7htASUMIUmyFnvNsB3qhySU9RjUVBFdN1ltTPvbowZOGZEWHe9xgTHo-M5SJM3iy5mHSTdI7w+OlnlgY6heJzbgUv4ytxaqtSCBFg1eJFM4XQEQA */
834
923
  id: 'emoji picker',
835
924
  context: ({input}) => ({
836
925
  editor: input.editor,
837
926
  keyword: '',
838
- keywordAnchor: undefined,
839
- keywordFocus: undefined,
927
+ focusSpan: undefined,
840
928
  matchEmojis: input.matchEmojis,
841
- incompleteKeywordRegex: /:[\S]*$/,
929
+ incompleteKeywordRegex: /^:[\S]*$/,
842
930
  matches: [],
843
931
  selectedIndex: 0,
844
932
  }),
@@ -860,17 +948,12 @@ export const emojiPickerMachine = setup({
860
948
  on: {
861
949
  'custom.trigger found': {
862
950
  target: 'searching',
863
- actions: ['set keyword anchor', 'set keyword focus', 'init keyword'],
864
- },
865
- 'custom.partial keyword found': {
866
- target: 'searching',
867
- actions: ['set keyword anchor', 'set keyword focus', 'init keyword'],
951
+ actions: ['set focus span', 'update keyword'],
868
952
  },
869
953
  'custom.keyword found': {
870
954
  actions: [
871
- 'set keyword anchor',
872
- 'set keyword focus',
873
- 'init keyword',
955
+ 'set focus span',
956
+ 'update keyword',
874
957
  'update matches',
875
958
  'insert selected match',
876
959
  ],
@@ -895,58 +978,42 @@ export const emojiPickerMachine = setup({
895
978
  input: ({context}) => ({editor: context.editor}),
896
979
  },
897
980
  {
898
- src: 'text change listener',
899
- input: ({context}) => ({editor: context.editor}),
981
+ src: 'text insertion listener',
982
+ id: 'text insertion listener',
983
+ input: ({context}) => ({context}),
900
984
  },
901
985
  ],
902
986
  on: {
903
- 'custom.keyword found': {
904
- actions: [
905
- 'set keyword anchor',
906
- 'set keyword focus',
907
- 'init keyword',
908
- 'update matches',
909
- 'insert selected match',
910
- ],
911
- },
912
- 'insert.text': [
913
- {
914
- guard: 'unexpected text insertion',
915
- target: 'idle',
916
- },
917
- {
918
- actions: ['update keyword focus'],
919
- },
920
- ],
921
- 'delete.forward': {
922
- actions: ['update keyword focus'],
923
- },
924
- 'delete.backward': {
925
- actions: ['update keyword focus'],
926
- },
927
987
  'dismiss': {
928
988
  target: 'idle',
929
989
  },
930
990
  'selection changed': [
931
- {
932
- guard: 'selection moved unexpectedly',
933
- target: 'idle',
934
- },
935
991
  {
936
992
  actions: [
993
+ 'update focus span',
937
994
  'update keyword',
938
995
  'update matches',
939
996
  'reset selected index',
940
997
  'update submit listener context',
998
+ 'update text insertion listener context',
941
999
  ],
942
1000
  },
943
1001
  ],
944
1002
  },
945
1003
  always: [
1004
+ {
1005
+ guard: 'no focus span',
1006
+ target: 'idle',
1007
+ },
946
1008
  {
947
1009
  guard: 'keyword is malformed',
948
1010
  target: 'idle',
949
1011
  },
1012
+ {
1013
+ guard: 'keyword is direct match',
1014
+ actions: ['insert selected match'],
1015
+ target: 'idle',
1016
+ },
950
1017
  ],
951
1018
  initial: 'no matches showing',
952
1019
  states: {