@portabletext/editor 1.11.3 → 1.12.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.
@@ -1,11 +1,15 @@
1
- import {isPortableTextSpan} from '@portabletext/toolkit'
1
+ import {isPortableTextTextBlock} from '@sanity/types'
2
2
  import type {PortableTextMemberSchemaTypes} from '../../types/editor'
3
3
  import {defineBehavior} from './behavior.types'
4
4
  import {
5
+ getFocusBlock,
5
6
  getFocusSpan,
6
7
  getFocusTextBlock,
8
+ getTextBlockText,
7
9
  selectionIsCollapsed,
8
10
  } from './behavior.utils'
11
+ import {spanSelectionPointToBlockOffset} from './behavior.utils.block-offset'
12
+ import {getBlockTextBefore} from './behavior.utilts.get-text-before'
9
13
 
10
14
  /**
11
15
  * @alpha
@@ -53,8 +57,25 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
53
57
  return false
54
58
  }
55
59
 
56
- const caretAtTheEndOfQuote = context.selection.focus.offset === 1
57
- const looksLikeMarkdownQuote = /^>/.test(focusSpan.node.text)
60
+ const blockOffset = spanSelectionPointToBlockOffset({
61
+ value: context.value,
62
+ selectionPoint: {
63
+ path: [
64
+ {_key: focusTextBlock.node._key},
65
+ 'children',
66
+ {_key: focusSpan.node._key},
67
+ ],
68
+ offset: context.selection.focus.offset,
69
+ },
70
+ })
71
+
72
+ if (!blockOffset) {
73
+ return false
74
+ }
75
+
76
+ const blockText = getTextBlockText(focusTextBlock.node)
77
+ const caretAtTheEndOfQuote = blockOffset.offset === 1
78
+ const looksLikeMarkdownQuote = /^>/.test(blockText)
58
79
  const blockquoteStyle = config.blockquoteStyle?.({schema: context.schema})
59
80
 
60
81
  if (
@@ -62,7 +83,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
62
83
  looksLikeMarkdownQuote &&
63
84
  blockquoteStyle !== undefined
64
85
  ) {
65
- return {focusTextBlock, focusSpan, style: blockquoteStyle}
86
+ return {focusTextBlock, style: blockquoteStyle}
66
87
  }
67
88
 
68
89
  return false
@@ -74,7 +95,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
74
95
  text: ' ',
75
96
  },
76
97
  ],
77
- (_, {focusTextBlock, focusSpan, style}) => [
98
+ (_, {focusTextBlock, style}) => [
78
99
  {
79
100
  type: 'unset block',
80
101
  props: ['listItem', 'level'],
@@ -86,22 +107,20 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
86
107
  paths: [focusTextBlock.path],
87
108
  },
88
109
  {
89
- type: 'delete',
90
- selection: {
91
- anchor: {
92
- path: focusSpan.path,
93
- offset: 0,
94
- },
95
- focus: {
96
- path: focusSpan.path,
97
- offset: 2,
98
- },
110
+ type: 'delete text',
111
+ anchor: {
112
+ path: focusTextBlock.path,
113
+ offset: 0,
114
+ },
115
+ focus: {
116
+ path: focusTextBlock.path,
117
+ offset: 2,
99
118
  },
100
119
  },
101
120
  ],
102
121
  ],
103
122
  })
104
- const automaticBreak = defineBehavior({
123
+ const automaticHr = defineBehavior({
105
124
  on: 'insert text',
106
125
  guard: ({context, event}) => {
107
126
  const hrCharacter =
@@ -117,23 +136,33 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
117
136
  return false
118
137
  }
119
138
 
120
- const breakObject = config.horizontalRuleObject?.({
139
+ const hrObject = config.horizontalRuleObject?.({
121
140
  schema: context.schema,
122
141
  })
123
142
  const focusBlock = getFocusTextBlock(context)
124
143
  const selectionCollapsed = selectionIsCollapsed(context)
125
144
 
126
- if (!breakObject || !focusBlock || !selectionCollapsed) {
145
+ if (!hrObject || !focusBlock || !selectionCollapsed) {
127
146
  return false
128
147
  }
129
148
 
130
- const onlyText = focusBlock.node.children.every(isPortableTextSpan)
131
- const blockText = focusBlock.node.children
132
- .map((child) => child.text ?? '')
133
- .join('')
149
+ const textBefore = getBlockTextBefore({
150
+ value: context.value,
151
+ point: context.selection.focus,
152
+ })
153
+ const hrBlockOffsets = {
154
+ anchor: {
155
+ path: focusBlock.path,
156
+ offset: 0,
157
+ },
158
+ focus: {
159
+ path: focusBlock.path,
160
+ offset: 3,
161
+ },
162
+ }
134
163
 
135
- if (onlyText && blockText === `${hrCharacter}${hrCharacter}`) {
136
- return {breakObject, focusBlock, hrCharacter}
164
+ if (textBefore === `${hrCharacter}${hrCharacter}`) {
165
+ return {hrObject, focusBlock, hrCharacter, hrBlockOffsets}
137
166
  }
138
167
 
139
168
  return false
@@ -145,29 +174,65 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
145
174
  text: hrCharacter,
146
175
  },
147
176
  ],
148
- (_, {breakObject, focusBlock}) => [
177
+ (_, {hrObject, hrBlockOffsets}) => [
149
178
  {
150
179
  type: 'insert block object',
151
- ...breakObject,
180
+ placement: 'before',
181
+ blockObject: hrObject,
152
182
  },
153
183
  {
154
- type: 'delete',
155
- selection: {
156
- anchor: {
157
- path: focusBlock.path,
158
- offset: 0,
159
- },
160
- focus: {
161
- path: focusBlock.path,
162
- offset: 0,
163
- },
164
- },
184
+ type: 'delete text',
185
+ ...hrBlockOffsets,
165
186
  },
187
+ ],
188
+ ],
189
+ })
190
+ const automaticHrOnPaste = defineBehavior({
191
+ on: 'paste',
192
+ guard: ({context, event}) => {
193
+ const text = event.clipboardData.getData('text/plain')
194
+ const hrRegExp = /^(---)$|(___)$|(\*\*\*)$/gm
195
+ const hrCharacters = text.match(hrRegExp)?.[0]
196
+ const hrObject = config.horizontalRuleObject?.({
197
+ schema: context.schema,
198
+ })
199
+ const focusBlock = getFocusBlock(context)
200
+
201
+ if (!hrCharacters || !hrObject || !focusBlock) {
202
+ return false
203
+ }
204
+
205
+ return {hrCharacters, hrObject, focusBlock}
206
+ },
207
+ actions: [
208
+ (_, {hrCharacters}) => [
166
209
  {
167
- type: 'insert text block',
168
- decorators: [],
210
+ type: 'insert text',
211
+ text: hrCharacters,
169
212
  },
170
213
  ],
214
+ (_, {hrObject, focusBlock}) =>
215
+ isPortableTextTextBlock(focusBlock.node)
216
+ ? [
217
+ {
218
+ type: 'insert text block',
219
+ textBlock: {children: focusBlock.node.children},
220
+ placement: 'after',
221
+ },
222
+ {
223
+ type: 'insert block object',
224
+ blockObject: hrObject,
225
+ placement: 'after',
226
+ },
227
+ {type: 'delete block', blockPath: focusBlock.path},
228
+ ]
229
+ : [
230
+ {
231
+ type: 'insert block object',
232
+ blockObject: hrObject,
233
+ placement: 'after',
234
+ },
235
+ ],
171
236
  ],
172
237
  })
173
238
  const automaticHeadingOnSpace = defineBehavior({
@@ -187,11 +252,28 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
187
252
  return false
188
253
  }
189
254
 
190
- const markdownHeadingSearch = /^#+/.exec(focusSpan.node.text)
255
+ const blockOffset = spanSelectionPointToBlockOffset({
256
+ value: context.value,
257
+ selectionPoint: {
258
+ path: [
259
+ {_key: focusTextBlock.node._key},
260
+ 'children',
261
+ {_key: focusSpan.node._key},
262
+ ],
263
+ offset: context.selection.focus.offset,
264
+ },
265
+ })
266
+
267
+ if (!blockOffset) {
268
+ return false
269
+ }
270
+
271
+ const blockText = getTextBlockText(focusTextBlock.node)
272
+ const markdownHeadingSearch = /^#+/.exec(blockText)
191
273
  const level = markdownHeadingSearch
192
274
  ? markdownHeadingSearch[0].length
193
275
  : undefined
194
- const caretAtTheEndOfHeading = context.selection.focus.offset === level
276
+ const caretAtTheEndOfHeading = blockOffset.offset === level
195
277
 
196
278
  if (!caretAtTheEndOfHeading) {
197
279
  return false
@@ -205,7 +287,6 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
205
287
  if (level !== undefined && style !== undefined) {
206
288
  return {
207
289
  focusTextBlock,
208
- focusSpan,
209
290
  style: style,
210
291
  level,
211
292
  }
@@ -220,7 +301,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
220
301
  text: ' ',
221
302
  },
222
303
  ],
223
- (_, {focusTextBlock, focusSpan, style, level}) => [
304
+ (_, {focusTextBlock, style, level}) => [
224
305
  {
225
306
  type: 'unset block',
226
307
  props: ['listItem', 'level'],
@@ -232,16 +313,14 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
232
313
  paths: [focusTextBlock.path],
233
314
  },
234
315
  {
235
- type: 'delete',
236
- selection: {
237
- anchor: {
238
- path: focusSpan.path,
239
- offset: 0,
240
- },
241
- focus: {
242
- path: focusSpan.path,
243
- offset: level + 1,
244
- },
316
+ type: 'delete text',
317
+ anchor: {
318
+ path: focusTextBlock.path,
319
+ offset: 0,
320
+ },
321
+ focus: {
322
+ path: focusTextBlock.path,
323
+ offset: level + 1,
245
324
  },
246
325
  },
247
326
  ],
@@ -301,12 +380,29 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
301
380
  return false
302
381
  }
303
382
 
383
+ const blockOffset = spanSelectionPointToBlockOffset({
384
+ value: context.value,
385
+ selectionPoint: {
386
+ path: [
387
+ {_key: focusTextBlock.node._key},
388
+ 'children',
389
+ {_key: focusSpan.node._key},
390
+ ],
391
+ offset: context.selection.focus.offset,
392
+ },
393
+ })
394
+
395
+ if (!blockOffset) {
396
+ return false
397
+ }
398
+
399
+ const blockText = getTextBlockText(focusTextBlock.node)
304
400
  const defaultStyle = config.defaultStyle?.({schema: context.schema})
305
- const looksLikeUnorderedList = /^(-|\*)/.test(focusSpan.node.text)
401
+ const looksLikeUnorderedList = /^(-|\*)/.test(blockText)
306
402
  const unorderedListStyle = config.unorderedListStyle?.({
307
403
  schema: context.schema,
308
404
  })
309
- const caretAtTheEndOfUnorderedList = context.selection.focus.offset === 1
405
+ const caretAtTheEndOfUnorderedList = blockOffset.offset === 1
310
406
 
311
407
  if (
312
408
  defaultStyle &&
@@ -316,7 +412,6 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
316
412
  ) {
317
413
  return {
318
414
  focusTextBlock,
319
- focusSpan,
320
415
  listItem: unorderedListStyle,
321
416
  listItemLength: 1,
322
417
  style: defaultStyle,
@@ -337,7 +432,6 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
337
432
  ) {
338
433
  return {
339
434
  focusTextBlock,
340
- focusSpan,
341
435
  listItem: orderedListStyle,
342
436
  listItemLength: 2,
343
437
  style: defaultStyle,
@@ -353,7 +447,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
353
447
  text: ' ',
354
448
  },
355
449
  ],
356
- (_, {focusTextBlock, focusSpan, style, listItem, listItemLength}) => [
450
+ (_, {focusTextBlock, style, listItem, listItemLength}) => [
357
451
  {
358
452
  type: 'set block',
359
453
  listItem,
@@ -362,16 +456,14 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
362
456
  paths: [focusTextBlock.path],
363
457
  },
364
458
  {
365
- type: 'delete',
366
- selection: {
367
- anchor: {
368
- path: focusSpan.path,
369
- offset: 0,
370
- },
371
- focus: {
372
- path: focusSpan.path,
373
- offset: listItemLength + 1,
374
- },
459
+ type: 'delete text',
460
+ anchor: {
461
+ path: focusTextBlock.path,
462
+ offset: 0,
463
+ },
464
+ focus: {
465
+ path: focusTextBlock.path,
466
+ offset: listItemLength + 1,
375
467
  },
376
468
  },
377
469
  ],
@@ -380,8 +472,9 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
380
472
 
381
473
  const markdownBehaviors = [
382
474
  automaticBlockquoteOnSpace,
383
- automaticBreak,
384
475
  automaticHeadingOnSpace,
476
+ automaticHr,
477
+ automaticHrOnPaste,
385
478
  clearStyleOnBackspace,
386
479
  automaticListOnSpace,
387
480
  ]
@@ -1,4 +1,8 @@
1
- import type {KeyedSegment, PortableTextBlock} from '@sanity/types'
1
+ import type {
2
+ KeyedSegment,
3
+ PortableTextBlock,
4
+ PortableTextTextBlock,
5
+ } from '@sanity/types'
2
6
  import type {TextUnit} from 'slate'
3
7
  import type {TextInsertTextOptions} from 'slate/dist/interfaces/transforms/text'
4
8
  import type {
@@ -6,6 +10,7 @@ import type {
6
10
  PortableTextMemberSchemaTypes,
7
11
  PortableTextSlateEditor,
8
12
  } from '../../types/editor'
13
+ import type {BlockOffset} from './behavior.utils.block-offset'
9
14
 
10
15
  /**
11
16
  * @alpha
@@ -100,8 +105,11 @@ export type BehaviorActionIntend =
100
105
  | BehaviorEvent
101
106
  | {
102
107
  type: 'insert block object'
103
- name: string
104
- value?: {[prop: string]: unknown}
108
+ placement: 'auto' | 'after' | 'before'
109
+ blockObject: {
110
+ name: string
111
+ value?: {[prop: string]: unknown}
112
+ }
105
113
  }
106
114
  | {
107
115
  type: 'insert span'
@@ -114,7 +122,10 @@ export type BehaviorActionIntend =
114
122
  }
115
123
  | {
116
124
  type: 'insert text block'
117
- decorators: Array<string>
125
+ placement: 'auto' | 'after' | 'before'
126
+ textBlock?: {
127
+ children?: PortableTextTextBlock['children']
128
+ }
118
129
  }
119
130
  | {
120
131
  type: 'set block'
@@ -129,8 +140,13 @@ export type BehaviorActionIntend =
129
140
  props: Array<'style' | 'listItem' | 'level'>
130
141
  }
131
142
  | {
132
- type: 'delete'
133
- selection: NonNullable<EditorSelection>
143
+ type: 'delete block'
144
+ blockPath: [KeyedSegment]
145
+ }
146
+ | {
147
+ type: 'delete text'
148
+ anchor: BlockOffset
149
+ focus: BlockOffset
134
150
  }
135
151
  | {
136
152
  type: 'effect'
@@ -0,0 +1,143 @@
1
+ import type {PortableTextBlock} from '@sanity/types'
2
+ import {expect, test} from 'vitest'
3
+ import {blockOffsetToSpanSelectionPoint} from './behavior.utils.block-offset'
4
+
5
+ test(blockOffsetToSpanSelectionPoint.name, () => {
6
+ const value: Array<PortableTextBlock> = [
7
+ {
8
+ _key: 'b1',
9
+ _type: 'image',
10
+ },
11
+ {
12
+ _key: 'b2',
13
+ _type: 'block',
14
+ children: [
15
+ {
16
+ _key: 's1',
17
+ _type: 'span',
18
+ text: 'Hello, ',
19
+ },
20
+ {
21
+ _key: 's2',
22
+ _type: 'span',
23
+ text: 'world!',
24
+ marks: ['strong'],
25
+ },
26
+ ],
27
+ },
28
+ {
29
+ _key: 'b3',
30
+ _type: 'block',
31
+ children: [
32
+ {
33
+ _key: 's3',
34
+ _type: 'span',
35
+ text: 'Here is a ',
36
+ },
37
+ {
38
+ _key: 's4',
39
+ _type: 'stock-ticker',
40
+ },
41
+ {
42
+ _key: 's5',
43
+ _type: 'span',
44
+ text: '.',
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ _key: 'b4',
50
+ _type: 'block',
51
+ children: [
52
+ {
53
+ _key: 's6',
54
+ _type: 'stock-ticker',
55
+ },
56
+ {
57
+ _key: 's7',
58
+ _type: 'stock-ticker',
59
+ },
60
+ {
61
+ _key: 's8',
62
+ _type: 'stock-ticker',
63
+ },
64
+ ],
65
+ },
66
+ ]
67
+
68
+ expect(
69
+ blockOffsetToSpanSelectionPoint({
70
+ value,
71
+ blockOffset: {
72
+ path: [{_key: 'b1'}],
73
+ offset: 0,
74
+ },
75
+ }),
76
+ ).toBeUndefined()
77
+ expect(
78
+ blockOffsetToSpanSelectionPoint({
79
+ value,
80
+ blockOffset: {
81
+ path: [{_key: 'b2'}],
82
+ offset: 9,
83
+ },
84
+ }),
85
+ ).toEqual({
86
+ path: [{_key: 'b2'}, 'children', {_key: 's2'}],
87
+ offset: 2,
88
+ })
89
+ expect(
90
+ blockOffsetToSpanSelectionPoint({
91
+ value,
92
+ blockOffset: {
93
+ path: [{_key: 'b3'}],
94
+ offset: 9,
95
+ },
96
+ }),
97
+ ).toEqual({
98
+ path: [{_key: 'b3'}, 'children', {_key: 's3'}],
99
+ offset: 9,
100
+ })
101
+ expect(
102
+ blockOffsetToSpanSelectionPoint({
103
+ value,
104
+ blockOffset: {
105
+ path: [{_key: 'b3'}],
106
+ offset: 10,
107
+ },
108
+ }),
109
+ ).toEqual({
110
+ path: [{_key: 'b3'}, 'children', {_key: 's3'}],
111
+ offset: 10,
112
+ })
113
+ expect(
114
+ blockOffsetToSpanSelectionPoint({
115
+ value,
116
+ blockOffset: {
117
+ path: [{_key: 'b3'}],
118
+ offset: 11,
119
+ },
120
+ }),
121
+ ).toEqual({
122
+ path: [{_key: 'b3'}, 'children', {_key: 's5'}],
123
+ offset: 1,
124
+ })
125
+ expect(
126
+ blockOffsetToSpanSelectionPoint({
127
+ value,
128
+ blockOffset: {
129
+ path: [{_key: 'b4'}],
130
+ offset: 0,
131
+ },
132
+ }),
133
+ ).toBeUndefined()
134
+ expect(
135
+ blockOffsetToSpanSelectionPoint({
136
+ value,
137
+ blockOffset: {
138
+ path: [{_key: 'b4'}],
139
+ offset: 1,
140
+ },
141
+ }),
142
+ ).toBeUndefined()
143
+ })
@@ -0,0 +1,101 @@
1
+ import {
2
+ isPortableTextSpan,
3
+ isPortableTextTextBlock,
4
+ type KeyedSegment,
5
+ type PortableTextBlock,
6
+ } from '@sanity/types'
7
+
8
+ /**
9
+ * @alpha
10
+ */
11
+ export type BlockOffset = {
12
+ path: [KeyedSegment]
13
+ offset: number
14
+ }
15
+
16
+ export function blockOffsetToSpanSelectionPoint({
17
+ value,
18
+ blockOffset,
19
+ }: {
20
+ value: Array<PortableTextBlock>
21
+ blockOffset: BlockOffset
22
+ }) {
23
+ let offsetLeft = blockOffset.offset
24
+ let selectionPoint:
25
+ | {path: [KeyedSegment, 'children', KeyedSegment]; offset: number}
26
+ | undefined
27
+
28
+ for (const block of value) {
29
+ if (block._key !== blockOffset.path[0]._key) {
30
+ continue
31
+ }
32
+
33
+ if (!isPortableTextTextBlock(block)) {
34
+ continue
35
+ }
36
+
37
+ for (const child of block.children) {
38
+ if (!isPortableTextSpan(child)) {
39
+ continue
40
+ }
41
+
42
+ if (offsetLeft === 0) {
43
+ selectionPoint = {
44
+ path: [...blockOffset.path, 'children', {_key: child._key}],
45
+ offset: 0,
46
+ }
47
+ break
48
+ }
49
+
50
+ if (offsetLeft <= child.text.length) {
51
+ selectionPoint = {
52
+ path: [...blockOffset.path, 'children', {_key: child._key}],
53
+ offset: offsetLeft,
54
+ }
55
+ break
56
+ }
57
+
58
+ offsetLeft -= child.text.length
59
+ }
60
+ }
61
+
62
+ return selectionPoint
63
+ }
64
+
65
+ export function spanSelectionPointToBlockOffset({
66
+ value,
67
+ selectionPoint,
68
+ }: {
69
+ value: Array<PortableTextBlock>
70
+ selectionPoint: {
71
+ path: [KeyedSegment, 'children', KeyedSegment]
72
+ offset: number
73
+ }
74
+ }): BlockOffset | undefined {
75
+ let offset = 0
76
+
77
+ for (const block of value) {
78
+ if (block._key !== selectionPoint.path[0]._key) {
79
+ continue
80
+ }
81
+
82
+ if (!isPortableTextTextBlock(block)) {
83
+ continue
84
+ }
85
+
86
+ for (const child of block.children) {
87
+ if (!isPortableTextSpan(child)) {
88
+ continue
89
+ }
90
+
91
+ if (child._key === selectionPoint.path[2]._key) {
92
+ return {
93
+ path: [{_key: block._key}],
94
+ offset: offset + selectionPoint.offset,
95
+ }
96
+ }
97
+
98
+ offset += child.text.length
99
+ }
100
+ }
101
+ }