@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.cjs +433 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +129 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +439 -0
- package/dist/index.js.map +1 -0
- package/package.json +85 -0
- package/src/edge-cases.feature +81 -0
- package/src/edge-cases.test.tsx +59 -0
- package/src/global.d.ts +4 -0
- package/src/index.ts +3 -0
- package/src/input-rule.ts +82 -0
- package/src/plugin.input-rule.tsx +603 -0
- package/src/text-transform-rule.ts +94 -0
|
@@ -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
|
+
}
|