diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index c6cce290985c22..03a84d530ba12a 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -605,6 +605,8 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { } moveFirstItemUp( rootClientId ); + } else { + removeBlock( clientId ); } } }, diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index aab10e9ab65476..9427962eced198 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -223,7 +223,7 @@ function RichTextWrapper( // an intentional user interaction distinguishing between Backspace and // Delete to remove the empty field, but also to avoid merge & remove // causing destruction of two fields (merge, then removed merged). - if ( onRemove && isEmpty( value ) && isReverse ) { + else if ( onRemove && isEmpty( value ) && isReverse ) { onRemove( ! isReverse ); } }, diff --git a/packages/block-editor/src/components/rich-text/native/index.native.js b/packages/block-editor/src/components/rich-text/native/index.native.js index 2381b9809eca86..ab465b24411549 100644 --- a/packages/block-editor/src/components/rich-text/native/index.native.js +++ b/packages/block-editor/src/components/rich-text/native/index.native.js @@ -650,6 +650,40 @@ export class RichText extends Component { return shouldDrop; } + /** + * Determines whether the text input should receive focus after an update. + * For cases where a RichText with a value is merged with an empty one. + * + * @param {Object} prevProps - The previous props of the component. + * @return {boolean} True if the text input should receive focus, false otherwise. + */ + shouldFocusTextInputAfterMerge( prevProps ) { + const { + __unstableIsSelected: isSelected, + blockIsSelected, + selectionStart, + selectionEnd, + __unstableMobileNoFocusOnMount, + } = this.props; + + const { + __unstableIsSelected: prevIsSelected, + blockIsSelected: prevBlockIsSelected, + } = prevProps; + + const noSelectionValues = + selectionStart === undefined && selectionEnd === undefined; + const textInputWasNotFocused = ! prevIsSelected && ! isSelected; + + return ( + ! __unstableMobileNoFocusOnMount && + noSelectionValues && + textInputWasNotFocused && + ! prevBlockIsSelected && + blockIsSelected + ); + } + onSelectionChangeFromAztec( start, end, text, event ) { if ( this.shouldDropEventFromAztec( event, 'onSelectionChange' ) ) { return; @@ -843,9 +877,8 @@ export class RichText extends Component { if ( this.props.value !== this.value ) { this.value = this.props.value; } - const { __unstableIsSelected: isSelected } = this.props; - const { __unstableIsSelected: prevIsSelected } = prevProps; + const { __unstableIsSelected: isSelected } = this.props; if ( isSelected && ! prevIsSelected ) { this._editor.focus(); @@ -855,6 +888,16 @@ export class RichText extends Component { this.props.selectionStart || 0, this.props.selectionEnd || 0 ); + } else if ( this.shouldFocusTextInputAfterMerge( prevProps ) ) { + // Since this is happening when merging blocks, the selection should be at the last character position. + // As a fallback the internal selectionEnd value is used. + const lastCharacterPosition = + this.value?.length ?? this.selectionEnd; + this._editor.focus(); + this.props.onSelectionChange( + lastCharacterPosition, + lastCharacterPosition + ); } else if ( ! isSelected && prevIsSelected ) { this._editor.blur(); } diff --git a/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap index 25867634d12d8e..1a55c807225d9d 100644 --- a/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap @@ -71,9 +71,3 @@ exports[`Buttons block when a button is shown removing button along with buttons

" `; - -exports[`Buttons block when a button is shown removing button along with buttons block removes the button and buttons block when deleting the block using the delete (backspace) key 1`] = ` -" -

-" -`; diff --git a/packages/block-library/src/buttons/test/edit.native.js b/packages/block-library/src/buttons/test/edit.native.js index 2fe70d034aa747..f393a31c7330ad 100644 --- a/packages/block-library/src/buttons/test/edit.native.js +++ b/packages/block-library/src/buttons/test/edit.native.js @@ -18,7 +18,6 @@ import { */ import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; -import { BACKSPACE } from '@wordpress/keycodes'; const BUTTONS_HTML = `
@@ -238,32 +237,6 @@ describe( 'Buttons block', () => { expect( getEditorHtml() ).toMatchSnapshot(); } ); - - it( 'removes the button and buttons block when deleting the block using the delete (backspace) key', async () => { - const screen = await initializeEditor( { - initialHtml: BUTTONS_HTML, - } ); - - // Get block - const buttonsBlock = await getBlock( screen, 'Buttons' ); - triggerBlockListLayout( buttonsBlock ); - - // Get inner button block - const buttonBlock = await getBlock( screen, 'Button' ); - fireEvent.press( buttonBlock ); - - const buttonInput = - within( buttonBlock ).getByLabelText( 'Text input. Empty' ); - - // Delete block - fireEvent( buttonInput, 'onKeyDown', { - nativeEvent: {}, - preventDefault() {}, - keyCode: BACKSPACE, - } ); - - expect( getEditorHtml() ).toMatchSnapshot(); - } ); } ); } ); diff --git a/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap b/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap index 308aa8ac729bff..c0397e823d4511 100644 --- a/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap +++ b/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap @@ -6,6 +6,12 @@ exports[`Heading block inserts block 1`] = ` " `; +exports[`Heading block should merge with an empty Paragraph block and keep being the Heading block 1`] = ` +" +

A quick brown fox jumps over the lazy dog.

+" +`; + exports[`Heading block should set a background color 1`] = ` "

A quick brown fox jumps over the lazy dog.

diff --git a/packages/block-library/src/heading/test/index.native.js b/packages/block-library/src/heading/test/index.native.js index 5b7abbc91ad94a..1582e96aae0f4d 100644 --- a/packages/block-library/src/heading/test/index.native.js +++ b/packages/block-library/src/heading/test/index.native.js @@ -17,6 +17,7 @@ import { */ import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; +import { BACKSPACE, ENTER } from '@wordpress/keycodes'; beforeAll( () => { // Register all core blocks @@ -134,4 +135,43 @@ describe( 'Heading block', () => { ) ).toBeVisible(); } ); + + it( 'should merge with an empty Paragraph block and keep being the Heading block', async () => { + // Arrange + const screen = await initializeEditor(); + await addBlock( screen, 'Paragraph' ); + + // Act + const paragraphBlock = getBlock( screen, 'Paragraph' ); + fireEvent.press( paragraphBlock ); + + const paragraphTextInput = + within( paragraphBlock ).getByPlaceholderText( 'Start writing…' ); + fireEvent( paragraphTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: ENTER, + } ); + + await addBlock( screen, 'Heading' ); + const headingBlock = getBlock( screen, 'Heading', { rowIndex: 2 } ); + fireEvent.press( headingBlock ); + + const headingTextInput = + within( headingBlock ).getByPlaceholderText( 'Heading' ); + typeInRichText( + headingTextInput, + 'A quick brown fox jumps over the lazy dog.', + { finalSelectionStart: 0, finalSelectionEnd: 0 } + ); + + fireEvent( headingTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: BACKSPACE, + } ); + + // Assert + expect( getEditorHtml() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/block-library/src/paragraph/test/edit.native.js b/packages/block-library/src/paragraph/test/edit.native.js index 8220ad0888c795..fdb082246171ba 100644 --- a/packages/block-library/src/paragraph/test/edit.native.js +++ b/packages/block-library/src/paragraph/test/edit.native.js @@ -17,11 +17,12 @@ import { waitForElementToBeRemoved, } from 'test/helpers'; import Clipboard from '@react-native-clipboard/clipboard'; +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** * WordPress dependencies */ -import { ENTER } from '@wordpress/keycodes'; +import { BACKSPACE, ENTER } from '@wordpress/keycodes'; /** * Internal dependencies @@ -685,4 +686,39 @@ describe( 'Paragraph block', () => { " ` ); } ); + + it( 'should focus on the previous Paragraph block when backspacing in an empty Paragraph block', async () => { + // Arrange + const screen = await initializeEditor(); + await addBlock( screen, 'Paragraph' ); + + // Act + const paragraphBlock = getBlock( screen, 'Paragraph' ); + fireEvent.press( paragraphBlock ); + const paragraphTextInput = + within( paragraphBlock ).getByPlaceholderText( 'Start writing…' ); + typeInRichText( paragraphTextInput, 'A quick brown fox jumps' ); + + await addBlock( screen, 'Paragraph' ); + const secondParagraphBlock = getBlock( screen, 'Paragraph', { + rowIndex: 2, + } ); + fireEvent.press( secondParagraphBlock ); + + // Clear mock history + TextInputState.focusTextInput.mockClear(); + + const secondParagraphTextInput = + within( secondParagraphBlock ).getByPlaceholderText( + 'Start writing…' + ); + fireEvent( secondParagraphTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: BACKSPACE, + } ); + + // Assert + expect( TextInputState.focusTextInput ).toHaveBeenCalled(); + } ); } ); diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 635937c4d8ce0b..4e509a232b3e52 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,7 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [***] Fix issue when backspacing in an empty Paragraph block [#56496] ## 1.109.0 - [*] Audio block: Improve legibility of audio file details on various background colors [#55627] diff --git a/test/native/integration-test-helpers/add-block.js b/test/native/integration-test-helpers/add-block.js index 5a15cb59fc6e16..eded603829c48a 100644 --- a/test/native/integration-test-helpers/add-block.js +++ b/test/native/integration-test-helpers/add-block.js @@ -6,7 +6,7 @@ import { Platform } from '@wordpress/element'; /** * External dependencies */ -import { act, fireEvent } from '@testing-library/react-native'; +import { act, fireEvent, within } from '@testing-library/react-native'; import { AccessibilityInfo } from 'react-native'; /** @@ -31,9 +31,9 @@ export const addBlock = async ( fireEvent.press( screen.getByLabelText( 'Add block' ) ); } - const blockList = screen.getByTestId( 'InserterUI-Blocks' ); + const inserterModal = screen.getByTestId( 'InserterUI-Blocks' ); // onScroll event used to force the FlatList to render all items - fireEvent.scroll( blockList, { + fireEvent.scroll( inserterModal, { nativeEvent: { contentOffset: { y: 0, x: 0 }, contentSize: { width: 100, height: 100 }, @@ -41,7 +41,7 @@ export const addBlock = async ( }, } ); - const blockButton = await screen.findByText( blockName ); + const blockButton = await within( inserterModal ).findByText( blockName ); // Blocks can perform belated state updates after they are inserted. // To avoid potential `act` warnings, we ensure that all timers and queued // microtasks are executed.