diff --git a/packages/@sanity/portable-text-editor/e2e-tests/__tests__/selectionAdjustment.collaborative.test.ts b/packages/@sanity/portable-text-editor/e2e-tests/__tests__/selectionAdjustment.collaborative.test.ts index b08418ef626..056a611fac6 100644 --- a/packages/@sanity/portable-text-editor/e2e-tests/__tests__/selectionAdjustment.collaborative.test.ts +++ b/packages/@sanity/portable-text-editor/e2e-tests/__tests__/selectionAdjustment.collaborative.test.ts @@ -28,11 +28,11 @@ describe('selection adjustment', () => { expect(await editorA.getValue()).toMatchInlineSnapshot(` Array [ Object { - "_key": "someKey", + "_key": "B-7", "_type": "block", "children": Array [ Object { - "_key": "anotherKey", + "_key": "B-6", "_type": "span", "marks": Array [], "text": "", @@ -42,11 +42,11 @@ describe('selection adjustment', () => { "style": "normal", }, Object { - "_key": "B-6", + "_key": "someKey", "_type": "block", "children": Array [ Object { - "_key": "B-5", + "_key": "anotherKey", "_type": "span", "marks": Array [], "text": "Hello", @@ -61,7 +61,7 @@ describe('selection adjustment', () => { expect(selectionA).toMatchInlineSnapshot(` Object { "anchor": Object { - "offset": 0, + "offset": 2, "path": Array [ Object { "_key": "someKey", @@ -74,7 +74,7 @@ describe('selection adjustment', () => { }, "backward": false, "focus": Object { - "offset": 0, + "offset": 2, "path": Array [ Object { "_key": "someKey", @@ -298,13 +298,13 @@ describe('selection adjustment', () => { ], }, { - _key: 'B-6', + _key: 'B-7', _type: 'block', markDefs: [], style: 'normal', children: [ { - _key: 'B-5', + _key: 'B-6', _type: 'span', text: '', marks: [], diff --git a/packages/@sanity/portable-text-editor/e2e-tests/__tests__/writingTogether.collaborative.test.ts b/packages/@sanity/portable-text-editor/e2e-tests/__tests__/writingTogether.collaborative.test.ts index 118c02352bb..02bf3efecee 100644 --- a/packages/@sanity/portable-text-editor/e2e-tests/__tests__/writingTogether.collaborative.test.ts +++ b/packages/@sanity/portable-text-editor/e2e-tests/__tests__/writingTogether.collaborative.test.ts @@ -621,7 +621,8 @@ describe('collaborate editing', () => { } `) await editorA.pressKey('Enter') - expect(valA).toMatchInlineSnapshot(` + const valAAfterSecondEnter = await editorA.getValue() + expect(valAAfterSecondEnter).toMatchInlineSnapshot(` Array [ Object { "_key": "randomKey0", @@ -637,6 +638,20 @@ describe('collaborate editing', () => { "markDefs": Array [], "style": "normal", }, + Object { + "_key": "A-9", + "_type": "block", + "children": Array [ + Object { + "_key": "A-8", + "_type": "span", + "marks": Array [], + "text": "", + }, + ], + "markDefs": Array [], + "style": "normal", + }, Object { "_key": "A-6", "_type": "block", @@ -669,8 +684,8 @@ describe('collaborate editing', () => { `) const selectionA = await editorA.getSelection() expect(selectionA).toEqual({ - anchor: {path: [{_key: 'A-8'}, 'children', {_key: 'A-7'}], offset: 0}, - focus: {path: [{_key: 'A-8'}, 'children', {_key: 'A-7'}], offset: 0}, + anchor: {path: [{_key: 'A-6'}, 'children', {_key: 'A-5'}], offset: 0}, + focus: {path: [{_key: 'A-6'}, 'children', {_key: 'A-5'}], offset: 0}, backward: false, }) const selectionB = await editorB.getSelection() diff --git a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx index 7f15c547d97..aa9b8b329ec 100644 --- a/packages/@sanity/portable-text-editor/src/editor/Editable.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/Editable.tsx @@ -154,8 +154,8 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable( [change$, keyGenerator, schemaTypes], ) const withHotKeys = useMemo( - () => createWithHotkeys(schemaTypes, keyGenerator, portableTextEditor, hotkeys), - [hotkeys, keyGenerator, portableTextEditor, schemaTypes], + () => createWithHotkeys(schemaTypes, portableTextEditor, hotkeys), + [hotkeys, portableTextEditor, schemaTypes], ) // Output a minimal React editor inside Editable when in readOnly mode. diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withInsertBreak.test.tsx b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withInsertBreak.test.tsx new file mode 100644 index 00000000000..962ac64cf94 --- /dev/null +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withInsertBreak.test.tsx @@ -0,0 +1,145 @@ +import {describe, expect, it, jest} from '@jest/globals' +import {render, waitFor} from '@testing-library/react' +import {createRef, type RefObject} from 'react' + +import {PortableTextEditorTester, schemaType} from '../../__tests__/PortableTextEditorTester' +import {PortableTextEditor} from '../../PortableTextEditor' + +const initialValue = [ + { + _key: 'a', + _type: 'myTestBlockType', + children: [ + { + _key: 'a1', + _type: 'span', + marks: [], + text: 'Block A', + }, + ], + markDefs: [], + style: 'normal', + }, + { + _key: 'b', + _type: 'myTestBlockType', + children: [ + { + _key: 'b1', + _type: 'span', + marks: [], + text: 'Block B', + }, + ], + markDefs: [], + style: 'normal', + }, +] + +describe('plugin:withInsertBreak: "enter"', () => { + it('keeps text block key if enter is pressed at the start of the block, creating a new one in "before" position', async () => { + const initialSelection = { + focus: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 0}, + anchor: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 0}, + } + + const editorRef: RefObject = createRef() + const onChange = jest.fn() + render( + , + ) + const editor = editorRef.current + const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject') + await waitFor(async () => { + if (editor && inlineType) { + PortableTextEditor.focus(editor) + PortableTextEditor.select(editor, initialSelection) + PortableTextEditor.insertBreak(editor) + + const value = PortableTextEditor.getValue(editor) + expect(value).toEqual([ + initialValue[0], + { + _type: 'myTestBlockType', + _key: '3', + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: '2', + text: '', + marks: [], + }, + ], + }, + initialValue[1], + ]) + } + }) + }) + it('splits the text block key if enter is pressed at the middle of the block', async () => { + const initialSelection = { + focus: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 2}, + anchor: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 2}, + } + + const editorRef: RefObject = createRef() + const onChange = jest.fn() + render( + , + ) + const editor = editorRef.current + const inlineType = editor?.schemaTypes.inlineObjects.find((t) => t.name === 'someObject') + await waitFor(async () => { + if (editor && inlineType) { + PortableTextEditor.focus(editor) + PortableTextEditor.select(editor, initialSelection) + PortableTextEditor.insertBreak(editor) + + const value = PortableTextEditor.getValue(editor) + expect(value).toEqual([ + initialValue[0], + { + _key: 'b', + _type: 'myTestBlockType', + children: [ + { + _key: 'b1', + _type: 'span', + marks: [], + text: 'Bl', + }, + ], + markDefs: [], + style: 'normal', + }, + { + _key: '2', + _type: 'myTestBlockType', + markDefs: [], + style: 'normal', + children: [ + { + _key: '1', + _type: 'span', + marks: [], + text: 'ock B', + }, + ], + }, + ]) + } + }) + }) +}) diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx index 2e8b54a1c62..26f71609aca 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx @@ -677,11 +677,11 @@ describe('plugin:withPortableTextMarksModel', () => { "style": "normal", }, Object { - "_key": "2f55670a03bb", + "_key": "3", "_type": "myTestBlockType", "children": Array [ Object { - "_key": "9f5ed7dee7ab", + "_key": "2", "_type": "span", "marks": Array [], "text": "", @@ -691,11 +691,11 @@ describe('plugin:withPortableTextMarksModel', () => { "style": "normal", }, Object { - "_key": "2", + "_key": "2f55670a03bb", "_type": "myTestBlockType", "children": Array [ Object { - "_key": "1", + "_key": "9f5ed7dee7ab", "_type": "span", "marks": Array [ "bab319ad3a9d", diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithHotKeys.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithHotKeys.ts index 1b9f876e4c0..58d02f29e95 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithHotKeys.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithHotKeys.ts @@ -10,7 +10,6 @@ import {type PortableTextMemberSchemaTypes, type PortableTextSlateEditor} from ' import {type HotkeyOptions} from '../../types/options' import {type SlateTextBlock, type VoidElement} from '../../types/slate' import {debugWithName} from '../../utils/debug' -import {toSlateValue} from '../../utils/values' import {type PortableTextEditor} from '../PortableTextEditor' const debug = debugWithName('plugin:withHotKeys') @@ -31,32 +30,11 @@ const DEFAULT_HOTKEYS: HotkeyOptions = { */ export function createWithHotkeys( types: PortableTextMemberSchemaTypes, - keyGenerator: () => string, portableTextEditor: PortableTextEditor, hotkeysFromOptions?: HotkeyOptions, ): (editor: PortableTextSlateEditor & ReactEditor) => any { const reservedHotkeys = ['enter', 'tab', 'shift', 'delete', 'end'] const activeHotkeys = hotkeysFromOptions || DEFAULT_HOTKEYS // TODO: Merge where possible? A union? - const createEmptyBlock = () => - toSlateValue( - [ - { - _type: types.block.name, - _key: keyGenerator(), - style: 'normal', - markDefs: [], - children: [ - { - _type: 'span', - _key: keyGenerator(), - text: '', - marks: [], - }, - ], - }, - ], - portableTextEditor, - )[0] return function withHotKeys(editor: PortableTextSlateEditor & ReactEditor) { editor.pteWithHotKeys = (event: KeyboardEvent): void => { // Wire up custom marks hotkeys @@ -228,7 +206,7 @@ export function createWithHotkeys( const [, end] = Range.edges(editor.selection) const endAtEndOfNode = Editor.isEnd(editor, end, end.path) if (endAtEndOfNode) { - Editor.insertNode(editor, createEmptyBlock()) + Editor.insertNode(editor, editor.pteCreateEmptyBlock()) event.preventDefault() editor.onChange() return @@ -236,7 +214,7 @@ export function createWithHotkeys( } // Block object enter key if (focusBlock && Editor.isVoid(editor, focusBlock)) { - Editor.insertNode(editor, createEmptyBlock()) + Editor.insertNode(editor, editor.pteCreateEmptyBlock()) event.preventDefault() editor.onChange() return diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithInsertBreak.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithInsertBreak.ts new file mode 100644 index 00000000000..7245763ede7 --- /dev/null +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithInsertBreak.ts @@ -0,0 +1,43 @@ +import {Editor, Node, Path, Range, Transforms} from 'slate' + +import {type PortableTextSlateEditor} from '../../types/editor' +import {type SlateTextBlock, type VoidElement} from '../../types/slate' + +/** + * Changes default behavior of insertBreak to insert a new block instead of splitting current when the cursor is at the + * start of the block. + */ +export function createWithInsertBreak(): ( + editor: PortableTextSlateEditor, +) => PortableTextSlateEditor { + return function withInsertBreak(editor: PortableTextSlateEditor): PortableTextSlateEditor { + const {insertBreak} = editor + + editor.insertBreak = () => { + if (editor.selection) { + const focusBlockPath = editor.selection.focus.path.slice(0, 1) + const focusBlock = Node.descendant(editor, focusBlockPath) as SlateTextBlock | VoidElement + + if (editor.isTextBlock(focusBlock)) { + // Enter from another style than the first (default one) + const [, end] = Range.edges(editor.selection) + // If it's at the start of block, we want to preserve the current block key and insert a new one in the current position instead of splitting the node. + const isEndAtStartOfNode = Editor.isStart(editor, end, end.path) + if (isEndAtStartOfNode) { + Editor.insertNode(editor, editor.pteCreateEmptyBlock()) + const [nextBlockPath] = Path.next(focusBlockPath) + Transforms.select(editor, { + anchor: {path: [nextBlockPath, 0], offset: 0}, + focus: {path: [nextBlockPath, 0], offset: 0}, + }) + + editor.onChange() + return + } + } + } + insertBreak() + } + return editor + } +} diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithUtils.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithUtils.ts index 1a3ba5ae420..df021d0587a 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/createWithUtils.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/createWithUtils.ts @@ -2,18 +2,21 @@ import {Editor, Range, Text, Transforms} from 'slate' import {type PortableTextMemberSchemaTypes, type PortableTextSlateEditor} from '../../types/editor' import {debugWithName} from '../../utils/debug' +import {toSlateValue} from '../../utils/values' +import {type PortableTextEditor} from '../PortableTextEditor' const debug = debugWithName('plugin:withUtils') interface Options { schemaTypes: PortableTextMemberSchemaTypes keyGenerator: () => string + portableTextEditor: PortableTextEditor } /** * This plugin makes various util commands available in the editor * */ -export function createWithUtils({schemaTypes, keyGenerator}: Options) { +export function createWithUtils({schemaTypes, keyGenerator, portableTextEditor}: Options) { return function withUtils(editor: PortableTextSlateEditor): PortableTextSlateEditor { // Expands the the selection to wrap around the word the focus is at editor.pteExpandToWord = () => { @@ -50,6 +53,29 @@ export function createWithUtils({schemaTypes, keyGenerator}: Options) { debug(`pteExpandToWord: Can't expand to word here`) } } + + editor.pteCreateEmptyBlock = () => { + const block = toSlateValue( + [ + { + _type: schemaTypes.block.name, + _key: keyGenerator(), + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: keyGenerator(), + text: '', + marks: [], + }, + ], + }, + ], + portableTextEditor, + )[0] + return block + } return editor } } diff --git a/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts b/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts index 4e42779bf51..be62dd6f7d4 100644 --- a/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts +++ b/packages/@sanity/portable-text-editor/src/editor/plugins/index.ts @@ -5,6 +5,7 @@ import {type PortableTextSlateEditor} from '../../types/editor' import {type createEditorOptions} from '../../types/options' import {createOperationToPatches} from '../../utils/operationToPatches' import {createWithEditableAPI} from './createWithEditableAPI' +import {createWithInsertBreak} from './createWithInsertBreak' import {createWithMaxBlocks} from './createWithMaxBlocks' import {createWithObjectKeys} from './createWithObjectKeys' import {createWithPatches} from './createWithPatches' @@ -85,7 +86,9 @@ export const withPlugins = ( schemaTypes, }) - const withUtils = createWithUtils({keyGenerator, schemaTypes}) + const withInsertBreak = createWithInsertBreak() + + const withUtils = createWithUtils({keyGenerator, schemaTypes, portableTextEditor}) const withPortableTextSelections = createWithPortableTextSelections(change$, schemaTypes) e.destroy = () => { @@ -106,7 +109,9 @@ export const withPlugins = ( withPortableTextBlockStyle( withUtils( withPlaceholderBlock( - withPortableTextLists(withPortableTextSelections(withEditableAPI(e))), + withPortableTextLists( + withPortableTextSelections(withEditableAPI(withInsertBreak(e))), + ), ), ), ), @@ -127,7 +132,9 @@ export const withPlugins = ( withPlaceholderBlock( withUtils( withMaxBlocks( - withUndoRedo(withPatches(withPortableTextSelections(withEditableAPI(e)))), + withUndoRedo( + withPatches(withPortableTextSelections(withEditableAPI(withInsertBreak(e)))), + ), ), ), ), diff --git a/packages/@sanity/portable-text-editor/src/types/editor.ts b/packages/@sanity/portable-text-editor/src/types/editor.ts index b794ebe32a9..023701bef0e 100644 --- a/packages/@sanity/portable-text-editor/src/types/editor.ts +++ b/packages/@sanity/portable-text-editor/src/types/editor.ts @@ -196,6 +196,11 @@ export interface PortableTextSlateEditor extends ReactEditor { */ pteWithHotKeys: (event: KeyboardEvent) => void + /** + * Helper function that creates an empty text block + */ + pteCreateEmptyBlock: () => Descendant + /** * Undo */