diff --git a/.changeset/cyan-shoes-try.md b/.changeset/cyan-shoes-try.md new file mode 100644 index 0000000000..2ab8d98ff9 --- /dev/null +++ b/.changeset/cyan-shoes-try.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix: stabilize span keys when writing between marks diff --git a/packages/editor/src/behaviors/behavior.core.insert.ts b/packages/editor/src/behaviors/behavior.core.insert.ts index f81ca326ea..557941680d 100644 --- a/packages/editor/src/behaviors/behavior.core.insert.ts +++ b/packages/editor/src/behaviors/behavior.core.insert.ts @@ -1,3 +1,5 @@ +import {isEqualMarks} from '../internal-utils/equality' +import {getNextSpan} from '../selectors' 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' @@ -36,19 +38,51 @@ export const coreInsertBehaviors = [ } } - return {activeDecorators, activeAnnotations} + const activeMarks = [...activeDecorators, ...activeAnnotations] + + if (isEqualMarks(focusSpan.node.marks ?? [], activeMarks)) { + return false + } + + const atEndOfFocusSpan = + snapshot.context.selection?.focus.offset === focusSpan.node.text.length + + const nextSpan = atEndOfFocusSpan ? getNextSpan(snapshot) : undefined + + return {activeMarks, nextSpan} }, actions: [ - ({snapshot, event}, {activeDecorators, activeAnnotations}) => [ - raise({ - type: 'insert.child', - child: { - _type: snapshot.context.schema.span.name, - text: event.text, - marks: [...activeDecorators, ...activeAnnotations], - }, - }), - ], + ({snapshot, event}, {activeMarks, nextSpan}) => { + if (nextSpan && isEqualMarks(nextSpan.node.marks, activeMarks)) { + return [ + raise({ + type: 'select', + at: { + anchor: { + path: nextSpan.path, + offset: 0, + }, + focus: { + path: nextSpan.path, + offset: 0, + }, + }, + }), + raise(event), + ] + } + + return [ + raise({ + type: 'insert.child', + child: { + _type: snapshot.context.schema.span.name, + text: event.text, + marks: activeMarks, + }, + }), + ] + }, ], }), ] diff --git a/packages/editor/tests/stable-keys.test.tsx b/packages/editor/tests/stable-keys.test.tsx new file mode 100644 index 0000000000..d61eda6673 --- /dev/null +++ b/packages/editor/tests/stable-keys.test.tsx @@ -0,0 +1,160 @@ +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {defineSchema} from '../src' +import {getActiveDecorators} from '../src/selectors/selector.get-active-decorators' +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', + }, + ]) + }) + }) + + test('Scenario: Typing with activated decorator at end of unmarked span absorbs into next matching span', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const helloKey = keyGenerator() + const worldKey = keyGenerator() + + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + decorators: [{name: 'strong'}], + }), + initialValue: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: helloKey, text: 'hello', marks: []}, + {_type: 'span', _key: worldKey, text: ' world', marks: ['strong']}, + ], + markDefs: [], + style: 'normal', + }, + ], + }) + + // When the editor is focused + await userEvent.click(locator) + + // And the caret is put at the end of "hello" + const endOfHelloSelection = { + anchor: { + path: [{_key: blockKey}, 'children', {_key: helloKey}], + offset: 5, + }, + focus: { + path: [{_key: blockKey}, 'children', {_key: helloKey}], + offset: 5, + }, + backward: false, + } + editor.send({ + type: 'select', + at: endOfHelloSelection, + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual( + endOfHelloSelection, + ) + }) + + // And "strong" is activated + editor.send({type: 'decorator.add', decorator: 'strong'}) + await vi.waitFor(() => { + expect(getActiveDecorators(editor.getSnapshot())).toEqual(['strong']) + }) + + // And "x" is typed + await userEvent.keyboard('x') + + // Then the next span absorbs the new character and keeps its key + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: helloKey, text: 'hello', marks: []}, + {_type: 'span', _key: worldKey, text: 'x world', marks: ['strong']}, + ], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) +})