@portabletext/editor 1.8.0 → 1.10.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.10.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
+ linkAnnotation?: (context: {
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.linkAnnotation?.({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.linkAnnotation?.({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
+ }
@@ -11,25 +11,25 @@ import {
11
11
  * @alpha
12
12
  */
13
13
  export type MarkdownBehaviorsConfig = {
14
- mapBreakObject?: (
15
- schema: PortableTextMemberSchemaTypes,
16
- ) => {name: string; value?: {[prop: string]: unknown}} | undefined
17
- mapDefaultStyle?: (
18
- schema: PortableTextMemberSchemaTypes,
19
- ) => string | undefined
20
- mapHeadingStyle?: (
21
- schema: PortableTextMemberSchemaTypes,
22
- level: number,
23
- ) => string | undefined
24
- mapBlockquoteStyle?: (
25
- schema: PortableTextMemberSchemaTypes,
26
- ) => string | undefined
27
- mapUnorderedListStyle?: (
28
- schema: PortableTextMemberSchemaTypes,
29
- ) => string | undefined
30
- mapOrderedListStyle?: (
31
- schema: PortableTextMemberSchemaTypes,
32
- ) => string | undefined
14
+ horizontalRuleObject?: (context: {
15
+ schema: PortableTextMemberSchemaTypes
16
+ }) => {name: string; value?: {[prop: string]: unknown}} | undefined
17
+ defaultStyle?: (context: {
18
+ schema: PortableTextMemberSchemaTypes
19
+ }) => string | undefined
20
+ headingStyle?: (context: {
21
+ schema: PortableTextMemberSchemaTypes
22
+ level: number
23
+ }) => string | undefined
24
+ blockquoteStyle?: (context: {
25
+ schema: PortableTextMemberSchemaTypes
26
+ }) => string | undefined
27
+ unorderedListStyle?: (context: {
28
+ schema: PortableTextMemberSchemaTypes
29
+ }) => string | undefined
30
+ orderedListStyle?: (context: {
31
+ schema: PortableTextMemberSchemaTypes
32
+ }) => string | undefined
33
33
  }
34
34
 
35
35
  /**
@@ -55,7 +55,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
55
55
 
56
56
  const caretAtTheEndOfQuote = context.selection.focus.offset === 1
57
57
  const looksLikeMarkdownQuote = /^>/.test(focusSpan.node.text)
58
- const blockquoteStyle = config.mapBlockquoteStyle?.(context.schema)
58
+ const blockquoteStyle = config.blockquoteStyle?.({schema: context.schema})
59
59
 
60
60
  if (
61
61
  caretAtTheEndOfQuote &&
@@ -104,13 +104,22 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
104
104
  const automaticBreak = defineBehavior({
105
105
  on: 'insert text',
106
106
  guard: ({context, event}) => {
107
- const isDash = event.text === '-'
108
-
109
- if (!isDash) {
107
+ const hrCharacter =
108
+ event.text === '-'
109
+ ? '-'
110
+ : event.text === '*'
111
+ ? '*'
112
+ : event.text === '_'
113
+ ? '_'
114
+ : undefined
115
+
116
+ if (hrCharacter === undefined) {
110
117
  return false
111
118
  }
112
119
 
113
- const breakObject = config.mapBreakObject?.(context.schema)
120
+ const breakObject = config.horizontalRuleObject?.({
121
+ schema: context.schema,
122
+ })
114
123
  const focusBlock = getFocusTextBlock(context)
115
124
  const selectionCollapsed = selectionIsCollapsed(context)
116
125
 
@@ -123,17 +132,17 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
123
132
  .map((child) => child.text ?? '')
124
133
  .join('')
125
134
 
126
- if (onlyText && blockText === '--') {
127
- return {breakObject, focusBlock}
135
+ if (onlyText && blockText === `${hrCharacter}${hrCharacter}`) {
136
+ return {breakObject, focusBlock, hrCharacter}
128
137
  }
129
138
 
130
139
  return false
131
140
  },
132
141
  actions: [
133
- () => [
142
+ (_, {hrCharacter}) => [
134
143
  {
135
144
  type: 'insert text',
136
- text: '-',
145
+ text: hrCharacter,
137
146
  },
138
147
  ],
139
148
  (_, {breakObject, focusBlock}) => [
@@ -179,27 +188,26 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
179
188
  }
180
189
 
181
190
  const markdownHeadingSearch = /^#+/.exec(focusSpan.node.text)
182
- const headingLevel = markdownHeadingSearch
191
+ const level = markdownHeadingSearch
183
192
  ? markdownHeadingSearch[0].length
184
193
  : undefined
185
- const caretAtTheEndOfHeading =
186
- context.selection.focus.offset === headingLevel
194
+ const caretAtTheEndOfHeading = context.selection.focus.offset === level
187
195
 
188
196
  if (!caretAtTheEndOfHeading) {
189
197
  return false
190
198
  }
191
199
 
192
- const headingStyle =
193
- headingLevel !== undefined
194
- ? config.mapHeadingStyle?.(context.schema, headingLevel)
200
+ const style =
201
+ level !== undefined
202
+ ? config.headingStyle?.({schema: context.schema, level})
195
203
  : undefined
196
204
 
197
- if (headingLevel !== undefined && headingStyle !== undefined) {
205
+ if (level !== undefined && style !== undefined) {
198
206
  return {
199
207
  focusTextBlock,
200
208
  focusSpan,
201
- style: headingStyle,
202
- level: headingLevel,
209
+ style: style,
210
+ level,
203
211
  }
204
212
  }
205
213
 
@@ -254,7 +262,7 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
254
262
  focusTextBlock.node.children[0]._key === focusSpan.node._key &&
255
263
  context.selection.focus.offset === 0
256
264
 
257
- const defaultStyle = config.mapDefaultStyle?.(context.schema)
265
+ const defaultStyle = config.defaultStyle?.({schema: context.schema})
258
266
 
259
267
  if (
260
268
  atTheBeginningOfBLock &&
@@ -293,9 +301,11 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
293
301
  return false
294
302
  }
295
303
 
296
- const defaultStyle = config.mapDefaultStyle?.(context.schema)
304
+ const defaultStyle = config.defaultStyle?.({schema: context.schema})
297
305
  const looksLikeUnorderedList = /^(-|\*)/.test(focusSpan.node.text)
298
- const unorderedListStyle = config.mapUnorderedListStyle?.(context.schema)
306
+ const unorderedListStyle = config.unorderedListStyle?.({
307
+ schema: context.schema,
308
+ })
299
309
  const caretAtTheEndOfUnorderedList = context.selection.focus.offset === 1
300
310
 
301
311
  if (
@@ -314,7 +324,9 @@ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
314
324
  }
315
325
 
316
326
  const looksLikeOrderedList = /^1./.test(focusSpan.node.text)
317
- const orderedListStyle = config.mapOrderedListStyle?.(context.schema)
327
+ const orderedListStyle = config.orderedListStyle?.({
328
+ schema: context.schema,
329
+ })
318
330
  const caretAtTheEndOfOrderedList = context.selection.focus.offset === 2
319
331
 
320
332
  if (
@@ -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,