diff --git a/.changeset/dedupe-identical-markdefs.md b/.changeset/dedupe-identical-markdefs.md new file mode 100644 index 0000000000..a1e63cac82 --- /dev/null +++ b/.changeset/dedupe-identical-markdefs.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: dedupe identical same-key markDefs on every block merge + +Splitting a block through the middle of an annotation and merging it back together rejoins the annotation into a single definition, instead of leaving two identical definitions under different keys. Backspace and forward delete at a block boundary get the same treatment: an annotation definition arriving in a block that already holds an identical one under the same `_key` keeps its key instead of being renamed, so anything tracking the annotation keeps following it. diff --git a/.changeset/identical-markdefs-non-conflicts.md b/.changeset/identical-markdefs-non-conflicts.md new file mode 100644 index 0000000000..8a4f110fb3 --- /dev/null +++ b/.changeset/identical-markdefs-non-conflicts.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: treat identical same-key markDefs as non-conflicts when inserting text block fragments + +Inserting a text block into another (pasting into a block, for example) no longer renames an annotation definition when the destination already carries an identical one under the same `_key`. The definition keeps its key, so anything tracking the annotation (comments, decorations) keeps following it. diff --git a/packages/editor/gherkin-spec/annotations-across-blocks.feature b/packages/editor/gherkin-spec/annotations-across-blocks.feature index 02f9852b7b..f34fbd246e 100644 --- a/packages/editor/gherkin-spec/annotations-across-blocks.feature +++ b/packages/editor/gherkin-spec/annotations-across-blocks.feature @@ -109,9 +109,6 @@ Feature: Annotations Across Blocks B: baz """ - # Warning: Possible wrong behaviour - # "foo" and "bar" should rejoin as one link - # Fixing this is possibly a breaking change Scenario: Splitting and merging an annotation across blocks Given the editor state is "B: foobar" And a "link" "l1" around "foobar" @@ -121,7 +118,7 @@ Feature: Annotations Across Blocks And "{Backspace}" is pressed Then the editor state is """ - B: [@link _key="l1":foo][@link _key="l2":bar] + B: [@link _key="l1":foobar] """ # Warning: Possible wrong behaviour diff --git a/packages/editor/src/behaviors/behavior.abstract.delete.ts b/packages/editor/src/behaviors/behavior.abstract.delete.ts index ebaedbda59..1fd480e59e 100644 --- a/packages/editor/src/behaviors/behavior.abstract.delete.ts +++ b/packages/editor/src/behaviors/behavior.abstract.delete.ts @@ -387,10 +387,6 @@ function planMergeKeyRenameActions(args: { context, mergingBlock, destinationBlock, - // `renamedBlock` feeds `insert.block`, which parses the block - // standalone and strips any mark that doesn't resolve in its own - // `markDefs`, so a deduped def's spans would lose the annotation. - dedupeEqualMarkDefs: false, }) const renameActions: Array = [] diff --git a/packages/editor/src/internal-utils/delete-internal.ts b/packages/editor/src/internal-utils/delete-internal.ts index 55ce138938..0fcd704a7f 100644 --- a/packages/editor/src/internal-utils/delete-internal.ts +++ b/packages/editor/src/internal-utils/delete-internal.ts @@ -766,12 +766,12 @@ function mergeBlock( // The span holding the range's start point survives as an empty span // until normalization runs, so it still occupies `startBlock`'s children // and is a genuine collision source the plan must include. - const {renamedBlock, childRenames, markDefRenames} = planMergeKeyRenames({ - context: editor.snapshot.context, - mergingBlock: endBlock.node, - destinationBlock: startBlock.node, - dedupeEqualMarkDefs: true, - }) + const {renamedBlock, childRenames, markDefRenames, dedupedMarkDefKeys} = + planMergeKeyRenames({ + context: editor.snapshot.context, + mergingBlock: endBlock.node, + destinationBlock: startBlock.node, + }) for (const {markDefKey, newKey} of markDefRenames) { // `getNode` can't resolve a keyed descent into `markDefs` (it's a @@ -791,7 +791,9 @@ function mergeBlock( ]) } - const endMarkDefs = renamedBlock.markDefs + const endMarkDefs = renamedBlock.markDefs?.filter( + (markDef) => !dedupedMarkDefKeys.includes(markDef._key), + ) if (Array.isArray(endMarkDefs) && endMarkDefs.length > 0) { const oldDefs = startBlock.node.markDefs ?? [] setNodeProperties( diff --git a/packages/editor/src/internal-utils/equality.ts b/packages/editor/src/internal-utils/equality.ts index 00b0c38581..e3613d6a45 100644 --- a/packages/editor/src/internal-utils/equality.ts +++ b/packages/editor/src/internal-utils/equality.ts @@ -264,7 +264,7 @@ function isEqualPortableTextObjects( /** * More or less copied from Remeda (https://github.com/remeda/remeda/blob/main/packages/remeda/src/isDeepEqual.ts) */ -export function isDeepEqual(data: A, other: B) { +export function isDeepEqual(data: A, other: B): boolean { return isDeepEqualImplementation(data, other) } diff --git a/packages/editor/src/internal-utils/plan-merge-key-renames.ts b/packages/editor/src/internal-utils/plan-merge-key-renames.ts index 9b9ecd60d0..ea5cf58feb 100644 --- a/packages/editor/src/internal-utils/plan-merge-key-renames.ts +++ b/packages/editor/src/internal-utils/plan-merge-key-renames.ts @@ -27,11 +27,21 @@ export type MergeMarkDefRename = { * `destinationBlock` by key. Any child key the merging block shares with * the destination has to be renamed before the merge, or the engine's own * collision handling mints a fresh key for it, which reads on the wire as - * that node being destroyed and a new one created instead of moved. There - * is no such backstop for markDefs: an unrenamed collision either loses - * one of the two defs to last-wins replacement, or, on the `insert.block` - * path, gets silently re-minted by `adjustFragmentKeys` (see - * `dedupeEqualMarkDefs` below). + * that node being destroyed and a new one created instead of moved. + * + * A colliding markDef that is deeply equal to the destination's is left + * unrenamed and reported in `dedupedMarkDefKeys` instead: the merge keeps + * a single copy under the original `_key` rather than duplicating + * identical content under two keys. A colliding markDef that differs gets + * renamed like a child would. + * + * `renamedBlock` keeps every deduped def in its own `markDefs`, even + * though the destination already carries it: `renamedBlock` feeds + * `insert.block` on some callers' paths, and that path parses the block + * standalone, stripping any mark that doesn't resolve in the block's own + * `markDefs`. A caller that appends `renamedBlock.markDefs` onto the + * destination's must skip the keys listed in `dedupedMarkDefKeys`, or the + * def duplicates. * * Pure: computes the renames and the block they produce without touching * an editor. Callers raise or apply the renames themselves, ahead of the @@ -41,26 +51,13 @@ export function planMergeKeyRenames(args: { context: {schema: Schema; keyGenerator: () => string} mergingBlock: PortableTextTextBlock destinationBlock: PortableTextTextBlock - /** - * When a merging markDef collides with a destination markDef that is - * deeply equal to it, drop the merging markDef (from `renamedBlock` and - * from the emitted renames) instead of renaming it, so the merge keeps - * a single copy under the original `_key` rather than duplicating - * identical content under two keys. - * - * Must be `false` when `renamedBlock` feeds `insert.block`: that path - * parses the block standalone, and `parseBlock`/`parseSpan` strip any - * mark that doesn't resolve in the block's own `markDefs`, so a span - * whose annotation was deduped away would arrive with the mark gone - * rather than re-keyed. - */ - dedupeEqualMarkDefs: boolean }): { renamedBlock: PortableTextTextBlock childRenames: Array markDefRenames: Array + dedupedMarkDefKeys: Array } { - const {context, mergingBlock, destinationBlock, dedupeEqualMarkDefs} = args + const {context, mergingBlock, destinationBlock} = args const destinationChildKeys = new Set( destinationBlock.children.map((child) => child._key), @@ -70,14 +67,16 @@ export function planMergeKeyRenames(args: { ) const markDefKeyMap = new Map() + const dedupedMarkDefKeys: Array = [] const renamedMarkDefs = mergingBlock.markDefs?.flatMap((markDef) => { const destinationMarkDef = destinationMarkDefsByKey.get(markDef._key) if (!destinationMarkDef) { return [markDef] } - if (dedupeEqualMarkDefs && isDeepEqual(markDef, destinationMarkDef)) { - return [] + if (isDeepEqual(markDef, destinationMarkDef)) { + dedupedMarkDefKeys.push(markDef._key) + return [markDef] } const newKey = context.keyGenerator() @@ -132,5 +131,6 @@ export function planMergeKeyRenames(args: { }, childRenames, markDefRenames, + dedupedMarkDefKeys, } } diff --git a/packages/editor/src/operations/operation.insert.block.ts b/packages/editor/src/operations/operation.insert.block.ts index f09ccdc839..217dac3b2c 100644 --- a/packages/editor/src/operations/operation.insert.block.ts +++ b/packages/editor/src/operations/operation.insert.block.ts @@ -20,7 +20,11 @@ import {applyInsertNodeAtPath} from '../internal-utils/apply-insert-node' import {applySelect, resolveSelection} from '../internal-utils/apply-selection' import {applySplitNode} from '../internal-utils/apply-split-node' import {deleteRange} from '../internal-utils/delete-range' -import {isEqualChildren, isEqualMarks} from '../internal-utils/equality' +import { + isDeepEqual, + isEqualChildren, + isEqualMarks, +} from '../internal-utils/equality' import {setNodeProperties} from '../internal-utils/set-node-properties' import {toEngineBlock} from '../internal-utils/values' import {getEnclosingBlock} from '../traversal/get-enclosing-block' @@ -728,8 +732,12 @@ function resolveChildIndex( /** * Reassign keys on spans, inline objects and markDefs that collide with the - * end block's existing keys. Returns the adjusted block and the adjusted - * markDefs (to be merged into the end block). + * end block's existing keys. A colliding markDef that is deeply equal to + * the end block's is left out of the returned `adjustedMarkDefs` instead + * of renamed, since the end block already carries it; its key isn't + * remapped, so the fragment's marks keep pointing at it unchanged. The + * caller must merge the returned `adjustedMarkDefs` into the end block's + * own `markDefs`, or the deduped defs are lost. */ function adjustFragmentKeys(args: { context: OperationSnapshot['context'] @@ -747,17 +755,24 @@ function adjustFragmentKeys(args: { } const endBlockChildKeys = endBlock.children.map((child) => child._key) - const endBlockMarkDefsKeys = - endBlock.markDefs?.map((markDef) => markDef._key) ?? [] + const endBlockMarkDefsByKey = new Map( + (endBlock.markDefs ?? []).map((markDef) => [markDef._key, markDef]), + ) const markDefKeyMap = new Map() - const adjustedMarkDefs = block.markDefs?.map((markDef) => { - if (endBlockMarkDefsKeys.includes(markDef._key)) { - const newKey = context.keyGenerator() - markDefKeyMap.set(markDef._key, newKey) - return {...markDef, _key: newKey} + const adjustedMarkDefs = block.markDefs?.flatMap((markDef) => { + const endBlockMarkDef = endBlockMarkDefsByKey.get(markDef._key) + if (!endBlockMarkDef) { + return [markDef] + } + + if (isDeepEqual(markDef, endBlockMarkDef)) { + return [] } - return markDef + + const newKey = context.keyGenerator() + markDefKeyMap.set(markDef._key, newKey) + return [{...markDef, _key: newKey}] }) const adjustedChildren = block.children.map((child) => { diff --git a/packages/editor/tests/block-merge-duplicate-keys.test.tsx b/packages/editor/tests/block-merge-duplicate-keys.test.tsx index c7d8e38a43..528178df6d 100644 --- a/packages/editor/tests/block-merge-duplicate-keys.test.tsx +++ b/packages/editor/tests/block-merge-duplicate-keys.test.tsx @@ -459,16 +459,27 @@ describe('Feature: block merge renames colliding keys', () => { }) }) - test('Scenario: a byte-identical colliding markDef is still renamed on the collapsed merge path', async () => { + test('Scenario: a byte-identical colliding markDef is deduped instead of renamed on the collapsed merge path', async () => { const schemaDefinition = defineSchema({ decorators: [{name: 'strong'}], annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], }) + const patches: Array = [] + const {editor, locator} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition, initialValue: duplicateKeyedInitialValueWithSameLink(), + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), }) await userEvent.click(locator) @@ -501,17 +512,307 @@ describe('Feature: block merge renames colliding keys', () => { marks: ['strong', 'link1'], }, {_type: 'span', _key: 's3', text: ' bazfoo ', marks: []}, - {_type: 'span', _key: 'k4', text: 'bar', marks: ['strong', 'k2']}, - {_type: 'span', _key: 'k5', text: ' baz', marks: []}, - ], - markDefs: [ - {_type: 'link', _key: 'link1', href: 'https://a.example'}, - {_type: 'link', _key: 'k2', href: 'https://a.example'}, + { + _type: 'span', + _key: 'k3', + text: 'bar', + marks: ['strong', 'link1'], + }, + {_type: 'span', _key: 'k4', text: ' baz', marks: []}, ], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], style: 'normal', }, ]) }) + + expect(patches).toEqual([ + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's1'}, '_key'], + value: 'k2', + origin: 'local', + }, + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's2'}, '_key'], + value: 'k3', + origin: 'local', + }, + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's3'}, '_key'], + value: 'k4', + origin: 'local', + }, + {type: 'unset', path: [{_key: 'kB'}], origin: 'local'}, + { + type: 'set', + path: [{_key: 'kA'}, 'markDefs'], + value: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 's3'}], + position: 'after', + items: [{_type: 'span', _key: 'k5', marks: [], text: ''}], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 's3'}], + position: 'after', + items: [{_type: 'span', _key: 'k2', text: 'foo ', marks: []}], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 'k2'}], + position: 'after', + items: [ + {_type: 'span', _key: 'k3', text: 'bar', marks: ['strong', 'link1']}, + ], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 'k3'}], + position: 'after', + items: [{_type: 'span', _key: 'k4', text: ' baz', marks: []}], + origin: 'local', + }, + { + type: 'diffMatchPatch', + path: [{_key: 'kA'}, 'children', {_key: 's3'}, 'text'], + value: '@@ -1,4 +1,8 @@\n baz\n+foo \n', + origin: 'local', + }, + { + type: 'unset', + path: [{_key: 'kA'}, 'children', {_key: 'k2'}], + origin: 'local', + }, + { + type: 'unset', + path: [{_key: 'kA'}, 'children', {_key: 'k5'}], + origin: 'local', + }, + ]) + }) + + test('Scenario: undoing a collapsed backspace merge with a byte-identical colliding markDef restores both original blocks, every `_key` included', async () => { + const schemaDefinition = defineSchema({ + decorators: [{name: 'strong'}], + annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], + }) + + const patches: Array = [] + + const {editor, locator} = await createTestEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition, + initialValue: duplicateKeyedInitialValueWithSameLink(), + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + await userEvent.click(locator) + + editor.send({ + type: 'select', + at: { + anchor: {path: [{_key: 'kB'}, 'children', {_key: 's1'}], offset: 0}, + focus: {path: [{_key: 'kB'}, 'children', {_key: 's1'}], offset: 0}, + }, + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).not.toBeNull() + }) + + editor.send({type: 'delete.backward', unit: 'character'}) + + const mergedValue: Array = [ + { + _type: 'block', + _key: 'kA', + children: [ + {_type: 'span', _key: 's1', text: 'foo ', marks: []}, + { + _type: 'span', + _key: 's2', + text: 'bar', + marks: ['strong', 'link1'], + }, + {_type: 'span', _key: 's3', text: ' bazfoo ', marks: []}, + { + _type: 'span', + _key: 'k3', + text: 'bar', + marks: ['strong', 'link1'], + }, + {_type: 'span', _key: 'k4', text: ' baz', marks: []}, + ], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + style: 'normal', + }, + ] + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual(mergedValue) + }) + + expect(patches).toEqual([ + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's1'}, '_key'], + value: 'k2', + origin: 'local', + }, + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's2'}, '_key'], + value: 'k3', + origin: 'local', + }, + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's3'}, '_key'], + value: 'k4', + origin: 'local', + }, + {type: 'unset', path: [{_key: 'kB'}], origin: 'local'}, + { + type: 'set', + path: [{_key: 'kA'}, 'markDefs'], + value: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 's3'}], + position: 'after', + items: [{_type: 'span', _key: 'k5', marks: [], text: ''}], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 's3'}], + position: 'after', + items: [{_type: 'span', _key: 'k2', text: 'foo ', marks: []}], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 'k2'}], + position: 'after', + items: [ + {_type: 'span', _key: 'k3', text: 'bar', marks: ['strong', 'link1']}, + ], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 'k3'}], + position: 'after', + items: [{_type: 'span', _key: 'k4', text: ' baz', marks: []}], + origin: 'local', + }, + { + type: 'diffMatchPatch', + path: [{_key: 'kA'}, 'children', {_key: 's3'}, 'text'], + value: '@@ -1,4 +1,8 @@\n baz\n+foo \n', + origin: 'local', + }, + { + type: 'unset', + path: [{_key: 'kA'}, 'children', {_key: 'k2'}], + origin: 'local', + }, + { + type: 'unset', + path: [{_key: 'kA'}, 'children', {_key: 'k5'}], + origin: 'local', + }, + ]) + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual( + duplicateKeyedInitialValueWithSameLink(), + ) + }) + + editor.send({type: 'history.redo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual(mergedValue) + }) + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual( + duplicateKeyedInitialValueWithSameLink(), + ) + }) }) }) @@ -888,13 +1189,76 @@ describe('Feature: range delete renames colliding keys before merging sibling bl // Normalization merges the two adjacent `strong`+`link1` spans once // deduping keeps them both pointing at the same markDef key; that // merge is incidental to this scenario, not what it pins. - const markDefKeySetPatches = patches.filter( - (patch) => - patch.type === 'set' && - patch.path.at(-1) === '_key' && - patch.path.at(-3) === 'markDefs', - ) - expect(markDefKeySetPatches).toEqual([]) + expect(patches).toEqual([ + { + type: 'diffMatchPatch', + path: [{_key: 'kA'}, 'children', {_key: 's3'}, 'text'], + value: '@@ -1,4 +0,0 @@\n- baz\n', + origin: 'local', + }, + { + type: 'unset', + path: [{_key: 'kB'}, 'children', {_key: 's1'}], + origin: 'local', + }, + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's2'}, '_key'], + value: 'k2', + origin: 'local', + }, + { + type: 'set', + path: [{_key: 'kB'}, 'children', {_key: 's3'}, '_key'], + value: 'k3', + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 's3'}], + position: 'after', + items: [ + {_type: 'span', _key: 'k2', text: 'bar', marks: ['strong', 'link1']}, + ], + origin: 'local', + }, + { + type: 'setIfMissing', + path: [{_key: 'kA'}, 'children'], + value: [], + origin: 'local', + }, + { + type: 'insert', + path: [{_key: 'kA'}, 'children', {_key: 'k2'}], + position: 'after', + items: [{_type: 'span', _key: 'k3', text: ' baz', marks: []}], + origin: 'local', + }, + {type: 'unset', path: [{_key: 'kB'}], origin: 'local'}, + { + type: 'unset', + path: [{_key: 'kA'}, 'children', {_key: 's3'}], + origin: 'local', + }, + { + type: 'diffMatchPatch', + path: [{_key: 'kA'}, 'children', {_key: 's2'}, 'text'], + value: '@@ -1,3 +1,6 @@\n bar\n+bar\n', + origin: 'local', + }, + { + type: 'unset', + path: [{_key: 'kA'}, 'children', {_key: 'k2'}], + origin: 'local', + }, + ]) }) test('Scenario: the range start survives as a trimmed empty span and is itself a collision source', async () => { diff --git a/packages/editor/tests/event.insert.block.test.tsx b/packages/editor/tests/event.insert.block.test.tsx index 1b8710a044..dd547b4783 100644 --- a/packages/editor/tests/event.insert.block.test.tsx +++ b/packages/editor/tests/event.insert.block.test.tsx @@ -1501,6 +1501,181 @@ describe('event.insert.block', () => { }) }) + test('Scenario: Inserting a text block whose span carries a byte-identical same-key markDef into a block that already has it', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + + const patches: Array = [] + + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], + }), + initialValue: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'bar', marks: ['link1']}, + ], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + style: 'normal', + }, + ], + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + editor.send({ + type: 'select', + at: { + anchor: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + }, + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual({ + anchor: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + backward: false, + }) + }) + + editor.send({ + type: 'insert.block', + block: { + _type: 'block', + children: [{_type: 'span', text: 'baz', marks: ['link1']}], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + }, + placement: 'auto', + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'barbaz', marks: ['link1']}, + ], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + style: 'normal', + }, + ]) + }) + + const markDefKeySetPatches = patches.filter( + (patch) => + patch.type === 'set' && + patch.path.at(-1) === '_key' && + patch.path.at(-3) === 'markDefs', + ) + expect(markDefKeySetPatches).toEqual([]) + }) + + test('Scenario: Inserting a text block whose span carries a same-key but different-content markDef into a block that already has it', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], + }), + initialValue: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'bar', marks: ['link1']}, + ], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://a.example'}], + style: 'normal', + }, + ], + }) + + editor.send({ + type: 'select', + at: { + anchor: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + }, + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual({ + anchor: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: spanKey}], + offset: 3, + }, + backward: false, + }) + }) + + editor.send({ + type: 'insert.block', + block: { + _type: 'block', + children: [{_type: 'span', text: 'baz', marks: ['link1']}], + markDefs: [{_type: 'link', _key: 'link1', href: 'https://b.example'}], + }, + placement: 'auto', + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'bar', marks: ['link1']}, + {_type: 'span', _key: 'k5', text: 'baz', marks: ['k6']}, + ], + markDefs: [ + {_type: 'link', _key: 'link1', href: 'https://a.example'}, + {_type: 'link', _key: 'k6', href: 'https://b.example'}, + ], + style: 'normal', + }, + ]) + }) + }) + test('Scenario: Inserting block object with expanded selection in middle of block', async () => { const keyGenerator = createTestKeyGenerator() const blockKey = keyGenerator() diff --git a/packages/editor/tests/unique-sibling-keys.test.tsx b/packages/editor/tests/unique-sibling-keys.test.tsx index 27f18efa56..590e100053 100644 --- a/packages/editor/tests/unique-sibling-keys.test.tsx +++ b/packages/editor/tests/unique-sibling-keys.test.tsx @@ -317,13 +317,9 @@ describe('unique sibling `_key`s', () => { _type: 'block', _key: blockKey, children: [ - {_type: 'span', _key: spanKey, text: 'foo ', marks: [linkKey]}, - {_type: 'span', _key: 'k7', text: 'bar', marks: ['k6']}, - ], - markDefs: [ - {_key: linkKey, _type: 'link', href: 'https://sanity.io'}, - {_key: 'k6', _type: 'link', href: 'https://sanity.io'}, + {_type: 'span', _key: spanKey, text: 'foo bar', marks: [linkKey]}, ], + markDefs: [{_key: linkKey, _type: 'link', href: 'https://sanity.io'}], style: 'normal', }, ])