@portabletext/editor 2.15.1 → 2.15.2

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": "2.15.1",
3
+ "version": "2.15.2",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -114,7 +114,7 @@
114
114
  "rxjs": "^7.8.2",
115
115
  "typescript": "5.9.3",
116
116
  "typescript-eslint": "^8.46.1",
117
- "vite": "^7.1.7",
117
+ "vite": "^7.1.10",
118
118
  "vitest": "^3.2.4",
119
119
  "vitest-browser-react": "^1.0.1",
120
120
  "@portabletext/sanity-bridge": "1.1.15",
@@ -193,6 +193,43 @@ export function createWithObjectKeys(editorActor: EditorActor) {
193
193
 
194
194
  editor.normalizeNode = (entry) => {
195
195
  const [node, path] = entry
196
+
197
+ if (Element.isElement(node)) {
198
+ const [parent] = Editor.parent(editor, path)
199
+
200
+ if (parent && Editor.isEditor(parent)) {
201
+ const blockKeys = new Set<string>()
202
+
203
+ for (const sibling of parent.children) {
204
+ if (sibling._key && blockKeys.has(sibling._key)) {
205
+ const _key = editorActor.getSnapshot().context.keyGenerator()
206
+
207
+ blockKeys.add(_key)
208
+
209
+ withNormalizeNode(editor, () => {
210
+ Transforms.setNodes(editor, {_key}, {at: path})
211
+ })
212
+
213
+ return
214
+ }
215
+
216
+ if (!sibling._key) {
217
+ const _key = editorActor.getSnapshot().context.keyGenerator()
218
+
219
+ blockKeys.add(_key)
220
+
221
+ withNormalizeNode(editor, () => {
222
+ Transforms.setNodes(editor, {_key}, {at: path})
223
+ })
224
+
225
+ return
226
+ }
227
+
228
+ blockKeys.add(sibling._key)
229
+ }
230
+ }
231
+ }
232
+
196
233
  if (
197
234
  Element.isElement(node) &&
198
235
  node._type === editorActor.getSnapshot().context.schema.block.name
@@ -208,18 +245,36 @@ export function createWithObjectKeys(editorActor: EditorActor) {
208
245
  })
209
246
  return
210
247
  }
211
- // Set keys on it's children
248
+
249
+ // Set unique keys on it's children
250
+ const childKeys = new Set<string>()
251
+
212
252
  for (const [child, childPath] of Node.children(editor, path)) {
253
+ if (child._key && childKeys.has(child._key)) {
254
+ const _key = editorActor.getSnapshot().context.keyGenerator()
255
+
256
+ childKeys.add(_key)
257
+
258
+ withNormalizeNode(editor, () => {
259
+ Transforms.setNodes(editor, {_key}, {at: childPath})
260
+ })
261
+
262
+ return
263
+ }
264
+
213
265
  if (!child._key) {
266
+ const _key = editorActor.getSnapshot().context.keyGenerator()
267
+
268
+ childKeys.add(_key)
269
+
214
270
  withNormalizeNode(editor, () => {
215
- Transforms.setNodes(
216
- editor,
217
- {_key: editorActor.getSnapshot().context.keyGenerator()},
218
- {at: childPath},
219
- )
271
+ Transforms.setNodes(editor, {_key}, {at: childPath})
220
272
  })
273
+
221
274
  return
222
275
  }
276
+
277
+ childKeys.add(child._key)
223
278
  }
224
279
  }
225
280
 
@@ -202,9 +202,19 @@ function setPatch(editor: PortableTextSlateEditor, patch: SetPatch) {
202
202
 
203
203
  const isTextBlock = editor.isTextBlock(block.node)
204
204
 
205
- // Ignore patches targeting nested void data, like 'markDefs'
206
- if (isTextBlock && patch.path.length > 1 && patch.path[1] !== 'children') {
207
- return false
205
+ if (isTextBlock && patch.path[1] !== 'children') {
206
+ const updatedBlock = applyAll(block.node, [
207
+ {
208
+ ...patch,
209
+ path: patch.path.slice(1),
210
+ },
211
+ ])
212
+
213
+ Transforms.setNodes(editor, updatedBlock as Partial<Node>, {
214
+ at: [block.index],
215
+ })
216
+
217
+ return true
208
218
  }
209
219
 
210
220
  const child = findBlockChild(block, patch.path)
@@ -9,7 +9,7 @@ import {
9
9
  } from '@portabletext/patches'
10
10
  import {isSpan, isTextBlock} from '@portabletext/schema'
11
11
  import type {Path, PortableTextSpan, PortableTextTextBlock} from '@sanity/types'
12
- import {get, isUndefined, omitBy} from 'lodash'
12
+ import {get} from 'lodash'
13
13
  import {
14
14
  Element,
15
15
  Text,
@@ -101,20 +101,55 @@ export function setNodePatch(
101
101
  children: Descendant[],
102
102
  operation: SetNodeOperation,
103
103
  ): Array<Patch> {
104
- if (operation.path.length === 1) {
105
- const block = children[operation.path[0]]
106
- if (typeof block._key !== 'string') {
107
- throw new Error('Expected block to have a _key')
104
+ const blockIndex = operation.path.at(0)
105
+
106
+ if (blockIndex !== undefined && operation.path.length === 1) {
107
+ const block = children.at(blockIndex)
108
+
109
+ if (!block) {
110
+ console.error('Could not find block at index', blockIndex)
111
+ return []
112
+ }
113
+
114
+ if (isTextBlock({schema}, block)) {
115
+ const patches: Patch[] = []
116
+
117
+ for (const key of Object.keys(operation.newProperties)) {
118
+ const value = (operation.newProperties as Record<string, unknown>)[key]
119
+
120
+ if (key === '_key') {
121
+ patches.push(set(value, [blockIndex, '_key']))
122
+ } else {
123
+ patches.push(set(value, [{_key: block._key}, key]))
124
+ }
125
+ }
126
+
127
+ return patches
128
+ } else {
129
+ const patches: Patch[] = []
130
+
131
+ const _key = operation.newProperties._key
132
+
133
+ if (_key !== undefined) {
134
+ patches.push(set(_key, [blockIndex, '_key']))
135
+ }
136
+
137
+ const properties =
138
+ 'value' in operation.newProperties &&
139
+ typeof operation.newProperties.value === 'object'
140
+ ? (operation.newProperties.value as Record<string, unknown>)
141
+ : ({} satisfies Record<string, unknown>)
142
+
143
+ const keys = Object.keys(properties)
144
+
145
+ for (const key of keys) {
146
+ const value = properties[key]
147
+
148
+ patches.push(set(value, [{_key: block._key}, key]))
149
+ }
150
+
151
+ return patches
108
152
  }
109
- const setNode = omitBy(
110
- {...children[operation.path[0]], ...operation.newProperties},
111
- isUndefined,
112
- ) as unknown as Descendant
113
- return [
114
- set(fromSlateValue([setNode], schema.block.name)[0], [
115
- {_key: block._key},
116
- ]),
117
- ]
118
153
  } else if (operation.path.length === 2) {
119
154
  const block = children[operation.path[0]]
120
155
  if (isTextBlock({schema}, block)) {
@@ -17,6 +17,7 @@ test(getTextSelection.name, () => {
17
17
  expect(getTextSelection({schema, value: [simpleBlock]}, 'foo')).toEqual({
18
18
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 0},
19
19
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 3},
20
+ backward: false,
20
21
  })
21
22
 
22
23
  const joinedBlock = {
@@ -28,18 +29,22 @@ test(getTextSelection.name, () => {
28
29
  expect(getTextSelection({schema, value: [joinedBlock]}, 'foo ')).toEqual({
29
30
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 0},
30
31
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 4},
32
+ backward: false,
31
33
  })
32
34
  expect(getTextSelection({schema, value: [joinedBlock]}, 'o')).toEqual({
33
35
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 1},
34
36
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 2},
37
+ backward: false,
35
38
  })
36
39
  expect(getTextSelection({schema, value: [joinedBlock]}, 'bar')).toEqual({
37
40
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 4},
38
41
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 7},
42
+ backward: false,
39
43
  })
40
44
  expect(getTextSelection({schema, value: [joinedBlock]}, ' baz')).toEqual({
41
45
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 7},
42
46
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 11},
47
+ backward: false,
43
48
  })
44
49
 
45
50
  const noSpaceBlock = {
@@ -54,6 +59,7 @@ test(getTextSelection.name, () => {
54
59
  expect(getTextSelection({schema, value: [noSpaceBlock]}, 'obar')).toEqual({
55
60
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 2},
56
61
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's2'}], offset: 3},
62
+ backward: false,
57
63
  })
58
64
 
59
65
  const emptyLineBlock = {
@@ -70,6 +76,7 @@ test(getTextSelection.name, () => {
70
76
  {
71
77
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 0},
72
78
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's3'}], offset: 3},
79
+ backward: false,
73
80
  },
74
81
  )
75
82
 
@@ -86,24 +93,29 @@ test(getTextSelection.name, () => {
86
93
  expect(getTextSelection({schema, value: [splitBlock]}, 'foo')).toEqual({
87
94
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 0},
88
95
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 3},
96
+ backward: false,
89
97
  })
90
98
  expect(getTextSelection({schema, value: [splitBlock]}, 'bar')).toEqual({
91
99
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's2'}], offset: 0},
92
100
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's2'}], offset: 3},
101
+ backward: false,
93
102
  })
94
103
  expect(getTextSelection({schema, value: [splitBlock]}, 'baz')).toEqual({
95
104
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's3'}], offset: 1},
96
105
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's3'}], offset: 4},
106
+ backward: false,
97
107
  })
98
108
  expect(
99
109
  getTextSelection({schema, value: [splitBlock]}, 'foo bar baz'),
100
110
  ).toEqual({
101
111
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 0},
102
112
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's3'}], offset: 4},
113
+ backward: false,
103
114
  })
104
115
  expect(getTextSelection({schema, value: [splitBlock]}, 'o bar b')).toEqual({
105
116
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 2},
106
117
  focus: {path: [{_key: 'b1'}, 'children', {_key: 's3'}], offset: 2},
118
+ backward: false,
107
119
  })
108
120
 
109
121
  const twoBlocks = [
@@ -122,6 +134,7 @@ test(getTextSelection.name, () => {
122
134
  expect(getTextSelection({schema, value: twoBlocks}, 'ooba')).toEqual({
123
135
  anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 1},
124
136
  focus: {path: [{_key: 'b2'}, 'children', {_key: 's2'}], offset: 2},
137
+ backward: false,
125
138
  })
126
139
  })
127
140
 
@@ -102,6 +102,7 @@ export function getTextSelection(
102
102
  return {
103
103
  anchor,
104
104
  focus,
105
+ backward: false,
105
106
  }
106
107
  }
107
108
 
@@ -1,5 +1,5 @@
1
1
  import {Transforms, type Element as SlateElement} from 'slate'
2
- import {toSlateValue} from '../internal-utils/values'
2
+ import {toSlateBlock} from '../internal-utils/values'
3
3
  import {parseBlock} from '../utils/parse-blocks'
4
4
  import type {BehaviorOperationImplementation} from './behavior.operations'
5
5
 
@@ -40,13 +40,9 @@ export const blockSetOperationImplementation: BehaviorOperationImplementation<
40
40
  throw new Error(`Unable to update block at ${JSON.stringify(operation.at)}`)
41
41
  }
42
42
 
43
- const slateBlock = toSlateValue([parsedBlock], {
43
+ const slateBlock = toSlateBlock(parsedBlock, {
44
44
  schemaTypes: context.schema,
45
- })?.at(0) as SlateElement | undefined
46
-
47
- if (!slateBlock) {
48
- throw new Error(`Unable to convert block to Slate value`)
49
- }
45
+ }) as SlateElement
50
46
 
51
47
  Transforms.setNodes(operation.editor, slateBlock, {at: [blockIndex]})
52
48
  }
@@ -6,6 +6,8 @@ import type {Editor} from '../../editor'
6
6
  */
7
7
  export type Context = {
8
8
  editor: Editor
9
+ editorB: Editor
9
10
  locator: Locator
11
+ locatorB: Locator
10
12
  keyMap?: Map<string, string>
11
13
  }
@@ -13,7 +13,7 @@ import {
13
13
  getTextSelection,
14
14
  } from '../../internal-utils/text-selection'
15
15
  import {getValueAnnotations} from '../../internal-utils/value-annotations'
16
- import {createTestEditor} from '../../test/vitest'
16
+ import {createTestEditor, createTestEditors} from '../../test/vitest'
17
17
  import {
18
18
  parseBlocks,
19
19
  parseInlineObject,
@@ -25,33 +25,45 @@ import {selectionPointToBlockOffset} from '../../utils/util.selection-point-to-b
25
25
  import type {Parameter} from '../gherkin-parameter-types'
26
26
  import type {Context} from './step-context'
27
27
 
28
+ const schemaDefinition = defineSchema({
29
+ annotations: [{name: 'comment'}, {name: 'link'}],
30
+ decorators: [{name: 'em'}, {name: 'strong'}],
31
+ blockObjects: [{name: 'image'}, {name: 'break'}],
32
+ inlineObjects: [{name: 'stock-ticker'}],
33
+ lists: [{name: 'bullet'}, {name: 'number'}],
34
+ styles: [
35
+ {name: 'normal'},
36
+ {name: 'h1'},
37
+ {name: 'h2'},
38
+ {name: 'h3'},
39
+ {name: 'h4'},
40
+ {name: 'h5'},
41
+ {name: 'h6'},
42
+ {name: 'blockquote'},
43
+ ],
44
+ })
45
+
28
46
  /**
29
47
  * @internal
30
48
  */
31
49
  export const stepDefinitions = [
32
50
  Given('one editor', async (context: Context) => {
33
51
  const {editor, locator} = await createTestEditor({
34
- schemaDefinition: defineSchema({
35
- annotations: [{name: 'comment'}, {name: 'link'}],
36
- decorators: [{name: 'em'}, {name: 'strong'}],
37
- blockObjects: [{name: 'image'}, {name: 'break'}],
38
- inlineObjects: [{name: 'stock-ticker'}],
39
- lists: [{name: 'bullet'}, {name: 'number'}],
40
- styles: [
41
- {name: 'normal'},
42
- {name: 'h1'},
43
- {name: 'h2'},
44
- {name: 'h3'},
45
- {name: 'h4'},
46
- {name: 'h5'},
47
- {name: 'h6'},
48
- {name: 'blockquote'},
49
- ],
50
- }),
52
+ schemaDefinition,
53
+ })
54
+
55
+ context.locator = locator
56
+ context.editor = editor
57
+ }),
58
+ Given('two editors', async (context: Context) => {
59
+ const {editor, locator, editorB, locatorB} = await createTestEditors({
60
+ schemaDefinition,
51
61
  })
52
62
 
53
63
  context.locator = locator
54
64
  context.editor = editor
65
+ context.locatorB = locatorB
66
+ context.editorB = editorB
55
67
  }),
56
68
 
57
69
  Given('a global keymap', (context: Context) => {
@@ -62,6 +74,10 @@ export const stepDefinitions = [
62
74
  await userEvent.click(context.locator)
63
75
  }),
64
76
 
77
+ When('Editor B is focused', async (context: Context) => {
78
+ await userEvent.click(context.locatorB)
79
+ }),
80
+
65
81
  Given(
66
82
  'the text {terse-pt}',
67
83
  (context: Context, tersePt: Parameter['tersePt']) => {
@@ -172,6 +188,12 @@ export const stepDefinitions = [
172
188
  When('{string} is typed', async (context: Context, text: string) => {
173
189
  await userEvent.type(context.locator, text)
174
190
  }),
191
+ When(
192
+ '{string} is typed by Editor B',
193
+ async (context: Context, text: string) => {
194
+ await userEvent.type(context.locatorB, text)
195
+ },
196
+ ),
175
197
  When('{string} is inserted', (context: Context, text: string) => {
176
198
  context.editor.send({type: 'insert.text', text})
177
199
  }),
@@ -276,6 +298,28 @@ export const stepDefinitions = [
276
298
  })
277
299
  },
278
300
  ),
301
+ When(
302
+ 'the caret is put after {string} by Editor B',
303
+ async (context: Context, text: string) => {
304
+ await vi.waitFor(() => {
305
+ const selection = getSelectionAfterText(
306
+ context.editorB.getSnapshot().context,
307
+ text,
308
+ )
309
+
310
+ expect(selection).not.toBeNull()
311
+
312
+ context.editorB.send({
313
+ type: 'select',
314
+ at: selection,
315
+ })
316
+
317
+ expect(context.editorB.getSnapshot().context.selection).toEqual(
318
+ selection,
319
+ )
320
+ })
321
+ },
322
+ ),
279
323
  Then(
280
324
  'the caret is after {string}',
281
325
  async (context: Context, text: string) => {
@@ -11,6 +11,7 @@ import {
11
11
  type PortableTextEditableProps,
12
12
  } from '../../editor/Editable'
13
13
  import {EditorProvider} from '../../editor/editor-provider'
14
+ import {EventListenerPlugin} from '../../plugins'
14
15
  import {EditorRefPlugin} from '../../plugins/plugin.editor-ref'
15
16
  import type {Context} from './step-context'
16
17
 
@@ -89,3 +90,96 @@ export async function createTestEditor(
89
90
  rerender,
90
91
  }
91
92
  }
93
+
94
+ /**
95
+ * @internal
96
+ */
97
+ export async function createTestEditors(
98
+ options: CreateTestEditorOptions,
99
+ ): Promise<Pick<Context, 'editor' | 'locator' | 'editorB' | 'locatorB'>> {
100
+ const editorRef = React.createRef<Editor>()
101
+ const editorBRef = React.createRef<Editor>()
102
+ const keyGenerator = options.keyGenerator ?? createTestKeyGenerator()
103
+
104
+ render(
105
+ <>
106
+ <EditorProvider
107
+ initialConfig={{
108
+ keyGenerator: keyGenerator,
109
+ schemaDefinition: options.schemaDefinition ?? defineSchema({}),
110
+ initialValue: options.initialValue,
111
+ }}
112
+ >
113
+ <EditorRefPlugin ref={editorRef} />
114
+ <PortableTextEditable
115
+ {...options.editableProps}
116
+ data-testid="editor-a"
117
+ />
118
+ <EventListenerPlugin
119
+ on={(event) => {
120
+ if (event.type === 'mutation') {
121
+ editorBRef.current?.send({
122
+ type: 'patches',
123
+ patches: event.patches.map((patch) => ({
124
+ ...patch,
125
+ origin: 'remote',
126
+ })),
127
+ snapshot: event.snapshot,
128
+ })
129
+ editorBRef.current?.send({
130
+ type: 'update value',
131
+ value: event.value,
132
+ })
133
+ }
134
+ }}
135
+ />
136
+ {options.children}
137
+ </EditorProvider>
138
+ <EditorProvider
139
+ initialConfig={{
140
+ keyGenerator: keyGenerator,
141
+ schemaDefinition: options.schemaDefinition ?? defineSchema({}),
142
+ initialValue: options.initialValue,
143
+ }}
144
+ >
145
+ <EditorRefPlugin ref={editorBRef} />
146
+ <PortableTextEditable
147
+ {...options.editableProps}
148
+ data-testid="editor-b"
149
+ />
150
+ <EventListenerPlugin
151
+ on={(event) => {
152
+ if (event.type === 'mutation') {
153
+ editorRef.current?.send({
154
+ type: 'patches',
155
+ patches: event.patches.map((patch) => ({
156
+ ...patch,
157
+ origin: 'remote',
158
+ })),
159
+ snapshot: event.value,
160
+ })
161
+ editorRef.current?.send({
162
+ type: 'update value',
163
+ value: event.value,
164
+ })
165
+ }
166
+ }}
167
+ />
168
+ {options.children}
169
+ </EditorProvider>
170
+ </>,
171
+ )
172
+
173
+ const locator = page.getByTestId('editor-a')
174
+ const locatorB = page.getByTestId('editor-b')
175
+
176
+ await vi.waitFor(() => expect.element(locator).toBeInTheDocument())
177
+ await vi.waitFor(() => expect.element(locatorB).toBeInTheDocument())
178
+
179
+ return {
180
+ editor: editorRef.current!,
181
+ locator,
182
+ editorB: editorBRef.current!,
183
+ locatorB,
184
+ }
185
+ }