diff --git a/.changeset/legal-bottles-love.md b/.changeset/legal-bottles-love.md new file mode 100644 index 000000000..a9e73ba4b --- /dev/null +++ b/.changeset/legal-bottles-love.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix: handle incoming `set` patches targeting text blocks diff --git a/packages/editor/src/internal-utils/applyPatch.ts b/packages/editor/src/internal-utils/applyPatch.ts index a45e05abb..ec2c8f18e 100644 --- a/packages/editor/src/internal-utils/applyPatch.ts +++ b/packages/editor/src/internal-utils/applyPatch.ts @@ -16,7 +16,7 @@ import { parsePatch, } from '@sanity/diff-match-patch' import type {Path, PortableTextBlock, PortableTextChild} from '@sanity/types' -import {Element, Node, Text, Transforms, type Descendant} from 'slate' +import {Editor, Element, Node, Text, Transforms, type Descendant} from 'slate' import type {EditorSchema} from '../editor/editor-schema' import {KEY_TO_SLATE_ELEMENT} from '../editor/weakMaps' import type {PortableTextSlateEditor} from '../types/editor' @@ -202,6 +202,51 @@ function setPatch(editor: PortableTextSlateEditor, patch: SetPatch) { const isTextBlock = editor.isTextBlock(block.node) + if (patch.path.length === 1) { + const updatedBlock = applyAll(block.node, [ + { + ...patch, + path: patch.path.slice(1), + }, + ]) + + if (editor.isTextBlock(block.node) && Element.isElement(updatedBlock)) { + Transforms.setNodes(editor, updatedBlock, {at: [block.index]}) + + const previousSelection = editor.selection + + // Remove the previous children + for (const [_, childPath] of Editor.nodes(editor, { + at: [block.index], + reverse: true, + mode: 'lowest', + })) { + Transforms.removeNodes(editor, {at: childPath}) + } + + // Insert the new children + Transforms.insertNodes(editor, updatedBlock.children, { + at: [block.index, 0], + }) + + // Restore the selection + if (previousSelection) { + // Update the selection on the editor object + Transforms.setSelection(editor, previousSelection) + // Actively select the previous selection + Transforms.select(editor, previousSelection) + } + + return true + } else { + Transforms.setNodes(editor, updatedBlock as Partial, { + at: [block.index], + }) + + return true + } + } + if (isTextBlock && patch.path[1] !== 'children') { const updatedBlock = applyAll(block.node, [ { diff --git a/packages/editor/tests/event.patches.test.tsx b/packages/editor/tests/event.patches.test.tsx index 878ad8482..fb8a88f60 100644 --- a/packages/editor/tests/event.patches.test.tsx +++ b/packages/editor/tests/event.patches.test.tsx @@ -1911,4 +1911,90 @@ describe('event.patches', () => { }) }) }) + + test('Scenario: `set` block with new markDef', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const fooKey = keyGenerator() + const barKey = keyGenerator() + const linkKey = keyGenerator() + const newLinkKey = keyGenerator() + + // Given the text foo,bar + // And a link around "foo" + const {editor, locator} = await createTestEditor({ + keyGenerator, + initialValue: [ + { + _key: blockKey, + _type: 'block', + children: [ + {_key: fooKey, _type: 'span', text: 'foo', marks: [linkKey]}, + {_key: barKey, _type: 'span', text: 'bar', marks: []}, + ], + markDefs: [{_key: linkKey, _type: 'link'}], + style: 'normal', + }, + ], + schemaDefinition: defineSchema({ + annotations: [{name: 'link'}], + styles: [{name: 'normal'}, {name: 'h1'}], + }), + }) + + // When the cursor is put after "foo b" + await userEvent.click(locator) + const midBarSelection = { + anchor: { + path: [{_key: blockKey}, 'children', {_key: barKey}], + offset: 1, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: barKey}], + offset: 1, + }, + backward: false, + } + editor.send({ + type: 'select', + at: midBarSelection, + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual(midBarSelection) + }) + + // And the block is replaced with a new block with different link _key + const newBlock = { + _key: blockKey, + _type: 'block', + children: [ + {_key: fooKey, _type: 'span', text: 'foo', marks: [newLinkKey]}, + {_key: barKey, _type: 'span', text: 'bar', marks: []}, + ], + markDefs: [{_key: newLinkKey, _type: 'link'}], + style: 'normal', + } + editor.send({ + type: 'patches', + patches: [ + { + origin: 'remote', + type: 'set', + path: [{_key: blockKey}], + value: newBlock, + }, + ], + snapshot: [newBlock], + }) + + // Then the block is replaced with the new block + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([newBlock]) + }) + + // And the selection is restored + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual(midBarSelection) + }) + }) })