@portabletext/plugin-emoji-picker 3.0.23 → 3.0.25

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,1080 +0,0 @@
1
- import type {
2
- ChildPath,
3
- Editor,
4
- EditorSelector,
5
- EditorSnapshot,
6
- PortableTextSpan,
7
- } from '@portabletext/editor'
8
- import {
9
- defineBehavior,
10
- effect,
11
- forward,
12
- raise,
13
- } from '@portabletext/editor/behaviors'
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'
27
- import {createKeyboardShortcut} from '@portabletext/keyboard-shortcuts'
28
- import {
29
- defineInputRule,
30
- defineInputRuleBehavior,
31
- type InputRuleMatch,
32
- } from '@portabletext/plugin-input-rule'
33
- import {
34
- assertEvent,
35
- assign,
36
- fromCallback,
37
- not,
38
- sendTo,
39
- setup,
40
- type AnyEventObject,
41
- type CallbackLogicFunction,
42
- } from 'xstate'
43
- import type {BaseEmojiMatch, MatchEmojis} from './match-emojis'
44
-
45
- /*******************
46
- * Keyboard shortcuts
47
- *******************/
48
- const arrowUpShortcut = createKeyboardShortcut({
49
- default: [{key: 'ArrowUp'}],
50
- })
51
- const arrowDownShortcut = createKeyboardShortcut({
52
- default: [{key: 'ArrowDown'}],
53
- })
54
- const enterShortcut = createKeyboardShortcut({
55
- default: [{key: 'Enter'}],
56
- })
57
- const tabShortcut = createKeyboardShortcut({
58
- default: [{key: 'Tab'}],
59
- })
60
- const escapeShortcut = createKeyboardShortcut({
61
- default: [{key: 'Escape'}],
62
- })
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
-
223
- /*******************
224
- * Input Rules
225
- *******************/
226
-
227
- /**
228
- * Listen for a single colon insertion
229
- */
230
- const triggerRule = defineInputRule({
231
- on: /:/,
232
- guard: ({snapshot, event}) => {
233
- const lastMatch = event.matches.at(-1)
234
-
235
- if (lastMatch === undefined) {
236
- return false
237
- }
238
-
239
- const triggerState = getTriggerState(snapshot)
240
-
241
- if (!triggerState) {
242
- return false
243
- }
244
-
245
- return {
246
- lastMatch,
247
- ...triggerState,
248
- }
249
- },
250
- actions: [
251
- ({snapshot}, payload) =>
252
- createTriggerActions({snapshot, payload, keywordState: 'partial'}),
253
- ],
254
- })
255
-
256
- type TriggerFoundEvent = ReturnType<typeof createTriggerFoundEvent>
257
-
258
- function createTriggerFoundEvent(payload: {
259
- focusSpan: {
260
- node: PortableTextSpan
261
- path: ChildPath
262
- textBefore: string
263
- textAfter: string
264
- }
265
- }) {
266
- return {
267
- type: 'custom.trigger found',
268
- ...payload,
269
- } as const
270
- }
271
-
272
- /**
273
- * Listen for a partial keyword like ":joy"
274
- */
275
- const partialKeywordRule = defineInputRule({
276
- on: /:[\S]+/,
277
- guard: ({snapshot, event}) => {
278
- const lastMatch = event.matches.at(-1)
279
-
280
- if (lastMatch === undefined) {
281
- return false
282
- }
283
-
284
- if (lastMatch.targetOffsets.anchor.offset < event.textBefore.length) {
285
- return false
286
- }
287
-
288
- const triggerState = getTriggerState(snapshot)
289
-
290
- if (!triggerState) {
291
- return false
292
- }
293
-
294
- return {
295
- ...triggerState,
296
- lastMatch,
297
- }
298
- },
299
- actions: [
300
- ({snapshot}, payload) =>
301
- createTriggerActions({snapshot, payload, keywordState: 'partial'}),
302
- ],
303
- })
304
-
305
- /**
306
- * Listen for a complete keyword like ":joy:"
307
- */
308
- const keywordRule = defineInputRule({
309
- on: /:[\S]+:/,
310
- guard: ({snapshot, event}) => {
311
- const lastMatch = event.matches.at(-1)
312
-
313
- if (lastMatch === undefined) {
314
- return false
315
- }
316
-
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
325
- }
326
-
327
- return {
328
- ...triggerState,
329
- lastMatch,
330
- }
331
- },
332
- actions: [
333
- ({snapshot}, payload) =>
334
- createTriggerActions({snapshot, payload, keywordState: 'complete'}),
335
- ],
336
- })
337
-
338
- type KeywordFoundEvent = ReturnType<typeof createKeywordFoundEvent>
339
-
340
- function createKeywordFoundEvent(payload: {
341
- focusSpan: {
342
- node: PortableTextSpan
343
- path: ChildPath
344
- textBefore: string
345
- textAfter: string
346
- }
347
- }) {
348
- return {
349
- type: 'custom.keyword found',
350
- ...payload,
351
- } as const
352
- }
353
-
354
- type EmojiPickerContext = {
355
- editor: Editor
356
- matches: ReadonlyArray<BaseEmojiMatch>
357
- matchEmojis: MatchEmojis<BaseEmojiMatch>
358
- selectedIndex: number
359
- focusSpan:
360
- | {
361
- node: PortableTextSpan
362
- path: ChildPath
363
- textBefore: string
364
- textAfter: string
365
- }
366
- | undefined
367
- incompleteKeywordRegex: RegExp
368
- keyword: string
369
- }
370
-
371
- type EmojiPickerEvent =
372
- | TriggerFoundEvent
373
- | KeywordFoundEvent
374
- | {
375
- type: 'selection changed'
376
- }
377
- | {
378
- type: 'dismiss'
379
- }
380
- | {
381
- type: 'navigate down'
382
- }
383
- | {
384
- type: 'navigate up'
385
- }
386
- | {
387
- type: 'navigate to'
388
- index: number
389
- }
390
- | {
391
- type: 'insert selected match'
392
- }
393
-
394
- const triggerListenerCallback: CallbackLogicFunction<
395
- AnyEventObject,
396
- EmojiPickerEvent,
397
- {editor: Editor}
398
- > = ({sendBack, input}) => {
399
- const unregisterBehaviors = [
400
- input.editor.registerBehavior({
401
- behavior: defineInputRuleBehavior({
402
- rules: [keywordRule, partialKeywordRule, triggerRule],
403
- }),
404
- }),
405
- input.editor.registerBehavior({
406
- behavior: defineBehavior<KeywordFoundEvent, KeywordFoundEvent['type']>({
407
- on: 'custom.keyword found',
408
- actions: [
409
- ({event}) => [
410
- effect(() => {
411
- sendBack(event)
412
- }),
413
- ],
414
- ],
415
- }),
416
- }),
417
- input.editor.registerBehavior({
418
- behavior: defineBehavior<TriggerFoundEvent, TriggerFoundEvent['type']>({
419
- on: 'custom.trigger found',
420
- actions: [
421
- ({event}) => [
422
- effect(() => {
423
- sendBack(event)
424
- }),
425
- ],
426
- ],
427
- }),
428
- }),
429
- ]
430
-
431
- return () => {
432
- for (const unregister of unregisterBehaviors) {
433
- unregister()
434
- }
435
- }
436
- }
437
-
438
- const escapeListenerCallback: CallbackLogicFunction<
439
- AnyEventObject,
440
- EmojiPickerEvent,
441
- {editor: Editor}
442
- > = ({sendBack, input}) => {
443
- return input.editor.registerBehavior({
444
- behavior: defineBehavior({
445
- on: 'keyboard.keydown',
446
- guard: ({event}) => escapeShortcut.guard(event.originEvent),
447
- actions: [
448
- () => [
449
- effect(() => {
450
- sendBack({type: 'dismiss'})
451
- }),
452
- ],
453
- ],
454
- }),
455
- })
456
- }
457
-
458
- const arrowListenerCallback: CallbackLogicFunction<
459
- AnyEventObject,
460
- EmojiPickerEvent,
461
- {editor: Editor}
462
- > = ({sendBack, input}) => {
463
- const unregisterBehaviors = [
464
- input.editor.registerBehavior({
465
- behavior: defineBehavior({
466
- on: 'keyboard.keydown',
467
- guard: ({event}) => arrowDownShortcut.guard(event.originEvent),
468
- actions: [
469
- () => [
470
- effect(() => {
471
- sendBack({type: 'navigate down'})
472
- }),
473
- ],
474
- ],
475
- }),
476
- }),
477
- input.editor.registerBehavior({
478
- behavior: defineBehavior({
479
- on: 'keyboard.keydown',
480
- guard: ({event}) => arrowUpShortcut.guard(event.originEvent),
481
- actions: [
482
- () => [
483
- effect(() => {
484
- sendBack({type: 'navigate up'})
485
- }),
486
- ],
487
- ],
488
- }),
489
- }),
490
- ]
491
-
492
- return () => {
493
- for (const unregister of unregisterBehaviors) {
494
- unregister()
495
- }
496
- }
497
- }
498
-
499
- const emojiInsertListener: CallbackLogicFunction<
500
- AnyEventObject,
501
- EmojiPickerEvent,
502
- {context: EmojiPickerContext}
503
- > = ({sendBack, input}) => {
504
- return input.context.editor.registerBehavior({
505
- behavior: defineBehavior<{
506
- emoji: string
507
- focusSpan: {
508
- node: PortableTextSpan
509
- path: ChildPath
510
- textBefore: string
511
- textAfter: string
512
- }
513
- }>({
514
- on: 'custom.insert emoji',
515
- actions: [
516
- ({event}) => [
517
- effect(() => {
518
- sendBack({type: 'dismiss'})
519
- }),
520
- raise({
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
- },
534
- }),
535
- raise({
536
- type: 'insert.text',
537
- text: event.emoji,
538
- }),
539
- ],
540
- ],
541
- }),
542
- })
543
- }
544
-
545
- const submitListenerCallback: CallbackLogicFunction<
546
- {type: 'context changed'; context: EmojiPickerContext},
547
- EmojiPickerEvent,
548
- {context: EmojiPickerContext}
549
- > = ({sendBack, input, receive}) => {
550
- let context = input.context
551
-
552
- receive((event) => {
553
- context = event.context
554
- })
555
-
556
- const unregisterBehaviors = [
557
- input.context.editor.registerBehavior({
558
- behavior: defineBehavior({
559
- on: 'keyboard.keydown',
560
- guard: ({event}) => {
561
- if (
562
- !enterShortcut.guard(event.originEvent) &&
563
- !tabShortcut.guard(event.originEvent)
564
- ) {
565
- return false
566
- }
567
-
568
- const focusSpan = context.focusSpan
569
- const match = context.matches[context.selectedIndex]
570
-
571
- return match && focusSpan ? {focusSpan, emoji: match.emoji} : false
572
- },
573
- actions: [
574
- (_, {focusSpan, emoji}) => [
575
- raise({
576
- type: 'custom.insert emoji',
577
- emoji,
578
- focusSpan,
579
- }),
580
- ],
581
- ],
582
- }),
583
- }),
584
- input.context.editor.registerBehavior({
585
- behavior: defineBehavior({
586
- on: 'keyboard.keydown',
587
- guard: ({event}) =>
588
- (enterShortcut.guard(event.originEvent) ||
589
- tabShortcut.guard(event.originEvent)) &&
590
- context.keyword.length === 1,
591
- actions: [
592
- ({event}) => [
593
- forward(event),
594
- effect(() => {
595
- sendBack({type: 'dismiss'})
596
- }),
597
- ],
598
- ],
599
- }),
600
- }),
601
- input.context.editor.registerBehavior({
602
- behavior: defineBehavior({
603
- on: 'keyboard.keydown',
604
- guard: ({event}) =>
605
- (enterShortcut.guard(event.originEvent) ||
606
- tabShortcut.guard(event.originEvent)) &&
607
- context.keyword.length > 1,
608
- actions: [
609
- () => [
610
- effect(() => {
611
- sendBack({type: 'dismiss'})
612
- }),
613
- ],
614
- ],
615
- }),
616
- }),
617
- ]
618
-
619
- return () => {
620
- for (const unregister of unregisterBehaviors) {
621
- unregister()
622
- }
623
- }
624
- }
625
-
626
- const selectionListenerCallback: CallbackLogicFunction<
627
- AnyEventObject,
628
- EmojiPickerEvent,
629
- {editor: Editor}
630
- > = ({sendBack, input}) => {
631
- const subscription = input.editor.on('selection', () => {
632
- sendBack({type: 'selection changed'})
633
- })
634
-
635
- return subscription.unsubscribe
636
- }
637
-
638
- const textInsertionListenerCallback: CallbackLogicFunction<
639
- {type: 'context changed'; context: EmojiPickerContext},
640
- EmojiPickerEvent,
641
- {context: EmojiPickerContext}
642
- > = ({sendBack, input, receive}) => {
643
- let context = input.context
644
-
645
- receive((event) => {
646
- context = event.context
647
- })
648
-
649
- return input.context.editor.registerBehavior({
650
- behavior: defineBehavior({
651
- on: 'insert.text',
652
- guard: ({snapshot}) => {
653
- if (!context.focusSpan) {
654
- return false
655
- }
656
-
657
- if (!snapshot.context.selection) {
658
- return false
659
- }
660
-
661
- const keywordAnchor = {
662
- path: context.focusSpan.path,
663
- offset: context.focusSpan.textBefore.length,
664
- }
665
-
666
- return isEqualSelectionPoints(
667
- snapshot.context.selection.focus,
668
- keywordAnchor,
669
- )
670
- },
671
- actions: [
672
- ({event}) => [
673
- forward(event),
674
- effect(() => {
675
- sendBack({type: 'dismiss'})
676
- }),
677
- ],
678
- ],
679
- }),
680
- })
681
- }
682
-
683
- export const emojiPickerMachine = setup({
684
- types: {
685
- context: {} as EmojiPickerContext,
686
- input: {} as {
687
- editor: Editor
688
- matchEmojis: MatchEmojis
689
- },
690
- events: {} as EmojiPickerEvent,
691
- },
692
- actors: {
693
- 'emoji insert listener': fromCallback(emojiInsertListener),
694
- 'submit listener': fromCallback(submitListenerCallback),
695
- 'arrow listener': fromCallback(arrowListenerCallback),
696
- 'trigger listener': fromCallback(triggerListenerCallback),
697
- 'escape listener': fromCallback(escapeListenerCallback),
698
- 'selection listener': fromCallback(selectionListenerCallback),
699
- 'text insertion listener': fromCallback(textInsertionListenerCallback),
700
- },
701
- actions: {
702
- 'set focus span': assign({
703
- focusSpan: ({context, event}) => {
704
- if (
705
- event.type !== 'custom.trigger found' &&
706
- event.type !== 'custom.keyword found'
707
- ) {
708
- return context.focusSpan
709
- }
710
-
711
- return event.focusSpan
712
- },
713
- }),
714
- 'update focus span': assign({
715
- focusSpan: ({context}) => {
716
- if (!context.focusSpan) {
717
- return undefined
718
- }
719
-
720
- const snapshot = context.editor.getSnapshot()
721
- const focusSpan = getFocusSpan(snapshot)
722
-
723
- if (!snapshot.context.selection) {
724
- return undefined
725
- }
726
-
727
- if (!focusSpan) {
728
- return undefined
729
- }
730
-
731
- const nextSpan = getNextSpan({
732
- ...snapshot,
733
- context: {
734
- ...snapshot.context,
735
- selection: {
736
- anchor: {
737
- path: context.focusSpan.path,
738
- offset: 0,
739
- },
740
- focus: {
741
- path: context.focusSpan.path,
742
- offset: 0,
743
- },
744
- },
745
- },
746
- })
747
-
748
- if (
749
- JSON.stringify(focusSpan.path) !==
750
- JSON.stringify(context.focusSpan.path)
751
- ) {
752
- if (
753
- nextSpan &&
754
- context.focusSpan.textAfter.length === 0 &&
755
- snapshot.context.selection.focus.offset === 0 &&
756
- isSelectionCollapsed(snapshot.context.selection)
757
- ) {
758
- // This is an edge case where the caret is moved from the end of
759
- // the focus span to the start of the next span.
760
- return context.focusSpan
761
- }
762
-
763
- return undefined
764
- }
765
-
766
- if (!focusSpan.node.text.startsWith(context.focusSpan.textBefore)) {
767
- return undefined
768
- }
769
-
770
- if (!focusSpan.node.text.endsWith(context.focusSpan.textAfter)) {
771
- return undefined
772
- }
773
-
774
- const keywordAnchor = {
775
- path: focusSpan.path,
776
- offset: context.focusSpan.textBefore.length,
777
- }
778
- const keywordFocus = {
779
- path: focusSpan.path,
780
- offset:
781
- focusSpan.node.text.length - context.focusSpan.textAfter.length,
782
- }
783
-
784
- const selectionIsBeforeKeyword =
785
- isPointAfterSelection(keywordAnchor)(snapshot)
786
-
787
- const selectionIsAfterKeyword =
788
- isPointBeforeSelection(keywordFocus)(snapshot)
789
-
790
- if (selectionIsBeforeKeyword || selectionIsAfterKeyword) {
791
- return undefined
792
- }
793
-
794
- return {
795
- node: focusSpan.node,
796
- path: focusSpan.path,
797
- textBefore: context.focusSpan.textBefore,
798
- textAfter: context.focusSpan.textAfter,
799
- }
800
- },
801
- }),
802
- 'update keyword': assign({
803
- keyword: ({context}) => {
804
- if (!context.focusSpan) {
805
- return ''
806
- }
807
-
808
- if (
809
- context.focusSpan.textBefore.length > 0 &&
810
- context.focusSpan.textAfter.length > 0
811
- ) {
812
- return context.focusSpan.node.text.slice(
813
- context.focusSpan.textBefore.length,
814
- -context.focusSpan.textAfter.length,
815
- )
816
- }
817
-
818
- if (context.focusSpan.textBefore.length > 0) {
819
- return context.focusSpan.node.text.slice(
820
- context.focusSpan.textBefore.length,
821
- )
822
- }
823
-
824
- if (context.focusSpan.textAfter.length > 0) {
825
- return context.focusSpan.node.text.slice(
826
- 0,
827
- -context.focusSpan.textAfter.length,
828
- )
829
- }
830
-
831
- return context.focusSpan.node.text
832
- },
833
- }),
834
- 'update matches': assign({
835
- matches: ({context}) => {
836
- // Strip leading colon
837
- let rawKeyword = context.keyword.startsWith(':')
838
- ? context.keyword.slice(1)
839
- : context.keyword
840
- // Strip trailing colon
841
- rawKeyword =
842
- rawKeyword.length > 1 && rawKeyword.endsWith(':')
843
- ? rawKeyword.slice(0, -1)
844
- : rawKeyword
845
-
846
- if (rawKeyword === undefined) {
847
- return []
848
- }
849
-
850
- return context.matchEmojis({keyword: rawKeyword})
851
- },
852
- }),
853
- 'reset selected index': assign({
854
- selectedIndex: 0,
855
- }),
856
- 'increment selected index': assign({
857
- selectedIndex: ({context}) => {
858
- if (context.selectedIndex === context.matches.length - 1) {
859
- return 0
860
- }
861
- return context.selectedIndex + 1
862
- },
863
- }),
864
- 'decrement selected index': assign({
865
- selectedIndex: ({context}) => {
866
- if (context.selectedIndex === 0) {
867
- return context.matches.length - 1
868
- }
869
- return context.selectedIndex - 1
870
- },
871
- }),
872
- 'set selected index': assign({
873
- selectedIndex: ({event}) => {
874
- assertEvent(event, 'navigate to')
875
-
876
- return event.index
877
- },
878
- }),
879
- 'update submit listener context': sendTo(
880
- 'submit listener',
881
- ({context}) => ({
882
- type: 'context changed',
883
- context,
884
- }),
885
- ),
886
- 'update text insertion listener context': sendTo(
887
- 'text insertion listener',
888
- ({context}) => ({
889
- type: 'context changed',
890
- context,
891
- }),
892
- ),
893
- 'insert selected match': ({context}) => {
894
- const match = context.matches[context.selectedIndex]
895
-
896
- if (!match || !context.focusSpan) {
897
- return
898
- }
899
-
900
- context.editor.send({
901
- type: 'custom.insert emoji',
902
- emoji: match.emoji,
903
- focusSpan: context.focusSpan,
904
- })
905
- },
906
- 'reset': assign({
907
- focusSpan: undefined,
908
- keyword: '',
909
- matches: [],
910
- selectedIndex: 0,
911
- }),
912
- },
913
- guards: {
914
- 'no focus span': ({context}) => {
915
- return !context.focusSpan
916
- },
917
- 'has matches': ({context}) => {
918
- return context.matches.length > 0
919
- },
920
- 'no matches': not('has matches'),
921
- 'keyword is malformed': ({context}) => {
922
- return !context.incompleteKeywordRegex.test(context.keyword)
923
- },
924
- 'keyword is direct match': ({context}) => {
925
- const fullKeywordRegex = /^:[\S]+:$/
926
-
927
- if (!fullKeywordRegex.test(context.keyword)) {
928
- return false
929
- }
930
-
931
- const match = context.matches.at(context.selectedIndex)
932
-
933
- if (!match || match.type !== 'exact') {
934
- return false
935
- }
936
-
937
- return true
938
- },
939
- },
940
- }).createMachine({
941
- id: 'emoji picker',
942
- context: ({input}) => ({
943
- editor: input.editor,
944
- keyword: '',
945
- focusSpan: undefined,
946
- matchEmojis: input.matchEmojis,
947
- incompleteKeywordRegex: /^:[\S]*$/,
948
- matches: [],
949
- selectedIndex: 0,
950
- }),
951
- initial: 'idle',
952
- invoke: [
953
- {
954
- src: 'emoji insert listener',
955
- id: 'emoji insert listener',
956
- input: ({context}) => ({context}),
957
- },
958
- ],
959
- states: {
960
- idle: {
961
- entry: ['reset'],
962
- invoke: {
963
- src: 'trigger listener',
964
- input: ({context}) => ({editor: context.editor}),
965
- },
966
- on: {
967
- 'custom.trigger found': {
968
- target: 'searching',
969
- actions: ['set focus span', 'update keyword'],
970
- },
971
- 'custom.keyword found': {
972
- actions: [
973
- 'set focus span',
974
- 'update keyword',
975
- 'update matches',
976
- 'insert selected match',
977
- ],
978
- target: 'idle',
979
- reenter: true,
980
- },
981
- },
982
- },
983
- searching: {
984
- invoke: [
985
- {
986
- src: 'submit listener',
987
- id: 'submit listener',
988
- input: ({context}) => ({context}),
989
- },
990
- {
991
- src: 'escape listener',
992
- input: ({context}) => ({editor: context.editor}),
993
- },
994
- {
995
- src: 'selection listener',
996
- input: ({context}) => ({editor: context.editor}),
997
- },
998
- {
999
- src: 'text insertion listener',
1000
- id: 'text insertion listener',
1001
- input: ({context}) => ({context}),
1002
- },
1003
- ],
1004
- on: {
1005
- 'dismiss': {
1006
- target: 'idle',
1007
- },
1008
- 'selection changed': [
1009
- {
1010
- actions: [
1011
- 'update focus span',
1012
- 'update keyword',
1013
- 'update matches',
1014
- 'reset selected index',
1015
- 'update submit listener context',
1016
- 'update text insertion listener context',
1017
- ],
1018
- },
1019
- ],
1020
- },
1021
- always: [
1022
- {
1023
- guard: 'no focus span',
1024
- target: 'idle',
1025
- },
1026
- {
1027
- guard: 'keyword is malformed',
1028
- target: 'idle',
1029
- },
1030
- {
1031
- guard: 'keyword is direct match',
1032
- actions: ['insert selected match'],
1033
- target: 'idle',
1034
- },
1035
- ],
1036
- initial: 'no matches showing',
1037
- states: {
1038
- 'no matches showing': {
1039
- entry: ['reset selected index'],
1040
- always: {
1041
- guard: 'has matches',
1042
- target: 'showing matches',
1043
- },
1044
- },
1045
- 'showing matches': {
1046
- invoke: {
1047
- src: 'arrow listener',
1048
- input: ({context}) => ({editor: context.editor}),
1049
- },
1050
- always: [
1051
- {
1052
- guard: 'no matches',
1053
- target: 'no matches showing',
1054
- },
1055
- ],
1056
- on: {
1057
- 'navigate down': {
1058
- actions: [
1059
- 'increment selected index',
1060
- 'update submit listener context',
1061
- ],
1062
- },
1063
- 'navigate up': {
1064
- actions: [
1065
- 'decrement selected index',
1066
- 'update submit listener context',
1067
- ],
1068
- },
1069
- 'navigate to': {
1070
- actions: ['set selected index', 'update submit listener context'],
1071
- },
1072
- 'insert selected match': {
1073
- actions: ['insert selected match'],
1074
- },
1075
- },
1076
- },
1077
- },
1078
- },
1079
- },
1080
- })