@portabletext/editor 1.1.9 → 1.1.11

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.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -57,13 +57,13 @@
57
57
  "@jest/types": "^29.6.3",
58
58
  "@playwright/test": "1.48.2",
59
59
  "@portabletext/toolkit": "^2.0.15",
60
- "@sanity/block-tools": "^3.62.2",
60
+ "@sanity/block-tools": "^3.62.3",
61
61
  "@sanity/diff-match-patch": "^3.1.1",
62
- "@sanity/pkg-utils": "^6.11.4",
63
- "@sanity/schema": "^3.62.2",
64
- "@sanity/types": "^3.62.2",
65
- "@sanity/ui": "^2.8.10",
66
- "@sanity/util": "^3.62.2",
62
+ "@sanity/pkg-utils": "^6.11.7",
63
+ "@sanity/schema": "^3.62.3",
64
+ "@sanity/types": "^3.62.3",
65
+ "@sanity/ui": "^2.8.13",
66
+ "@sanity/util": "^3.62.3",
67
67
  "@testing-library/dom": "^10.4.0",
68
68
  "@testing-library/jest-dom": "^6.6.2",
69
69
  "@testing-library/react": "^16.0.1",
@@ -78,7 +78,7 @@
78
78
  "@types/react-dom": "^18.3.1",
79
79
  "@types/ws": "~8.5.12",
80
80
  "@vitejs/plugin-react": "^4.3.3",
81
- "@vitest/browser": "^2.1.3",
81
+ "@vitest/browser": "^2.1.4",
82
82
  "@xstate/react": "^4.1.3",
83
83
  "dotenv": "^16.4.5",
84
84
  "express": "^4.21.1",
@@ -96,15 +96,15 @@
96
96
  "ts-node": "^10.9.2",
97
97
  "typescript": "5.6.3",
98
98
  "vite": "^5.4.10",
99
- "vitest": "^2.1.3",
99
+ "vitest": "^2.1.4",
100
100
  "vitest-browser-react": "^0.0.3",
101
101
  "@sanity/gherkin-driver": "^0.0.1"
102
102
  },
103
103
  "peerDependencies": {
104
- "@sanity/block-tools": "^3.62.2",
105
- "@sanity/schema": "^3.62.2",
106
- "@sanity/types": "^3.62.2",
107
- "@sanity/util": "^3.62.2",
104
+ "@sanity/block-tools": "^3.62.3",
105
+ "@sanity/schema": "^3.62.3",
106
+ "@sanity/types": "^3.62.3",
107
+ "@sanity/util": "^3.62.3",
108
108
  "react": "^16.9 || ^17 || ^18",
109
109
  "rxjs": "^7.8.1",
110
110
  "styled-components": "^6.1.13"
@@ -550,14 +550,6 @@ export const PortableTextEditable = forwardRef<
550
550
  if (onBeforeInput) {
551
551
  onBeforeInput(event)
552
552
  }
553
-
554
- if (!event.defaultPrevented && event.inputType === 'insertText') {
555
- editorActor.send({
556
- type: 'before insert text',
557
- nativeEvent: event,
558
- editor: slateEditor,
559
- })
560
- }
561
553
  },
562
554
  [onBeforeInput],
563
555
  )
@@ -646,11 +638,6 @@ export const PortableTextEditable = forwardRef<
646
638
  props.onKeyDown(event)
647
639
  }
648
640
  if (!event.isDefaultPrevented()) {
649
- editorActor.send({
650
- type: 'key down',
651
- nativeEvent: event.nativeEvent,
652
- editor: slateEditor,
653
- })
654
641
  slateEditor.pteWithHotKeys(event)
655
642
  }
656
643
  },
@@ -0,0 +1,206 @@
1
+ import {isEqual} from 'lodash'
2
+ import {Editor, Node, Path, Range, Transforms} from 'slate'
3
+ import type {SlateTextBlock, VoidElement} from '../../types/slate'
4
+ import type {BehaviourActionImplementation} from './behavior.actions'
5
+ import type {BehaviorAction, PickFromUnion} from './behavior.types'
6
+
7
+ export const insertBreakActionImplementation: BehaviourActionImplementation<
8
+ PickFromUnion<BehaviorAction, 'type', 'insert break' | 'insert soft break'>
9
+ > = ({context, action}) => {
10
+ const keyGenerator = context.keyGenerator
11
+ const schema = context.schema
12
+ const editor = action.editor
13
+
14
+ if (!editor.selection) {
15
+ return
16
+ }
17
+
18
+ const [focusSpan] = Array.from(
19
+ Editor.nodes(editor, {
20
+ mode: 'lowest',
21
+ at: editor.selection.focus,
22
+ match: (n) => editor.isTextSpan(n),
23
+ voids: false,
24
+ }),
25
+ )[0] ?? [undefined]
26
+ const focusDecorators =
27
+ focusSpan.marks?.filter((mark) =>
28
+ schema.decorators.some((decorator) => decorator.value === mark),
29
+ ) ?? []
30
+ const focusAnnotations =
31
+ focusSpan.marks?.filter(
32
+ (mark) =>
33
+ !schema.decorators.some((decorator) => decorator.value === mark),
34
+ ) ?? []
35
+
36
+ const focusBlockPath = editor.selection.focus.path.slice(0, 1)
37
+ const focusBlock = Node.descendant(editor, focusBlockPath) as
38
+ | SlateTextBlock
39
+ | VoidElement
40
+
41
+ if (editor.isTextBlock(focusBlock)) {
42
+ const [start, end] = Range.edges(editor.selection)
43
+ const atTheStartOfBlock = isEqual(end, {
44
+ path: [...focusBlockPath, 0],
45
+ offset: 0,
46
+ })
47
+
48
+ if (atTheStartOfBlock && Range.isCollapsed(editor.selection)) {
49
+ Editor.insertNode(
50
+ editor,
51
+ editor.pteCreateTextBlock({
52
+ decorators: focusAnnotations.length === 0 ? focusDecorators : [],
53
+ listItem: focusBlock.listItem,
54
+ level: focusBlock.level,
55
+ }),
56
+ )
57
+
58
+ const [nextBlockPath] = Path.next(focusBlockPath)
59
+
60
+ Transforms.select(editor, {
61
+ anchor: {path: [nextBlockPath, 0], offset: 0},
62
+ focus: {path: [nextBlockPath, 0], offset: 0},
63
+ })
64
+
65
+ return
66
+ }
67
+
68
+ const lastFocusBlockChild =
69
+ focusBlock.children[focusBlock.children.length - 1]
70
+ const atTheEndOfBlock = isEqual(start, {
71
+ path: [...focusBlockPath, focusBlock.children.length - 1],
72
+ offset: editor.isTextSpan(lastFocusBlockChild)
73
+ ? lastFocusBlockChild.text.length
74
+ : 0,
75
+ })
76
+
77
+ if (atTheEndOfBlock && Range.isCollapsed(editor.selection)) {
78
+ Editor.insertNode(
79
+ editor,
80
+ editor.pteCreateTextBlock({
81
+ decorators: [],
82
+ listItem: focusBlock.listItem,
83
+ level: focusBlock.level,
84
+ }),
85
+ )
86
+
87
+ const [nextBlockPath] = Path.next(focusBlockPath)
88
+
89
+ Transforms.setSelection(editor, {
90
+ anchor: {path: [nextBlockPath, 0], offset: 0},
91
+ focus: {path: [nextBlockPath, 0], offset: 0},
92
+ })
93
+
94
+ return
95
+ }
96
+
97
+ const isInTheMiddleOfNode = !atTheStartOfBlock && !atTheEndOfBlock
98
+
99
+ if (isInTheMiddleOfNode) {
100
+ Editor.withoutNormalizing(editor, () => {
101
+ if (!editor.selection) {
102
+ return
103
+ }
104
+
105
+ Transforms.splitNodes(editor, {
106
+ at: editor.selection,
107
+ })
108
+
109
+ const [nextNode, nextNodePath] = Editor.node(
110
+ editor,
111
+ Path.next(focusBlockPath),
112
+ {depth: 1},
113
+ )
114
+
115
+ Transforms.setSelection(editor, {
116
+ anchor: {path: [...nextNodePath, 0], offset: 0},
117
+ focus: {path: [...nextNodePath, 0], offset: 0},
118
+ })
119
+
120
+ /**
121
+ * Assign new keys to markDefs that are now split across two blocks
122
+ */
123
+ if (
124
+ editor.isTextBlock(nextNode) &&
125
+ nextNode.markDefs &&
126
+ nextNode.markDefs.length > 0
127
+ ) {
128
+ const newMarkDefKeys = new Map<string, string>()
129
+
130
+ const prevNodeSpans = Array.from(
131
+ Node.children(editor, focusBlockPath),
132
+ )
133
+ .map((entry) => entry[0])
134
+ .filter((node) => editor.isTextSpan(node))
135
+ const children = Node.children(editor, nextNodePath)
136
+
137
+ for (const [child, childPath] of children) {
138
+ if (!editor.isTextSpan(child)) {
139
+ continue
140
+ }
141
+
142
+ const marks = child.marks ?? []
143
+
144
+ // Go through the marks of the span and figure out if any of
145
+ // them refer to annotations that are also present in the
146
+ // previous block
147
+ for (const mark of marks) {
148
+ if (
149
+ schema.decorators.some((decorator) => decorator.value === mark)
150
+ ) {
151
+ continue
152
+ }
153
+
154
+ if (
155
+ prevNodeSpans.some((prevNodeSpan) =>
156
+ prevNodeSpan.marks?.includes(mark),
157
+ ) &&
158
+ !newMarkDefKeys.has(mark)
159
+ ) {
160
+ // This annotation is both present in the previous block
161
+ // and this block, so let's assign a new key to it
162
+ newMarkDefKeys.set(mark, keyGenerator())
163
+ }
164
+ }
165
+
166
+ const newMarks = marks.map(
167
+ (mark) => newMarkDefKeys.get(mark) ?? mark,
168
+ )
169
+
170
+ // No need to update the marks if they are the same
171
+ if (!isEqual(marks, newMarks)) {
172
+ Transforms.setNodes(
173
+ editor,
174
+ {marks: newMarks},
175
+ {
176
+ at: childPath,
177
+ },
178
+ )
179
+ }
180
+ }
181
+
182
+ // Time to update all the markDefs that need a new key because
183
+ // they've been split across blocks
184
+ const newMarkDefs = nextNode.markDefs.map((markDef) => ({
185
+ ...markDef,
186
+ _key: newMarkDefKeys.get(markDef._key) ?? markDef._key,
187
+ }))
188
+
189
+ // No need to update the markDefs if they are the same
190
+ if (!isEqual(nextNode.markDefs, newMarkDefs)) {
191
+ Transforms.setNodes(
192
+ editor,
193
+ {markDefs: newMarkDefs},
194
+ {
195
+ at: nextNodePath,
196
+ match: (node) => editor.isTextBlock(node),
197
+ },
198
+ )
199
+ }
200
+ }
201
+ })
202
+ editor.onChange()
203
+ return
204
+ }
205
+ }
206
+ }
@@ -1,19 +1,26 @@
1
- import {Editor, Transforms} from 'slate'
1
+ import {deleteBackward, Editor, insertText, Transforms} from 'slate'
2
2
  import type {PortableTextMemberSchemaTypes} from '../../types/editor'
3
3
  import {toSlateRange} from '../../utils/ranges'
4
- import type {BehaviorAction, PickFromUnion} from './behavior.types'
4
+ import {insertBreakActionImplementation} from './behavior.action.insert-break'
5
+ import type {
6
+ BehaviorAction,
7
+ BehaviorEvent,
8
+ PickFromUnion,
9
+ } from './behavior.types'
5
10
 
6
- type BehaviorActionContext = {
11
+ export type BehaviorActionContext = {
7
12
  keyGenerator: () => string
8
13
  schema: PortableTextMemberSchemaTypes
9
14
  }
10
15
 
11
- type BehaviourActionImplementation<TBehaviorAction extends BehaviorAction> = ({
16
+ export type BehaviourActionImplementation<
17
+ TBehaviorAction extends BehaviorAction,
18
+ > = ({
12
19
  context,
13
- event,
20
+ action,
14
21
  }: {
15
22
  context: BehaviorActionContext
16
- event: TBehaviorAction
23
+ action: TBehaviorAction
17
24
  }) => void
18
25
 
19
26
  type BehaviourActionImplementations = {
@@ -23,26 +30,51 @@ type BehaviourActionImplementations = {
23
30
  }
24
31
 
25
32
  export const behaviorActionImplementations: BehaviourActionImplementations = {
26
- 'apply block style': ({event}) => {
27
- for (const path of event.paths) {
33
+ 'set block': ({action}) => {
34
+ for (const path of action.paths) {
28
35
  const at = toSlateRange(
29
36
  {anchor: {path, offset: 0}, focus: {path, offset: 0}},
30
- event.editor,
37
+ action.editor,
31
38
  )!
32
39
 
33
- Transforms.setNodes(event.editor, {style: event.style}, {at})
40
+ Transforms.setNodes(
41
+ action.editor,
42
+ {
43
+ ...(action.style ? {style: action.style} : {}),
44
+ ...(action.listItem ? {listItem: action.listItem} : {}),
45
+ ...(action.level ? {level: action.level} : {}),
46
+ },
47
+ {at},
48
+ )
49
+ }
50
+ },
51
+ 'unset block': ({action}) => {
52
+ for (const path of action.paths) {
53
+ const at = toSlateRange(
54
+ {anchor: {path, offset: 0}, focus: {path, offset: 0}},
55
+ action.editor,
56
+ )!
57
+
58
+ Transforms.unsetNodes(action.editor, action.props, {at})
34
59
  }
35
60
  },
36
- 'delete text': ({event}) => {
37
- Transforms.delete(event.editor, {
38
- at: toSlateRange(event.selection, event.editor)!,
61
+ 'delete backward': ({action}) => {
62
+ deleteBackward(action.editor, action.unit)
63
+ },
64
+ 'delete text': ({action}) => {
65
+ Transforms.delete(action.editor, {
66
+ at: toSlateRange(action.selection, action.editor)!,
39
67
  })
40
68
  },
41
- 'insert text': ({event}) => {
42
- Editor.insertText(event.editor, event.text)
69
+ 'insert break': insertBreakActionImplementation,
70
+ // This mimics Slate's internal which also just does a regular insert break
71
+ // when on soft break
72
+ 'insert soft break': insertBreakActionImplementation,
73
+ 'insert text': ({action}) => {
74
+ insertText(action.editor, action.text)
43
75
  },
44
- 'insert text block': ({context, event}) => {
45
- Editor.insertNode(event.editor, {
76
+ 'insert text block': ({context, action}) => {
77
+ Editor.insertNode(action.editor, {
46
78
  _key: context.keyGenerator(),
47
79
  _type: context.schema.block.name,
48
80
  style: context.schema.styles[0].value ?? 'normal',
@@ -56,4 +88,46 @@ export const behaviorActionImplementations: BehaviourActionImplementations = {
56
88
  ],
57
89
  })
58
90
  },
91
+ 'effect': ({action}) => {
92
+ action.effect()
93
+ },
94
+ }
95
+
96
+ export function performDefaultAction({
97
+ context,
98
+ action,
99
+ }: {
100
+ context: BehaviorActionContext
101
+ action: PickFromUnion<BehaviorAction, 'type', BehaviorEvent['type']>
102
+ }) {
103
+ switch (action.type) {
104
+ case 'delete backward': {
105
+ behaviorActionImplementations['delete backward']({
106
+ context,
107
+ action,
108
+ })
109
+ break
110
+ }
111
+ case 'insert break': {
112
+ behaviorActionImplementations['insert break']({
113
+ context,
114
+ action,
115
+ })
116
+ break
117
+ }
118
+ case 'insert soft break': {
119
+ behaviorActionImplementations['insert soft break']({
120
+ context,
121
+ action,
122
+ })
123
+ break
124
+ }
125
+ case 'insert text': {
126
+ behaviorActionImplementations['insert text']({
127
+ context,
128
+ action,
129
+ })
130
+ break
131
+ }
132
+ }
59
133
  }
@@ -1,37 +1,19 @@
1
- import {isHotkey} from 'is-hotkey-esm'
2
1
  import {defineBehavior} from './behavior.types'
3
2
  import {getFocusBlockObject} from './behavior.utils'
4
3
 
5
- const overwriteSoftReturn = defineBehavior({
6
- on: 'key down',
7
- guard: ({event}) => isHotkey('shift+enter', event.nativeEvent),
8
- actions: [
9
- ({event}) => {
10
- event.nativeEvent.preventDefault()
11
- return {type: 'insert text', text: '\n'}
12
- },
13
- ],
4
+ const softReturn = defineBehavior({
5
+ on: 'insert soft break',
6
+ actions: [() => [{type: 'insert text', text: '\n'}]],
14
7
  })
15
8
 
16
- const enterOnVoidBlock = defineBehavior({
17
- on: 'key down',
18
- guard: ({context, event}) => {
19
- const isEnter = isHotkey('enter', event.nativeEvent)
20
-
21
- if (!isEnter) {
22
- return false
23
- }
24
-
9
+ const breakingVoidBlock = defineBehavior({
10
+ on: 'insert break',
11
+ guard: ({context}) => {
25
12
  const focusBlockObject = getFocusBlockObject(context)
26
13
 
27
14
  return !!focusBlockObject
28
15
  },
29
- actions: [
30
- ({event}) => {
31
- event.nativeEvent.preventDefault()
32
- return {type: 'insert text block', decorators: []}
33
- },
34
- ],
16
+ actions: [() => [{type: 'insert text block', decorators: []}]],
35
17
  })
36
18
 
37
- export const coreBehaviors = [overwriteSoftReturn, enterOnVoidBlock]
19
+ export const coreBehaviors = [softReturn, breakingVoidBlock]
@@ -1,4 +1,6 @@
1
1
  import type {KeyedSegment, PortableTextBlock} from '@sanity/types'
2
+ import type {TextUnit} from 'slate'
3
+ import type {TextInsertTextOptions} from 'slate/dist/interfaces/transforms/text'
2
4
  import type {
3
5
  EditorSelection,
4
6
  PortableTextMemberSchemaTypes,
@@ -19,14 +21,19 @@ export type BehaviorContext = {
19
21
  */
20
22
  export type BehaviorEvent =
21
23
  | {
22
- type: 'key down'
23
- nativeEvent: KeyboardEvent
24
- editor: PortableTextSlateEditor
24
+ type: 'delete backward'
25
+ unit: TextUnit
25
26
  }
26
27
  | {
27
- type: 'before insert text'
28
- nativeEvent: InputEvent
29
- editor: PortableTextSlateEditor
28
+ type: 'insert soft break'
29
+ }
30
+ | {
31
+ type: 'insert break'
32
+ }
33
+ | {
34
+ type: 'insert text'
35
+ text: string
36
+ options?: TextInsertTextOptions
30
37
  }
31
38
 
32
39
  /**
@@ -47,23 +54,31 @@ export type BehaviorGuard<
47
54
  * @alpha
48
55
  */
49
56
  export type BehaviorActionIntend =
50
- | {
51
- type: 'insert text'
52
- text: string
53
- }
57
+ | BehaviorEvent
54
58
  | {
55
59
  type: 'insert text block'
56
60
  decorators: Array<string>
57
61
  }
58
62
  | {
59
- type: 'apply block style'
63
+ type: 'set block'
60
64
  paths: Array<[KeyedSegment]>
61
- style: string
65
+ style?: string
66
+ listItem?: string
67
+ level?: number
68
+ }
69
+ | {
70
+ type: 'unset block'
71
+ paths: Array<[KeyedSegment]>
72
+ props: Array<'style' | 'listItem' | 'level'>
62
73
  }
63
74
  | {
64
75
  type: 'delete text'
65
76
  selection: NonNullable<EditorSelection>
66
77
  }
78
+ | {
79
+ type: 'effect'
80
+ effect: () => void
81
+ }
67
82
 
68
83
  /**
69
84
  * @alpha
@@ -79,18 +94,29 @@ export type Behavior<
79
94
  TBehaviorEventType extends BehaviorEvent['type'] = BehaviorEvent['type'],
80
95
  TGuardResponse = true,
81
96
  > = {
97
+ /**
98
+ * The internal editor event that triggers this behavior.
99
+ */
82
100
  on: TBehaviorEventType
101
+ /**
102
+ * Predicate function that determines if the behavior should be executed.
103
+ * Returning a non-nullable value from the guard will pass the value to the
104
+ * actions and execute them.
105
+ */
83
106
  guard?: BehaviorGuard<
84
107
  PickFromUnion<BehaviorEvent, 'type', TBehaviorEventType>,
85
108
  TGuardResponse
86
109
  >
87
- actions: Array<RaiseBehaviorActionIntend<TBehaviorEventType, TGuardResponse>>
110
+ /**
111
+ * Array of behavior action sets.
112
+ */
113
+ actions: Array<BehaviorActionIntendSet<TBehaviorEventType, TGuardResponse>>
88
114
  }
89
115
 
90
116
  /**
91
117
  * @alpha
92
118
  */
93
- export type RaiseBehaviorActionIntend<
119
+ export type BehaviorActionIntendSet<
94
120
  TBehaviorEventType extends BehaviorEvent['type'] = BehaviorEvent['type'],
95
121
  TGuardResponse = true,
96
122
  > = (
@@ -102,7 +128,7 @@ export type RaiseBehaviorActionIntend<
102
128
  event: PickFromUnion<BehaviorEvent, 'type', TBehaviorEventType>
103
129
  },
104
130
  guardResponse: TGuardResponse,
105
- ) => BehaviorActionIntend | void
131
+ ) => Array<BehaviorActionIntend>
106
132
 
107
133
  /**
108
134
  * @alpha