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 ) {