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 2c21f63ee8c27f..70c5a78de54766 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -114,6 +114,7 @@ function RichTextWrapper( disableSuggestions, disableAutocorrection, containerWidth, + onEnter: onCustomEnter, ...props }, forwardedRef @@ -345,6 +346,10 @@ function RichTextWrapper( } } + if ( onCustomEnter ) { + onCustomEnter(); + } + if ( multiline ) { if ( shiftKey ) { if ( ! disableLineBreaks ) { diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index fa75d537b0cbcb..b292bf6437c388 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1002,7 +1002,7 @@ export const __unstableExpandSelection = */ export const mergeBlocks = ( firstBlockClientId, secondBlockClientId ) => - ( { select, dispatch } ) => { + ( { registry, select, dispatch } ) => { const blocks = [ firstBlockClientId, secondBlockClientId ]; dispatch( { type: 'MERGE_BLOCKS', blocks } ); @@ -1010,13 +1010,41 @@ export const mergeBlocks = const blockA = select.getBlock( clientIdA ); const blockAType = getBlockType( blockA.name ); - // Only focus the previous block if it's not mergeable. + if ( ! blockAType ) return; + + const blockB = select.getBlock( clientIdB ); + if ( blockAType && ! blockAType.merge ) { - dispatch.selectBlock( blockA.clientId ); + // If there's no merge function defined, attempt merging inner + // blocks. + const blocksWithTheSameType = switchToBlockType( + blockB, + blockAType.name + ); + // Only focus the previous block if it's not mergeable. + if ( blocksWithTheSameType?.length !== 1 ) { + dispatch.selectBlock( blockA.clientId ); + return; + } + const [ blockWithSameType ] = blocksWithTheSameType; + if ( blockWithSameType.innerBlocks.length < 1 ) { + dispatch.selectBlock( blockA.clientId ); + return; + } + registry.batch( () => { + dispatch.insertBlocks( + blockWithSameType.innerBlocks, + undefined, + clientIdA + ); + dispatch.removeBlock( clientIdB ); + dispatch.selectBlock( + blockWithSameType.innerBlocks[ 0 ].clientId + ); + } ); return; } - const blockB = select.getBlock( clientIdB ); const blockBType = getBlockType( blockB.name ); const { clientId, attributeKey, offset } = select.getSelectionStart(); const selectedBlockType = diff --git a/packages/block-library/src/list-item/edit.native.js b/packages/block-library/src/list-item/edit.native.js index 5326cbd79b0e47..dcd20f11c9d5e8 100644 --- a/packages/block-library/src/list-item/edit.native.js +++ b/packages/block-library/src/list-item/edit.native.js @@ -16,12 +16,12 @@ import { import { __ } from '@wordpress/i18n'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; -import { useState, useCallback } from '@wordpress/element'; +import { useState, useCallback, useRef } from '@wordpress/element'; /** * Internal dependencies */ -import { useSplit, useMerge } from './hooks'; +import { useSplit, useMerge, useEnter } from './hooks'; import { convertToListItems } from './utils'; import { IndentUI } from './edit.js'; import styles from './style.scss'; @@ -116,8 +116,26 @@ export default function ListItemEdit( { } ), }; + const preventDefault = useRef( false ); + const { onEnter } = useEnter( { content, clientId }, preventDefault ); const onSplit = useSplit( clientId ); const onMerge = useMerge( clientId ); + const onSplitList = useCallback( + ( value ) => { + if ( ! preventDefault.current ) { + return onSplit( value ); + } + }, + [ clientId, onSplit ] + ); + const onReplaceList = useCallback( + ( blocks, ...args ) => { + if ( ! preventDefault.current ) { + onReplace( convertToListItems( blocks ), ...args ); + } + }, + [ clientId, onReplace, convertToListItems ] + ); const onLayout = useCallback( ( { nativeEvent } ) => { setContentWidth( ( prevState ) => { const { width } = nativeEvent.layout; @@ -158,11 +176,10 @@ export default function ListItemEdit( { placeholderTextColor={ defaultPlaceholderTextColorWithOpacity } - onSplit={ onSplit } + onSplit={ onSplitList } onMerge={ onMerge } - onReplace={ ( blocks, ...args ) => { - onReplace( convertToListItems( blocks ), ...args ); - } } + onReplace={ onReplaceList } + onEnter={ onEnter } style={ styleWithPlaceholderOpacity } deleteEnter={ true } containerWidth={ contentWidth } diff --git a/packages/block-library/src/list-item/hooks/use-enter.native.js b/packages/block-library/src/list-item/hooks/use-enter.native.js new file mode 100644 index 00000000000000..d3be5f1ea0e1bb --- /dev/null +++ b/packages/block-library/src/list-item/hooks/use-enter.native.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + getDefaultBlockName, + cloneBlock, +} from '@wordpress/blocks'; +import { useRef } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import useOutdentListItem from './use-outdent-list-item'; + +export default function useEnter( props, preventDefault ) { + const { replaceBlocks, selectionChange } = useDispatch( blockEditorStore ); + const { getBlock, getBlockRootClientId, getBlockIndex } = + useSelect( blockEditorStore ); + const propsRef = useRef( props ); + propsRef.current = props; + const [ canOutdent, outdentListItem ] = useOutdentListItem( + propsRef.current.clientId + ); + + return { + onEnter() { + const { content, clientId } = propsRef.current; + if ( content.length ) { + return; + } + preventDefault.current = true; + if ( canOutdent ) { + outdentListItem(); + return; + } + // Here we are in top level list so we need to split. + const topParentListBlock = getBlock( + getBlockRootClientId( clientId ) + ); + const blockIndex = getBlockIndex( clientId ); + const head = cloneBlock( { + ...topParentListBlock, + innerBlocks: topParentListBlock.innerBlocks.slice( + 0, + blockIndex + ), + } ); + const middle = createBlock( getDefaultBlockName() ); + // Last list item might contain a `list` block innerBlock + // In that case append remaining innerBlocks blocks. + const after = [ + ...( topParentListBlock.innerBlocks[ blockIndex ] + .innerBlocks[ 0 ]?.innerBlocks || [] ), + ...topParentListBlock.innerBlocks.slice( blockIndex + 1 ), + ]; + const tail = after.length + ? [ + cloneBlock( { + ...topParentListBlock, + innerBlocks: after, + } ), + ] + : []; + replaceBlocks( + topParentListBlock.clientId, + [ head, middle, ...tail ], + 1 + ); + // We manually change the selection here because we are replacing + // a different block than the selected one. + selectionChange( middle.clientId ); + }, + }; +} diff --git a/packages/e2e-tests/specs/editor/blocks/__snapshots__/group.test.js.snap b/packages/e2e-tests/specs/editor/blocks/__snapshots__/group.test.js.snap deleted file mode 100644 index df7b3f6419f155..00000000000000 --- a/packages/e2e-tests/specs/editor/blocks/__snapshots__/group.test.js.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Group can be created using the block inserter 1`] = ` -" -
-" -`; - -exports[`Group can be created using the slash inserter 1`] = ` -" -
-" -`; - -exports[`Group can have other blocks appended to it using the button appender 1`] = ` -" -
-

Group Block with a Paragraph

-
-" -`; diff --git a/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap b/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap index d1121eebc29bda..2478dc53f7cf74 100644 --- a/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap +++ b/packages/e2e-tests/specs/editor/blocks/__snapshots__/quote.test.js.snap @@ -114,6 +114,10 @@ exports[`Quote can be split at the end 2`] = ` "

1

+ + + +

" `; diff --git a/packages/e2e-tests/specs/editor/blocks/group.test.js b/packages/e2e-tests/specs/editor/blocks/group.test.js deleted file mode 100644 index 142cfe79523df0..00000000000000 --- a/packages/e2e-tests/specs/editor/blocks/group.test.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - searchForBlock, - getEditedPostContent, - createNewPost, - pressKeyWithModifier, - transformBlockTo, -} from '@wordpress/e2e-test-utils'; - -describe( 'Group', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'can be created using the block inserter', async () => { - await searchForBlock( 'Group' ); - await page.click( '.editor-block-list-item-group' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'can be created using the slash inserter', async () => { - await clickBlockAppender(); - await page.keyboard.type( '/group' ); - await page.waitForXPath( - `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Group')]` - ); - await page.keyboard.press( 'Enter' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'can have other blocks appended to it using the button appender', async () => { - await searchForBlock( 'Group' ); - await page.click( '.editor-block-list-item-group' ); - await page.click( '.block-editor-button-block-appender' ); - await page.click( '.editor-block-list-item-paragraph' ); - await page.keyboard.type( 'Group Block with a Paragraph' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'can wrap in group and unwrap group', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'shift', 'ArrowUp' ); - await transformBlockTo( 'Group' ); - - expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` - " -
-

1

- - - -

2

-
- " - ` ); - - await transformBlockTo( 'Unwrap' ); - - expect( await getEditedPostContent() ).toMatchInlineSnapshot( ` - " -

1

- - - -

2

- " - ` ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/blocks/quote.test.js b/packages/e2e-tests/specs/editor/blocks/quote.test.js index 710b10a4f592d8..c598f02d452341 100644 --- a/packages/e2e-tests/specs/editor/blocks/quote.test.js +++ b/packages/e2e-tests/specs/editor/blocks/quote.test.js @@ -143,8 +143,9 @@ describe( 'Quote', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( '2' ); - // Expect the paragraph to be deleted. + // Expect the paragraph to be merged into the quote block. expect( await getEditedPostContent() ).toMatchSnapshot(); } ); diff --git a/test/e2e/specs/editor/blocks/__snapshots__/Group-can-be-created-using-the-block-inserter-1-chromium.txt b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-be-created-using-the-block-inserter-1-chromium.txt new file mode 100644 index 00000000000000..44774b1bf76800 --- /dev/null +++ b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-be-created-using-the-block-inserter-1-chromium.txt @@ -0,0 +1,3 @@ + +
+ \ No newline at end of file diff --git a/test/e2e/specs/editor/blocks/__snapshots__/Group-can-be-created-using-the-slash-inserter-1-chromium.txt b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-be-created-using-the-slash-inserter-1-chromium.txt new file mode 100644 index 00000000000000..44774b1bf76800 --- /dev/null +++ b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-be-created-using-the-slash-inserter-1-chromium.txt @@ -0,0 +1,3 @@ + +
+ \ No newline at end of file diff --git a/test/e2e/specs/editor/blocks/__snapshots__/Group-can-have-other-blocks-appended-to-it-using-the-button-appender-1-chromium.txt b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-have-other-blocks-appended-to-it-using-the-button-appender-1-chromium.txt new file mode 100644 index 00000000000000..09839ec9963532 --- /dev/null +++ b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-have-other-blocks-appended-to-it-using-the-button-appender-1-chromium.txt @@ -0,0 +1,5 @@ + +
+

Group Block with a Paragraph

+
+ \ No newline at end of file diff --git a/test/e2e/specs/editor/blocks/__snapshots__/Group-can-merge-into-group-with-Backspace-1-chromium.txt b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-merge-into-group-with-Backspace-1-chromium.txt new file mode 100644 index 00000000000000..11f6c1766c3d19 --- /dev/null +++ b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-merge-into-group-with-Backspace-1-chromium.txt @@ -0,0 +1,9 @@ + +
+

1

+
+ + + +

2

+ \ No newline at end of file diff --git a/test/e2e/specs/editor/blocks/__snapshots__/Group-can-merge-into-group-with-Backspace-2-chromium.txt b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-merge-into-group-with-Backspace-2-chromium.txt new file mode 100644 index 00000000000000..b7bf710d8b9e93 --- /dev/null +++ b/test/e2e/specs/editor/blocks/__snapshots__/Group-can-merge-into-group-with-Backspace-2-chromium.txt @@ -0,0 +1,9 @@ + +
+

1

+ + + +

2

+
+ \ No newline at end of file diff --git a/test/e2e/specs/editor/blocks/group.spec.js b/test/e2e/specs/editor/blocks/group.spec.js new file mode 100644 index 00000000000000..ae4da6153bad5a --- /dev/null +++ b/test/e2e/specs/editor/blocks/group.spec.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Group', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'can be created using the block inserter', async ( { + editor, + page, + } ) => { + // Search for the group block and insert it. + const inserterButton = page.locator( + 'role=button[name="Toggle block inserter"i]' + ); + + await inserterButton.click(); + + await page.type( + 'role=searchbox[name="Search for blocks and patterns"i]', + 'Group' + ); + + await page.click( + 'role=listbox[name="Blocks"i] >> role=option[name="Group"i]' + ); + + expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + } ); + + test( 'can be created using the slash inserter', async ( { + editor, + page, + } ) => { + await page.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '/group' ); + await expect( + page.locator( 'role=option[name="Group"i][selected]' ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); + + expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + } ); + + test( 'can have other blocks appended to it using the button appender', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { name: 'core/group' } ); + await page.click( 'role=button[name="Add block"i]' ); + await page.click( + 'role=listbox[name="Blocks"i] >> role=option[name="Paragraph"i]' + ); + await page.keyboard.type( 'Group Block with a Paragraph' ); + + expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + } ); + + test( 'can merge into group with Backspace', async ( { editor, page } ) => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '1' ); + await editor.transformBlockTo( 'core/group' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + + // Confirm last paragraph is outside of group. + expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + + // Merge the last paragraph into the group. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + } ); +} );