@portabletext/editor 1.8.0 → 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.8.0",
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
+ }
@@ -23,6 +23,7 @@ import {
23
23
  insertBreakActionImplementation,
24
24
  insertSoftBreakActionImplementation,
25
25
  } from './behavior.action.insert-break'
26
+ import {insertSpanActionImplementation} from './behavior.action.insert-span'
26
27
  import type {
27
28
  BehaviorAction,
28
29
  BehaviorEvent,
@@ -116,6 +117,7 @@ const behaviorActionImplementations: BehaviourActionImplementations = {
116
117
  'insert block object': insertBlockObjectActionImplementation,
117
118
  'insert break': insertBreakActionImplementation,
118
119
  'insert soft break': insertSoftBreakActionImplementation,
120
+ 'insert span': insertSpanActionImplementation,
119
121
  'insert text': ({action}) => {
120
122
  insertText(action.editor, action.text)
121
123
  },
@@ -137,6 +139,9 @@ const behaviorActionImplementations: BehaviourActionImplementations = {
137
139
  'effect': ({action}) => {
138
140
  action.effect()
139
141
  },
142
+ 'paste': ({action}) => {
143
+ action.editor.insertData(action.clipboardData)
144
+ },
140
145
  'select': ({action}) => {
141
146
  const newSelection = toSlateRange(action.selection, action.editor)
142
147
 
@@ -178,6 +183,13 @@ export function performAction({
178
183
  })
179
184
  break
180
185
  }
186
+ case 'insert span': {
187
+ behaviorActionImplementations['insert span']({
188
+ context,
189
+ action,
190
+ })
191
+ break
192
+ }
181
193
  case 'insert text block': {
182
194
  behaviorActionImplementations['insert text block']({
183
195
  context,
@@ -311,11 +323,18 @@ function performDefaultAction({
311
323
  })
312
324
  break
313
325
  }
314
- default: {
326
+ case 'insert text': {
315
327
  behaviorActionImplementations['insert text']({
316
328
  context,
317
329
  action,
318
330
  })
331
+ break
332
+ }
333
+ default: {
334
+ behaviorActionImplementations.paste({
335
+ context,
336
+ action,
337
+ })
319
338
  }
320
339
  }
321
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
+ }
@@ -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
@@ -99,6 +103,15 @@ export type BehaviorActionIntend =
99
103
  name: string
100
104
  value?: {[prop: string]: unknown}
101
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
+ }
102
115
  | {
103
116
  type: 'insert text block'
104
117
  decorators: Array<string>
@@ -837,12 +837,16 @@ export function isDecoratorActive({
837
837
  return false
838
838
  }
839
839
 
840
- const selectedNodes = Array.from(
840
+ const selectedTextNodes = Array.from(
841
841
  Editor.nodes(editor, {match: Text.isText, at: editor.selection}),
842
842
  )
843
843
 
844
+ if (selectedTextNodes.length === 0) {
845
+ return false
846
+ }
847
+
844
848
  if (Range.isExpanded(editor.selection)) {
845
- return selectedNodes.every((n) => {
849
+ return selectedTextNodes.every((n) => {
846
850
  const [node] = n
847
851
 
848
852
  return node.marks?.includes(decorator)
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ export {
5
5
  createMarkdownBehaviors,
6
6
  type MarkdownBehaviorsConfig,
7
7
  } from './editor/behavior/behavior.markdown'
8
+ export {
9
+ createLinkBehaviors,
10
+ type LinkBehaviorsConfig,
11
+ } from './editor/behavior/behavior.links'
8
12
  export {
9
13
  defineBehavior,
10
14
  type Behavior,