From b9c3d7899e824d24b1337d6990ff6cd33826d8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 24 Mar 2026 09:09:37 +0100 Subject: [PATCH 1/3] test: add test for stable span key when typing after annotation --- packages/editor/tests/stable-keys.test.tsx | 83 ++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/editor/tests/stable-keys.test.tsx diff --git a/packages/editor/tests/stable-keys.test.tsx b/packages/editor/tests/stable-keys.test.tsx new file mode 100644 index 000000000..7a5ef3b61 --- /dev/null +++ b/packages/editor/tests/stable-keys.test.tsx @@ -0,0 +1,83 @@ +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {defineSchema} from '../src' +import {createTestEditor} from '../src/test/vitest' + +describe('Feature: Stable keys', () => { + test('Scenario: Typing after annotation', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const fooKey = keyGenerator() + const barKey = keyGenerator() + const bazKey = keyGenerator() + const linkKey = keyGenerator() + + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + annotations: [{name: 'link'}], + }), + initialValue: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: fooKey, text: 'foo ', marks: []}, + {_type: 'span', _key: barKey, text: 'bar', marks: [linkKey]}, + {_type: 'span', _key: bazKey, text: ' baz', marks: []}, + ], + markDefs: [ + {_type: 'link', _key: linkKey, href: 'https://portabletext.org'}, + ], + style: 'normal', + }, + ], + }) + + // When the editor is focused + await userEvent.click(locator) + + // And the caret is put before " baz" + const beforeBazSelection = { + anchor: { + path: [{_key: blockKey}, 'children', {_key: bazKey}], + offset: 0, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: bazKey}], + offset: 0, + }, + backward: false, + } + editor.send({ + type: 'select', + at: beforeBazSelection, + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual(beforeBazSelection) + }) + + // And "new" is typed + await userEvent.keyboard('new') + + // Then the text is "foo ,bar,new baz" + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: fooKey, text: 'foo ', marks: []}, + {_type: 'span', _key: barKey, text: 'bar', marks: [linkKey]}, + {_type: 'span', _key: bazKey, text: 'new baz', marks: []}, + ], + markDefs: [ + {_type: 'link', _key: linkKey, href: 'https://portabletext.org'}, + ], + style: 'normal', + }, + ]) + }) + }) +}) From f4d5fa2f8921e4d01f730a395056908bb57d9a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 24 Mar 2026 08:57:19 +0000 Subject: [PATCH 2/3] fix: prefer adjacent span over insert.child for stable keys When inserting text at a span boundary where the active marks differ from the focus span but match an adjacent span, move the selection to the adjacent span instead of creating a new span via insert.child. This preserves span keys when typing at annotation boundaries where the DOM selection resolves to the annotated span but the intended insertion target is the adjacent unannotated span. --- .../src/behaviors/behavior.core.insert.ts | 119 ++++++++++++++---- 1 file changed, 93 insertions(+), 26 deletions(-) diff --git a/packages/editor/src/behaviors/behavior.core.insert.ts b/packages/editor/src/behaviors/behavior.core.insert.ts index 862fe70bd..c20b586eb 100644 --- a/packages/editor/src/behaviors/behavior.core.insert.ts +++ b/packages/editor/src/behaviors/behavior.core.insert.ts @@ -1,8 +1,9 @@ import {getActiveAnnotationsMarks} from '../selectors/selector.get-active-annotation-marks' import {getActiveDecorators} from '../selectors/selector.get-active-decorators' import {getFocusSpan} from '../selectors/selector.get-focus-span' -import {getMarkState} from '../selectors/selector.get-mark-state' -import {raise} from './behavior.types.action' +import {getNextSpan} from '../selectors/selector.get-next-span' +import {getPreviousSpan} from '../selectors/selector.get-previous-span' +import {forward, raise} from './behavior.types.action' import {defineBehavior} from './behavior.types.behavior' export const coreInsertBehaviors = [ @@ -15,38 +16,104 @@ export const coreInsertBehaviors = [ return false } - const markState = getMarkState(snapshot) const activeDecorators = getActiveDecorators(snapshot) const activeAnnotations = getActiveAnnotationsMarks(snapshot) + const activeMarks = [...activeDecorators, ...activeAnnotations] + const focusMarks = focusSpan.node.marks ?? [] - if (markState && markState.state === 'unchanged') { - const markStateDecorators = (markState.marks ?? []).filter((mark) => - snapshot.context.schema.decorators - .map((decorator) => decorator.name) - .includes(mark), - ) - - if ( - markStateDecorators.length === activeDecorators.length && - markStateDecorators.every((mark) => activeDecorators.includes(mark)) - ) { - return false + if ( + activeMarks.length === focusMarks.length && + activeMarks.every((mark) => focusMarks.includes(mark)) + ) { + return false + } + + const selection = snapshot.context.selection + + if (selection) { + const offset = selection.focus.offset + const atStart = offset === 0 + const atEnd = offset === focusSpan.node.text.length + + if (atEnd) { + const nextSpan = getNextSpan(snapshot) + + if (nextSpan) { + const nextMarks = nextSpan.node.marks ?? [] + + if ( + activeMarks.length === nextMarks.length && + activeMarks.every((mark) => nextMarks.includes(mark)) + ) { + return { + type: 'move' as const, + targetSpanKey: nextSpan.node._key, + targetOffset: 0, + } + } + } + } + + if (atStart) { + const previousSpan = getPreviousSpan(snapshot) + + if (previousSpan) { + const prevMarks = previousSpan.node.marks ?? [] + + if ( + activeMarks.length === prevMarks.length && + activeMarks.every((mark) => prevMarks.includes(mark)) + ) { + return { + type: 'move' as const, + targetSpanKey: previousSpan.node._key, + targetOffset: previousSpan.node.text.length, + } + } + } } } - return {activeDecorators, activeAnnotations} + return {type: 'insert' as const, activeDecorators, activeAnnotations} }, actions: [ - ({snapshot, event}, {activeDecorators, activeAnnotations}) => [ - raise({ - type: 'insert.child', - child: { - _type: snapshot.context.schema.span.name, - text: event.text, - marks: [...activeDecorators, ...activeAnnotations], - }, - }), - ], + ({snapshot, event}, guardResponse) => { + if (guardResponse.type === 'move') { + const {targetSpanKey, targetOffset} = guardResponse + const blockPath = snapshot.context.selection!.focus.path.slice(0, 1) + + return [ + raise({ + type: 'select', + at: { + anchor: { + path: [...blockPath, 'children', {_key: targetSpanKey}], + offset: targetOffset, + }, + focus: { + path: [...blockPath, 'children', {_key: targetSpanKey}], + offset: targetOffset, + }, + backward: false, + }, + }), + forward(event), + ] + } + + const {activeDecorators, activeAnnotations} = guardResponse + + return [ + raise({ + type: 'insert.child', + child: { + _type: snapshot.context.schema.span.name, + text: event.text, + marks: [...activeDecorators, ...activeAnnotations], + }, + }), + ] + }, ], }), ] From 1e4552e7c79a95022798c0fa55d1e025284caa9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gr=C3=B8ngaard?= Date: Tue, 24 Mar 2026 10:22:34 +0100 Subject: [PATCH 3/3] fix: check immediate sibling for adjacent span move Only move the cursor to an adjacent span when it is the immediate sibling in the children array. This prevents incorrectly jumping over inline objects to a non-adjacent span with matching marks. --- .../src/behaviors/behavior.core.insert.ts | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/editor/src/behaviors/behavior.core.insert.ts b/packages/editor/src/behaviors/behavior.core.insert.ts index c20b586eb..389cf0426 100644 --- a/packages/editor/src/behaviors/behavior.core.insert.ts +++ b/packages/editor/src/behaviors/behavior.core.insert.ts @@ -1,8 +1,8 @@ +import {isSpan} from '@portabletext/schema' import {getActiveAnnotationsMarks} from '../selectors/selector.get-active-annotation-marks' import {getActiveDecorators} from '../selectors/selector.get-active-decorators' import {getFocusSpan} from '../selectors/selector.get-focus-span' -import {getNextSpan} from '../selectors/selector.get-next-span' -import {getPreviousSpan} from '../selectors/selector.get-previous-span' +import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block' import {forward, raise} from './behavior.types.action' import {defineBehavior} from './behavior.types.behavior' @@ -35,39 +35,48 @@ export const coreInsertBehaviors = [ const atStart = offset === 0 const atEnd = offset === focusSpan.node.text.length - if (atEnd) { - const nextSpan = getNextSpan(snapshot) - - if (nextSpan) { - const nextMarks = nextSpan.node.marks ?? [] - - if ( - activeMarks.length === nextMarks.length && - activeMarks.every((mark) => nextMarks.includes(mark)) - ) { - return { - type: 'move' as const, - targetSpanKey: nextSpan.node._key, - targetOffset: 0, + const focusBlock = getFocusTextBlock(snapshot) + + if (focusBlock) { + const children = focusBlock.node.children + const focusIndex = children.findIndex( + (child) => child._key === focusSpan.node._key, + ) + + if (atEnd && focusIndex < children.length - 1) { + const nextChild = children[focusIndex + 1] + + if (nextChild && isSpan(snapshot.context, nextChild)) { + const nextMarks = nextChild.marks ?? [] + + if ( + activeMarks.length === nextMarks.length && + activeMarks.every((mark) => nextMarks.includes(mark)) + ) { + return { + type: 'move' as const, + targetSpanKey: nextChild._key, + targetOffset: 0, + } } } } - } - - if (atStart) { - const previousSpan = getPreviousSpan(snapshot) - - if (previousSpan) { - const prevMarks = previousSpan.node.marks ?? [] - if ( - activeMarks.length === prevMarks.length && - activeMarks.every((mark) => prevMarks.includes(mark)) - ) { - return { - type: 'move' as const, - targetSpanKey: previousSpan.node._key, - targetOffset: previousSpan.node.text.length, + if (atStart && focusIndex > 0) { + const prevChild = children[focusIndex - 1] + + if (prevChild && isSpan(snapshot.context, prevChild)) { + const prevMarks = prevChild.marks ?? [] + + if ( + activeMarks.length === prevMarks.length && + activeMarks.every((mark) => prevMarks.includes(mark)) + ) { + return { + type: 'move' as const, + targetSpanKey: prevChild._key, + targetOffset: prevChild.text.length, + } } } }