@portabletext/plugin-emoji-picker 0.0.15

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