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 f4a9d048f5e5b..f6f80cdfec535 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -20,6 +20,18 @@ _Returns_ - `boolean`: Whether the given block type is allowed to be inserted. +# **didAutomaticChange** + +Returns true if the last change was an automatic change, false otherwise. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether the last change was automatic. + # **getAdjacentBlockClientId** Returns the client ID of the block adjacent one at the given reference diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 51af2159ec92f..fdf10696cbeda 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -64,6 +64,7 @@ class RichTextWrapper extends Component { this.onPaste = this.onPaste.bind( this ); this.onDelete = this.onDelete.bind( this ); this.inputRule = this.inputRule.bind( this ); + this.markAutomaticChange = this.markAutomaticChange.bind( this ); } onEnter( { value, onChange, shiftKey } ) { @@ -81,6 +82,7 @@ class RichTextWrapper extends Component { onReplace( [ transformation.transform( { content: value.text } ), ] ); + this.markAutomaticChange(); } } @@ -273,6 +275,7 @@ class RichTextWrapper extends Component { const block = transformation.transform( content ); onReplace( [ block ] ); + this.markAutomaticChange(); } getAllowedFormats() { @@ -293,6 +296,16 @@ class RichTextWrapper extends Component { return formattingControls.map( ( name ) => `core/${ name }` ); } + /** + * Marks the last change as an automatic change at the next idle period to + * ensure all selection changes have been recorded. + */ + markAutomaticChange() { + window.requestIdleCallback( () => { + this.props.markAutomaticChange(); + } ); + } + render() { const { children, @@ -313,6 +326,10 @@ class RichTextWrapper extends Component { onExitFormattedText, isSelected: originalIsSelected, onCreateUndoLevel, + // eslint-disable-next-line no-unused-vars + markAutomaticChange, + didAutomaticChange, + undo, placeholder, // eslint-disable-next-line no-unused-vars allowedFormats, @@ -375,6 +392,9 @@ class RichTextWrapper extends Component { __unstableOnEnterFormattedText={ onEnterFormattedText } __unstableOnExitFormattedText={ onExitFormattedText } __unstableOnCreateUndoLevel={ onCreateUndoLevel } + __unstableMarkAutomaticChange={ this.markAutomaticChange } + __unstableDidAutomaticChange={ didAutomaticChange } + __unstableUndo={ undo } > { ( { isSelected, value, onChange, Editable } ) => <> @@ -435,6 +455,7 @@ const RichTextContainer = compose( [ getSelectionStart, getSelectionEnd, getSettings, + didAutomaticChange, } = select( 'core/block-editor' ); const selectionStart = getSelectionStart(); @@ -453,6 +474,7 @@ const RichTextContainer = compose( [ selectionStart: isSelected ? selectionStart.offset : undefined, selectionEnd: isSelected ? selectionEnd.offset : undefined, isSelected, + didAutomaticChange: didAutomaticChange(), }; } ), withDispatch( ( dispatch, { @@ -465,7 +487,9 @@ const RichTextContainer = compose( [ enterFormattedText, exitFormattedText, selectionChange, + __unstableMarkAutomaticChange, } = dispatch( 'core/block-editor' ); + const { undo } = dispatch( 'core/editor' ); return { onCreateUndoLevel: __unstableMarkLastChangeAsPersistent, @@ -474,6 +498,8 @@ const RichTextContainer = compose( [ onSelectionChange( start, end ) { selectionChange( clientId, identifier, start, end ); }, + markAutomaticChange: __unstableMarkAutomaticChange, + undo, }; } ), withFilters( 'experimentalRichText' ), diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index fd7d3dbbe0329..8bebe25de7057 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -711,6 +711,20 @@ export function __unstableMarkLastChangeAsPersistent() { return { type: 'MARK_LAST_CHANGE_AS_PERSISTENT' }; } +/** + * Returns an action object used in signalling that the last block change is + * an automatic change, meaning it was not performed by the user, and can be + * undone using the `Escape` and `Backspace` keys. This action must be called + * after the change was made, and any actions that are a consequence of it, so + * it is recommended to be called at the next idle period to ensure all + * selection changes have been recorded. + * + * @return {Object} Action object. + */ +export function __unstableMarkAutomaticChange() { + return { type: 'MARK_AUTOMATIC_CHANGE' }; +} + /** * Returns an action object used to enable or disable the navigation mode. * diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index ca277cb1e43dd..33a40dc81af1a 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1251,6 +1251,18 @@ export function lastBlockAttributesChange( state, action ) { return null; } +/** + * Reducer returning automatic change state. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function didAutomaticChange( state, action ) { + return action.type === 'MARK_AUTOMATIC_CHANGE'; +} + export default combineReducers( { blocks, isTyping, @@ -1264,4 +1276,5 @@ export default combineReducers( { preferences, lastBlockAttributesChange, isNavigationMode, + didAutomaticChange, } ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 792b27542f7a5..9bafe5b832844 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1415,3 +1415,14 @@ function getReusableBlocks( state ) { export function isNavigationMode( state ) { return state.isNavigationMode; } + +/** + * Returns true if the last change was an automatic change, false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the last change was automatic. + */ +export function didAutomaticChange( state ) { + return state.didAutomaticChange; +} diff --git a/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap b/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap index c2013529d0a3f..78b78101a59a5 100644 --- a/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/rich-text.test.js.snap @@ -48,6 +48,10 @@ exports[`RichText should not lose selection direction 1`] = ` " `; +exports[`RichText should not undo backtick transform with backspace after selection change 1`] = `""`; + +exports[`RichText should not undo backtick transform with backspace after typing 1`] = `""`; + exports[`RichText should only mutate text data on input 1`] = ` "

1234

@@ -77,3 +81,9 @@ exports[`RichText should update internal selection after fresh focus 1`] = `

12

" `; + +exports[`RichText should undo backtick transform with backspace 1`] = ` +" +

\`a\`

+" +`; diff --git a/packages/e2e-tests/specs/blocks/__snapshots__/list.test.js.snap b/packages/e2e-tests/specs/blocks/__snapshots__/list.test.js.snap index 9ce876ee4aebe..afb80c7420d2d 100644 --- a/packages/e2e-tests/specs/blocks/__snapshots__/list.test.js.snap +++ b/packages/e2e-tests/specs/blocks/__snapshots__/list.test.js.snap @@ -200,6 +200,10 @@ exports[`List should not transform lines in block when transforming multiple blo " `; +exports[`List should not undo asterisk transform with backspace after selection change 1`] = `""`; + +exports[`List should not undo asterisk transform with backspace after typing 1`] = `""`; + exports[`List should outdent with children 1`] = ` " @@ -267,3 +271,15 @@ exports[`List should split into two with paragraph and merge lists 3`] = ` " `; + +exports[`List should undo asterisk transform with backspace 1`] = ` +" +

*

+" +`; + +exports[`List should undo asterisk transform with escape 1`] = ` +" +

*

+" +`; diff --git a/packages/e2e-tests/specs/blocks/list.test.js b/packages/e2e-tests/specs/blocks/list.test.js index 54d726aca6ae8..b81010b5026a4 100644 --- a/packages/e2e-tests/specs/blocks/list.test.js +++ b/packages/e2e-tests/specs/blocks/list.test.js @@ -55,6 +55,47 @@ describe( 'List', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should undo asterisk transform with backspace', async () => { + await clickBlockAppender(); + await page.keyboard.type( '* ' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should undo asterisk transform with escape', async () => { + await clickBlockAppender(); + await page.keyboard.type( '* ' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.press( 'Escape' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not undo asterisk transform with backspace after typing', async () => { + await clickBlockAppender(); + await page.keyboard.type( '* a' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not undo asterisk transform with backspace after selection change', async () => { + await clickBlockAppender(); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '* ' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Backspace' ); + + // Expect list to be deleted + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'can be created by typing "/list"', async () => { // Create a list with the slash block shortcut. await clickBlockAppender(); diff --git a/packages/e2e-tests/specs/rich-text.test.js b/packages/e2e-tests/specs/rich-text.test.js index 4a45faa12a301..9c00e0d062c14 100644 --- a/packages/e2e-tests/specs/rich-text.test.js +++ b/packages/e2e-tests/specs/rich-text.test.js @@ -103,6 +103,42 @@ describe( 'RichText', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'should undo backtick transform with backspace', async () => { + await clickBlockAppender(); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.press( 'Backspace' ); + + // Expect "`a`" to be restored. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not undo backtick transform with backspace after typing', async () => { + await clickBlockAppender(); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + await page.keyboard.type( 'b' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.press( 'Backspace' ); + + // Expect "a" to be deleted. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not undo backtick transform with backspace after selection change', async () => { + await clickBlockAppender(); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + // Move inside format boundary. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Backspace' ); + + // Expect "a" to be deleted. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'should not format text after code backtick', async () => { await clickBlockAppender(); await page.keyboard.type( 'A `backtick` and more.' ); diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index f75aceb79cc59..880549ab617ea 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -12,7 +12,7 @@ import { * WordPress dependencies */ import { Component } from '@wordpress/element'; -import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, SPACE } from '@wordpress/keycodes'; +import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT, SPACE, ESCAPE } from '@wordpress/keycodes'; import { withSelect } from '@wordpress/data'; import { withSafeTimeout, compose } from '@wordpress/compose'; import isShallowEqual from '@wordpress/is-shallow-equal'; @@ -320,19 +320,21 @@ class RichText extends Component { return; } - if ( event && event.nativeEvent.inputType ) { - const { inputType } = event.nativeEvent; + let inputType; - // The browser formatted something or tried to insert HTML. - // Overwrite it. It will be handled later by the format library if - // needed. - if ( - inputType.indexOf( 'format' ) === 0 || - INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) - ) { - this.applyRecord( this.record ); - return; - } + if ( event ) { + inputType = event.nativeEvent.inputType; + } + + // The browser formatted something or tried to insert HTML. + // Overwrite it. It will be handled later by the format library if + // needed. + if ( inputType && ( + inputType.indexOf( 'format' ) === 0 || + INSERTION_INPUT_TYPES_TO_IGNORE.has( inputType ) + ) ) { + this.applyRecord( this.record ); + return; } const value = this.createRecord(); @@ -348,7 +350,22 @@ class RichText extends Component { this.onChange( change, { withoutHistory: true } ); - const { __unstableInputRule: inputRule, formatTypes } = this.props; + const { + __unstableInputRule: inputRule, + __unstableMarkAutomaticChange: markAutomaticChange, + formatTypes, + setTimeout, + clearTimeout, + } = this.props; + + // Create an undo level when input stops for over a second. + clearTimeout( this.onInput.timeout ); + this.onInput.timeout = setTimeout( this.onCreateUndoLevel, 1000 ); + + // Only run input rules when inserting text. + if ( inputType !== 'insertText' ) { + return; + } if ( inputRule ) { inputRule( change, this.valueToFormat ); @@ -365,11 +382,8 @@ class RichText extends Component { if ( transformed !== change ) { this.onCreateUndoLevel(); this.onChange( { ...transformed, activeFormats } ); + markAutomaticChange(); } - - // Create an undo level when input stops for over a second. - this.props.clearTimeout( this.onInput.timeout ); - this.onInput.timeout = this.props.setTimeout( this.onCreateUndoLevel, 1000 ); } onCompositionEnd() { @@ -518,7 +532,17 @@ class RichText extends Component { handleDelete( event ) { const { keyCode } = event; - if ( keyCode !== DELETE && keyCode !== BACKSPACE ) { + if ( keyCode !== DELETE && keyCode !== BACKSPACE && keyCode !== ESCAPE ) { + return; + } + + if ( this.props.__unstableDidAutomaticChange ) { + event.preventDefault(); + this.props.__unstableUndo(); + return; + } + + if ( keyCode === ESCAPE ) { return; }