diff --git a/.changeset/insert-blocks-inherit-active-decorators.md b/.changeset/insert-blocks-inherit-active-decorators.md new file mode 100644 index 0000000000..433abe5b78 --- /dev/null +++ b/.changeset/insert-blocks-inherit-active-decorators.md @@ -0,0 +1,9 @@ +--- +"@portabletext/editor": patch +--- + +fix: inherit active decorators when `insert.blocks` merges a fragment span at a caret in a text block + +When the abstract `text/plain` deserializer raises `insert.span`, the pasted text inherits the caret's active decorators. Consumer behaviors that bypass that path - for example a markdown deserializer that catches `deserialize.data` and raises `deserialization.success` directly with a block whose spans carry empty marks - went through the engine's fragment-merge path, which dropped the caret context and inserted the spans bare. Pasting inside a bold span would land the new text unbolded. + +The fragment-merge path now copies the decorators of the span at the caret onto any fragment span with no marks, matching the abstract `insert.span` behavior. Spans that arrive with their own marks are left alone. Annotation refs are not inherited; those continue to be threaded explicitly via `event.annotations`. diff --git a/packages/editor/src/operations/operation.insert.block.ts b/packages/editor/src/operations/operation.insert.block.ts index dc91c78d12..89139afaae 100644 --- a/packages/editor/src/operations/operation.insert.block.ts +++ b/packages/editor/src/operations/operation.insert.block.ts @@ -793,6 +793,13 @@ function adjustFragmentKeys(args: { * Insert a text block's children at a point within another text block. * Prepending a single span at offset 0 uses insert_text so React's DOM * selection stays valid through deferred normalization. + * + * Spans in the fragment with no marks inherit the decorators of the span at + * the caret position. This matches the abstract `text/plain` -> `insert.span` + * behavior that consumer-deserializer plugins bypass when they raise + * `deserialization.success` directly. The fragment's annotation keys are not + * inherited; those are threaded explicitly via `event.annotations` on the + * `insert.span` pathway. */ function insertFragmentChildren( editor: PortableTextSlateEditor, @@ -814,6 +821,20 @@ function insertFragmentChildren( const parent = parentPath(at.path) let firstInsertedKey: string | undefined + // Resolve the caret span's decorators so empty-marks spans in the fragment + // can inherit them. The caret span sits at `at.path` whether or not we + // just split (split leaves the left half at the original path). + const caretSpanEntry = getSpanNode(editor, at.path) + const decoratorNames = new Set( + editor.schema.decorators.map((decorator) => decorator.name), + ) + const inheritedDecorators = + caretSpanEntry && + isSpan({schema: editor.schema}, caretSpanEntry.node) && + Array.isArray(caretSpanEntry.node.marks) + ? caretSpanEntry.node.marks.filter((mark) => decoratorNames.has(mark)) + : [] + if (at.offset === 0 && block.children.length === 1) { const firstChild = block.children[0]! const existingEntry = getSpanNode(editor, at.path) @@ -834,10 +855,17 @@ function insertFragmentChildren( let insertAfterPath = at.path for (const child of block.children) { + const inheritedChild = + inheritedDecorators.length > 0 && + isSpan({schema: editor.schema}, child) && + (!Array.isArray(child.marks) || child.marks.length === 0) + ? {...child, marks: [...inheritedDecorators]} + : child + const operation: InsertOperation = { type: 'insert', path: insertAfterPath, - node: child, + node: inheritedChild, position: at.offset > 0 || child !== block.children[0] ? 'after' : 'before', } diff --git a/packages/editor/tests/text-plain-paste.test.tsx b/packages/editor/tests/text-plain-paste.test.tsx index f9d5604916..bb39e7c40c 100644 --- a/packages/editor/tests/text-plain-paste.test.tsx +++ b/packages/editor/tests/text-plain-paste.test.tsx @@ -178,4 +178,106 @@ describe('`text/plain` paste', () => { ) }) }) + + test('Scenario: Pasted `text/plain` inherits formatting even when a consumer behavior raises `deserialization.success` directly', async () => { + // Mirror the playground's markdown-deserializer pattern: a consumer + // behavior catches `deserialize.data` for `text/plain` and emits + // `deserialization.success` with a bare text block. This bypasses the + // engine's abstract `text/plain` -> `insert.span` behavior that inherits + // active marks. The engine should still inherit active marks when the + // resulting `insert.blocks` merges a bare span at a caret in a text block. + const keyGenerator = createTestKeyGenerator() + + const consumerDeserializer = defineBehavior({ + on: 'deserialize.data', + guard: ({event}) => { + if (event.mimeType !== 'text/plain') { + return false + } + + return { + blocks: [ + { + _type: 'block', + _key: keyGenerator(), + children: [ + { + _type: 'span', + _key: keyGenerator(), + text: event.data, + marks: [], + }, + ], + style: 'normal', + markDefs: [], + }, + ], + } + }, + actions: [ + ({event}, {blocks}) => [ + raise({ + type: 'deserialization.success', + data: blocks, + mimeType: event.mimeType, + originEvent: event.originEvent, + }), + ], + ], + }) + + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: { + decorators: [{name: 'strong'}], + }, + children: , + initialValue: [ + { + _key: keyGenerator(), + _type: 'block', + children: [ + {_key: keyGenerator(), _type: 'span', text: 'foo ', marks: []}, + { + _key: keyGenerator(), + _type: 'span', + text: 'bar', + marks: ['strong'], + }, + {_key: keyGenerator(), _type: 'span', text: ' buz', marks: []}, + ], + style: 'normal', + markDefs: [], + }, + ], + }) + + await userEvent.click(locator) + + // Place caret inside the bold span, after "ba" + await vi.waitFor(() => { + const selection = getSelectionAfterText( + editor.getSnapshot().context, + 'ba', + ) + editor.send({type: 'select', at: selection}) + expect(editor.getSnapshot().context.selection).toEqual(selection) + }) + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'new') + editor.send({ + type: 'clipboard.paste', + originEvent: {dataTransfer}, + position: { + selection: editor.getSnapshot().context.selection!, + }, + }) + + await vi.waitFor(() => { + expect(toTextspec(editor.getSnapshot().context)).toEqual( + 'B: foo [strong:banew|r] buz', + ) + }) + }) })