@portabletext/editor 1.1.5 → 1.1.6

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.5",
3
+ "version": "1.1.6",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -51,19 +51,19 @@
51
51
  "xstate": "^5.18.2"
52
52
  },
53
53
  "devDependencies": {
54
- "@babel/preset-env": "^7.25.4",
55
- "@babel/preset-react": "^7.24.7",
54
+ "@babel/preset-env": "^7.25.9",
55
+ "@babel/preset-react": "^7.25.9",
56
56
  "@jest/globals": "^29.7.0",
57
57
  "@jest/types": "^29.6.3",
58
58
  "@playwright/test": "1.48.1",
59
59
  "@portabletext/toolkit": "^2.0.15",
60
- "@sanity/block-tools": "^3.61.0",
60
+ "@sanity/block-tools": "^3.62.1",
61
61
  "@sanity/diff-match-patch": "^3.1.1",
62
- "@sanity/pkg-utils": "^6.11.2",
63
- "@sanity/schema": "^3.61.0",
64
- "@sanity/types": "^3.61.0",
65
- "@sanity/ui": "^2.8.9",
66
- "@sanity/util": "^3.61.0",
62
+ "@sanity/pkg-utils": "^6.11.4",
63
+ "@sanity/schema": "^3.62.1",
64
+ "@sanity/types": "^3.62.1",
65
+ "@sanity/ui": "^2.8.10",
66
+ "@sanity/util": "^3.62.1",
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",
@@ -71,17 +71,17 @@
71
71
  "@types/debug": "^4.1.5",
72
72
  "@types/express": "^4.17.21",
73
73
  "@types/express-ws": "^3.0.5",
74
- "@types/lodash": "^4.17.7",
74
+ "@types/lodash": "^4.17.12",
75
75
  "@types/node": "^18.19.8",
76
76
  "@types/node-ipc": "^9.2.3",
77
- "@types/react": "^18.3.11",
77
+ "@types/react": "^18.3.12",
78
78
  "@types/react-dom": "^18.3.1",
79
79
  "@types/ws": "~8.5.12",
80
80
  "@vitejs/plugin-react": "^4.3.3",
81
81
  "@vitest/browser": "^2.1.3",
82
82
  "@xstate/react": "^4.1.3",
83
83
  "dotenv": "^16.4.5",
84
- "express": "^4.19.2",
84
+ "express": "^4.21.1",
85
85
  "express-ws": "^5.0.2",
86
86
  "jest": "^29.7.0",
87
87
  "jest-dev-server": "^10.1.1",
@@ -95,16 +95,16 @@
95
95
  "styled-components": "^6.1.13",
96
96
  "ts-node": "^10.9.2",
97
97
  "typescript": "5.6.3",
98
- "vite": "^5.4.9",
98
+ "vite": "^5.4.10",
99
99
  "vitest": "^2.1.3",
100
- "vitest-browser-react": "^0.0.1",
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.61.0",
105
- "@sanity/schema": "^3.61.0",
106
- "@sanity/types": "^3.61.0",
107
- "@sanity/util": "^3.61.0",
104
+ "@sanity/block-tools": "^3.62.1",
105
+ "@sanity/schema": "^3.62.1",
106
+ "@sanity/types": "^3.62.1",
107
+ "@sanity/util": "^3.62.1",
108
108
  "react": "^16.9 || ^17 || ^18",
109
109
  "rxjs": "^7.8.1",
110
110
  "styled-components": "^6.1.13"
@@ -634,6 +634,11 @@ export const PortableTextEditable = forwardRef<
634
634
  props.onKeyDown(event)
635
635
  }
636
636
  if (!event.isDefaultPrevented()) {
637
+ editorActor.send({
638
+ type: 'key down',
639
+ nativeEvent: event.nativeEvent,
640
+ editor: slateEditor,
641
+ })
637
642
  slateEditor.pteWithHotKeys(event)
638
643
  }
639
644
  },
@@ -24,6 +24,7 @@ import type {
24
24
  import {debugWithName} from '../utils/debug'
25
25
  import {getPortableTextMemberSchemaTypes} from '../utils/getPortableTextMemberSchemaTypes'
26
26
  import {compileType} from '../utils/schema'
27
+ import {coreBehaviors} from './behavior/behavior.core'
27
28
  import {SlateContainer} from './components/SlateContainer'
28
29
  import {Synchronizer} from './components/Synchronizer'
29
30
  import {editorMachine, type EditorActor} from './editor-machine'
@@ -122,18 +123,20 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
122
123
  )
123
124
  }
124
125
 
126
+ this.schemaTypes = getPortableTextMemberSchemaTypes(
127
+ props.schemaType.hasOwnProperty('jsonType')
128
+ ? props.schemaType
129
+ : compileType(props.schemaType),
130
+ )
131
+
125
132
  this.editorActor = createActor(editorMachine, {
126
133
  input: {
134
+ behaviors: coreBehaviors,
127
135
  keyGenerator: props.keyGenerator || defaultKeyGenerator,
136
+ schema: this.schemaTypes,
128
137
  },
129
138
  })
130
139
  this.editorActor.start()
131
-
132
- this.schemaTypes = getPortableTextMemberSchemaTypes(
133
- props.schemaType.hasOwnProperty('jsonType')
134
- ? props.schemaType
135
- : compileType(props.schemaType),
136
- )
137
140
  }
138
141
 
139
142
  componentDidUpdate(prevProps: PortableTextEditorProps) {
@@ -144,7 +147,13 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
144
147
  ? this.props.schemaType
145
148
  : compileType(this.props.schemaType),
146
149
  )
150
+
151
+ this.editorActor.send({
152
+ type: 'update schema',
153
+ schema: this.schemaTypes,
154
+ })
147
155
  }
156
+
148
157
  if (this.props.editorRef !== prevProps.editorRef && this.props.editorRef) {
149
158
  this.props.editorRef.current = this
150
159
  }
@@ -4,7 +4,7 @@ import type {PortableTextBlock, PortableTextSpan} from '@sanity/types'
4
4
  import {render, waitFor} from '@testing-library/react'
5
5
  import {createRef, type ComponentProps, type RefObject} from 'react'
6
6
  import {describe, expect, it, vi} from 'vitest'
7
- import {getTextSelection} from '../../../gherkin-spec/gherkin-step-helpers'
7
+ import {getTextSelection} from '../../../gherkin-tests/gherkin-step-helpers'
8
8
  import {PortableTextEditable} from '../Editable'
9
9
  import {PortableTextEditor} from '../PortableTextEditor'
10
10
 
@@ -0,0 +1,39 @@
1
+ import {Editor} from 'slate'
2
+ import type {PortableTextMemberSchemaTypes} from '../../types/editor'
3
+ import type {BehaviorAction, PickFromUnion} from './behavior.types'
4
+
5
+ type BehaviorActionContext = {
6
+ keyGenerator: () => string
7
+ schema: PortableTextMemberSchemaTypes
8
+ }
9
+
10
+ export function inserText({
11
+ event,
12
+ }: {
13
+ context: BehaviorActionContext
14
+ event: PickFromUnion<BehaviorAction, 'type', 'insert text'>
15
+ }) {
16
+ Editor.insertText(event.editor, event.text)
17
+ }
18
+
19
+ export function inserTextBlock({
20
+ context,
21
+ event,
22
+ }: {
23
+ context: BehaviorActionContext
24
+ event: PickFromUnion<BehaviorAction, 'type', 'insert text block'>
25
+ }) {
26
+ Editor.insertNode(event.editor, {
27
+ _key: context.keyGenerator(),
28
+ _type: context.schema.block.name,
29
+ style: context.schema.styles[0].value ?? 'normal',
30
+ markDefs: [],
31
+ children: [
32
+ {
33
+ _key: context.keyGenerator(),
34
+ _type: 'span',
35
+ text: '',
36
+ },
37
+ ],
38
+ })
39
+ }
@@ -0,0 +1,37 @@
1
+ import {isHotkey} from 'is-hotkey-esm'
2
+ import {defineBehavior} from './behavior.types'
3
+ import {getFocusBlockObject} from './behavior.utils'
4
+
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
+ ],
14
+ })
15
+
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
+
25
+ const focusBlockObject = getFocusBlockObject(context)
26
+
27
+ return !!focusBlockObject
28
+ },
29
+ actions: [
30
+ ({event}) => {
31
+ event.nativeEvent.preventDefault()
32
+ return {type: 'insert text block', decorators: []}
33
+ },
34
+ ],
35
+ })
36
+
37
+ export const coreBehaviors = [overwriteSoftReturn, enterOnVoidBlock]
@@ -0,0 +1,106 @@
1
+ import type {PortableTextBlock} from '@sanity/types'
2
+ import type {
3
+ EditorSelection,
4
+ PortableTextMemberSchemaTypes,
5
+ PortableTextSlateEditor,
6
+ } from '../../types/editor'
7
+
8
+ /**
9
+ * @alpha
10
+ */
11
+ export type BehaviorContext = {
12
+ schema: PortableTextMemberSchemaTypes
13
+ value: Array<PortableTextBlock>
14
+ selection: NonNullable<EditorSelection>
15
+ }
16
+
17
+ /**
18
+ * @alpha
19
+ */
20
+ export type BehaviorEvent = {
21
+ type: 'key down'
22
+ nativeEvent: KeyboardEvent
23
+ editor: PortableTextSlateEditor
24
+ }
25
+
26
+ /**
27
+ * @alpha
28
+ */
29
+ export type BehaviorGuard<
30
+ TBehaviorEvent extends BehaviorEvent,
31
+ TGuardResponse,
32
+ > = ({
33
+ context,
34
+ event,
35
+ }: {
36
+ event: TBehaviorEvent
37
+ context: BehaviorContext
38
+ }) => TGuardResponse | false
39
+
40
+ /**
41
+ * @alpha
42
+ */
43
+ export type BehaviorActionIntend =
44
+ | {
45
+ type: 'insert text'
46
+ text: string
47
+ }
48
+ | {
49
+ type: 'insert text block'
50
+ decorators: Array<string>
51
+ }
52
+
53
+ /**
54
+ * @alpha
55
+ */
56
+ export type BehaviorAction = BehaviorActionIntend & {
57
+ editor: PortableTextSlateEditor
58
+ }
59
+
60
+ /**
61
+ * @alpha
62
+ */
63
+ export type Behavior<
64
+ TBehaviorEventType extends BehaviorEvent['type'] = BehaviorEvent['type'],
65
+ TGuardResponse = true,
66
+ > = {
67
+ on: TBehaviorEventType
68
+ guard?: BehaviorGuard<
69
+ PickFromUnion<BehaviorEvent, 'type', TBehaviorEventType>,
70
+ TGuardResponse
71
+ >
72
+ actions: Array<RaiseBehaviorActionIntend<TBehaviorEventType, TGuardResponse>>
73
+ }
74
+
75
+ /**
76
+ * @alpha
77
+ */
78
+ export type RaiseBehaviorActionIntend<
79
+ TBehaviorEventType extends BehaviorEvent['type'] = BehaviorEvent['type'],
80
+ TGuardResponse = true,
81
+ > = (
82
+ {
83
+ context,
84
+ event,
85
+ }: {
86
+ context: BehaviorContext
87
+ event: PickFromUnion<BehaviorEvent, 'type', TBehaviorEventType>
88
+ },
89
+ guardResponse: TGuardResponse,
90
+ ) => BehaviorActionIntend | void
91
+
92
+ export function defineBehavior<
93
+ TBehaviorEventType extends BehaviorEvent['type'],
94
+ TGuardResponse = true,
95
+ >(behavior: Behavior<TBehaviorEventType, TGuardResponse>): Behavior {
96
+ return behavior as unknown as Behavior
97
+ }
98
+
99
+ /**
100
+ * @alpha
101
+ */
102
+ export type PickFromUnion<
103
+ TUnion,
104
+ TTagKey extends keyof TUnion,
105
+ TPickedTags extends TUnion[TTagKey],
106
+ > = TUnion extends Record<TTagKey, TPickedTags> ? TUnion : never
@@ -0,0 +1,34 @@
1
+ import {
2
+ isKeySegment,
3
+ isPortableTextTextBlock,
4
+ type KeyedSegment,
5
+ type PortableTextBlock,
6
+ type PortableTextObject,
7
+ } from '@sanity/types'
8
+ import type {BehaviorContext} from './behavior.types'
9
+
10
+ export function getFocusBlock(
11
+ context: BehaviorContext,
12
+ ): {node: PortableTextBlock; path: [KeyedSegment]} | undefined {
13
+ const key = context.selection
14
+ ? isKeySegment(context.selection.focus.path[0])
15
+ ? context.selection.focus.path[0]._key
16
+ : undefined
17
+ : undefined
18
+
19
+ const node = key
20
+ ? context.value.find((block) => block._key === key)
21
+ : undefined
22
+
23
+ return node && key ? {node, path: [{_key: key}]} : undefined
24
+ }
25
+
26
+ export function getFocusBlockObject(
27
+ context: BehaviorContext,
28
+ ): {node: PortableTextObject; path: [KeyedSegment]} | undefined {
29
+ const focusBlock = getFocusBlock(context)
30
+
31
+ return focusBlock && !isPortableTextTextBlock(focusBlock.node)
32
+ ? {node: focusBlock.node, path: focusBlock.path}
33
+ : undefined
34
+ }
@@ -10,7 +10,21 @@ import {
10
10
  setup,
11
11
  type ActorRefFrom,
12
12
  } from 'xstate'
13
- import type {EditorSelection, InvalidValueResolution} from '../types/editor'
13
+ import type {
14
+ EditorSelection,
15
+ InvalidValueResolution,
16
+ PortableTextMemberSchemaTypes,
17
+ } from '../types/editor'
18
+ import {toPortableTextRange} from '../utils/ranges'
19
+ import {fromSlateValue} from '../utils/values'
20
+ import {KEY_TO_VALUE_ELEMENT} from '../utils/weakMaps'
21
+ import {inserText, inserTextBlock} from './behavior/behavior.actions'
22
+ import type {
23
+ Behavior,
24
+ BehaviorAction,
25
+ BehaviorContext,
26
+ BehaviorEvent,
27
+ } from './behavior/behavior.types'
14
28
 
15
29
  /**
16
30
  * @internal
@@ -51,6 +65,12 @@ export type MutationEvent = {
51
65
  type EditorEvent =
52
66
  | {type: 'normalizing'}
53
67
  | {type: 'done normalizing'}
68
+ | BehaviorEvent
69
+ | BehaviorAction
70
+ | {
71
+ type: 'update schema'
72
+ schema: PortableTextMemberSchemaTypes
73
+ }
54
74
  | EditorEmittedEvent
55
75
 
56
76
  type EditorEmittedEvent =
@@ -90,16 +110,34 @@ type EditorEmittedEvent =
90
110
  export const editorMachine = setup({
91
111
  types: {
92
112
  context: {} as {
113
+ behaviors: Array<Behavior>
93
114
  keyGenerator: () => string
94
115
  pendingEvents: Array<PatchEvent | MutationEvent>
116
+ schema: PortableTextMemberSchemaTypes
95
117
  },
96
118
  events: {} as EditorEvent,
97
119
  emitted: {} as EditorEmittedEvent,
98
120
  input: {} as {
121
+ behaviors: Array<Behavior>
99
122
  keyGenerator: () => string
123
+ schema: PortableTextMemberSchemaTypes
100
124
  },
101
125
  },
102
126
  actions: {
127
+ 'apply:insert text': ({context, event}) => {
128
+ assertEvent(event, 'insert text')
129
+ inserText({context, event})
130
+ },
131
+ 'apply:insert text block': ({context, event}) => {
132
+ assertEvent(event, 'insert text block')
133
+ inserTextBlock({context, event})
134
+ },
135
+ 'assign schema': assign({
136
+ schema: ({event}) => {
137
+ assertEvent(event, 'update schema')
138
+ return event.schema
139
+ },
140
+ }),
103
141
  'emit patch event': emit(({event}) => {
104
142
  assertEvent(event, 'patch')
105
143
  return event
@@ -122,6 +160,68 @@ export const editorMachine = setup({
122
160
  'clear pending events': assign({
123
161
  pendingEvents: [],
124
162
  }),
163
+ 'handle behavior event': enqueueActions(({context, event, enqueue}) => {
164
+ assertEvent(event, ['key down'])
165
+
166
+ const eventBehaviors = context.behaviors.filter(
167
+ (behavior) => behavior.on === event.type,
168
+ )
169
+
170
+ if (eventBehaviors.length === 0) {
171
+ return
172
+ }
173
+
174
+ const value = fromSlateValue(
175
+ event.editor.children,
176
+ context.schema.block.name,
177
+ KEY_TO_VALUE_ELEMENT.get(event.editor),
178
+ )
179
+ const selection = toPortableTextRange(
180
+ value,
181
+ event.editor.selection,
182
+ context.schema,
183
+ )
184
+
185
+ if (!selection) {
186
+ console.warn(
187
+ `Unable to handle event ${event.type} due to missing selection`,
188
+ )
189
+ return
190
+ }
191
+
192
+ const behaviorContext = {
193
+ schema: context.schema,
194
+ value,
195
+ selection,
196
+ } satisfies BehaviorContext
197
+
198
+ for (const eventBehavior of eventBehaviors) {
199
+ const shouldRun =
200
+ eventBehavior.guard?.({
201
+ context: behaviorContext,
202
+ event,
203
+ }) ?? true
204
+
205
+ if (!shouldRun) {
206
+ continue
207
+ }
208
+
209
+ const actions = eventBehavior.actions.map((action) =>
210
+ action({context: behaviorContext, event}, shouldRun),
211
+ )
212
+
213
+ for (const action of actions) {
214
+ if (typeof action !== 'object') {
215
+ continue
216
+ }
217
+
218
+ enqueue.raise({
219
+ ...action,
220
+ editor: event.editor,
221
+ })
222
+ }
223
+ }
224
+ }),
125
225
  },
126
226
  actors: {
127
227
  networkLogic,
@@ -129,8 +229,10 @@ export const editorMachine = setup({
129
229
  }).createMachine({
130
230
  id: 'editor',
131
231
  context: ({input}) => ({
232
+ behaviors: input.behaviors,
132
233
  keyGenerator: input.keyGenerator,
133
234
  pendingEvents: [],
235
+ schema: input.schema,
134
236
  }),
135
237
  invoke: {
136
238
  id: 'networkLogic',
@@ -149,6 +251,16 @@ export const editorMachine = setup({
149
251
  'offline': {actions: emit({type: 'offline'})},
150
252
  'loading': {actions: emit({type: 'loading'})},
151
253
  'done loading': {actions: emit({type: 'done loading'})},
254
+ 'update schema': {actions: 'assign schema'},
255
+ 'key down': {
256
+ actions: ['handle behavior event'],
257
+ },
258
+ 'insert text': {
259
+ actions: ['apply:insert text'],
260
+ },
261
+ 'insert text block': {
262
+ actions: ['apply:insert text block'],
263
+ },
152
264
  },
153
265
  initial: 'pristine',
154
266
  states: {
@@ -186,38 +186,6 @@ export function createWithHotkeys(
186
186
  return
187
187
  }
188
188
  }
189
- // Block object enter key
190
- if (focusBlock && Editor.isVoid(editor, focusBlock)) {
191
- Editor.insertNode(editor, editor.pteCreateTextBlock({decorators: []}))
192
- event.preventDefault()
193
- editor.onChange()
194
- return
195
- }
196
- // Default enter key behavior
197
- event.preventDefault()
198
- editor.insertBreak()
199
- editor.onChange()
200
- }
201
-
202
- // Soft line breaks
203
- if (isShiftEnter) {
204
- event.preventDefault()
205
- editor.insertText('\n')
206
- return
207
- }
208
-
209
- // Undo/redo
210
- if (isHotkey('mod+z', event.nativeEvent)) {
211
- event.preventDefault()
212
- editor.undo()
213
- return
214
- }
215
- if (
216
- isHotkey('mod+y', event.nativeEvent) ||
217
- isHotkey('mod+shift+z', event.nativeEvent)
218
- ) {
219
- event.preventDefault()
220
- editor.redo()
221
189
  }
222
190
  }
223
191
  return editor