@portabletext/editor 1.16.4 → 1.17.1

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,416 @@
1
+ import {assertEvent, assign, createActor, setup} from 'xstate'
2
+ import * as selectors from '../selectors'
3
+ import {isHotkey} from '../utils/is-hotkey'
4
+ import {defineBehavior} from './behavior.types'
5
+
6
+ const emojiCharRegEx = /^[a-zA-Z-_0-9]{1}$/
7
+ const incompleteEmojiRegEx = /:([a-zA-Z-_0-9]+)$/
8
+ const emojiRegEx = /:([a-zA-Z-_0-9]+):$/
9
+
10
+ /**
11
+ * @alpha
12
+ */
13
+ export type EmojiPickerBehaviorsConfig<TEmojiMatch> = {
14
+ /**
15
+ * Match emojis by keyword.
16
+ */
17
+ matchEmojis: ({keyword}: {keyword: string}) => Array<TEmojiMatch>
18
+ onMatchesChanged: ({matches}: {matches: Array<TEmojiMatch>}) => void
19
+ onSelectedIndexChanged: ({selectedIndex}: {selectedIndex: number}) => void
20
+ /**
21
+ * Parse an emoji match to a string that will be inserted into the editor.
22
+ */
23
+ parseMatch: ({match}: {match: TEmojiMatch}) => string | undefined
24
+ }
25
+
26
+ /**
27
+ * @alpha
28
+ */
29
+ export function createEmojiPickerBehaviors<TEmojiMatch>(
30
+ config: EmojiPickerBehaviorsConfig<TEmojiMatch>,
31
+ ) {
32
+ const emojiPickerActor = createActor(createEmojiPickerMachine<TEmojiMatch>())
33
+ emojiPickerActor.start()
34
+ emojiPickerActor.subscribe((state) => {
35
+ config.onMatchesChanged({matches: state.context.matches})
36
+ config.onSelectedIndexChanged({selectedIndex: state.context.selectedIndex})
37
+ })
38
+
39
+ return [
40
+ defineBehavior({
41
+ on: 'insert.text',
42
+ guard: ({context, event}) => {
43
+ const isEmojiChar = emojiCharRegEx.test(event.text)
44
+
45
+ if (!isEmojiChar) {
46
+ return {emojis: []}
47
+ }
48
+
49
+ const focusBlock = selectors.getFocusTextBlock({context})
50
+ const textBefore = selectors.getBlockTextBefore({context})
51
+ const emojiKeyword = `${textBefore}${event.text}`.match(
52
+ incompleteEmojiRegEx,
53
+ )?.[1]
54
+
55
+ if (!focusBlock || emojiKeyword === undefined) {
56
+ return {emojis: []}
57
+ }
58
+
59
+ const emojis = config.matchEmojis({keyword: emojiKeyword})
60
+
61
+ return {emojis}
62
+ },
63
+ actions: [
64
+ (_, params) => [
65
+ {
66
+ type: 'effect',
67
+ effect: () => {
68
+ emojiPickerActor.send({
69
+ type: 'emojis found',
70
+ matches: params.emojis,
71
+ })
72
+ },
73
+ },
74
+ ],
75
+ ],
76
+ }),
77
+ defineBehavior({
78
+ on: 'insert.text',
79
+ guard: ({context, event}) => {
80
+ const isColon = event.text === ':'
81
+
82
+ if (!isColon) {
83
+ return false
84
+ }
85
+
86
+ const matches = emojiPickerActor.getSnapshot().context.matches
87
+ const selectedIndex =
88
+ emojiPickerActor.getSnapshot().context.selectedIndex
89
+ const emoji = matches[selectedIndex]
90
+ ? config.parseMatch({match: matches[selectedIndex]})
91
+ : undefined
92
+
93
+ const focusBlock = selectors.getFocusTextBlock({context})
94
+ const textBefore = selectors.getBlockTextBefore({context})
95
+ const emojiKeyword = `${textBefore}:`.match(emojiRegEx)?.[1]
96
+
97
+ if (!focusBlock || emojiKeyword === undefined) {
98
+ return false
99
+ }
100
+
101
+ const emojiStringLength = emojiKeyword.length + 2
102
+
103
+ if (emoji) {
104
+ return {
105
+ focusBlock,
106
+ emoji,
107
+ emojiStringLength,
108
+ textBeforeLength: textBefore.length + 1,
109
+ }
110
+ }
111
+
112
+ return false
113
+ },
114
+ actions: [
115
+ () => [
116
+ {
117
+ type: 'insert.text',
118
+ text: ':',
119
+ },
120
+ ],
121
+ (_, params) => [
122
+ {
123
+ type: 'effect',
124
+ effect: () => {
125
+ emojiPickerActor.send({type: 'select'})
126
+ },
127
+ },
128
+ {
129
+ type: 'delete.text',
130
+ anchor: {
131
+ path: params.focusBlock.path,
132
+ offset: params.textBeforeLength - params.emojiStringLength,
133
+ },
134
+ focus: {
135
+ path: params.focusBlock.path,
136
+ offset: params.textBeforeLength,
137
+ },
138
+ },
139
+ {
140
+ type: 'insert.text',
141
+ text: params.emoji,
142
+ },
143
+ ],
144
+ ],
145
+ }),
146
+ defineBehavior({
147
+ on: 'key.down',
148
+ guard: ({context, event}) => {
149
+ const isShift = isHotkey('Shift', event.keyboardEvent)
150
+ const isColon = event.keyboardEvent.key === ':'
151
+ const isEmojiChar = emojiCharRegEx.test(event.keyboardEvent.key)
152
+
153
+ if (isShift || isColon || isEmojiChar) {
154
+ return false
155
+ }
156
+
157
+ const isArrowDown = isHotkey('ArrowDown', event.keyboardEvent)
158
+ const isArrowUp = isHotkey('ArrowUp', event.keyboardEvent)
159
+ const isEnter = isHotkey('Enter', event.keyboardEvent)
160
+ const isTab = isHotkey('Tab', event.keyboardEvent)
161
+ const matches = emojiPickerActor.getSnapshot().context.matches
162
+
163
+ if (isEnter || isTab) {
164
+ const selectedIndex =
165
+ emojiPickerActor.getSnapshot().context.selectedIndex
166
+
167
+ const emoji = matches[selectedIndex]
168
+ ? config.parseMatch({match: matches[selectedIndex]})
169
+ : undefined
170
+
171
+ if (!emoji) {
172
+ return false
173
+ }
174
+
175
+ const focusBlock = selectors.getFocusTextBlock({context})
176
+ const textBefore = selectors.getBlockTextBefore({context})
177
+ const emojiKeyword = textBefore.match(incompleteEmojiRegEx)?.[1]
178
+
179
+ if (!focusBlock || emojiKeyword === undefined) {
180
+ return false
181
+ }
182
+
183
+ const emojiStringLength = emojiKeyword.length + 1
184
+
185
+ if (emoji) {
186
+ return {
187
+ action: 'select' as const,
188
+ focusBlock,
189
+ emoji,
190
+ emojiStringLength,
191
+ textBeforeLength: textBefore.length,
192
+ }
193
+ }
194
+
195
+ return false
196
+ }
197
+
198
+ if (isArrowDown && matches.length > 0) {
199
+ return {action: 'navigate down' as const}
200
+ }
201
+
202
+ if (isArrowUp && matches.length > 0) {
203
+ return {action: 'navigate up' as const}
204
+ }
205
+
206
+ return {action: 'reset' as const}
207
+ },
208
+ actions: [
209
+ (_, params) => {
210
+ if (params.action === 'select') {
211
+ return [
212
+ {
213
+ type: 'effect',
214
+ effect: () => {
215
+ emojiPickerActor.send({type: 'select'})
216
+ },
217
+ },
218
+ {
219
+ type: 'delete.text',
220
+ anchor: {
221
+ path: params.focusBlock.path,
222
+ offset: params.textBeforeLength - params.emojiStringLength,
223
+ },
224
+ focus: {
225
+ path: params.focusBlock.path,
226
+ offset: params.textBeforeLength,
227
+ },
228
+ },
229
+ {
230
+ type: 'insert.text',
231
+ text: params.emoji,
232
+ },
233
+ ]
234
+ }
235
+
236
+ if (params.action === 'navigate up') {
237
+ return [
238
+ // If we are navigating then we want to hijack the key event and
239
+ // turn it into a noop.
240
+ {
241
+ type: 'noop',
242
+ },
243
+ {
244
+ type: 'effect',
245
+ effect: () => {
246
+ emojiPickerActor.send({type: 'navigate up'})
247
+ },
248
+ },
249
+ ]
250
+ }
251
+
252
+ if (params.action === 'navigate down') {
253
+ return [
254
+ // If we are navigating then we want to hijack the key event and
255
+ // turn it into a noop.
256
+ {
257
+ type: 'noop',
258
+ },
259
+ {
260
+ type: 'effect',
261
+ effect: () => {
262
+ emojiPickerActor.send({type: 'navigate down'})
263
+ },
264
+ },
265
+ ]
266
+ }
267
+
268
+ return [
269
+ {
270
+ type: 'effect',
271
+ effect: () => {
272
+ emojiPickerActor.send({type: 'reset'})
273
+ },
274
+ },
275
+ ]
276
+ },
277
+ ],
278
+ }),
279
+ defineBehavior({
280
+ on: 'delete.backward',
281
+ guard: ({context, event}) => {
282
+ if (event.unit !== 'character') {
283
+ return false
284
+ }
285
+
286
+ const matches = emojiPickerActor.getSnapshot().context.matches
287
+
288
+ if (matches.length === 0) {
289
+ return false
290
+ }
291
+
292
+ const focusBlock = selectors.getFocusTextBlock({context})
293
+ const textBefore = selectors.getBlockTextBefore({context})
294
+ const emojiKeyword = textBefore
295
+ .slice(0, textBefore.length - 1)
296
+ .match(incompleteEmojiRegEx)?.[1]
297
+
298
+ if (!focusBlock || emojiKeyword === undefined) {
299
+ return {emojis: []}
300
+ }
301
+
302
+ const emojis = config.matchEmojis({keyword: emojiKeyword})
303
+
304
+ return {emojis}
305
+ },
306
+ actions: [
307
+ (_, params) => [
308
+ {
309
+ type: 'effect',
310
+ effect: () => {
311
+ emojiPickerActor.send({
312
+ type: 'emojis found',
313
+ matches: params.emojis,
314
+ })
315
+ },
316
+ },
317
+ ],
318
+ ],
319
+ }),
320
+ ]
321
+ }
322
+
323
+ function createEmojiPickerMachine<TEmojiSearchResult>() {
324
+ return setup({
325
+ types: {
326
+ context: {} as {
327
+ matches: Array<TEmojiSearchResult>
328
+ selectedIndex: number
329
+ },
330
+ events: {} as
331
+ | {
332
+ type: 'emojis found'
333
+ matches: Array<TEmojiSearchResult>
334
+ }
335
+ | {
336
+ type: 'navigate down' | 'navigate up' | 'select' | 'reset'
337
+ },
338
+ },
339
+ actions: {
340
+ 'assign matches': assign({
341
+ matches: ({event}) => {
342
+ assertEvent(event, 'emojis found')
343
+ return event.matches
344
+ },
345
+ }),
346
+ 'reset matches': assign({
347
+ matches: [],
348
+ }),
349
+ 'reset selected index': assign({
350
+ selectedIndex: 0,
351
+ }),
352
+ 'increment selected index': assign({
353
+ selectedIndex: ({context}) => {
354
+ if (context.selectedIndex === context.matches.length - 1) {
355
+ return 0
356
+ }
357
+ return context.selectedIndex + 1
358
+ },
359
+ }),
360
+ 'decrement selected index': assign({
361
+ selectedIndex: ({context}) => {
362
+ if (context.selectedIndex === 0) {
363
+ return context.matches.length - 1
364
+ }
365
+ return context.selectedIndex - 1
366
+ },
367
+ }),
368
+ },
369
+ guards: {
370
+ 'no matches': ({context}) => context.matches.length === 0,
371
+ },
372
+ }).createMachine({
373
+ id: 'emoji picker',
374
+ context: {
375
+ matches: [],
376
+ selectedIndex: 0,
377
+ },
378
+ initial: 'idle',
379
+ states: {
380
+ 'idle': {
381
+ on: {
382
+ 'emojis found': {
383
+ actions: 'assign matches',
384
+ target: 'showing matches',
385
+ },
386
+ },
387
+ },
388
+ 'showing matches': {
389
+ always: {
390
+ guard: 'no matches',
391
+ target: 'idle',
392
+ },
393
+ exit: ['reset selected index'],
394
+ on: {
395
+ 'emojis found': {
396
+ actions: 'assign matches',
397
+ },
398
+ 'navigate down': {
399
+ actions: 'increment selected index',
400
+ },
401
+ 'navigate up': {
402
+ actions: 'decrement selected index',
403
+ },
404
+ 'reset': {
405
+ target: 'idle',
406
+ actions: ['reset selected index', 'reset matches'],
407
+ },
408
+ 'select': {
409
+ target: 'idle',
410
+ actions: ['reset selected index', 'reset matches'],
411
+ },
412
+ },
413
+ },
414
+ },
415
+ })
416
+ }
@@ -11,6 +11,10 @@ export {
11
11
  type CodeEditorBehaviorsConfig,
12
12
  } from './behavior.code-editor'
13
13
  export {coreBehavior, coreBehaviors} from './behavior.core'
14
+ export {
15
+ type EmojiPickerBehaviorsConfig,
16
+ createEmojiPickerBehaviors,
17
+ } from './behavior.emoji-picker'
14
18
  export {createLinkBehaviors, type LinkBehaviorsConfig} from './behavior.links'
15
19
  export {
16
20
  createMarkdownBehaviors,