@portabletext/editor 1.7.1 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -58,11 +58,11 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@portabletext/toolkit": "^2.0.16",
61
- "@sanity/block-tools": "^3.64.0",
61
+ "@sanity/block-tools": "^3.64.1",
62
62
  "@sanity/diff-match-patch": "^3.1.1",
63
- "@sanity/pkg-utils": "^6.11.10",
64
- "@sanity/schema": "^3.64.0",
65
- "@sanity/types": "^3.64.0",
63
+ "@sanity/pkg-utils": "^6.11.11",
64
+ "@sanity/schema": "^3.64.1",
65
+ "@sanity/types": "^3.64.1",
66
66
  "@testing-library/jest-dom": "^6.6.3",
67
67
  "@testing-library/react": "^16.0.1",
68
68
  "@types/debug": "^4.1.5",
@@ -73,7 +73,7 @@
73
73
  "@typescript-eslint/eslint-plugin": "^8.14.0",
74
74
  "@typescript-eslint/parser": "^8.14.0",
75
75
  "@vitejs/plugin-react": "^4.3.3",
76
- "@vitest/browser": "^2.1.4",
76
+ "@vitest/browser": "^2.1.5",
77
77
  "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
78
78
  "eslint": "8.57.1",
79
79
  "eslint-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
@@ -85,14 +85,14 @@
85
85
  "styled-components": "^6.1.13",
86
86
  "typescript": "5.6.3",
87
87
  "vite": "^5.4.11",
88
- "vitest": "^2.1.4",
88
+ "vitest": "^2.1.5",
89
89
  "vitest-browser-react": "^0.0.3",
90
90
  "@sanity/gherkin-driver": "^0.0.1"
91
91
  },
92
92
  "peerDependencies": {
93
- "@sanity/block-tools": "^3.64.0",
94
- "@sanity/schema": "^3.64.0",
95
- "@sanity/types": "^3.64.0",
93
+ "@sanity/block-tools": "^3.64.1",
94
+ "@sanity/schema": "^3.64.1",
95
+ "@sanity/types": "^3.64.1",
96
96
  "react": "^16.9 || ^17 || ^18",
97
97
  "rxjs": "^7.8.1",
98
98
  "styled-components": "^6.1.13"
@@ -428,16 +428,6 @@ export const PortableTextEditable = forwardRef<
428
428
  // Handle incoming pasting events in the editor
429
429
  const handlePaste = useCallback(
430
430
  (event: ClipboardEvent<HTMLDivElement>): Promise<void> | void => {
431
- event.preventDefault()
432
- if (!slateEditor.selection) {
433
- return
434
- }
435
- if (!onPaste) {
436
- debug('Pasting normally')
437
- slateEditor.insertData(event.clipboardData)
438
- return
439
- }
440
-
441
431
  const value = PortableTextEditor.getValue(portableTextEditor)
442
432
  const ptRange = toPortableTextRange(
443
433
  value,
@@ -445,19 +435,21 @@ export const PortableTextEditable = forwardRef<
445
435
  schemaTypes,
446
436
  )
447
437
  const path = ptRange?.focus.path || []
448
- const onPasteResult = onPaste({event, value, path, schemaTypes})
438
+ const onPasteResult = onPaste?.({event, value, path, schemaTypes})
439
+
440
+ if (onPasteResult || !slateEditor.selection) {
441
+ event.preventDefault()
449
442
 
450
- if (onPasteResult === undefined) {
451
- debug('No result from custom paste handler, pasting normally')
452
- slateEditor.insertData(event.clipboardData)
453
- } else {
454
443
  // Resolve it as promise (can be either async promise or sync return value)
455
444
  editorActor.send({type: 'loading'})
445
+
456
446
  Promise.resolve(onPasteResult)
457
447
  .then((result) => {
458
448
  debug('Custom paste function from client resolved', result)
449
+
459
450
  if (!result || !result.insert) {
460
451
  debug('No result from custom paste handler, pasting normally')
452
+
461
453
  slateEditor.insertData(event.clipboardData)
462
454
  } else if (result.insert) {
463
455
  slateEditor.insertFragment(
@@ -474,12 +466,26 @@ export const PortableTextEditable = forwardRef<
474
466
  })
475
467
  .catch((error) => {
476
468
  console.error(error)
469
+
477
470
  return error
478
471
  })
479
472
  .finally(() => {
480
473
  editorActor.send({type: 'done loading'})
481
474
  })
475
+ } else if (event.nativeEvent.clipboardData) {
476
+ event.preventDefault()
477
+
478
+ editorActor.send({
479
+ type: 'behavior event',
480
+ behaviorEvent: {
481
+ type: 'paste',
482
+ clipboardData: event.nativeEvent.clipboardData,
483
+ },
484
+ editor: slateEditor,
485
+ })
482
486
  }
487
+
488
+ debug('No result from custom paste handler, pasting normally')
483
489
  },
484
490
  [editorActor, onPaste, portableTextEditor, schemaTypes, slateEditor],
485
491
  )
@@ -515,23 +521,32 @@ export const PortableTextEditable = forwardRef<
515
521
  if (onClick) {
516
522
  onClick(event)
517
523
  }
518
- // Inserts a new block if it's clicking on the editor, focused on the last block and it's a void element
519
- if (slateEditor.selection && event.target === event.currentTarget) {
520
- const [lastBlock, path] = Node.last(slateEditor, [])
521
- const focusPath = slateEditor.selection.focus.path.slice(0, 1)
522
- const lastPath = path.slice(0, 1)
523
- if (Path.equals(focusPath, lastPath)) {
524
- const node = Node.descendant(slateEditor, path.slice(0, 1)) as
524
+
525
+ const focusBlockPath = slateEditor.selection
526
+ ? slateEditor.selection.focus.path.slice(0, 1)
527
+ : undefined
528
+ const focusBlock = focusBlockPath
529
+ ? (Node.descendant(slateEditor, focusBlockPath) as
525
530
  | SlateTextBlock
526
- | VoidElement
527
- if (lastBlock && Editor.isVoid(slateEditor, node)) {
528
- Transforms.insertNodes(
529
- slateEditor,
530
- slateEditor.pteCreateTextBlock({decorators: []}),
531
- )
532
- slateEditor.onChange()
533
- }
534
- }
531
+ | VoidElement)
532
+ : undefined
533
+ const [_, lastNodePath] = Node.last(slateEditor, [])
534
+ const lastBlockPath = lastNodePath.slice(0, 1)
535
+ const lastNodeFocused = focusBlockPath
536
+ ? Path.equals(lastBlockPath, focusBlockPath)
537
+ : false
538
+ const lastBlockIsVoid = focusBlock
539
+ ? !slateEditor.isTextBlock(focusBlock)
540
+ : false
541
+ const collapsedSelection =
542
+ slateEditor.selection && SlateRange.isCollapsed(slateEditor.selection)
543
+
544
+ if (collapsedSelection && lastNodeFocused && lastBlockIsVoid) {
545
+ Transforms.insertNodes(
546
+ slateEditor,
547
+ slateEditor.pteCreateTextBlock({decorators: []}),
548
+ )
549
+ slateEditor.onChange()
535
550
  }
536
551
  },
537
552
  [onClick, slateEditor],
@@ -0,0 +1,48 @@
1
+ import {Editor, Transforms} from 'slate'
2
+ import type {BehaviourActionImplementation} from './behavior.actions'
3
+
4
+ export const insertSpanActionImplementation: BehaviourActionImplementation<
5
+ 'insert span'
6
+ > = ({context, action}) => {
7
+ if (!action.editor.selection) {
8
+ console.error('Unable to perform action without selection', action)
9
+ return
10
+ }
11
+
12
+ const [focusBlock, focusBlockPath] = Array.from(
13
+ Editor.nodes(action.editor, {
14
+ at: action.editor.selection.focus.path,
15
+ match: (node) => action.editor.isTextBlock(node),
16
+ }),
17
+ )[0] ?? [undefined, undefined]
18
+
19
+ if (!focusBlock || !focusBlockPath) {
20
+ console.error('Unable to perform action without focus block', action)
21
+ return
22
+ }
23
+
24
+ const markDefs = focusBlock.markDefs ?? []
25
+ const annotations = action.annotations
26
+ ? action.annotations.map((annotation) => ({
27
+ _type: annotation.name,
28
+ _key: context.keyGenerator(),
29
+ ...annotation.value,
30
+ }))
31
+ : undefined
32
+
33
+ if (annotations && annotations.length > 0) {
34
+ Transforms.setNodes(action.editor, {
35
+ markDefs: [...markDefs, ...annotations],
36
+ })
37
+ }
38
+
39
+ Transforms.insertNodes(action.editor, {
40
+ _type: 'span',
41
+ _key: context.keyGenerator(),
42
+ text: action.text,
43
+ marks: [
44
+ ...(annotations?.map((annotation) => annotation._key) ?? []),
45
+ ...(action.decorators ?? []),
46
+ ],
47
+ })
48
+ }
@@ -10,6 +10,7 @@ import type {PortableTextMemberSchemaTypes} from '../../types/editor'
10
10
  import {toSlateRange} from '../../utils/ranges'
11
11
  import {
12
12
  addAnnotationActionImplementation,
13
+ insertBlockObjectActionImplementation,
13
14
  removeAnnotationActionImplementation,
14
15
  toggleAnnotationActionImplementation,
15
16
  } from '../plugins/createWithEditableAPI'
@@ -22,6 +23,7 @@ import {
22
23
  insertBreakActionImplementation,
23
24
  insertSoftBreakActionImplementation,
24
25
  } from './behavior.action.insert-break'
26
+ import {insertSpanActionImplementation} from './behavior.action.insert-span'
25
27
  import type {
26
28
  BehaviorAction,
27
29
  BehaviorEvent,
@@ -112,8 +114,10 @@ const behaviorActionImplementations: BehaviourActionImplementations = {
112
114
  })
113
115
  }
114
116
  },
117
+ 'insert block object': insertBlockObjectActionImplementation,
115
118
  'insert break': insertBreakActionImplementation,
116
119
  'insert soft break': insertSoftBreakActionImplementation,
120
+ 'insert span': insertSpanActionImplementation,
117
121
  'insert text': ({action}) => {
118
122
  insertText(action.editor, action.text)
119
123
  },
@@ -135,6 +139,9 @@ const behaviorActionImplementations: BehaviourActionImplementations = {
135
139
  'effect': ({action}) => {
136
140
  action.effect()
137
141
  },
142
+ 'paste': ({action}) => {
143
+ action.editor.insertData(action.clipboardData)
144
+ },
138
145
  'select': ({action}) => {
139
146
  const newSelection = toSlateRange(action.selection, action.editor)
140
147
 
@@ -169,6 +176,20 @@ export function performAction({
169
176
  })
170
177
  break
171
178
  }
179
+ case 'insert block object': {
180
+ behaviorActionImplementations['insert block object']({
181
+ context,
182
+ action,
183
+ })
184
+ break
185
+ }
186
+ case 'insert span': {
187
+ behaviorActionImplementations['insert span']({
188
+ context,
189
+ action,
190
+ })
191
+ break
192
+ }
172
193
  case 'insert text block': {
173
194
  behaviorActionImplementations['insert text block']({
174
195
  context,
@@ -302,11 +323,18 @@ function performDefaultAction({
302
323
  })
303
324
  break
304
325
  }
305
- default: {
326
+ case 'insert text': {
306
327
  behaviorActionImplementations['insert text']({
307
328
  context,
308
329
  action,
309
330
  })
331
+ break
332
+ }
333
+ default: {
334
+ behaviorActionImplementations.paste({
335
+ context,
336
+ action,
337
+ })
310
338
  }
311
339
  }
312
340
  }
@@ -0,0 +1,91 @@
1
+ import type {PortableTextMemberSchemaTypes} from '../../types/editor'
2
+ import {defineBehavior} from './behavior.types'
3
+ import {getFocusSpan, selectionIsCollapsed} from './behavior.utils'
4
+
5
+ /**
6
+ * @alpha
7
+ */
8
+ export type LinkBehaviorsConfig = {
9
+ mapLinkAnnotation?: (config: {
10
+ schema: PortableTextMemberSchemaTypes
11
+ url: string
12
+ }) => {name: string; value: {[prop: string]: unknown}} | undefined
13
+ }
14
+
15
+ /**
16
+ * @alpha
17
+ */
18
+ export function createLinkBehaviors(config: LinkBehaviorsConfig) {
19
+ const pasteLinkOnSelection = defineBehavior({
20
+ on: 'paste',
21
+ guard: ({context, event}) => {
22
+ const selectionCollapsed = selectionIsCollapsed(context)
23
+ const text = event.clipboardData.getData('text/plain')
24
+ const url = looksLikeUrl(text) ? text : undefined
25
+ const annotation =
26
+ url !== undefined
27
+ ? config.mapLinkAnnotation?.({url, schema: context.schema})
28
+ : undefined
29
+
30
+ if (annotation && !selectionCollapsed) {
31
+ return {annotation}
32
+ }
33
+
34
+ return false
35
+ },
36
+ actions: [
37
+ (_, {annotation}) => [
38
+ {
39
+ type: 'annotation.add',
40
+ annotation,
41
+ },
42
+ ],
43
+ ],
44
+ })
45
+ const pasteLinkAtCaret = defineBehavior({
46
+ on: 'paste',
47
+ guard: ({context, event}) => {
48
+ const focusSpan = getFocusSpan(context)
49
+ const selectionCollapsed = selectionIsCollapsed(context)
50
+
51
+ if (!focusSpan || !selectionCollapsed) {
52
+ return false
53
+ }
54
+
55
+ const text = event.clipboardData.getData('text/plain')
56
+ const url = looksLikeUrl(text) ? text : undefined
57
+ const annotation =
58
+ url !== undefined
59
+ ? config.mapLinkAnnotation?.({url, schema: context.schema})
60
+ : undefined
61
+
62
+ if (url && annotation && selectionCollapsed) {
63
+ return {focusSpan, annotation, url}
64
+ }
65
+
66
+ return false
67
+ },
68
+ actions: [
69
+ (_, {annotation, url}) => [
70
+ {
71
+ type: 'insert span',
72
+ text: url,
73
+ annotations: [annotation],
74
+ },
75
+ ],
76
+ ],
77
+ })
78
+
79
+ const linkBehaviors = [pasteLinkOnSelection, pasteLinkAtCaret]
80
+
81
+ return linkBehaviors
82
+ }
83
+
84
+ function looksLikeUrl(text: string) {
85
+ let looksLikeUrl = false
86
+ try {
87
+ new URL(text)
88
+ looksLikeUrl = true
89
+ } catch {}
90
+ return looksLikeUrl
91
+ }
@@ -1,3 +1,4 @@
1
+ import {isPortableTextSpan} from '@portabletext/toolkit'
1
2
  import type {PortableTextMemberSchemaTypes} from '../../types/editor'
2
3
  import {defineBehavior} from './behavior.types'
3
4
  import {
@@ -10,18 +11,23 @@ import {
10
11
  * @alpha
11
12
  */
12
13
  export type MarkdownBehaviorsConfig = {
13
- mapDefaultStyle: (schema: PortableTextMemberSchemaTypes) => string | undefined
14
- mapHeadingStyle: (
14
+ mapBreakObject?: (
15
+ schema: PortableTextMemberSchemaTypes,
16
+ ) => {name: string; value?: {[prop: string]: unknown}} | undefined
17
+ mapDefaultStyle?: (
18
+ schema: PortableTextMemberSchemaTypes,
19
+ ) => string | undefined
20
+ mapHeadingStyle?: (
15
21
  schema: PortableTextMemberSchemaTypes,
16
22
  level: number,
17
23
  ) => string | undefined
18
- mapBlockquoteStyle: (
24
+ mapBlockquoteStyle?: (
19
25
  schema: PortableTextMemberSchemaTypes,
20
26
  ) => string | undefined
21
- mapUnorderedListStyle: (
27
+ mapUnorderedListStyle?: (
22
28
  schema: PortableTextMemberSchemaTypes,
23
29
  ) => string | undefined
24
- mapOrderedListStyle: (
30
+ mapOrderedListStyle?: (
25
31
  schema: PortableTextMemberSchemaTypes,
26
32
  ) => string | undefined
27
33
  }
@@ -49,7 +55,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
49
55
 
50
56
  const caretAtTheEndOfQuote = context.selection.focus.offset === 1
51
57
  const looksLikeMarkdownQuote = /^>/.test(focusSpan.node.text)
52
- const blockquoteStyle = config.mapBlockquoteStyle(context.schema)
58
+ const blockquoteStyle = config.mapBlockquoteStyle?.(context.schema)
53
59
 
54
60
  if (
55
61
  caretAtTheEndOfQuote &&
@@ -95,6 +101,66 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
95
101
  ],
96
102
  ],
97
103
  })
104
+ const automaticBreak = defineBehavior({
105
+ on: 'insert text',
106
+ guard: ({context, event}) => {
107
+ const isDash = event.text === '-'
108
+
109
+ if (!isDash) {
110
+ return false
111
+ }
112
+
113
+ const breakObject = config.mapBreakObject?.(context.schema)
114
+ const focusBlock = getFocusTextBlock(context)
115
+ const selectionCollapsed = selectionIsCollapsed(context)
116
+
117
+ if (!breakObject || !focusBlock || !selectionCollapsed) {
118
+ return false
119
+ }
120
+
121
+ const onlyText = focusBlock.node.children.every(isPortableTextSpan)
122
+ const blockText = focusBlock.node.children
123
+ .map((child) => child.text ?? '')
124
+ .join('')
125
+
126
+ if (onlyText && blockText === '--') {
127
+ return {breakObject, focusBlock}
128
+ }
129
+
130
+ return false
131
+ },
132
+ actions: [
133
+ () => [
134
+ {
135
+ type: 'insert text',
136
+ text: '-',
137
+ },
138
+ ],
139
+ (_, {breakObject, focusBlock}) => [
140
+ {
141
+ type: 'insert block object',
142
+ ...breakObject,
143
+ },
144
+ {
145
+ type: 'delete',
146
+ selection: {
147
+ anchor: {
148
+ path: focusBlock.path,
149
+ offset: 0,
150
+ },
151
+ focus: {
152
+ path: focusBlock.path,
153
+ offset: 0,
154
+ },
155
+ },
156
+ },
157
+ {
158
+ type: 'insert text block',
159
+ decorators: [],
160
+ },
161
+ ],
162
+ ],
163
+ })
98
164
  const automaticHeadingOnSpace = defineBehavior({
99
165
  on: 'insert text',
100
166
  guard: ({context, event}) => {
@@ -125,7 +191,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
125
191
 
126
192
  const headingStyle =
127
193
  headingLevel !== undefined
128
- ? config.mapHeadingStyle(context.schema, headingLevel)
194
+ ? config.mapHeadingStyle?.(context.schema, headingLevel)
129
195
  : undefined
130
196
 
131
197
  if (headingLevel !== undefined && headingStyle !== undefined) {
@@ -188,7 +254,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
188
254
  focusTextBlock.node.children[0]._key === focusSpan.node._key &&
189
255
  context.selection.focus.offset === 0
190
256
 
191
- const defaultStyle = config.mapDefaultStyle(context.schema)
257
+ const defaultStyle = config.mapDefaultStyle?.(context.schema)
192
258
 
193
259
  if (
194
260
  atTheBeginningOfBLock &&
@@ -227,9 +293,9 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
227
293
  return false
228
294
  }
229
295
 
230
- const defaultStyle = config.mapDefaultStyle(context.schema)
296
+ const defaultStyle = config.mapDefaultStyle?.(context.schema)
231
297
  const looksLikeUnorderedList = /^(-|\*)/.test(focusSpan.node.text)
232
- const unorderedListStyle = config.mapUnorderedListStyle(context.schema)
298
+ const unorderedListStyle = config.mapUnorderedListStyle?.(context.schema)
233
299
  const caretAtTheEndOfUnorderedList = context.selection.focus.offset === 1
234
300
 
235
301
  if (
@@ -248,7 +314,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
248
314
  }
249
315
 
250
316
  const looksLikeOrderedList = /^1./.test(focusSpan.node.text)
251
- const orderedListStyle = config.mapOrderedListStyle(context.schema)
317
+ const orderedListStyle = config.mapOrderedListStyle?.(context.schema)
252
318
  const caretAtTheEndOfOrderedList = context.selection.focus.offset === 2
253
319
 
254
320
  if (
@@ -302,6 +368,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
302
368
 
303
369
  const markdownBehaviors = [
304
370
  automaticBlockquoteOnSpace,
371
+ automaticBreak,
305
372
  automaticHeadingOnSpace,
306
373
  clearStyleOnBackspace,
307
374
  automaticListOnSpace,
@@ -74,6 +74,10 @@ export type BehaviorEvent =
74
74
  text: string
75
75
  options?: TextInsertTextOptions
76
76
  }
77
+ | {
78
+ type: 'paste'
79
+ clipboardData: NonNullable<ClipboardEvent['clipboardData']>
80
+ }
77
81
 
78
82
  /**
79
83
  * @alpha
@@ -94,6 +98,20 @@ export type BehaviorGuard<
94
98
  */
95
99
  export type BehaviorActionIntend =
96
100
  | BehaviorEvent
101
+ | {
102
+ type: 'insert block object'
103
+ name: string
104
+ value?: {[prop: string]: unknown}
105
+ }
106
+ | {
107
+ type: 'insert span'
108
+ text: string
109
+ annotations?: Array<{
110
+ name: string
111
+ value: {[prop: string]: unknown}
112
+ }>
113
+ decorators?: Array<string>
114
+ }
97
115
  | {
98
116
  type: 'insert text block'
99
117
  decorators: Array<string>