@portabletext/plugin-input-rule 0.1.0

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,603 @@
1
+ import {useEditor, type BlockOffset, type Editor} from '@portabletext/editor'
2
+ import {
3
+ defineBehavior,
4
+ effect,
5
+ forward,
6
+ raise,
7
+ type BehaviorAction,
8
+ } from '@portabletext/editor/behaviors'
9
+ import {
10
+ getBlockOffsets,
11
+ getBlockTextBefore,
12
+ getFocusTextBlock,
13
+ } from '@portabletext/editor/selectors'
14
+ import {blockOffsetsToSelection} from '@portabletext/editor/utils'
15
+ import {useActorRef} from '@xstate/react'
16
+ import {
17
+ fromCallback,
18
+ setup,
19
+ type AnyEventObject,
20
+ type CallbackLogicFunction,
21
+ } from 'xstate'
22
+ import type {InputRule, InputRuleMatch} from './input-rule'
23
+
24
+ function createInputRuleBehavior(config: {
25
+ rules: Array<InputRule>
26
+ onApply: ({
27
+ endOffsets,
28
+ }: {
29
+ endOffsets: {start: BlockOffset; end: BlockOffset} | undefined
30
+ }) => void
31
+ }) {
32
+ return defineBehavior({
33
+ on: 'insert.text',
34
+ guard: ({snapshot, event, dom}) => {
35
+ const focusTextBlock = getFocusTextBlock(snapshot)
36
+
37
+ if (!focusTextBlock) {
38
+ return false
39
+ }
40
+
41
+ const originalTextBefore = getBlockTextBefore(snapshot)
42
+ let textBefore = originalTextBefore
43
+ const originalNewText = textBefore + event.text
44
+ let newText = originalNewText
45
+
46
+ const foundMatches: Array<InputRuleMatch['groupMatches'][number]> = []
47
+ const foundActions: Array<BehaviorAction> = []
48
+
49
+ for (const rule of config.rules) {
50
+ const matcher = new RegExp(rule.on.source, 'gd')
51
+
52
+ while (true) {
53
+ // Find matches in the text before the insertion
54
+ const matchesInTextBefore: Array<InputRuleMatch> = [
55
+ ...textBefore.matchAll(matcher),
56
+ ].flatMap((regExpMatch) => {
57
+ if (regExpMatch.indices === undefined) {
58
+ return []
59
+ }
60
+
61
+ const [index] = regExpMatch.indices.at(0) ?? [undefined, undefined]
62
+
63
+ if (index === undefined) {
64
+ return []
65
+ }
66
+
67
+ const [firstMatchStart, firstMatchEnd] = regExpMatch.indices.at(
68
+ 0,
69
+ ) ?? [undefined, undefined]
70
+
71
+ if (firstMatchStart === undefined || firstMatchEnd === undefined) {
72
+ return []
73
+ }
74
+
75
+ const match = {
76
+ index: firstMatchStart,
77
+ length: firstMatchEnd - firstMatchStart,
78
+ }
79
+ const adjustedIndex =
80
+ match.index + originalNewText.length - newText.length
81
+ const targetOffsets = {
82
+ anchor: {
83
+ path: focusTextBlock.path,
84
+ offset: adjustedIndex,
85
+ },
86
+ focus: {
87
+ path: focusTextBlock.path,
88
+ offset: adjustedIndex + match.length,
89
+ },
90
+ backward: false,
91
+ }
92
+ const selection = blockOffsetsToSelection({
93
+ context: snapshot.context,
94
+ offsets: targetOffsets,
95
+ backward: false,
96
+ })
97
+
98
+ if (!selection) {
99
+ return []
100
+ }
101
+
102
+ const groupMatches =
103
+ regExpMatch.indices.length > 1
104
+ ? regExpMatch.indices.slice(1).map(([start, end]) => ({
105
+ index: start,
106
+ length: end - start,
107
+ }))
108
+ : []
109
+ const ruleMatch = {
110
+ selection,
111
+ targetOffsets,
112
+ groupMatches: groupMatches.flatMap((groupMatch) => {
113
+ const adjustedIndex =
114
+ groupMatch.index + originalNewText.length - newText.length
115
+
116
+ const targetOffsets = {
117
+ anchor: {
118
+ path: focusTextBlock.path,
119
+ offset: adjustedIndex,
120
+ },
121
+ focus: {
122
+ path: focusTextBlock.path,
123
+ offset: adjustedIndex + groupMatch.length,
124
+ },
125
+ backward: false,
126
+ }
127
+ const normalizedOffsets = {
128
+ anchor: {
129
+ path: focusTextBlock.path,
130
+ offset: Math.min(
131
+ targetOffsets.anchor.offset,
132
+ originalTextBefore.length,
133
+ ),
134
+ },
135
+ focus: {
136
+ path: focusTextBlock.path,
137
+ offset: Math.min(
138
+ targetOffsets.focus.offset,
139
+ originalTextBefore.length,
140
+ ),
141
+ },
142
+ backward: false,
143
+ }
144
+ const selection = blockOffsetsToSelection({
145
+ context: snapshot.context,
146
+ offsets: normalizedOffsets,
147
+ backward: false,
148
+ })
149
+
150
+ if (!selection) {
151
+ return []
152
+ }
153
+
154
+ return {
155
+ selection,
156
+ targetOffsets,
157
+ }
158
+ }),
159
+ }
160
+
161
+ return [ruleMatch]
162
+ })
163
+ const matchesInNewText = [...newText.matchAll(matcher)]
164
+ // Find matches in the text after the insertion
165
+ const ruleMatches = matchesInNewText.flatMap((regExpMatch) => {
166
+ if (regExpMatch.indices === undefined) {
167
+ return []
168
+ }
169
+
170
+ const [index] = regExpMatch.indices.at(0) ?? [undefined, undefined]
171
+
172
+ if (index === undefined) {
173
+ return []
174
+ }
175
+
176
+ const [firstMatchStart, firstMatchEnd] = regExpMatch.indices.at(
177
+ 0,
178
+ ) ?? [undefined, undefined]
179
+
180
+ if (firstMatchStart === undefined || firstMatchEnd === undefined) {
181
+ return []
182
+ }
183
+
184
+ const match = {
185
+ index: firstMatchStart,
186
+ length: firstMatchEnd - firstMatchStart,
187
+ }
188
+ const adjustedIndex =
189
+ match.index + originalNewText.length - newText.length
190
+ const targetOffsets = {
191
+ anchor: {
192
+ path: focusTextBlock.path,
193
+ offset: adjustedIndex,
194
+ },
195
+ focus: {
196
+ path: focusTextBlock.path,
197
+ offset: adjustedIndex + match.length,
198
+ },
199
+ backward: false,
200
+ }
201
+ const normalizedOffsets = {
202
+ anchor: {
203
+ path: focusTextBlock.path,
204
+ offset: Math.min(
205
+ targetOffsets.anchor.offset,
206
+ originalTextBefore.length,
207
+ ),
208
+ },
209
+ focus: {
210
+ path: focusTextBlock.path,
211
+ offset: Math.min(
212
+ targetOffsets.focus.offset,
213
+ originalTextBefore.length,
214
+ ),
215
+ },
216
+ backward: false,
217
+ }
218
+ const selection = blockOffsetsToSelection({
219
+ context: snapshot.context,
220
+ offsets: normalizedOffsets,
221
+ backward: false,
222
+ })
223
+
224
+ if (!selection) {
225
+ return []
226
+ }
227
+
228
+ const groupMatches =
229
+ regExpMatch.indices.length > 1
230
+ ? regExpMatch.indices.slice(1).map(([start, end]) => ({
231
+ index: start,
232
+ length: end - start,
233
+ }))
234
+ : []
235
+
236
+ const ruleMatch = {
237
+ selection,
238
+ targetOffsets,
239
+ groupMatches: groupMatches.flatMap((groupMatch) => {
240
+ const adjustedIndex =
241
+ groupMatch.index + originalNewText.length - newText.length
242
+
243
+ const targetOffsets = {
244
+ anchor: {
245
+ path: focusTextBlock.path,
246
+ offset: adjustedIndex,
247
+ },
248
+ focus: {
249
+ path: focusTextBlock.path,
250
+ offset: adjustedIndex + groupMatch.length,
251
+ },
252
+ backward: false,
253
+ }
254
+ const normalizedOffsets = {
255
+ anchor: {
256
+ path: focusTextBlock.path,
257
+ offset: Math.min(
258
+ targetOffsets.anchor.offset,
259
+ originalTextBefore.length,
260
+ ),
261
+ },
262
+ focus: {
263
+ path: focusTextBlock.path,
264
+ offset: Math.min(
265
+ targetOffsets.focus.offset,
266
+ originalTextBefore.length,
267
+ ),
268
+ },
269
+ backward: false,
270
+ }
271
+ const selection = blockOffsetsToSelection({
272
+ context: snapshot.context,
273
+ offsets: normalizedOffsets,
274
+ backward: false,
275
+ })
276
+
277
+ if (!selection) {
278
+ return []
279
+ }
280
+
281
+ return [
282
+ {
283
+ targetOffsets,
284
+ selection,
285
+ },
286
+ ]
287
+ }),
288
+ }
289
+
290
+ const alreadyFound = foundMatches.some(
291
+ (foundMatch) =>
292
+ foundMatch.targetOffsets.anchor.offset === adjustedIndex,
293
+ )
294
+
295
+ // Ignore if this match has already been found
296
+ if (alreadyFound) {
297
+ return []
298
+ }
299
+
300
+ const existsInTextBefore = matchesInTextBefore.some(
301
+ (matchInTextBefore) =>
302
+ matchInTextBefore.targetOffsets.anchor.offset === adjustedIndex,
303
+ )
304
+
305
+ // Ignore if this match occurs in the text before the insertion
306
+ if (existsInTextBefore) {
307
+ return []
308
+ }
309
+
310
+ return [ruleMatch]
311
+ })
312
+
313
+ if (ruleMatches.length > 0) {
314
+ const guardResult =
315
+ rule.guard?.({
316
+ snapshot,
317
+ event: {
318
+ type: 'custom.input rule',
319
+ matches: ruleMatches,
320
+ focusTextBlock,
321
+ textBefore: originalTextBefore,
322
+ textInserted: event.text,
323
+ },
324
+ dom,
325
+ }) ?? true
326
+
327
+ if (!guardResult) {
328
+ break
329
+ }
330
+
331
+ const actionSets = rule.actions.map((action) =>
332
+ action(
333
+ {
334
+ snapshot,
335
+ event: {
336
+ type: 'custom.input rule',
337
+ matches: ruleMatches,
338
+ focusTextBlock,
339
+ textBefore: originalTextBefore,
340
+ textInserted: event.text,
341
+ },
342
+ dom,
343
+ },
344
+ guardResult,
345
+ ),
346
+ )
347
+
348
+ for (const actionSet of actionSets) {
349
+ for (const action of actionSet) {
350
+ foundActions.push(action)
351
+ }
352
+ }
353
+
354
+ const matches = ruleMatches.flatMap((match) =>
355
+ match.groupMatches.length === 0 ? [match] : match.groupMatches,
356
+ )
357
+ for (const match of matches) {
358
+ // Remember each match and adjust `textBefore` and `newText` so
359
+ // no subsequent matches can overlap with this one
360
+ foundMatches.push(match)
361
+ textBefore = newText.slice(
362
+ 0,
363
+ match.targetOffsets.focus.offset ?? 0,
364
+ )
365
+ newText = originalNewText.slice(
366
+ match.targetOffsets.focus.offset ?? 0,
367
+ )
368
+ }
369
+ } else {
370
+ // If no match was found, break out of the loop to try the next
371
+ // rule
372
+ break
373
+ }
374
+ }
375
+ }
376
+
377
+ if (foundActions.length === 0) {
378
+ return false
379
+ }
380
+
381
+ return {actions: foundActions}
382
+ },
383
+ actions: [
384
+ ({event}) => [forward(event)],
385
+ (_, {actions}) => actions,
386
+ ({snapshot}) => [
387
+ effect(() => {
388
+ const blockOffsets = getBlockOffsets(snapshot)
389
+
390
+ config.onApply({endOffsets: blockOffsets})
391
+ }),
392
+ ],
393
+ ],
394
+ })
395
+ }
396
+
397
+ type InputRulePluginProps = {
398
+ rules: Array<InputRule>
399
+ }
400
+
401
+ /**
402
+ * Turn an array of `InputRule`s into a Behavior that can be used to apply the
403
+ * rules to the editor.
404
+ *
405
+ * The plugin handles undo/redo out of the box including smart undo with
406
+ * Backspace.
407
+ *
408
+ * @example
409
+ * ```tsx
410
+ * <InputRulePlugin rules={smartQuotesRules} />
411
+ * ```
412
+ *
413
+ * @alpha
414
+ */
415
+ export function InputRulePlugin(props: InputRulePluginProps) {
416
+ const editor = useEditor()
417
+
418
+ useActorRef(inputRuleMachine, {
419
+ input: {editor, rules: props.rules},
420
+ })
421
+
422
+ return null
423
+ }
424
+
425
+ type InputRuleMachineEvent =
426
+ | {
427
+ type: 'input rule raised'
428
+ endOffsets: {start: BlockOffset; end: BlockOffset} | undefined
429
+ }
430
+ | {type: 'history.undo raised'}
431
+ | {
432
+ type: 'selection changed'
433
+ blockOffsets: {start: BlockOffset; end: BlockOffset} | undefined
434
+ }
435
+
436
+ const inputRuleListenerCallback: CallbackLogicFunction<
437
+ AnyEventObject,
438
+ InputRuleMachineEvent,
439
+ {
440
+ editor: Editor
441
+ rules: Array<InputRule>
442
+ }
443
+ > = ({input, sendBack}) => {
444
+ const unregister = input.editor.registerBehavior({
445
+ behavior: createInputRuleBehavior({
446
+ rules: input.rules,
447
+ onApply: ({endOffsets}) => {
448
+ sendBack({type: 'input rule raised', endOffsets})
449
+ },
450
+ }),
451
+ })
452
+
453
+ return () => {
454
+ unregister()
455
+ }
456
+ }
457
+
458
+ const deleteBackwardListenerCallback: CallbackLogicFunction<
459
+ AnyEventObject,
460
+ InputRuleMachineEvent,
461
+ {editor: Editor}
462
+ > = ({input, sendBack}) => {
463
+ return input.editor.registerBehavior({
464
+ behavior: defineBehavior({
465
+ on: 'delete.backward',
466
+ actions: [
467
+ () => [
468
+ raise({type: 'history.undo'}),
469
+ effect(() => {
470
+ sendBack({type: 'history.undo raised'})
471
+ }),
472
+ ],
473
+ ],
474
+ }),
475
+ })
476
+ }
477
+
478
+ const selectionListenerCallback: CallbackLogicFunction<
479
+ AnyEventObject,
480
+ InputRuleMachineEvent,
481
+ {editor: Editor}
482
+ > = ({sendBack, input}) => {
483
+ const unregister = input.editor.registerBehavior({
484
+ behavior: defineBehavior({
485
+ on: 'select',
486
+ guard: ({snapshot, event}) => {
487
+ const blockOffsets = getBlockOffsets({
488
+ ...snapshot,
489
+ context: {
490
+ ...snapshot.context,
491
+ selection: event.at,
492
+ },
493
+ })
494
+
495
+ return {blockOffsets}
496
+ },
497
+ actions: [
498
+ ({event}, {blockOffsets}) => [
499
+ effect(() => {
500
+ sendBack({type: 'selection changed', blockOffsets})
501
+ }),
502
+ forward(event),
503
+ ],
504
+ ],
505
+ }),
506
+ })
507
+
508
+ return unregister
509
+ }
510
+
511
+ const inputRuleSetup = setup({
512
+ types: {
513
+ context: {} as {
514
+ editor: Editor
515
+ rules: Array<InputRule>
516
+ endOffsets: {start: BlockOffset; end: BlockOffset} | undefined
517
+ },
518
+ input: {} as {
519
+ editor: Editor
520
+ rules: Array<InputRule>
521
+ },
522
+ events: {} as InputRuleMachineEvent,
523
+ },
524
+ actors: {
525
+ 'delete.backward listener': fromCallback(deleteBackwardListenerCallback),
526
+ 'input rule listener': fromCallback(inputRuleListenerCallback),
527
+ 'selection listener': fromCallback(selectionListenerCallback),
528
+ },
529
+ guards: {
530
+ 'block offset changed': ({context, event}) => {
531
+ if (event.type !== 'selection changed') {
532
+ return false
533
+ }
534
+
535
+ if (!event.blockOffsets || !context.endOffsets) {
536
+ return true
537
+ }
538
+
539
+ const startChanged =
540
+ context.endOffsets.start.path[0]._key !==
541
+ event.blockOffsets.start.path[0]._key ||
542
+ context.endOffsets.start.offset !== event.blockOffsets.start.offset
543
+ const endChanged =
544
+ context.endOffsets.end.path[0]._key !==
545
+ event.blockOffsets.end.path[0]._key ||
546
+ context.endOffsets.end.offset !== event.blockOffsets.end.offset
547
+
548
+ return startChanged || endChanged
549
+ },
550
+ },
551
+ })
552
+
553
+ const assignEndOffsets = inputRuleSetup.assign({
554
+ endOffsets: ({context, event}) =>
555
+ event.type === 'input rule raised' ? event.endOffsets : context.endOffsets,
556
+ })
557
+
558
+ const inputRuleMachine = inputRuleSetup.createMachine({
559
+ id: 'input rule',
560
+ context: ({input}) => ({
561
+ editor: input.editor,
562
+ rules: input.rules,
563
+ endOffsets: undefined,
564
+ }),
565
+ initial: 'idle',
566
+ invoke: {
567
+ src: 'input rule listener',
568
+ input: ({context}) => ({
569
+ editor: context.editor,
570
+ rules: context.rules,
571
+ }),
572
+ },
573
+ on: {
574
+ 'input rule raised': {
575
+ target: '.input rule applied',
576
+ actions: assignEndOffsets,
577
+ },
578
+ },
579
+ states: {
580
+ 'idle': {},
581
+ 'input rule applied': {
582
+ invoke: [
583
+ {
584
+ src: 'delete.backward listener',
585
+ input: ({context}) => ({editor: context.editor}),
586
+ },
587
+ {
588
+ src: 'selection listener',
589
+ input: ({context}) => ({editor: context.editor}),
590
+ },
591
+ ],
592
+ on: {
593
+ 'selection changed': {
594
+ target: 'idle',
595
+ guard: 'block offset changed',
596
+ },
597
+ 'history.undo raised': {
598
+ target: 'idle',
599
+ },
600
+ },
601
+ },
602
+ },
603
+ })
@@ -0,0 +1,94 @@
1
+ import {raise} from '@portabletext/editor/behaviors'
2
+ import {getMarkState} from '@portabletext/editor/selectors'
3
+ import type {InputRule, InputRuleGuard} from './input-rule'
4
+
5
+ /**
6
+ * @alpha
7
+ */
8
+ export type TextTransformRule = {
9
+ on: RegExp
10
+ guard?: InputRuleGuard
11
+ transform: () => string
12
+ }
13
+
14
+ /**
15
+ * Define an `InputRule` specifically designed to transform matched text into
16
+ * some other text.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const transformRule = defineTextTransformRule({
21
+ * on: /--/,
22
+ * transform: () => '—',
23
+ * })
24
+ * ```
25
+ *
26
+ * @alpha
27
+ */
28
+ export function defineTextTransformRule(config: TextTransformRule): InputRule {
29
+ return {
30
+ on: config.on,
31
+ guard: config.guard ?? (() => true),
32
+ actions: [
33
+ ({snapshot, event}) => {
34
+ const matches = event.matches.flatMap((match) =>
35
+ match.groupMatches.length === 0 ? [match] : match.groupMatches,
36
+ )
37
+ const textLengthDelta = matches.reduce((length, match) => {
38
+ return (
39
+ length -
40
+ (config.transform().length -
41
+ (match.targetOffsets.focus.offset -
42
+ match.targetOffsets.anchor.offset))
43
+ )
44
+ }, 0)
45
+
46
+ const newText = event.textBefore + event.textInserted
47
+ const endCaretPosition = {
48
+ path: event.focusTextBlock.path,
49
+ offset: newText.length - textLengthDelta,
50
+ }
51
+
52
+ const actions = matches.reverse().flatMap((match) => [
53
+ raise({type: 'select', at: match.targetOffsets}),
54
+ raise({type: 'delete', at: match.targetOffsets}),
55
+ raise({
56
+ type: 'insert.child',
57
+ child: {
58
+ _type: snapshot.context.schema.span.name,
59
+ text: config.transform(),
60
+ marks:
61
+ getMarkState({
62
+ ...snapshot,
63
+ context: {
64
+ ...snapshot.context,
65
+ selection: {
66
+ anchor: match.selection.anchor,
67
+ focus: {
68
+ path: match.selection.focus.path,
69
+ offset: Math.min(
70
+ match.selection.focus.offset,
71
+ event.textBefore.length,
72
+ ),
73
+ },
74
+ },
75
+ },
76
+ })?.marks ?? [],
77
+ },
78
+ }),
79
+ ])
80
+
81
+ return [
82
+ ...actions,
83
+ raise({
84
+ type: 'select',
85
+ at: {
86
+ anchor: endCaretPosition,
87
+ focus: endCaretPosition,
88
+ },
89
+ }),
90
+ ]
91
+ },
92
+ ],
93
+ }
94
+ }