diff --git a/packages/editor/src/behaviors/behavior.core.insert.ts b/packages/editor/src/behaviors/behavior.core.insert.ts index 862fe70bd..389cf0426 100644 --- a/packages/editor/src/behaviors/behavior.core.insert.ts +++ b/packages/editor/src/behaviors/behavior.core.insert.ts @@ -1,8 +1,9 @@ +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 {getMarkState} from '../selectors/selector.get-mark-state' -import {raise} from './behavior.types.action' +import {getFocusTextBlock} from '../selectors/selector.get-focus-text-block' +import {forward, raise} from './behavior.types.action' import {defineBehavior} from './behavior.types.behavior' export const coreInsertBehaviors = [ @@ -15,38 +16,113 @@ 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 + + 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 && 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, + } + } + } + } } } - 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], + }, + }), + ] + }, ], }), ] 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', + }, + ]) + }) + }) +})