Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/insert-blocks-inherit-active-decorators.md
Original file line number Diff line number Diff line change
@@ -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`.
30 changes: 29 additions & 1 deletion packages/editor/src/operations/operation.insert.block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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',
}
Expand Down
102 changes: 102 additions & 0 deletions packages/editor/tests/text-plain-paste.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <BehaviorPlugin behaviors={[consumerDeserializer]} />,
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',
)
})
})
})
Loading