@portabletext/plugin-emoji-picker 1.0.3 → 1.0.4

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,28 +1,30 @@
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'
8
+ import {defineBehavior, effect, raise} from '@portabletext/editor/behaviors'
7
9
  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'
10
+ getFocusSpan,
11
+ getMarkState,
12
+ getNextSpan,
13
+ isPointAfterSelection,
14
+ isPointBeforeSelection,
15
+ type MarkState,
16
+ } from '@portabletext/editor/selectors'
15
17
  import {createKeyboardShortcut} from '@portabletext/keyboard-shortcuts'
16
18
  import {
17
19
  defineInputRule,
18
20
  defineInputRuleBehavior,
21
+ type InputRuleMatch,
19
22
  } from '@portabletext/plugin-input-rule'
20
23
  import {
21
24
  assertEvent,
22
25
  assign,
23
26
  fromCallback,
24
27
  not,
25
- or,
26
28
  sendTo,
27
29
  setup,
28
30
  type AnyEventObject,
@@ -49,6 +51,134 @@ const escapeShortcut = createKeyboardShortcut({
49
51
  default: [{key: 'Escape'}],
50
52
  })
51
53
 
54
+ const getTriggerState: EditorSelector<
55
+ | {
56
+ focusSpan: {
57
+ node: PortableTextSpan
58
+ path: ChildPath
59
+ }
60
+ markState: MarkState
61
+ focusSpanTextBefore: string
62
+ focusSpanTextAfter: string
63
+ nextSpan:
64
+ | {
65
+ node: PortableTextSpan
66
+ path: ChildPath
67
+ }
68
+ | undefined
69
+ }
70
+ | undefined
71
+ > = (snapshot) => {
72
+ const focusSpan = getFocusSpan(snapshot)
73
+ const markState = getMarkState(snapshot)
74
+
75
+ if (!focusSpan || !markState || !snapshot.context.selection) {
76
+ return undefined
77
+ }
78
+
79
+ const focusSpanTextBefore = focusSpan.node.text.slice(
80
+ 0,
81
+ snapshot.context.selection.focus.offset,
82
+ )
83
+ const focusSpanTextAfter = focusSpan.node.text.slice(
84
+ snapshot.context.selection.focus.offset,
85
+ )
86
+ const nextSpan = getNextSpan(snapshot)
87
+
88
+ return {
89
+ focusSpan,
90
+ markState,
91
+ focusSpanTextBefore,
92
+ focusSpanTextAfter,
93
+ nextSpan,
94
+ }
95
+ }
96
+
97
+ function createTriggerActions({
98
+ snapshot,
99
+ payload,
100
+ keywordState,
101
+ }: {
102
+ snapshot: EditorSnapshot
103
+ payload: ReturnType<typeof getTriggerState> & {lastMatch: InputRuleMatch}
104
+ keywordState: 'partial' | 'complete'
105
+ }) {
106
+ if (payload.markState.state === 'unchanged') {
107
+ const focusSpan = {
108
+ node: {
109
+ _key: payload.focusSpan.node._key,
110
+ _type: payload.focusSpan.node._type,
111
+ text: `${payload.focusSpanTextBefore}${payload.lastMatch.text}${payload.focusSpanTextAfter}`,
112
+ marks: payload.markState.marks,
113
+ },
114
+ path: payload.focusSpan.path,
115
+ textBefore: payload.focusSpanTextBefore,
116
+ textAfter: payload.focusSpanTextAfter,
117
+ }
118
+
119
+ if (keywordState === 'complete') {
120
+ return [
121
+ raise(
122
+ createKeywordFoundEvent({
123
+ focusSpan,
124
+ }),
125
+ ),
126
+ ]
127
+ }
128
+
129
+ return [
130
+ raise(
131
+ createTriggerFoundEvent({
132
+ focusSpan,
133
+ }),
134
+ ),
135
+ ]
136
+ }
137
+
138
+ const newSpan = {
139
+ _key: snapshot.context.keyGenerator(),
140
+ _type: payload.focusSpan.node._type,
141
+ text: payload.lastMatch.text,
142
+ marks: payload.markState.marks,
143
+ }
144
+ const focusSpan = {
145
+ node: {
146
+ _key: newSpan._key,
147
+ _type: newSpan._type,
148
+ text: `${newSpan.text}${payload.nextSpan?.node.text ?? ''}`,
149
+ marks: payload.markState.marks,
150
+ },
151
+ path: [
152
+ {_key: payload.focusSpan.path[0]._key},
153
+ 'children',
154
+ {_key: newSpan._key},
155
+ ] satisfies ChildPath,
156
+ textBefore: '',
157
+ textAfter: payload.nextSpan?.node.text ?? '',
158
+ }
159
+
160
+ return [
161
+ raise({type: 'select', at: payload.lastMatch.targetOffsets}),
162
+ raise({type: 'delete', at: payload.lastMatch.targetOffsets}),
163
+ raise({type: 'insert.child', child: newSpan}),
164
+ ...(keywordState === 'complete'
165
+ ? [
166
+ raise(
167
+ createKeywordFoundEvent({
168
+ focusSpan,
169
+ }),
170
+ ),
171
+ ]
172
+ : [
173
+ raise(
174
+ createTriggerFoundEvent({
175
+ focusSpan,
176
+ }),
177
+ ),
178
+ ]),
179
+ ]
180
+ }
181
+
52
182
  /*******************
53
183
  * Input Rules
54
184
  *******************/
@@ -58,34 +188,39 @@ const escapeShortcut = createKeyboardShortcut({
58
188
  */
59
189
  const triggerRule = defineInputRule({
60
190
  on: /:/,
61
- guard: ({event}) => {
191
+ guard: ({snapshot, event}) => {
62
192
  const lastMatch = event.matches.at(-1)
63
193
 
64
194
  if (lastMatch === undefined) {
65
195
  return false
66
196
  }
67
197
 
198
+ const triggerState = getTriggerState(snapshot)
199
+
200
+ if (!triggerState) {
201
+ return false
202
+ }
203
+
68
204
  return {
69
- keyword: lastMatch.text,
70
- keywordAnchor: {
71
- point: lastMatch.selection.anchor,
72
- blockOffset: lastMatch.targetOffsets.anchor,
73
- },
74
- keywordFocus: lastMatch.targetOffsets.focus,
205
+ lastMatch,
206
+ ...triggerState,
75
207
  }
76
208
  },
77
- actions: [(_, payload) => [raise(createTriggerFoundEvent(payload))]],
209
+ actions: [
210
+ ({snapshot}, payload) =>
211
+ createTriggerActions({snapshot, payload, keywordState: 'partial'}),
212
+ ],
78
213
  })
79
214
 
80
215
  type TriggerFoundEvent = ReturnType<typeof createTriggerFoundEvent>
81
216
 
82
217
  function createTriggerFoundEvent(payload: {
83
- keyword: string
84
- keywordAnchor: {
85
- point: EditorSelectionPoint
86
- blockOffset: BlockOffset
218
+ focusSpan: {
219
+ node: PortableTextSpan
220
+ path: ChildPath
221
+ textBefore: string
222
+ textAfter: string
87
223
  }
88
- keywordFocus: BlockOffset
89
224
  }) {
90
225
  return {
91
226
  type: 'custom.trigger found',
@@ -98,76 +233,76 @@ function createTriggerFoundEvent(payload: {
98
233
  */
99
234
  const partialKeywordRule = defineInputRule({
100
235
  on: /:[\S]+/,
101
- guard: ({event}) => {
236
+ guard: ({snapshot, event}) => {
102
237
  const lastMatch = event.matches.at(-1)
103
238
 
104
239
  if (lastMatch === undefined) {
105
240
  return false
106
241
  }
107
242
 
108
- const keyword = lastMatch.text
109
- const keywordAnchor = {
110
- point: lastMatch.selection.anchor,
111
- blockOffset: lastMatch.targetOffsets.anchor,
243
+ if (lastMatch.targetOffsets.anchor.offset < event.textBefore.length) {
244
+ return false
112
245
  }
113
- const keywordFocus = lastMatch.targetOffsets.focus
114
246
 
115
- return {keyword, keywordAnchor, keywordFocus}
116
- },
117
- actions: [(_, payload) => [raise(createPartialKeywordFoundEvent(payload))]],
118
- })
247
+ const triggerState = getTriggerState(snapshot)
119
248
 
120
- type PartialKeywordFoundEvent = ReturnType<
121
- typeof createPartialKeywordFoundEvent
122
- >
249
+ if (!triggerState) {
250
+ return false
251
+ }
123
252
 
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
- }
253
+ return {
254
+ ...triggerState,
255
+ lastMatch,
256
+ }
257
+ },
258
+ actions: [
259
+ ({snapshot}, payload) =>
260
+ createTriggerActions({snapshot, payload, keywordState: 'partial'}),
261
+ ],
262
+ })
137
263
 
138
264
  /**
139
265
  * Listen for a complete keyword like ":joy:"
140
266
  */
141
267
  const keywordRule = defineInputRule({
142
268
  on: /:[\S]+:/,
143
- guard: ({event}) => {
269
+ guard: ({snapshot, event}) => {
144
270
  const lastMatch = event.matches.at(-1)
145
271
 
146
272
  if (lastMatch === undefined) {
147
273
  return false
148
274
  }
149
275
 
150
- const keyword = lastMatch.text
151
- const keywordAnchor = {
152
- point: lastMatch.selection.anchor,
153
- blockOffset: lastMatch.targetOffsets.anchor,
276
+ if (lastMatch.targetOffsets.anchor.offset < event.textBefore.length) {
277
+ return false
278
+ }
279
+
280
+ const triggerState = getTriggerState(snapshot)
281
+
282
+ if (!triggerState) {
283
+ return false
154
284
  }
155
- const keywordFocus = lastMatch.targetOffsets.focus
156
285
 
157
- return {keyword, keywordAnchor, keywordFocus}
286
+ return {
287
+ ...triggerState,
288
+ lastMatch,
289
+ }
158
290
  },
159
- actions: [(_, payload) => [raise(createKeywordFoundEvent(payload))]],
291
+ actions: [
292
+ ({snapshot}, payload) =>
293
+ createTriggerActions({snapshot, payload, keywordState: 'complete'}),
294
+ ],
160
295
  })
161
296
 
162
297
  type KeywordFoundEvent = ReturnType<typeof createKeywordFoundEvent>
163
298
 
164
299
  function createKeywordFoundEvent(payload: {
165
- keyword: string
166
- keywordAnchor: {
167
- point: EditorSelectionPoint
168
- blockOffset: BlockOffset
300
+ focusSpan: {
301
+ node: PortableTextSpan
302
+ path: ChildPath
303
+ textBefore: string
304
+ textAfter: string
169
305
  }
170
- keywordFocus: BlockOffset
171
306
  }) {
172
307
  return {
173
308
  type: 'custom.keyword found',
@@ -180,38 +315,25 @@ type EmojiPickerContext = {
180
315
  matches: ReadonlyArray<BaseEmojiMatch>
181
316
  matchEmojis: MatchEmojis<BaseEmojiMatch>
182
317
  selectedIndex: number
183
- keywordAnchor:
318
+ focusSpan:
184
319
  | {
185
- point: EditorSelectionPoint
186
- blockOffset: BlockOffset
320
+ node: PortableTextSpan
321
+ path: ChildPath
322
+ textBefore: string
323
+ textAfter: string
187
324
  }
188
325
  | undefined
189
- keywordFocus: BlockOffset | undefined
190
326
  incompleteKeywordRegex: RegExp
191
327
  keyword: string
192
328
  }
193
329
 
194
330
  type EmojiPickerEvent =
195
331
  | TriggerFoundEvent
196
- | PartialKeywordFoundEvent
197
332
  | KeywordFoundEvent
198
333
  | {
199
334
  type: 'selection changed'
200
335
  snapshot: EditorSnapshot
201
336
  }
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
- }
215
337
  | {
216
338
  type: 'dismiss'
217
339
  }
@@ -252,21 +374,6 @@ const triggerListenerCallback: CallbackLogicFunction<
252
374
  ],
253
375
  }),
254
376
  }),
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
377
  input.editor.registerBehavior({
271
378
  behavior: defineBehavior<TriggerFoundEvent, TriggerFoundEvent['type']>({
272
379
  on: 'custom.trigger found',
@@ -357,8 +464,12 @@ const emojiInsertListener: CallbackLogicFunction<
357
464
  return input.context.editor.registerBehavior({
358
465
  behavior: defineBehavior<{
359
466
  emoji: string
360
- anchor: BlockOffset
361
- focus: BlockOffset
467
+ focusSpan: {
468
+ node: PortableTextSpan
469
+ path: ChildPath
470
+ textBefore: string
471
+ textAfter: string
472
+ }
362
473
  }>({
363
474
  on: 'custom.insert emoji',
364
475
  actions: [
@@ -367,8 +478,19 @@ const emojiInsertListener: CallbackLogicFunction<
367
478
  sendBack({type: 'dismiss'})
368
479
  }),
369
480
  raise({
370
- type: 'delete.text',
371
- at: {anchor: event.anchor, focus: event.focus},
481
+ type: 'delete',
482
+ at: {
483
+ anchor: {
484
+ path: event.focusSpan.path,
485
+ offset: event.focusSpan.textBefore.length,
486
+ },
487
+ focus: {
488
+ path: event.focusSpan.path,
489
+ offset:
490
+ event.focusSpan.node.text.length -
491
+ event.focusSpan.textAfter.length,
492
+ },
493
+ },
372
494
  }),
373
495
  raise({
374
496
  type: 'insert.text',
@@ -403,21 +525,17 @@ const submitListenerCallback: CallbackLogicFunction<
403
525
  return false
404
526
  }
405
527
 
406
- const anchor = context.keywordAnchor?.blockOffset
407
- const focus = context.keywordFocus
528
+ const focusSpan = context.focusSpan
408
529
  const match = context.matches[context.selectedIndex]
409
530
 
410
- return match && anchor && focus
411
- ? {anchor, focus, emoji: match.emoji}
412
- : false
531
+ return match && focusSpan ? {focusSpan, emoji: match.emoji} : false
413
532
  },
414
533
  actions: [
415
- (_, {anchor, focus, emoji}) => [
534
+ (_, {focusSpan, emoji}) => [
416
535
  raise({
417
536
  type: 'custom.insert emoji',
418
537
  emoji,
419
- anchor,
420
- focus,
538
+ focusSpan,
421
539
  }),
422
540
  ],
423
541
  ],
@@ -438,31 +556,6 @@ const submitListenerCallback: CallbackLogicFunction<
438
556
  ],
439
557
  }),
440
558
  }),
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
559
  ]
467
560
 
468
561
  return () => {
@@ -485,81 +578,6 @@ const selectionListenerCallback: CallbackLogicFunction<
485
578
  return subscription.unsubscribe
486
579
  }
487
580
 
488
- const textChangeListener: CallbackLogicFunction<
489
- AnyEventObject,
490
- 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
- ],
551
- ],
552
- }),
553
- }),
554
- ]
555
-
556
- return () => {
557
- for (const unregister of unregisterBehaviors) {
558
- unregister()
559
- }
560
- }
561
- }
562
-
563
581
  export const emojiPickerMachine = setup({
564
582
  types: {
565
583
  context: {} as EmojiPickerContext,
@@ -576,96 +594,106 @@ export const emojiPickerMachine = setup({
576
594
  'trigger listener': fromCallback(triggerListenerCallback),
577
595
  'escape listener': fromCallback(escapeListenerCallback),
578
596
  'selection listener': fromCallback(selectionListenerCallback),
579
- 'text change listener': fromCallback(textChangeListener),
580
597
  },
581
598
  actions: {
582
- 'init keyword': assign({
583
- keyword: ({context, event}) => {
599
+ 'set focus span': assign({
600
+ focusSpan: ({context, event}) => {
584
601
  if (
585
602
  event.type !== 'custom.trigger found' &&
586
- event.type !== 'custom.partial keyword found' &&
587
603
  event.type !== 'custom.keyword found'
588
604
  ) {
589
- return context.keyword
605
+ return context.focusSpan
590
606
  }
591
607
 
592
- return event.keyword
608
+ return event.focusSpan
593
609
  },
594
610
  }),
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
611
+ 'update focus span': assign({
612
+ focusSpan: ({context}) => {
613
+ if (!context.focusSpan) {
614
+ return undefined
615
+ }
616
+
617
+ const snapshot = context.editor.getSnapshot()
618
+ const focusSpan = getFocusSpan(snapshot)
619
+
620
+ if (!focusSpan) {
621
+ return undefined
603
622
  }
604
623
 
605
- return event.keywordAnchor
606
- },
607
- }),
608
- 'set keyword focus': assign({
609
- keywordFocus: ({context, event}) => {
610
624
  if (
611
- event.type !== 'custom.trigger found' &&
612
- event.type !== 'custom.partial keyword found' &&
613
- event.type !== 'custom.keyword found'
625
+ JSON.stringify(focusSpan.path) !==
626
+ JSON.stringify(context.focusSpan.path)
614
627
  ) {
615
- return context.keywordFocus
628
+ return undefined
616
629
  }
617
630
 
618
- return event.keywordFocus
619
- },
620
- }),
621
- 'update keyword focus': assign({
622
- keywordFocus: ({context, event}) => {
623
- assertEvent(event, ['insert.text', 'delete.backward', 'delete.forward'])
631
+ if (!focusSpan.node.text.startsWith(context.focusSpan.textBefore)) {
632
+ return undefined
633
+ }
624
634
 
625
- if (!context.keywordFocus) {
626
- return context.keywordFocus
635
+ if (!focusSpan.node.text.endsWith(context.focusSpan.textAfter)) {
636
+ return undefined
627
637
  }
628
638
 
629
- return {
630
- path: context.keywordFocus.path,
639
+ const keywordAnchor = {
640
+ path: focusSpan.path,
641
+ offset: context.focusSpan.textBefore.length,
642
+ }
643
+ const keywordFocus = {
644
+ path: focusSpan.path,
631
645
  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,
646
+ focusSpan.node.text.length - context.focusSpan.textAfter.length,
647
+ }
648
+
649
+ const selectionIsBeforeKeyword =
650
+ isPointAfterSelection(keywordAnchor)(snapshot)
651
+
652
+ const selectionIsAfterKeyword =
653
+ isPointBeforeSelection(keywordFocus)(snapshot)
654
+
655
+ if (selectionIsBeforeKeyword || selectionIsAfterKeyword) {
656
+ return undefined
657
+ }
658
+
659
+ return {
660
+ node: focusSpan.node,
661
+ path: focusSpan.path,
662
+ textBefore: context.focusSpan.textBefore,
663
+ textAfter: context.focusSpan.textAfter,
638
664
  }
639
665
  },
640
666
  }),
641
667
  'update keyword': assign({
642
- keyword: ({context, event}) => {
643
- assertEvent(event, 'selection changed')
644
-
645
- if (!context.keywordAnchor || !context.keywordFocus) {
668
+ keyword: ({context}) => {
669
+ if (!context.focusSpan) {
646
670
  return ''
647
671
  }
648
672
 
649
- const keywordFocusPoint = utils.blockOffsetToSpanSelectionPoint({
650
- context: event.snapshot.context,
651
- blockOffset: context.keywordFocus,
652
- direction: 'forward',
653
- })
673
+ if (
674
+ context.focusSpan.textBefore.length > 0 &&
675
+ context.focusSpan.textAfter.length > 0
676
+ ) {
677
+ return context.focusSpan.node.text.slice(
678
+ context.focusSpan.textBefore.length,
679
+ -context.focusSpan.textAfter.length,
680
+ )
681
+ }
654
682
 
655
- if (!keywordFocusPoint) {
656
- return ''
683
+ if (context.focusSpan.textBefore.length > 0) {
684
+ return context.focusSpan.node.text.slice(
685
+ context.focusSpan.textBefore.length,
686
+ )
657
687
  }
658
688
 
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
- })
689
+ if (context.focusSpan.textAfter.length > 0) {
690
+ return context.focusSpan.node.text.slice(
691
+ 0,
692
+ -context.focusSpan.textAfter.length,
693
+ )
694
+ }
695
+
696
+ return context.focusSpan.node.text
669
697
  },
670
698
  }),
671
699
  'update matches': assign({
@@ -713,13 +741,6 @@ export const emojiPickerMachine = setup({
713
741
  return event.index
714
742
  },
715
743
  }),
716
- 'update emoji insert listener context': sendTo(
717
- 'emoji insert listener',
718
- ({context}) => ({
719
- type: 'context changed',
720
- context,
721
- }),
722
- ),
723
744
  'update submit listener context': sendTo(
724
745
  'submit listener',
725
746
  ({context}) => ({
@@ -727,118 +748,61 @@ export const emojiPickerMachine = setup({
727
748
  context,
728
749
  }),
729
750
  ),
730
- 'insert selected match': ({context, event}) => {
751
+ 'insert selected match': ({context}) => {
731
752
  const match = context.matches[context.selectedIndex]
732
753
 
733
- if (!match || !context.keywordAnchor || !context.keywordFocus) {
734
- return
735
- }
736
-
737
- if (event.type === 'custom.keyword found' && match.type !== 'exact') {
754
+ if (!match || !context.focusSpan) {
738
755
  return
739
756
  }
740
757
 
741
758
  context.editor.send({
742
759
  type: 'custom.insert emoji',
743
760
  emoji: match.emoji,
744
- anchor: context.keywordAnchor.blockOffset,
745
- focus: context.keywordFocus,
761
+ focusSpan: context.focusSpan,
746
762
  })
747
763
  },
748
764
  'reset': assign({
749
- keywordAnchor: undefined,
750
- keywordFocus: undefined,
765
+ focusSpan: undefined,
751
766
  keyword: '',
752
767
  matches: [],
753
768
  selectedIndex: 0,
754
769
  }),
755
770
  },
756
771
  guards: {
772
+ 'no focus span': ({context}) => {
773
+ return !context.focusSpan
774
+ },
757
775
  'has matches': ({context}) => {
758
776
  return context.matches.length > 0
759
777
  },
760
778
  'no matches': not('has matches'),
761
- 'keyword is wel-formed': ({context}) => {
762
- return context.incompleteKeywordRegex.test(context.keyword)
763
- },
764
- 'keyword is malformed': not('keyword is wel-formed'),
765
- 'selection is before keyword': ({context, event}) => {
766
- assertEvent(event, 'selection changed')
767
-
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)
779
+ 'keyword is malformed': ({context}) => {
780
+ return !context.incompleteKeywordRegex.test(context.keyword)
794
781
  },
795
- 'selection is expanded': ({event}) => {
796
- assertEvent(event, 'selection changed')
782
+ 'keyword is direct match': ({context}) => {
783
+ const fullKeywordRegex = /^:[\S]+:$/
797
784
 
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') {
785
+ if (!fullKeywordRegex.test(context.keyword)) {
807
786
  return false
808
787
  }
809
788
 
810
- if (!context.keywordAnchor) {
789
+ const match = context.matches.at(context.selectedIndex)
790
+
791
+ if (!match || match.type !== 'exact') {
811
792
  return false
812
793
  }
813
794
 
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
795
+ return true
830
796
  },
831
797
  },
832
798
  }).createMachine({
833
- /** @xstate-layout N4IgpgJg5mDOIC5RgLYHsBWBLABABywGMBrMAJwDosIAbMAYkLRrQDsctXZyAXSAbQAMAXUSg8aWFh5Y2YkAA9EARmUB2AJwUAzADYNADgCs65YO1q1ygDQgAnojUG1O5c+W7tAJmdfdRgF8A21RMXAIScgpuAEMyQgALTih6Tm4yHgo+BR4hUSQQCSkZOQKlBABaZW0DHSMAFksNQUF6oyNzL1sHBC9vCnrGtS8GtQaNNt0gkPRsfCJSSlj4pNYUiDA6PgoAMzQyAHc4iDz5IulZVnlyr3MKE30LA31DZoNuxD6vAaHdP29vOo1NNwLNwgsostEsl6BstmAKAAjGIkI5kE4iM6SC6lUDlerabT3arDfS3eoaPQfXr9QaWEaNcaTEGhOYRRbRMBxaFrWFYWAofmwU4Fc4lK5lFTqWqDAwUjReeoGbwaNTU1TPCh-QxNNS6XQGHwssHzSJLLkrGHcOiEcU4RIxNYCTGi7Hi66IfxE1oeZX1EbeZXUhUUZSKjxOOV6bTmY1hU0cqGrFLWsC2y72hKOmAnZT5cRuy4ehDVXQUVXDIyWGpmAzveyfLRKwQaVRVwR1ixeONsiHm7nJ+gigvFIuSkvKVuhgwaEwKmMKwbqkaCAaverqVuvKbBUHx9mQi08qAUVhoHAoGI8RJwHCwBJoA4w4eFQu4xSfaoDA3KOmCVSTn06r-i4-hGH0ZiEoI4H1D24JmpyA7JNED5PmsF5XjesD0KwMQAG5YFAV5gDgECPqwL5imOeKIIM9ShsoRh1i2erPB41L6C40GqISUb6n8cEJoeSFrChj7JBh14JHAOH4YRxE4AArnglFvhKNEIGoq4+E0yraPULa6PUHGqhQ3HVDUBL8dogkHv2lqife4noZeUkybhBFEXwOA8Ggqmju+5RGPqFBqP6ujDD4v4tjYDYIMqLjVKo5gdM4TGBLurLwYmR7JmJaFQJJWGpFwvB3psaZ8BARUJP5OLqR+CD1Loq5ymYfyeP4spGOqv70fpv7NBSBKtBlMz7n2iEOSeTkFTVMl1e645eB49wGP+K0UpBbjaMBzQUF4IzBYq7TNa2QS7meGzwAUWVCWQWIBQ15RVJq2ijJoLRtB03jUhUVYUHKtz-gqeoxkYGi2ZN1B0I99XFqobTlh4-5-Ot4H1j0ZirbcENhR4RmKrBmUmnZU3HnDS0aVUjHlh2cqtkqfTBdSSr0RMGieBS7htASUMIUmyFnvNsB3qhySU9RjUVBFdN1ltTPvbowZOGZEWHe9xgTHo-M5SJM3iy5mHSTdI7w+OlnlgY6heJzbgUv4ytxaqtSCBFg1eJFM4XQEQA */
834
799
  id: 'emoji picker',
835
800
  context: ({input}) => ({
836
801
  editor: input.editor,
837
802
  keyword: '',
838
- keywordAnchor: undefined,
839
- keywordFocus: undefined,
803
+ focusSpan: undefined,
840
804
  matchEmojis: input.matchEmojis,
841
- incompleteKeywordRegex: /:[\S]*$/,
805
+ incompleteKeywordRegex: /^:[\S]*$/,
842
806
  matches: [],
843
807
  selectedIndex: 0,
844
808
  }),
@@ -860,17 +824,12 @@ export const emojiPickerMachine = setup({
860
824
  on: {
861
825
  'custom.trigger found': {
862
826
  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'],
827
+ actions: ['set focus span', 'update keyword'],
868
828
  },
869
829
  'custom.keyword found': {
870
830
  actions: [
871
- 'set keyword anchor',
872
- 'set keyword focus',
873
- 'init keyword',
831
+ 'set focus span',
832
+ 'update keyword',
874
833
  'update matches',
875
834
  'insert selected match',
876
835
  ],
@@ -894,46 +853,15 @@ export const emojiPickerMachine = setup({
894
853
  src: 'selection listener',
895
854
  input: ({context}) => ({editor: context.editor}),
896
855
  },
897
- {
898
- src: 'text change listener',
899
- input: ({context}) => ({editor: context.editor}),
900
- },
901
856
  ],
902
857
  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
858
  'dismiss': {
928
859
  target: 'idle',
929
860
  },
930
861
  'selection changed': [
931
- {
932
- guard: 'selection moved unexpectedly',
933
- target: 'idle',
934
- },
935
862
  {
936
863
  actions: [
864
+ 'update focus span',
937
865
  'update keyword',
938
866
  'update matches',
939
867
  'reset selected index',
@@ -943,10 +871,19 @@ export const emojiPickerMachine = setup({
943
871
  ],
944
872
  },
945
873
  always: [
874
+ {
875
+ guard: 'no focus span',
876
+ target: 'idle',
877
+ },
946
878
  {
947
879
  guard: 'keyword is malformed',
948
880
  target: 'idle',
949
881
  },
882
+ {
883
+ guard: 'keyword is direct match',
884
+ actions: ['insert selected match'],
885
+ target: 'idle',
886
+ },
950
887
  ],
951
888
  initial: 'no matches showing',
952
889
  states: {