@portabletext/editor 1.3.0 → 1.4.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.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -67,7 +67,7 @@
67
67
  "@sanity/ui": "^2.8.17",
68
68
  "@sanity/util": "^3.62.3",
69
69
  "@testing-library/dom": "^10.4.0",
70
- "@testing-library/jest-dom": "^6.6.2",
70
+ "@testing-library/jest-dom": "^6.6.3",
71
71
  "@testing-library/react": "^16.0.1",
72
72
  "@testing-library/user-event": "^14.5.2",
73
73
  "@types/debug": "^4.1.5",
@@ -9,7 +9,12 @@ import type {
9
9
  PortableTextObject,
10
10
  SpanSchemaType,
11
11
  } from '@sanity/types'
12
- import {Component, type MutableRefObject, type PropsWithChildren} from 'react'
12
+ import {
13
+ Component,
14
+ useEffect,
15
+ type MutableRefObject,
16
+ type PropsWithChildren,
17
+ } from 'react'
13
18
  import {Subject} from 'rxjs'
14
19
  import {createActor} from 'xstate'
15
20
  import type {
@@ -24,7 +29,6 @@ import type {
24
29
  import {debugWithName} from '../utils/debug'
25
30
  import {getPortableTextMemberSchemaTypes} from '../utils/getPortableTextMemberSchemaTypes'
26
31
  import {compileType} from '../utils/schema'
27
- import {coreBehaviors} from './behavior/behavior.core'
28
32
  import {SlateContainer} from './components/SlateContainer'
29
33
  import {Synchronizer} from './components/Synchronizer'
30
34
  import {EditorActorContext} from './editor-actor-context'
@@ -33,6 +37,7 @@ import {PortableTextEditorContext} from './hooks/usePortableTextEditor'
33
37
  import {PortableTextEditorSelectionProvider} from './hooks/usePortableTextEditorSelection'
34
38
  import {PortableTextEditorReadOnlyContext} from './hooks/usePortableTextReadOnly'
35
39
  import {defaultKeyGenerator} from './key-generator'
40
+ import type {Editor} from './use-editor'
36
41
 
37
42
  const debug = debugWithName('component:PortableTextEditor')
38
43
 
@@ -41,58 +46,73 @@ const debug = debugWithName('component:PortableTextEditor')
41
46
  *
42
47
  * @public
43
48
  */
44
- export type PortableTextEditorProps = PropsWithChildren<{
45
- /**
46
- * Function that gets called when the editor changes the value
47
- */
48
- onChange: (change: EditorChange) => void
49
+ export type PortableTextEditorProps<
50
+ TEditor extends Editor | undefined = undefined,
51
+ > = PropsWithChildren<
52
+ (TEditor extends Editor
53
+ ? {
54
+ /**
55
+ * @alpha
56
+ */
57
+ editor: TEditor
58
+ }
59
+ : {
60
+ editor?: undefined
49
61
 
50
- /**
51
- * Schema type for the portable text field
52
- */
53
- schemaType: ArraySchemaType<PortableTextBlock> | ArrayDefinition
62
+ /**
63
+ * Function that gets called when the editor changes the value
64
+ */
65
+ onChange: (change: EditorChange) => void
54
66
 
55
- /**
56
- * Maximum number of blocks to allow within the editor
57
- */
58
- maxBlocks?: number | string
67
+ /**
68
+ * Schema type for the portable text field
69
+ */
70
+ schemaType: ArraySchemaType<PortableTextBlock> | ArrayDefinition
59
71
 
60
- /**
61
- * Whether or not the editor should be in read-only mode
62
- */
63
- readOnly?: boolean
72
+ /**
73
+ * Maximum number of blocks to allow within the editor
74
+ */
75
+ maxBlocks?: number | string
64
76
 
65
- /**
66
- * The current value of the portable text field
67
- */
68
- value?: PortableTextBlock[]
77
+ /**
78
+ * Function used to generate keys for array items (`_key`)
79
+ */
80
+ keyGenerator?: () => string
69
81
 
70
- /**
71
- * Function used to generate keys for array items (`_key`)
72
- */
73
- keyGenerator?: () => string
82
+ /**
83
+ * Observable of local and remote patches for the edited value.
84
+ */
85
+ patches$?: PatchObservable
74
86
 
75
- /**
76
- * Observable of local and remote patches for the edited value.
77
- */
78
- patches$?: PatchObservable
87
+ /**
88
+ * Backward compatibility (renamed to patches$).
89
+ */
90
+ incomingPatches$?: PatchObservable
91
+ }) & {
92
+ /**
93
+ * Whether or not the editor should be in read-only mode
94
+ */
95
+ readOnly?: boolean
79
96
 
80
- /**
81
- * Backward compatibility (renamed to patches$).
82
- */
83
- incomingPatches$?: PatchObservable
97
+ /**
98
+ * The current value of the portable text field
99
+ */
100
+ value?: PortableTextBlock[]
84
101
 
85
- /**
86
- * A ref to the editor instance
87
- */
88
- editorRef?: MutableRefObject<PortableTextEditor | null>
89
- }>
102
+ /**
103
+ * A ref to the editor instance
104
+ */
105
+ editorRef?: MutableRefObject<PortableTextEditor | null>
106
+ }
107
+ >
90
108
 
91
109
  /**
92
110
  * The main Portable Text Editor component.
93
111
  * @public
94
112
  */
95
- export class PortableTextEditor extends Component<PortableTextEditorProps> {
113
+ export class PortableTextEditor extends Component<
114
+ PortableTextEditorProps<Editor | undefined>
115
+ > {
96
116
  public static displayName = 'PortableTextEditor'
97
117
  /**
98
118
  * An observable of all the editor changes.
@@ -111,35 +131,46 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
111
131
  constructor(props: PortableTextEditorProps) {
112
132
  super(props)
113
133
 
114
- if (!props.schemaType) {
115
- throw new Error('PortableTextEditor: missing "schemaType" property')
116
- }
134
+ if (props.editor) {
135
+ this.editorActor = props.editor
136
+ this.editorActor.start()
137
+ this.schemaTypes = this.editorActor.getSnapshot().context.schema
138
+ } else {
139
+ if (!props.schemaType) {
140
+ throw new Error('PortableTextEditor: missing "schemaType" property')
141
+ }
117
142
 
118
- if (props.incomingPatches$) {
119
- console.warn(
120
- `The prop 'incomingPatches$' is deprecated and renamed to 'patches$'`,
121
- )
122
- }
143
+ if (props.incomingPatches$) {
144
+ console.warn(
145
+ `The prop 'incomingPatches$' is deprecated and renamed to 'patches$'`,
146
+ )
147
+ }
123
148
 
124
- this.schemaTypes = getPortableTextMemberSchemaTypes(
125
- props.schemaType.hasOwnProperty('jsonType')
126
- ? props.schemaType
127
- : compileType(props.schemaType),
128
- )
149
+ this.schemaTypes = getPortableTextMemberSchemaTypes(
150
+ props.schemaType.hasOwnProperty('jsonType')
151
+ ? props.schemaType
152
+ : compileType(props.schemaType),
153
+ )
129
154
 
130
- this.editorActor = createActor(editorMachine, {
131
- input: {
132
- behaviors: coreBehaviors,
133
- keyGenerator: props.keyGenerator || defaultKeyGenerator,
134
- schema: this.schemaTypes,
135
- },
136
- })
137
- this.editorActor.start()
155
+ this.editorActor =
156
+ props.editor ??
157
+ createActor(editorMachine, {
158
+ input: {
159
+ keyGenerator: props.keyGenerator || defaultKeyGenerator,
160
+ schema: this.schemaTypes,
161
+ },
162
+ })
163
+ this.editorActor.start()
164
+ }
138
165
  }
139
166
 
140
167
  componentDidUpdate(prevProps: PortableTextEditorProps) {
141
168
  // Set up the schema type lookup table again if the source schema type changes
142
- if (this.props.schemaType !== prevProps.schemaType) {
169
+ if (
170
+ !this.props.editor &&
171
+ !prevProps.editor &&
172
+ this.props.schemaType !== prevProps.schemaType
173
+ ) {
143
174
  this.schemaTypes = getPortableTextMemberSchemaTypes(
144
175
  this.props.schemaType.hasOwnProperty('jsonType')
145
176
  ? this.props.schemaType
@@ -170,49 +201,59 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
170
201
  }
171
202
 
172
203
  render() {
173
- const {value, children, patches$, incomingPatches$} = this.props
174
- const _patches$ = incomingPatches$ || patches$ // Backward compatibility
175
-
176
- const maxBlocks =
177
- typeof this.props.maxBlocks === 'undefined'
204
+ const maxBlocks = !this.props.editor
205
+ ? typeof this.props.maxBlocks === 'undefined'
178
206
  ? undefined
179
207
  : Number.parseInt(this.props.maxBlocks.toString(), 10) || undefined
208
+ : undefined
180
209
 
181
210
  const readOnly = Boolean(this.props.readOnly)
211
+ const legacyPatches = !this.props.editor
212
+ ? (this.props.incomingPatches$ ?? this.props.patches$)
213
+ : undefined
182
214
 
183
215
  return (
184
- <EditorActorContext.Provider value={this.editorActor}>
185
- <SlateContainer
186
- editorActor={this.editorActor}
187
- maxBlocks={maxBlocks}
188
- patches$={_patches$}
189
- portableTextEditor={this}
190
- readOnly={readOnly}
191
- >
192
- <PortableTextEditorContext.Provider value={this}>
193
- <PortableTextEditorReadOnlyContext.Provider value={readOnly}>
194
- <PortableTextEditorSelectionProvider
195
- editorActor={this.editorActor}
196
- >
197
- <Synchronizer
216
+ <>
217
+ {legacyPatches ? (
218
+ <RoutePatchesObservableToEditorActor
219
+ editorActor={this.editorActor}
220
+ patches$={legacyPatches}
221
+ />
222
+ ) : null}
223
+ <EditorActorContext.Provider value={this.editorActor}>
224
+ <SlateContainer
225
+ editorActor={this.editorActor}
226
+ maxBlocks={maxBlocks}
227
+ portableTextEditor={this}
228
+ readOnly={readOnly}
229
+ >
230
+ <PortableTextEditorContext.Provider value={this}>
231
+ <PortableTextEditorReadOnlyContext.Provider value={readOnly}>
232
+ <PortableTextEditorSelectionProvider
198
233
  editorActor={this.editorActor}
199
- getValue={this.getValue}
200
- onChange={(change) => {
201
- this.props.onChange(change)
202
- /**
203
- * For backwards compatibility, we relay all changes to the
204
- * `change$` Subject as well.
205
- */
206
- this.change$.next(change)
207
- }}
208
- value={value}
209
- />
210
- {children}
211
- </PortableTextEditorSelectionProvider>
212
- </PortableTextEditorReadOnlyContext.Provider>
213
- </PortableTextEditorContext.Provider>
214
- </SlateContainer>
215
- </EditorActorContext.Provider>
234
+ >
235
+ <Synchronizer
236
+ editorActor={this.editorActor}
237
+ getValue={this.getValue}
238
+ onChange={(change) => {
239
+ if (!this.props.editor) {
240
+ this.props.onChange(change)
241
+ }
242
+ /**
243
+ * For backwards compatibility, we relay all changes to the
244
+ * `change$` Subject as well.
245
+ */
246
+ this.change$.next(change)
247
+ }}
248
+ value={this.props.value}
249
+ />
250
+ {this.props.children}
251
+ </PortableTextEditorSelectionProvider>
252
+ </PortableTextEditorReadOnlyContext.Provider>
253
+ </PortableTextEditorContext.Provider>
254
+ </SlateContainer>
255
+ </EditorActorContext.Provider>
256
+ </>
216
257
  )
217
258
  }
218
259
 
@@ -378,3 +419,23 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
378
419
  return editor.editable?.isSelectionsOverlapping(selectionA, selectionB)
379
420
  }
380
421
  }
422
+
423
+ function RoutePatchesObservableToEditorActor(props: {
424
+ editorActor: EditorActor
425
+ patches$: PatchObservable
426
+ }) {
427
+ useEffect(() => {
428
+ const subscription = props.patches$.subscribe((payload) => {
429
+ props.editorActor.send({
430
+ type: 'patches',
431
+ ...payload,
432
+ })
433
+ })
434
+
435
+ return () => {
436
+ subscription.unsubscribe()
437
+ }
438
+ }, [props.editorActor, props.patches$])
439
+
440
+ return null
441
+ }
@@ -0,0 +1,203 @@
1
+ import type {PortableTextMemberSchemaTypes} from '../../types/editor'
2
+ import {defineBehavior} from './behavior.types'
3
+ import {
4
+ getFocusSpan,
5
+ getFocusTextBlock,
6
+ selectionIsCollapsed,
7
+ } from './behavior.utils'
8
+
9
+ /**
10
+ * @alpha
11
+ */
12
+ export type MarkdownBehaviorsConfig = {
13
+ mapDefaultStyle: (schema: PortableTextMemberSchemaTypes) => string | undefined
14
+ mapHeadingStyle: (
15
+ schema: PortableTextMemberSchemaTypes,
16
+ level: number,
17
+ ) => string | undefined
18
+ mapBlockquoteStyle: (
19
+ schema: PortableTextMemberSchemaTypes,
20
+ ) => string | undefined
21
+ mapUnorderedListStyle: (
22
+ schema: PortableTextMemberSchemaTypes,
23
+ ) => string | undefined
24
+ mapOrderedListStyle: (
25
+ schema: PortableTextMemberSchemaTypes,
26
+ ) => string | undefined
27
+ }
28
+
29
+ /**
30
+ * @alpha
31
+ */
32
+ export function createMarkdownBehaviors(config: MarkdownBehaviorsConfig) {
33
+ const automaticStyleOnSpace = defineBehavior({
34
+ on: 'insert text',
35
+ guard: ({context, event}) => {
36
+ const isSpace = event.text === ' '
37
+
38
+ if (!isSpace) {
39
+ return false
40
+ }
41
+
42
+ const selectionCollapsed = selectionIsCollapsed(context)
43
+ const focusTextBlock = getFocusTextBlock(context)
44
+ const focusSpan = getFocusSpan(context)
45
+
46
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
47
+ return false
48
+ }
49
+
50
+ const looksLikeMarkdownHeading = /^#+/.test(focusSpan.node.text)
51
+ const headingStyle = config.mapHeadingStyle(
52
+ context.schema,
53
+ focusSpan.node.text.length,
54
+ )
55
+
56
+ const looksLikeMarkdownQuote = /^>/.test(focusSpan.node.text)
57
+ const blockquoteStyle = config.mapBlockquoteStyle(context.schema)
58
+
59
+ if (looksLikeMarkdownHeading && headingStyle !== undefined) {
60
+ return {focusTextBlock, focusSpan, style: headingStyle}
61
+ }
62
+
63
+ if (looksLikeMarkdownQuote && blockquoteStyle !== undefined) {
64
+ return {focusTextBlock, focusSpan, style: blockquoteStyle}
65
+ }
66
+
67
+ return false
68
+ },
69
+ actions: [
70
+ () => [
71
+ {
72
+ type: 'insert text',
73
+ text: ' ',
74
+ },
75
+ ],
76
+ (_, {focusTextBlock, focusSpan, style}) => [
77
+ {
78
+ type: 'set block',
79
+ style,
80
+ paths: [focusTextBlock.path],
81
+ },
82
+ {
83
+ type: 'delete',
84
+ selection: {
85
+ anchor: {path: focusSpan.path, offset: 0},
86
+ focus: {
87
+ path: focusSpan.path,
88
+ offset: focusSpan.node.text.length + 1,
89
+ },
90
+ },
91
+ },
92
+ ],
93
+ ],
94
+ })
95
+ const clearStyleOnBackspace = defineBehavior({
96
+ on: 'delete backward',
97
+ guard: ({context}) => {
98
+ const selectionCollapsed = selectionIsCollapsed(context)
99
+ const focusTextBlock = getFocusTextBlock(context)
100
+ const focusSpan = getFocusSpan(context)
101
+
102
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
103
+ return false
104
+ }
105
+
106
+ const defaultStyle = config.mapDefaultStyle(context.schema)
107
+
108
+ if (
109
+ defaultStyle &&
110
+ focusTextBlock.node.children.length === 1 &&
111
+ focusTextBlock.node.style !== config.mapDefaultStyle(context.schema) &&
112
+ focusSpan.node.text === ''
113
+ ) {
114
+ return {defaultStyle, focusTextBlock}
115
+ }
116
+
117
+ return false
118
+ },
119
+ actions: [
120
+ (_, {defaultStyle, focusTextBlock}) => [
121
+ {
122
+ type: 'set block',
123
+ style: defaultStyle,
124
+ paths: [focusTextBlock.path],
125
+ },
126
+ ],
127
+ ],
128
+ })
129
+
130
+ const automaticListOnSpace = defineBehavior({
131
+ on: 'insert text',
132
+ guard: ({context, event}) => {
133
+ const isSpace = event.text === ' '
134
+
135
+ if (!isSpace) {
136
+ return false
137
+ }
138
+
139
+ const selectionCollapsed = selectionIsCollapsed(context)
140
+ const focusTextBlock = getFocusTextBlock(context)
141
+ const focusSpan = getFocusSpan(context)
142
+
143
+ if (!selectionCollapsed || !focusTextBlock || !focusSpan) {
144
+ return false
145
+ }
146
+
147
+ const looksLikeUnorderedList = /^-/.test(focusSpan.node.text)
148
+ const unorderedListStyle = config.mapUnorderedListStyle(context.schema)
149
+
150
+ if (looksLikeUnorderedList && unorderedListStyle !== undefined) {
151
+ return {focusTextBlock, focusSpan, listItem: unorderedListStyle}
152
+ }
153
+
154
+ const looksLikeOrderedList = /^1./.test(focusSpan.node.text)
155
+ const orderedListStyle = config.mapOrderedListStyle(context.schema)
156
+
157
+ if (looksLikeOrderedList && orderedListStyle !== undefined) {
158
+ return {focusTextBlock, focusSpan, listItem: orderedListStyle}
159
+ }
160
+
161
+ return false
162
+ },
163
+ actions: [
164
+ () => [
165
+ {
166
+ type: 'insert text',
167
+ text: ' ',
168
+ },
169
+ ],
170
+ (_, {focusTextBlock, focusSpan, listItem}) => [
171
+ {
172
+ type: 'unset block',
173
+ props: ['style'],
174
+ paths: [focusTextBlock.path],
175
+ },
176
+ {
177
+ type: 'set block',
178
+ listItem,
179
+ level: 1,
180
+ paths: [focusTextBlock.path],
181
+ },
182
+ {
183
+ type: 'delete',
184
+ selection: {
185
+ anchor: {path: focusSpan.path, offset: 0},
186
+ focus: {
187
+ path: focusSpan.path,
188
+ offset: focusSpan.node.text.length + 1,
189
+ },
190
+ },
191
+ },
192
+ ],
193
+ ],
194
+ })
195
+
196
+ const markdownBehaviors = [
197
+ automaticStyleOnSpace,
198
+ clearStyleOnBackspace,
199
+ automaticListOnSpace,
200
+ ]
201
+
202
+ return markdownBehaviors
203
+ }
@@ -1,7 +1,6 @@
1
1
  import {useEffect, useMemo, useState, type PropsWithChildren} from 'react'
2
2
  import {createEditor} from 'slate'
3
3
  import {Slate, withReact} from 'slate-react'
4
- import type {PatchObservable} from '../../types/editor'
5
4
  import {debugWithName} from '../../utils/debug'
6
5
  import {KEY_TO_SLATE_ELEMENT, KEY_TO_VALUE_ELEMENT} from '../../utils/weakMaps'
7
6
  import type {EditorActor} from '../editor-machine'
@@ -16,7 +15,6 @@ const debug = debugWithName('component:PortableTextEditor:SlateContainer')
16
15
  export interface SlateContainerProps extends PropsWithChildren {
17
16
  editorActor: EditorActor
18
17
  maxBlocks: number | undefined
19
- patches$?: PatchObservable
20
18
  portableTextEditor: PortableTextEditor
21
19
  readOnly: boolean
22
20
  }
@@ -26,7 +24,7 @@ export interface SlateContainerProps extends PropsWithChildren {
26
24
  * @internal
27
25
  */
28
26
  export function SlateContainer(props: SlateContainerProps) {
29
- const {editorActor, patches$, portableTextEditor, readOnly, maxBlocks} = props
27
+ const {editorActor, portableTextEditor, readOnly, maxBlocks} = props
30
28
 
31
29
  // Create the slate instance, using `useState` ensures setup is only run once, initially
32
30
  const [[slateEditor, subscribe]] = useState(() => {
@@ -34,7 +32,6 @@ export function SlateContainer(props: SlateContainerProps) {
34
32
  const {editor, subscribe: _sub} = withPlugins(withReact(createEditor()), {
35
33
  editorActor,
36
34
  maxBlocks,
37
- patches$,
38
35
  portableTextEditor,
39
36
  readOnly,
40
37
  })
@@ -56,18 +53,10 @@ export function SlateContainer(props: SlateContainerProps) {
56
53
  withPlugins(slateEditor, {
57
54
  editorActor,
58
55
  maxBlocks,
59
- patches$,
60
56
  portableTextEditor,
61
57
  readOnly,
62
58
  })
63
- }, [
64
- editorActor,
65
- portableTextEditor,
66
- maxBlocks,
67
- readOnly,
68
- patches$,
69
- slateEditor,
70
- ])
59
+ }, [editorActor, portableTextEditor, maxBlocks, readOnly, slateEditor])
71
60
 
72
61
  const initialValue = useMemo(() => {
73
62
  return [slateEditor.pteCreateTextBlock({decorators: []})]