diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md index a88536ab2943d6..99cc264cc7e2e4 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -1075,6 +1075,20 @@ _Returns_ - `Object`: Action object. +# **resetSelection** + +Returns an action object used in signalling that selection state should be +reset to the specified selection. + +_Parameters_ + +- _selectionStart_ `WPBlockSelection`: The selection start. +- _selectionEnd_ `WPBlockSelection`: The selection end. + +_Returns_ + +- `Object`: Action object. + # **selectBlock** Returns an action object used in signalling that the block with the diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 8b20edac6d37c1..bc0b8b25b765dc 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -359,6 +359,30 @@ _Returns_ - `Array`: Block list. +# **getEditorSelectionEnd** + +Returns the current selection end. + +_Parameters_ + +- _state_ `Object`: + +_Returns_ + +- `WPBlockSelection`: The selection end. + +# **getEditorSelectionStart** + +Returns the current selection start. + +_Parameters_ + +- _state_ `Object`: + +_Returns_ + +- `WPBlockSelection`: The selection start. + # **getEditorSettings** Returns the post editor settings. diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index 2f49a5d7e77d57..ff3528d2520521 100644 --- a/packages/block-editor/src/components/provider/index.js +++ b/packages/block-editor/src/components/provider/index.js @@ -29,6 +29,9 @@ class BlockEditorProvider extends Component { updateSettings, value, resetBlocks, + selectionStart, + selectionEnd, + resetSelection, registry, } = this.props; @@ -58,6 +61,10 @@ class BlockEditorProvider extends Component { this.isSyncingOutcomingValue = []; this.isSyncingIncomingValue = value; resetBlocks( value ); + + if ( selectionStart && selectionEnd ) { + resetSelection( selectionStart, selectionEnd ); + } } } @@ -86,6 +93,8 @@ class BlockEditorProvider extends Component { const { getBlocks, + getSelectionStart, + getSelectionEnd, isLastBlockChangePersistent, __unstableIsLastBlockChangeIgnored, } = registry.select( 'core/block-editor' ); @@ -128,10 +137,13 @@ class BlockEditorProvider extends Component { blocks = newBlocks; isPersistent = newIsPersistent; + const selectionStart = getSelectionStart(); + const selectionEnd = getSelectionEnd(); + if ( isPersistent ) { - onChange( blocks ); + onChange( blocks, { selectionStart, selectionEnd } ); } else { - onInput( blocks ); + onInput( blocks, { selectionStart, selectionEnd } ); } } } ); @@ -150,11 +162,13 @@ export default compose( [ const { updateSettings, resetBlocks, + resetSelection, } = dispatch( 'core/block-editor' ); return { updateSettings, resetBlocks, + resetSelection, }; } ), ] )( BlockEditorProvider ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 3c4c69b91d4b54..a7956c70e0d216 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -48,6 +48,31 @@ export function resetBlocks( blocks ) { }; } +/** + * @typedef {WPBlockSelection} A block selection object. + * + * @property {string} clientId A block client ID. + * @property {string} attributeKey A block attribute key. + * @property {number} offset A block attribute offset. + */ + +/** + * Returns an action object used in signalling that selection state should be + * reset to the specified selection. + * + * @param {WPBlockSelection} selectionStart The selection start. + * @param {WPBlockSelection} selectionEnd The selection end. + * + * @return {Object} Action object. + */ +export function resetSelection( selectionStart, selectionEnd ) { + return { + type: 'RESET_SELECTION', + selectionStart, + selectionEnd, + }; +} + /** * Returns an action object used in signalling that blocks have been received. * Unlike resetBlocks, these should be appended to the existing known set, not diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index b75d0e44632373..ec4c3712da6d14 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -36,7 +36,11 @@ function* loadPostTypeEntities() { kind: 'postType', baseURL: '/wp/v2/' + postType.rest_base, name, - transientEdits: { blocks: true }, + transientEdits: { + blocks: true, + selectionStart: true, + selectionEnd: true, + }, mergedEdits: { meta: true }, }; } ); diff --git a/packages/e2e-tests/specs/editor/various/undo.test.js b/packages/e2e-tests/specs/editor/various/undo.test.js index 01b1336c5ceb87..c8c1582eed9893 100644 --- a/packages/e2e-tests/specs/editor/various/undo.test.js +++ b/packages/e2e-tests/specs/editor/various/undo.test.js @@ -13,6 +13,40 @@ import { disableNavigationMode, } from '@wordpress/e2e-test-utils'; +const getSelection = async () => { + return await page.evaluate( () => { + const selectedBlock = document.activeElement.closest( '.wp-block' ); + const blocks = Array.from( document.querySelectorAll( '.wp-block' ) ); + const blockIndex = blocks.indexOf( selectedBlock ); + + if ( blockIndex === -1 ) { + return {}; + } + + const editables = Array.from( selectedBlock.querySelectorAll( '[contenteditable]' ) ); + const editableIndex = editables.indexOf( document.activeElement ); + const selection = window.getSelection(); + + if ( editableIndex === -1 || ! selection.rangeCount ) { + return { blockIndex }; + } + + const range = selection.getRangeAt( 0 ); + const cloneStart = range.cloneRange(); + const cloneEnd = range.cloneRange(); + + cloneStart.setStart( document.activeElement, 0 ); + cloneEnd.setStart( document.activeElement, 0 ); + + return { + blockIndex, + editableIndex, + startOffset: cloneStart.toString().length, + endOffset: cloneEnd.toString().length, + }; + } ); +}; + describe( 'undo', () => { beforeEach( async () => { await createNewPost(); @@ -34,18 +68,42 @@ describe( 'undo', () => { const before = await getEditedPostContent(); expect( before ).toMatchSnapshot(); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'before pause'.length, + endOffset: 'before pause'.length, + } ); await pressKeyWithModifier( 'primary', 'z' ); expect( await getEditedPostContent() ).toBe( '' ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); expect( await getEditedPostContent() ).toBe( before ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'before pause'.length, + endOffset: 'before pause'.length, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); expect( await getEditedPostContent() ).toBe( after ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'before pause after pause'.length, + endOffset: 'before pause after pause'.length, + } ); } ); it( 'should undo typing after non input change', async () => { @@ -64,18 +122,42 @@ describe( 'undo', () => { const before = await getEditedPostContent(); expect( before ).toMatchSnapshot(); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'before keyboard '.length, + endOffset: 'before keyboard '.length, + } ); await pressKeyWithModifier( 'primary', 'z' ); expect( await getEditedPostContent() ).toBe( '' ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); expect( await getEditedPostContent() ).toBe( before ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'before keyboard '.length, + endOffset: 'before keyboard '.length, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); expect( await getEditedPostContent() ).toBe( after ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'before keyboard after keyboard'.length, + endOffset: 'before keyboard after keyboard'.length, + } ); } ); it( 'should undo bold', async () => { @@ -120,54 +202,121 @@ describe( 'undo', () => { await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd paragraph text. expect( await getEditedPostContent() ).toBe( thirdBlock ); + expect( await getSelection() ).toEqual( { + blockIndex: 3, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 3rd block. expect( await getEditedPostContent() ).toBe( secondText ); + expect( await getSelection() ).toEqual( { + blockIndex: 2, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd paragraph text. expect( await getEditedPostContent() ).toBe( secondBlock ); + expect( await getSelection() ).toEqual( { + blockIndex: 2, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 2nd block. expect( await getEditedPostContent() ).toBe( firstText ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st paragraph text. expect( await getEditedPostContent() ).toBe( firstBlock ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primary', 'z' ); // Undo 1st block. expect( await getEditedPostContent() ).toBe( '' ); + expect( await getSelection() ).toEqual( {} ); // After undoing every action, there should be no more undo history. expect( await page.$( '.editor-history__undo[aria-disabled="true"]' ) ).not.toBeNull(); await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 1st block. expect( await getEditedPostContent() ).toBe( firstBlock ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); // After redoing one change, the undo button should be enabled again. expect( await page.$( '.editor-history__undo[aria-disabled="true"]' ) ).toBeNull(); await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 1st paragraph text. expect( await getEditedPostContent() ).toBe( firstText ); + expect( await getSelection() ).toEqual( { + blockIndex: 1, + editableIndex: 0, + startOffset: 'This'.length, + endOffset: 'This'.length, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 2nd block. expect( await getEditedPostContent() ).toBe( secondBlock ); + expect( await getSelection() ).toEqual( { + blockIndex: 2, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 2nd paragraph text. expect( await getEditedPostContent() ).toBe( secondText ); + expect( await getSelection() ).toEqual( { + blockIndex: 2, + editableIndex: 0, + startOffset: 'is'.length, + endOffset: 'is'.length, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 3rd block. expect( await getEditedPostContent() ).toBe( thirdBlock ); + expect( await getSelection() ).toEqual( { + blockIndex: 3, + editableIndex: 0, + startOffset: 0, + endOffset: 0, + } ); await pressKeyWithModifier( 'primaryShift', 'z' ); // Redo 3rd paragraph text. expect( await getEditedPostContent() ).toBe( thirdText ); + expect( await getSelection() ).toEqual( { + blockIndex: 3, + editableIndex: 0, + startOffset: 'test'.length, + endOffset: 'test'.length, + } ); } ); it( 'should undo for explicit persistence editing post', async () => { diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index fa39d3871c7717..c44819b867a9d0 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -152,6 +152,8 @@ class EditorProvider extends Component { post, blocks, resetEditorBlocks, + selectionStart, + selectionEnd, isReady, settings, reusableBlocks, @@ -177,6 +179,8 @@ class EditorProvider extends Component { value={ blocks } onInput={ resetEditorBlocksWithoutUndoLevel } onChange={ resetEditorBlocks } + selectionStart={ selectionStart } + selectionEnd={ selectionEnd } settings={ editorSettings } useSubRegistry={ false } > @@ -197,6 +201,8 @@ export default compose( [ canUserUseUnfilteredHTML, __unstableIsEditorReady: isEditorReady, getEditorBlocks, + getEditorSelectionStart, + getEditorSelectionEnd, __experimentalGetReusableBlocks, } = select( 'core/editor' ); const { canUser } = select( 'core' ); @@ -205,6 +211,8 @@ export default compose( [ canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(), isReady: isEditorReady(), blocks: getEditorBlocks(), + selectionStart: getEditorSelectionStart(), + selectionEnd: getEditorSelectionEnd(), reusableBlocks: __experimentalGetReusableBlocks(), hasUploadPermissions: defaultTo( canUser( 'create', 'media' ), true ), }; @@ -225,8 +233,9 @@ export default compose( [ createWarningNotice, resetEditorBlocks, updateEditorSettings, - resetEditorBlocksWithoutUndoLevel( blocks ) { + resetEditorBlocksWithoutUndoLevel( blocks, options ) { resetEditorBlocks( blocks, { + ...options, __unstableShouldCreateUndoLevel: false, } ); }, diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 0cde2bc7f606ef..689f2f31957268 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -646,8 +646,14 @@ export function unlockPostSaving( lockName ) { * @yield {Object} Action object */ export function* resetEditorBlocks( blocks, options = {} ) { - const edits = { blocks }; - if ( options.__unstableShouldCreateUndoLevel !== false ) { + const { + __unstableShouldCreateUndoLevel, + selectionStart, + selectionEnd, + } = options; + const edits = { blocks, selectionStart, selectionEnd }; + + if ( __unstableShouldCreateUndoLevel !== false ) { const { id, type } = yield select( STORE_KEY, 'getCurrentPost' ); const noChange = ( yield select( 'core', 'getEditedEntityRecord', 'postType', type, id ) ) diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index a8c216d4b2083f..70d0dda8f3b940 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -1192,6 +1192,34 @@ export function getEditorBlocks( state ) { return getEditedPostAttribute( state, 'blocks' ) || EMPTY_ARRAY; } +/** + * @typedef {WPBlockSelection} A block selection object. + * + * @property {string} clientId A block client ID. + * @property {string} attributeKey A block attribute key. + * @property {number} offset A block attribute offset. + */ + +/** + * Returns the current selection start. + * + * @param {Object} state + * @return {WPBlockSelection} The selection start. + */ +export function getEditorSelectionStart( state ) { + return getEditedPostAttribute( state, 'selectionStart' ); +} + +/** + * Returns the current selection end. + * + * @param {Object} state + * @return {WPBlockSelection} The selection end. + */ +export function getEditorSelectionEnd( state ) { + return getEditedPostAttribute( state, 'selectionEnd' ); +} + /** * Is the editor ready * diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 3c5ef92805eb4a..bb591f0f497da7 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -598,8 +598,10 @@ class RichText extends Component { this.value = this.valueToFormat( record ); this.record = record; - this.props.onChange( this.value ); + // Selection must be updated first, so it is recorded in history when + // the content change happens. this.props.onSelectionChange( start, end ); + this.props.onChange( this.value ); this.setState( { activeFormats } ); if ( ! withoutHistory ) {