@portabletext/editor 1.0.9 → 1.0.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.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -42,7 +42,7 @@
42
42
  "src"
43
43
  ],
44
44
  "dependencies": {
45
- "@portabletext/patches": "1.0.2",
45
+ "@portabletext/patches": "1.1.0",
46
46
  "debug": "^4.3.4",
47
47
  "is-hotkey-esm": "^1.0.0",
48
48
  "lodash": "^4.17.21",
@@ -50,32 +50,31 @@
50
50
  "slate-react": "0.101.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@babel/plugin-proposal-class-properties": "^7.18.6",
54
53
  "@jest/globals": "^29.7.0",
55
- "@playwright/test": "1.45.0",
54
+ "@playwright/test": "1.45.3",
56
55
  "@portabletext/toolkit": "^2.0.15",
57
- "@sanity/block-tools": "^3.48.1",
56
+ "@sanity/block-tools": "^3.52.4",
58
57
  "@sanity/diff-match-patch": "^3.1.1",
59
58
  "@sanity/eslint-config-i18n": "^1.1.0",
60
59
  "@sanity/eslint-config-studio": "^4.0.0",
61
- "@sanity/pkg-utils": "^6.10.0",
62
- "@sanity/schema": "^3.48.1",
60
+ "@sanity/pkg-utils": "^6.10.8",
61
+ "@sanity/schema": "^3.52.4",
63
62
  "@sanity/test": "0.0.1-alpha.1",
64
- "@sanity/types": "^3.48.1",
65
- "@sanity/ui": "^2.5.0",
66
- "@sanity/util": "^3.48.1",
63
+ "@sanity/types": "^3.52.4",
64
+ "@sanity/ui": "^2.8.8",
65
+ "@sanity/util": "^3.52.4",
67
66
  "@testing-library/react": "^13.4.0",
68
67
  "@types/debug": "^4.1.5",
69
68
  "@types/express": "^4.17.21",
70
69
  "@types/express-ws": "^3.0.4",
71
- "@types/lodash": "^4.17.5",
70
+ "@types/lodash": "^4.17.7",
72
71
  "@types/node": "^18.19.8",
73
72
  "@types/node-ipc": "^9.2.3",
74
73
  "@types/react": "^18.3.3",
75
74
  "@types/react-dom": "^18.3.0",
76
- "@types/ws": "~8.5.10",
77
- "@typescript-eslint/eslint-plugin": "^7.14.1",
78
- "@typescript-eslint/parser": "^7.14.1",
75
+ "@types/ws": "~8.5.11",
76
+ "@typescript-eslint/eslint-plugin": "^7.17.0",
77
+ "@typescript-eslint/parser": "^7.17.0",
79
78
  "@vitejs/plugin-react": "^4.3.1",
80
79
  "dotenv": "^16.4.5",
81
80
  "eslint": "^8.57.0",
@@ -83,11 +82,11 @@
83
82
  "eslint-config-sanity": "^7.1.2",
84
83
  "eslint-import-resolver-typescript": "^3.6.1",
85
84
  "eslint-plugin-import": "^2.29.1",
86
- "eslint-plugin-prettier": "^5.1.3",
87
- "eslint-plugin-react-compiler": "0.0.0-experimental-51a85ea-20240601",
85
+ "eslint-plugin-prettier": "^5.2.1",
86
+ "eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725",
88
87
  "eslint-plugin-tsdoc": "^0.3.0",
89
88
  "eslint-plugin-unicorn": "^54.0.0",
90
- "eslint-plugin-unused-imports": "^4.0.0",
89
+ "eslint-plugin-unused-imports": "^4.0.1",
91
90
  "express": "^4.19.2",
92
91
  "express-ws": "^5.0.2",
93
92
  "jest": "^29.7.0",
@@ -98,9 +97,9 @@
98
97
  "react": "^18.3.1",
99
98
  "react-dom": "^18.3.1",
100
99
  "rxjs": "^7.8.1",
101
- "styled-components": "^6.1.11",
102
- "tsx": "^4.15.7",
103
- "typescript": "^5.4.5",
100
+ "styled-components": "^6.1.12",
101
+ "tsx": "^4.16.2",
102
+ "typescript": "5.5.4",
104
103
  "vite": "^4.5.3"
105
104
  },
106
105
  "peerDependencies": {
@@ -36,11 +36,6 @@ import {PortableTextEditorReadOnlyContext} from './hooks/usePortableTextReadOnly
36
36
 
37
37
  const debug = debugWithName('component:PortableTextEditor')
38
38
 
39
- /**
40
- * Props for the PortableTextEditor component
41
- *
42
- * @public
43
- */
44
39
  /**
45
40
  * Props for the PortableTextEditor component
46
41
  *
@@ -115,7 +110,7 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
115
110
  super(props)
116
111
 
117
112
  if (!props.schemaType) {
118
- throw new Error('PortableTextEditor: missing "type" property')
113
+ throw new Error('PortableTextEditor: missing "schemaType" property')
119
114
  }
120
115
 
121
116
  if (props.incomingPatches$) {
@@ -0,0 +1,229 @@
1
+ import {describe, expect, jest, test} from '@jest/globals'
2
+ import {Schema} from '@sanity/schema'
3
+ import {type PortableTextBlock} from '@sanity/types'
4
+ import {render, waitFor} from '@testing-library/react'
5
+ import {createRef, type RefObject} from 'react'
6
+
7
+ import {type EditorChange, type EditorSelection} from '../../types/editor'
8
+ import {PortableTextEditable} from '../Editable'
9
+ import {PortableTextEditor} from '../PortableTextEditor'
10
+
11
+ const schema = Schema.compile({
12
+ types: [
13
+ {name: 'portable-text', type: 'array', of: [{type: 'block'}, {type: 'image'}]},
14
+ {name: 'image', type: 'object'},
15
+ ],
16
+ }).get('portable-text')
17
+
18
+ describe(PortableTextEditor.insertBlock.name, () => {
19
+ test('Scenario: Inserting a custom block without a selection #1', async () => {
20
+ const editorRef: RefObject<PortableTextEditor> = createRef()
21
+ const emptyTextBlock: PortableTextBlock = {
22
+ _key: 'ba',
23
+ _type: 'block',
24
+ children: [
25
+ {
26
+ _type: 'span',
27
+ _key: 'sa',
28
+ text: '',
29
+ marks: [],
30
+ },
31
+ ],
32
+ style: 'normal',
33
+ }
34
+ const initialValue: Array<PortableTextBlock> = [emptyTextBlock]
35
+ const onChange: (change: EditorChange) => void = jest.fn()
36
+
37
+ render(
38
+ <PortableTextEditor
39
+ ref={editorRef}
40
+ schemaType={schema}
41
+ value={initialValue}
42
+ keyGenerator={() => 'bb'}
43
+ onChange={onChange}
44
+ >
45
+ <PortableTextEditable />
46
+ </PortableTextEditor>,
47
+ )
48
+
49
+ // Given an empty text block
50
+ await waitFor(() => {
51
+ if (editorRef.current) {
52
+ expect(onChange).toHaveBeenCalledWith({type: 'value', value: initialValue})
53
+ expect(onChange).toHaveBeenCalledWith({type: 'ready'})
54
+ }
55
+ })
56
+
57
+ // And no selection
58
+ await waitFor(() => {
59
+ if (editorRef.current) {
60
+ expect(PortableTextEditor.getSelection(editorRef.current)).toBeNull()
61
+ }
62
+ })
63
+
64
+ // When a new image is inserted
65
+ await waitFor(() => {
66
+ if (editorRef.current) {
67
+ const imageBlockType = editorRef.current.schemaTypes.blockObjects.find(
68
+ (object) => object.name === 'image',
69
+ )!
70
+ PortableTextEditor.insertBlock(editorRef.current, imageBlockType)
71
+ }
72
+ })
73
+
74
+ // Then the empty text block is replaced with the new image
75
+ await waitFor(() => {
76
+ if (editorRef.current) {
77
+ expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
78
+ {_key: 'bb', _type: 'image'},
79
+ ])
80
+ }
81
+ })
82
+ })
83
+
84
+ test('Scenario: Inserting a custom block without a selection #2', async () => {
85
+ const editorRef: RefObject<PortableTextEditor> = createRef()
86
+ const nonEmptyTextBlock: PortableTextBlock = {
87
+ _key: 'ba',
88
+ _type: 'block',
89
+ children: [
90
+ {
91
+ _type: 'span',
92
+ _key: 'xs',
93
+ text: 'foo',
94
+ marks: [],
95
+ },
96
+ ],
97
+ style: 'normal',
98
+ }
99
+ const initialValue: Array<PortableTextBlock> = [nonEmptyTextBlock]
100
+ const onChange: (change: EditorChange) => void = jest.fn()
101
+
102
+ render(
103
+ <PortableTextEditor
104
+ ref={editorRef}
105
+ schemaType={schema}
106
+ value={initialValue}
107
+ keyGenerator={() => 'bb'}
108
+ onChange={onChange}
109
+ >
110
+ <PortableTextEditable />
111
+ </PortableTextEditor>,
112
+ )
113
+
114
+ // Given an non-empty text block
115
+ await waitFor(() => {
116
+ if (editorRef.current) {
117
+ expect(onChange).toHaveBeenCalledWith({type: 'value', value: initialValue})
118
+ expect(onChange).toHaveBeenCalledWith({type: 'ready'})
119
+ }
120
+ })
121
+
122
+ // And no selection
123
+ await waitFor(() => {
124
+ if (editorRef.current) {
125
+ expect(PortableTextEditor.getSelection(editorRef.current)).toBeNull()
126
+ }
127
+ })
128
+
129
+ // When a new image is inserted
130
+ await waitFor(() => {
131
+ if (editorRef.current) {
132
+ const imageBlockType = editorRef.current.schemaTypes.blockObjects.find(
133
+ (object) => object.name === 'image',
134
+ )!
135
+ PortableTextEditor.insertBlock(editorRef.current, imageBlockType)
136
+ }
137
+ })
138
+
139
+ // Then the empty text block is replaced with the new image
140
+ await waitFor(() => {
141
+ if (editorRef.current) {
142
+ expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
143
+ nonEmptyTextBlock,
144
+ {_key: 'bb', _type: 'image'},
145
+ ])
146
+ }
147
+ })
148
+ })
149
+
150
+ test('Scenario: Replacing an empty text block with a custom block', async () => {
151
+ const editorRef: RefObject<PortableTextEditor> = createRef()
152
+ const emptyTextBlock: PortableTextBlock = {
153
+ _key: 'ba',
154
+ _type: 'block',
155
+ children: [
156
+ {
157
+ _type: 'span',
158
+ _key: 'sa',
159
+ text: '',
160
+ marks: [],
161
+ },
162
+ ],
163
+ style: 'normal',
164
+ }
165
+ const imageBlock: PortableTextBlock = {
166
+ _key: 'bb',
167
+ _type: 'image',
168
+ }
169
+ const initialValue: Array<PortableTextBlock> = [emptyTextBlock, imageBlock]
170
+ const onChange: (change: EditorChange) => void = jest.fn()
171
+
172
+ render(
173
+ <PortableTextEditor
174
+ ref={editorRef}
175
+ schemaType={schema}
176
+ value={initialValue}
177
+ keyGenerator={() => 'bc'}
178
+ onChange={onChange}
179
+ >
180
+ <PortableTextEditable />
181
+ </PortableTextEditor>,
182
+ )
183
+
184
+ // Given an empty text block followed by an image
185
+ await waitFor(() => {
186
+ if (editorRef.current) {
187
+ expect(onChange).toHaveBeenCalledWith({type: 'value', value: initialValue})
188
+ expect(onChange).toHaveBeenCalledWith({type: 'ready'})
189
+ }
190
+ })
191
+
192
+ // And a selection in the empty text block
193
+ const initialSelection: EditorSelection = {
194
+ anchor: {path: [{_key: 'ba'}, 'children', {_key: 'sa'}], offset: 0},
195
+ focus: {path: [{_key: 'ba'}, 'children', {_key: 'sa'}], offset: 0},
196
+ backward: false,
197
+ }
198
+ await waitFor(() => {
199
+ if (editorRef.current) {
200
+ PortableTextEditor.select(editorRef.current, initialSelection)
201
+ }
202
+ })
203
+ await waitFor(() => {
204
+ if (editorRef.current) {
205
+ expect(onChange).toHaveBeenCalledWith({type: 'selection', selection: initialSelection})
206
+ }
207
+ })
208
+
209
+ // When a new image is inserted
210
+ await waitFor(() => {
211
+ if (editorRef.current) {
212
+ const imageBlockType = editorRef.current.schemaTypes.blockObjects.find(
213
+ (object) => object.name === 'image',
214
+ )!
215
+ PortableTextEditor.insertBlock(editorRef.current, imageBlockType)
216
+ }
217
+ })
218
+
219
+ // Then the empty text block is replaced with the new image
220
+ await waitFor(() => {
221
+ if (editorRef.current) {
222
+ expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
223
+ {_key: 'bc', _type: 'image'},
224
+ {_key: 'bb', _type: 'image'},
225
+ ])
226
+ }
227
+ })
228
+ })
229
+ })
@@ -8,6 +8,7 @@ import {type PortableTextEditor} from '../PortableTextEditor'
8
8
  export const PortableTextEditorContext = createContext<PortableTextEditor | null>(null)
9
9
 
10
10
  /**
11
+ * @public
11
12
  * Get the current editor object from the React context.
12
13
  */
13
14
  export const usePortableTextEditor = (): PortableTextEditor => {
@@ -1,6 +1,9 @@
1
1
  import {randomKey} from '@sanity/util/content'
2
2
  import {createContext, useContext} from 'react'
3
3
 
4
+ /**
5
+ * @public
6
+ */
4
7
  export const defaultKeyGenerator = (): string => randomKey(12)
5
8
 
6
9
  /**
@@ -9,6 +9,7 @@ import {debugWithName} from '../../utils/debug'
9
9
  const PortableTextEditorSelectionContext = createContext<EditorSelection | null>(null)
10
10
 
11
11
  /**
12
+ * @public
12
13
  * Get the current editor selection from the React context.
13
14
  */
14
15
  export const usePortableTextEditorSelection = (): EditorSelection => {
@@ -160,9 +160,6 @@ export function createWithEditableAPI(
160
160
  )
161
161
  },
162
162
  insertBlock: (type: SchemaType, value?: {[prop: string]: any}): Path => {
163
- if (!editor.selection) {
164
- throw new Error('The editor has no selection')
165
- }
166
163
  const block = toSlateValue(
167
164
  [
168
165
  {
@@ -173,22 +170,52 @@ export function createWithEditableAPI(
173
170
  ],
174
171
  portableTextEditor,
175
172
  )[0] as unknown as Node
176
- const [focusBlock] = Array.from(
173
+
174
+ if (!editor.selection) {
175
+ const lastBlock = Array.from(
176
+ Editor.nodes(editor, {
177
+ match: (n) => !Editor.isEditor(n),
178
+ at: [],
179
+ reverse: true,
180
+ }),
181
+ )[0]
182
+
183
+ // If there is no selection, let's just insert the new block at the
184
+ // end of the document
185
+ Editor.insertNode(editor, block)
186
+
187
+ if (lastBlock && isEqualToEmptyEditor([lastBlock[0]], types)) {
188
+ // And if the last block was an empty text block, let's remove
189
+ // that too
190
+ Transforms.removeNodes(editor, {at: lastBlock[1]})
191
+ }
192
+
193
+ editor.onChange()
194
+
195
+ return (
196
+ toPortableTextRange(
197
+ fromSlateValue(editor.children, types.block.name, KEY_TO_VALUE_ELEMENT.get(editor)),
198
+ editor.selection,
199
+ types,
200
+ )?.focus.path ?? []
201
+ )
202
+ }
203
+
204
+ const focusBlock = Array.from(
177
205
  Editor.nodes(editor, {
178
206
  at: editor.selection.focus.path.slice(0, 1),
179
207
  match: (n) => n._type === types.block.name,
180
208
  }),
181
- )[0] || [undefined]
209
+ )[0]
182
210
 
183
- const isEmptyTextBlock = focusBlock && isEqualToEmptyEditor([focusBlock], types)
211
+ Editor.insertNode(editor, block)
184
212
 
185
- if (isEmptyTextBlock) {
186
- // If the text block is empty, remove it before inserting the new block.
187
- Transforms.removeNodes(editor, {at: editor.selection})
213
+ if (focusBlock && isEqualToEmptyEditor([focusBlock[0]], types)) {
214
+ Transforms.removeNodes(editor, {at: focusBlock[1]})
188
215
  }
189
216
 
190
- Editor.insertNode(editor, block)
191
217
  editor.onChange()
218
+
192
219
  return (
193
220
  toPortableTextRange(
194
221
  fromSlateValue(editor.children, types.block.name, KEY_TO_VALUE_ELEMENT.get(editor)),
@@ -308,6 +335,10 @@ export function createWithEditableAPI(
308
335
  }),
309
336
  ]
310
337
 
338
+ if (spans.length === 0) {
339
+ return false
340
+ }
341
+
311
342
  if (
312
343
  spans.some(
313
344
  ([span]) => !isPortableTextSpan(span) || !span.marks || span.marks?.length === 0,
@@ -195,12 +195,16 @@ export function createWithHotkeys(
195
195
  ) as SlateTextBlock | VoidElement
196
196
  const focusBlockPath = editor.selection.focus.path.slice(0, 1)
197
197
  const focusBlock = Node.descendant(editor, focusBlockPath) as SlateTextBlock | VoidElement
198
+ const isTextBlock = isPortableTextTextBlock(focusBlock)
199
+ const isEmptyFocusBlock =
200
+ isTextBlock && focusBlock.children.length === 1 && focusBlock.children?.[0]?.text === ''
198
201
 
199
202
  if (
200
203
  nextBlock &&
201
204
  focusBlock &&
202
205
  !Editor.isVoid(editor, focusBlock) &&
203
- Editor.isVoid(editor, nextBlock)
206
+ Editor.isVoid(editor, nextBlock) &&
207
+ isEmptyFocusBlock
204
208
  ) {
205
209
  debug('Preventing deleting void block below')
206
210
  event.preventDefault()
@@ -375,6 +375,9 @@ export type EditorChange =
375
375
  | UnsetChange
376
376
  | ValueChange
377
377
 
378
+ /**
379
+ * @beta
380
+ */
378
381
  export type EditorChanges = Subject<EditorChange>
379
382
 
380
383
  /** @beta */
@@ -384,6 +387,10 @@ export type OnPasteResult =
384
387
  path?: Path
385
388
  }
386
389
  | undefined
390
+
391
+ /**
392
+ * @beta
393
+ */
387
394
  export type OnPasteResultOrPromise = OnPasteResult | Promise<OnPasteResult>
388
395
 
389
396
  /** @beta */
@@ -3,6 +3,9 @@ import {type BaseSyntheticEvent} from 'react'
3
3
  import {type PortableTextEditor} from '../editor/PortableTextEditor'
4
4
  import {type PatchObservable} from './editor'
5
5
 
6
+ /**
7
+ * @internal
8
+ */
6
9
  export type createEditorOptions = {
7
10
  keyGenerator: () => string
8
11
  patches$?: PatchObservable
@@ -11,6 +14,9 @@ export type createEditorOptions = {
11
14
  maxBlocks?: number
12
15
  }
13
16
 
17
+ /**
18
+ * @beta
19
+ */
14
20
  export type HotkeyOptions = {
15
21
  marks?: Record<string, string>
16
22
  custom?: Record<string, (event: BaseSyntheticEvent, editor: PortableTextEditor) => void>
@@ -1,15 +0,0 @@
1
- import {defer, EMPTY, type Observable, of, type OperatorFunction, switchMap, tap} from 'rxjs'
2
-
3
- export function bufferUntil<T>(
4
- emitWhen: (currentBuffer: T[]) => boolean,
5
- ): OperatorFunction<T, T[]> {
6
- return (source: Observable<T>) =>
7
- defer(() => {
8
- let buffer: T[] = [] // custom buffer
9
- return source.pipe(
10
- tap((v) => buffer.push(v)), // add values to buffer
11
- switchMap(() => (emitWhen(buffer) ? of(buffer) : EMPTY)), // emit the buffer when the condition is met
12
- tap(() => (buffer = [])), // clear the buffer
13
- )
14
- })
15
- }