@portabletext/plugin-markdown-shortcuts 1.0.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,486 @@
1
+ import type {EditorSchema} from '@portabletext/editor'
2
+ import {defineBehavior, execute} from '@portabletext/editor/behaviors'
3
+ import * as selectors from '@portabletext/editor/selectors'
4
+ import * as utils from '@portabletext/editor/utils'
5
+
6
+ export type MarkdownBehaviorsConfig = {
7
+ horizontalRuleObject?: (context: {
8
+ schema: EditorSchema
9
+ }) => {name: string; value?: {[prop: string]: unknown}} | undefined
10
+ defaultStyle?: (context: {schema: EditorSchema}) => string | undefined
11
+ headingStyle?: (context: {
12
+ schema: EditorSchema
13
+ level: number
14
+ }) => string | undefined
15
+ blockquoteStyle?: (context: {schema: EditorSchema}) => string | undefined
16
+ unorderedList?: (context: {schema: EditorSchema}) => string | undefined
17
+ orderedList?: (context: {schema: EditorSchema}) => string | undefined
18
+ }
19
+
20
+ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
21
+ const automaticBlockquoteOnSpace = defineBehavior({
22
+ on: 'insert.text',
23
+ guard: ({snapshot, event}) => {
24
+ const isSpace = event.text === ' '
25
+
26
+ if (!isSpace) {
27
+ return false
28
+ }
29
+
30
+ const selectionCollapsed = selectors.isSelectionCollapsed(snapshot)
31
+ const focusTextBlock = selectors.getFocusTextBlock(snapshot)
32
+ const focusSpan = selectors.getFocusSpan(snapshot)
33
+
34
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
35
+ return false
36
+ }
37
+
38
+ const previousInlineObject = selectors.getPreviousInlineObject(snapshot)
39
+ const blockOffset = utils.spanSelectionPointToBlockOffset({
40
+ value: snapshot.context.value,
41
+ selectionPoint: {
42
+ path: [
43
+ {_key: focusTextBlock.node._key},
44
+ 'children',
45
+ {_key: focusSpan.node._key},
46
+ ],
47
+ offset: snapshot.context.selection?.focus.offset ?? 0,
48
+ },
49
+ })
50
+
51
+ if (previousInlineObject || !blockOffset) {
52
+ return false
53
+ }
54
+
55
+ const blockText = utils.getTextBlockText(focusTextBlock.node)
56
+ const caretAtTheEndOfQuote = blockOffset.offset === 1
57
+ const looksLikeMarkdownQuote = /^>/.test(blockText)
58
+ const blockquoteStyle = config.blockquoteStyle?.({
59
+ schema: snapshot.context.schema,
60
+ })
61
+
62
+ if (
63
+ caretAtTheEndOfQuote &&
64
+ looksLikeMarkdownQuote &&
65
+ blockquoteStyle !== undefined
66
+ ) {
67
+ return {focusTextBlock, style: blockquoteStyle}
68
+ }
69
+
70
+ return false
71
+ },
72
+ actions: [
73
+ () => [
74
+ execute({
75
+ type: 'insert.text',
76
+ text: ' ',
77
+ }),
78
+ ],
79
+ (_, {focusTextBlock, style}) => [
80
+ execute({
81
+ type: 'block.unset',
82
+ props: ['listItem', 'level'],
83
+ at: focusTextBlock.path,
84
+ }),
85
+ execute({
86
+ type: 'block.set',
87
+ props: {style},
88
+ at: focusTextBlock.path,
89
+ }),
90
+ execute({
91
+ type: 'delete.text',
92
+ at: {
93
+ anchor: {
94
+ path: focusTextBlock.path,
95
+ offset: 0,
96
+ },
97
+ focus: {
98
+ path: focusTextBlock.path,
99
+ offset: 2,
100
+ },
101
+ },
102
+ }),
103
+ ],
104
+ ],
105
+ })
106
+ const automaticHr = defineBehavior({
107
+ on: 'insert.text',
108
+ guard: ({snapshot, event}) => {
109
+ const hrCharacter =
110
+ event.text === '-'
111
+ ? '-'
112
+ : event.text === '*'
113
+ ? '*'
114
+ : event.text === '_'
115
+ ? '_'
116
+ : undefined
117
+
118
+ if (hrCharacter === undefined) {
119
+ return false
120
+ }
121
+
122
+ const hrObject = config.horizontalRuleObject?.({
123
+ schema: snapshot.context.schema,
124
+ })
125
+ const focusBlock = selectors.getFocusTextBlock(snapshot)
126
+ const selectionCollapsed = selectors.isSelectionCollapsed(snapshot)
127
+
128
+ if (!hrObject || !focusBlock || !selectionCollapsed) {
129
+ return false
130
+ }
131
+
132
+ const previousInlineObject = selectors.getPreviousInlineObject(snapshot)
133
+ const textBefore = selectors.getBlockTextBefore(snapshot)
134
+ const hrBlockOffsets = {
135
+ anchor: {
136
+ path: focusBlock.path,
137
+ offset: 0,
138
+ },
139
+ focus: {
140
+ path: focusBlock.path,
141
+ offset: 3,
142
+ },
143
+ }
144
+
145
+ if (
146
+ !previousInlineObject &&
147
+ textBefore === `${hrCharacter}${hrCharacter}`
148
+ ) {
149
+ return {hrObject, focusBlock, hrCharacter, hrBlockOffsets}
150
+ }
151
+
152
+ return false
153
+ },
154
+ actions: [
155
+ (_, {hrCharacter}) => [
156
+ execute({
157
+ type: 'insert.text',
158
+ text: hrCharacter,
159
+ }),
160
+ ],
161
+ (_, {hrObject, hrBlockOffsets}) => [
162
+ execute({
163
+ type: 'insert.block',
164
+ block: {
165
+ _type: hrObject.name,
166
+ ...(hrObject.value ?? {}),
167
+ },
168
+ placement: 'before',
169
+ select: 'none',
170
+ }),
171
+ execute({
172
+ type: 'delete.text',
173
+ at: hrBlockOffsets,
174
+ }),
175
+ ],
176
+ ],
177
+ })
178
+ const automaticHrOnPaste = defineBehavior({
179
+ on: 'clipboard.paste',
180
+ guard: ({snapshot, event}) => {
181
+ const text = event.originEvent.dataTransfer.getData('text/plain')
182
+ const hrRegExp = /^(---)$|(___)$|(\*\*\*)$/
183
+ const hrCharacters = text.match(hrRegExp)?.[0]
184
+ const hrObject = config.horizontalRuleObject?.({
185
+ schema: snapshot.context.schema,
186
+ })
187
+ const focusBlock = selectors.getFocusBlock(snapshot)
188
+ const focusTextBlock = selectors.getFocusTextBlock(snapshot)
189
+
190
+ if (!hrCharacters || !hrObject || !focusBlock) {
191
+ return false
192
+ }
193
+
194
+ return {hrCharacters, hrObject, focusBlock, focusTextBlock}
195
+ },
196
+ actions: [
197
+ (_, {hrCharacters}) => [
198
+ execute({
199
+ type: 'insert.text',
200
+ text: hrCharacters,
201
+ }),
202
+ ],
203
+ ({snapshot}, {hrObject, focusBlock, focusTextBlock}) =>
204
+ focusTextBlock
205
+ ? [
206
+ execute({
207
+ type: 'insert.block',
208
+ block: {
209
+ _type: snapshot.context.schema.block.name,
210
+ children: focusTextBlock.node.children,
211
+ },
212
+ placement: 'after',
213
+ }),
214
+ execute({
215
+ type: 'insert.block',
216
+ block: {
217
+ _type: hrObject.name,
218
+ ...(hrObject.value ?? {}),
219
+ },
220
+ placement: 'after',
221
+ }),
222
+ execute({
223
+ type: 'delete.block',
224
+ at: focusBlock.path,
225
+ }),
226
+ ]
227
+ : [
228
+ execute({
229
+ type: 'insert.block',
230
+ block: {
231
+ _type: hrObject.name,
232
+ ...(hrObject.value ?? {}),
233
+ },
234
+ placement: 'after',
235
+ }),
236
+ ],
237
+ ],
238
+ })
239
+ const automaticHeadingOnSpace = defineBehavior({
240
+ on: 'insert.text',
241
+ guard: ({snapshot, event}) => {
242
+ const isSpace = event.text === ' '
243
+
244
+ if (!isSpace) {
245
+ return false
246
+ }
247
+
248
+ const selectionCollapsed = selectors.isSelectionCollapsed(snapshot)
249
+ const focusTextBlock = selectors.getFocusTextBlock(snapshot)
250
+ const focusSpan = selectors.getFocusSpan(snapshot)
251
+
252
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
253
+ return false
254
+ }
255
+
256
+ const blockOffset = utils.spanSelectionPointToBlockOffset({
257
+ value: snapshot.context.value,
258
+ selectionPoint: {
259
+ path: [
260
+ {_key: focusTextBlock.node._key},
261
+ 'children',
262
+ {_key: focusSpan.node._key},
263
+ ],
264
+ offset: snapshot.context.selection?.focus.offset ?? 0,
265
+ },
266
+ })
267
+
268
+ if (!blockOffset) {
269
+ return false
270
+ }
271
+
272
+ const previousInlineObject = selectors.getPreviousInlineObject(snapshot)
273
+ const blockText = utils.getTextBlockText(focusTextBlock.node)
274
+ const markdownHeadingSearch = /^#+/.exec(blockText)
275
+ const level = markdownHeadingSearch
276
+ ? markdownHeadingSearch[0].length
277
+ : undefined
278
+ const caretAtTheEndOfHeading = blockOffset.offset === level
279
+
280
+ if (previousInlineObject || !caretAtTheEndOfHeading) {
281
+ return false
282
+ }
283
+
284
+ const style =
285
+ level !== undefined
286
+ ? config.headingStyle?.({schema: snapshot.context.schema, level})
287
+ : undefined
288
+
289
+ if (level !== undefined && style !== undefined) {
290
+ return {
291
+ focusTextBlock,
292
+ style: style,
293
+ level,
294
+ }
295
+ }
296
+
297
+ return false
298
+ },
299
+ actions: [
300
+ ({event}) => [execute(event)],
301
+ (_, {focusTextBlock, style, level}) => [
302
+ execute({
303
+ type: 'block.unset',
304
+ props: ['listItem', 'level'],
305
+ at: focusTextBlock.path,
306
+ }),
307
+ execute({
308
+ type: 'block.set',
309
+ props: {style},
310
+ at: focusTextBlock.path,
311
+ }),
312
+ execute({
313
+ type: 'delete.text',
314
+ at: {
315
+ anchor: {
316
+ path: focusTextBlock.path,
317
+ offset: 0,
318
+ },
319
+ focus: {
320
+ path: focusTextBlock.path,
321
+ offset: level + 1,
322
+ },
323
+ },
324
+ }),
325
+ ],
326
+ ],
327
+ })
328
+ const clearStyleOnBackspace = defineBehavior({
329
+ on: 'delete.backward',
330
+ guard: ({snapshot}) => {
331
+ const selectionCollapsed = selectors.isSelectionCollapsed(snapshot)
332
+ const focusTextBlock = selectors.getFocusTextBlock(snapshot)
333
+ const focusSpan = selectors.getFocusSpan(snapshot)
334
+
335
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
336
+ return false
337
+ }
338
+
339
+ const atTheBeginningOfBLock =
340
+ focusTextBlock.node.children[0]._key === focusSpan.node._key &&
341
+ snapshot.context.selection?.focus.offset === 0
342
+
343
+ const defaultStyle = config.defaultStyle?.({
344
+ schema: snapshot.context.schema,
345
+ })
346
+
347
+ if (
348
+ atTheBeginningOfBLock &&
349
+ defaultStyle &&
350
+ focusTextBlock.node.style !== defaultStyle
351
+ ) {
352
+ return {defaultStyle, focusTextBlock}
353
+ }
354
+
355
+ return false
356
+ },
357
+ actions: [
358
+ (_, {defaultStyle, focusTextBlock}) => [
359
+ execute({
360
+ type: 'block.set',
361
+ props: {style: defaultStyle},
362
+ at: focusTextBlock.path,
363
+ }),
364
+ ],
365
+ ],
366
+ })
367
+ const automaticListOnSpace = defineBehavior({
368
+ on: 'insert.text',
369
+ guard: ({snapshot, event}) => {
370
+ const isSpace = event.text === ' '
371
+
372
+ if (!isSpace) {
373
+ return false
374
+ }
375
+
376
+ const selectionCollapsed = selectors.isSelectionCollapsed(snapshot)
377
+ const focusTextBlock = selectors.getFocusTextBlock(snapshot)
378
+ const focusSpan = selectors.getFocusSpan(snapshot)
379
+
380
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
381
+ return false
382
+ }
383
+
384
+ const previousInlineObject = selectors.getPreviousInlineObject(snapshot)
385
+ const blockOffset = utils.spanSelectionPointToBlockOffset({
386
+ value: snapshot.context.value,
387
+ selectionPoint: {
388
+ path: [
389
+ {_key: focusTextBlock.node._key},
390
+ 'children',
391
+ {_key: focusSpan.node._key},
392
+ ],
393
+ offset: snapshot.context.selection?.focus.offset ?? 0,
394
+ },
395
+ })
396
+
397
+ if (previousInlineObject || !blockOffset) {
398
+ return false
399
+ }
400
+
401
+ const blockText = utils.getTextBlockText(focusTextBlock.node)
402
+ const defaultStyle = config.defaultStyle?.({
403
+ schema: snapshot.context.schema,
404
+ })
405
+ const looksLikeUnorderedList = /^(-|\*)/.test(blockText)
406
+ const unorderedList = config.unorderedList?.({
407
+ schema: snapshot.context.schema,
408
+ })
409
+ const caretAtTheEndOfUnorderedList = blockOffset.offset === 1
410
+
411
+ if (
412
+ defaultStyle &&
413
+ caretAtTheEndOfUnorderedList &&
414
+ looksLikeUnorderedList &&
415
+ unorderedList !== undefined
416
+ ) {
417
+ return {
418
+ focusTextBlock,
419
+ listItem: unorderedList,
420
+ listItemLength: 1,
421
+ style: defaultStyle,
422
+ }
423
+ }
424
+
425
+ const looksLikeOrderedList = /^1\./.test(blockText)
426
+ const orderedList = config.orderedList?.({
427
+ schema: snapshot.context.schema,
428
+ })
429
+ const caretAtTheEndOfOrderedList = blockOffset.offset === 2
430
+
431
+ if (
432
+ defaultStyle &&
433
+ caretAtTheEndOfOrderedList &&
434
+ looksLikeOrderedList &&
435
+ orderedList !== undefined
436
+ ) {
437
+ return {
438
+ focusTextBlock,
439
+ listItem: orderedList,
440
+ listItemLength: 2,
441
+ style: defaultStyle,
442
+ }
443
+ }
444
+
445
+ return false
446
+ },
447
+ actions: [
448
+ ({event}) => [execute(event)],
449
+ (_, {focusTextBlock, style, listItem, listItemLength}) => [
450
+ execute({
451
+ type: 'block.set',
452
+ props: {
453
+ listItem,
454
+ level: 1,
455
+ style,
456
+ },
457
+ at: focusTextBlock.path,
458
+ }),
459
+ execute({
460
+ type: 'delete.text',
461
+ at: {
462
+ anchor: {
463
+ path: focusTextBlock.path,
464
+ offset: 0,
465
+ },
466
+ focus: {
467
+ path: focusTextBlock.path,
468
+ offset: listItemLength + 1,
469
+ },
470
+ },
471
+ }),
472
+ ],
473
+ ],
474
+ })
475
+
476
+ const markdownBehaviors = [
477
+ automaticBlockquoteOnSpace,
478
+ automaticHeadingOnSpace,
479
+ automaticHr,
480
+ automaticHrOnPaste,
481
+ clearStyleOnBackspace,
482
+ automaticListOnSpace,
483
+ ]
484
+
485
+ return markdownBehaviors
486
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './plugin.markdown-shortcuts'
@@ -0,0 +1,84 @@
1
+ import {useEditor} from '@portabletext/editor'
2
+ import type {EditorSchema} from '@portabletext/editor'
3
+ import {CharacterPairDecoratorPlugin} from '@portabletext/plugin-character-pair-decorator'
4
+ import {useEffect} from 'react'
5
+ import {
6
+ createMarkdownBehaviors,
7
+ type MarkdownBehaviorsConfig,
8
+ } from './behavior.markdown-shortcuts'
9
+
10
+ /**
11
+ * @beta
12
+ */
13
+ export type MarkdownShortcutsPluginProps = MarkdownBehaviorsConfig & {
14
+ boldDecorator?: ({schema}: {schema: EditorSchema}) => string | undefined
15
+ codeDecorator?: ({schema}: {schema: EditorSchema}) => string | undefined
16
+ italicDecorator?: ({schema}: {schema: EditorSchema}) => string | undefined
17
+ strikeThroughDecorator?: ({
18
+ schema,
19
+ }: {
20
+ schema: EditorSchema
21
+ }) => string | undefined
22
+ }
23
+
24
+ /**
25
+ * @beta
26
+ */
27
+ export function MarkdownShortcutsPlugin(props: MarkdownShortcutsPluginProps) {
28
+ const editor = useEditor()
29
+
30
+ useEffect(() => {
31
+ const behaviors = createMarkdownBehaviors(props)
32
+
33
+ const unregisterBehaviors = behaviors.map((behavior) =>
34
+ editor.registerBehavior({behavior}),
35
+ )
36
+
37
+ return () => {
38
+ for (const unregisterBehavior of unregisterBehaviors) {
39
+ unregisterBehavior()
40
+ }
41
+ }
42
+ }, [editor, props])
43
+
44
+ return (
45
+ <>
46
+ {props.boldDecorator ? (
47
+ <>
48
+ <CharacterPairDecoratorPlugin
49
+ decorator={props.boldDecorator}
50
+ pair={{char: '*', amount: 2}}
51
+ />
52
+ <CharacterPairDecoratorPlugin
53
+ decorator={props.boldDecorator}
54
+ pair={{char: '_', amount: 2}}
55
+ />
56
+ </>
57
+ ) : null}
58
+ {props.codeDecorator ? (
59
+ <CharacterPairDecoratorPlugin
60
+ decorator={props.codeDecorator}
61
+ pair={{char: '`', amount: 1}}
62
+ />
63
+ ) : null}
64
+ {props.italicDecorator ? (
65
+ <>
66
+ <CharacterPairDecoratorPlugin
67
+ decorator={props.italicDecorator}
68
+ pair={{char: '*', amount: 1}}
69
+ />
70
+ <CharacterPairDecoratorPlugin
71
+ decorator={props.italicDecorator}
72
+ pair={{char: '_', amount: 1}}
73
+ />
74
+ </>
75
+ ) : null}
76
+ {props.strikeThroughDecorator ? (
77
+ <CharacterPairDecoratorPlugin
78
+ decorator={props.strikeThroughDecorator}
79
+ pair={{char: '~', amount: 2}}
80
+ />
81
+ ) : null}
82
+ </>
83
+ )
84
+ }