Skip to content
Merged
99 changes: 53 additions & 46 deletions packages/block-library/src/list-item/hooks/use-merge.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { useRegistry, useDispatch, useSelect } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { isUnmodifiedBlock } from '@wordpress/blocks';
import { isUnmodifiedBlock, switchToBlockType } from '@wordpress/blocks';

/**
* Internal dependencies
Expand All @@ -20,7 +20,7 @@ export default function useMerge( clientId, onMerge ) {
getBlockName,
getBlock,
} = useSelect( blockEditorStore );
const { mergeBlocks, moveBlocksToPosition, removeBlock } =
const { mergeBlocks, moveBlocksToPosition, removeBlock, insertBlocks } =
useDispatch( blockEditorStore );
const outdentListItem = useOutdentListItem();

Expand All @@ -46,45 +46,6 @@ export default function useMerge( clientId, onMerge ) {
return parentListItemId;
}

/**
* Return the next list item with respect to the given list item. If none,
* return the next list item of the parent list item if it exists.
*
* @param {string} id A list item client ID.
* @return {?string} The client ID of the next list item.
*/
function _getNextId( id ) {
const next = getNextBlockClientId( id );
if ( next ) {
return next;
}
const parentListItemId = getParentListItemId( id );
if ( ! parentListItemId ) {
return;
}
return _getNextId( parentListItemId );
}

/**
* Given a client ID, return the client ID of the list item on the next
* line, regardless of indentation level.
*
* @param {string} id The client ID of the current list item.
* @return {?string} The client ID of the next list item.
*/
function getNextId( id ) {
const order = getBlockOrder( id );

// If the list item does not have a nested list, return the next list
// item.
if ( ! order.length ) {
return _getNextId( id );
}

// Get the first list item in the nested list.
return getBlockOrder( order[ 0 ] )[ 0 ];
}

return ( forward ) => {
function mergeWithNested( clientIdA, clientIdB ) {
registry.batch( () => {
Expand Down Expand Up @@ -119,14 +80,60 @@ export default function useMerge( clientId, onMerge ) {
}

if ( forward ) {
const nextBlockClientId = getNextId( clientId );
// Start by diving into the nested list (if any); otherwise walk up
// parent list items for a next sibling. `listItemId` ends on the
// topmost list item if none is found.
const innerListId = getBlockOrder( clientId )[ 0 ];
let nextBlockClientId;
let listItemId = clientId;
if ( innerListId ) {
nextBlockClientId = getBlockOrder( innerListId )[ 0 ];
} else {
while (
! ( nextBlockClientId = getNextBlockClientId( listItemId ) )
) {
const parentLi = getParentListItemId( listItemId );
if ( ! parentLi ) {
break;
}
listItemId = parentLi;
}
}

if ( ! nextBlockClientId ) {
onMerge( forward );
return;
}
const outerListId = getBlockRootClientId( listItemId );
const followingBlockId = getNextBlockClientId( outerListId );

if ( getParentListItemId( nextBlockClientId ) ) {
if ( followingBlockId ) {
if ( getBlockName( followingBlockId ) === 'core/list' ) {
registry.batch( () => {
moveBlocksToPosition(
getBlockOrder( followingBlockId ),
followingBlockId,
outerListId
);
removeBlock( followingBlockId, false );
} );
} else {
const transformed = switchToBlockType(
getBlock( followingBlockId ),
'core/list'
);
const newInnerBlocks = transformed?.[ 0 ]?.innerBlocks;
if ( newInnerBlocks?.length ) {
registry.batch( () => {
insertBlocks(
newInnerBlocks,
undefined,
outerListId,
false
);
removeBlock( followingBlockId, false );
} );
}
}
}
} else if ( getParentListItemId( nextBlockClientId ) ) {
outdentListItem( nextBlockClientId );
} else {
mergeWithNested( clientId, nextBlockClientId );
Expand Down
218 changes: 218 additions & 0 deletions test/e2e/specs/editor/blocks/list.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,224 @@ test.describe( 'List (@firefox)', () => {
} );
} );

test( 'should merge a following paragraph into the outermost list with Delete from a nested item (#77245)', async ( {
editor,
page,
} ) => {
const startingContent = [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'outer' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'inner' },
},
],
},
],
},
],
},
{
name: 'core/paragraph',
attributes: { content: 'text' },
},
];
for ( const block of startingContent ) {
await editor.insertBlock( block );
}

// Place the cursor at the end of "inner" by stepping back from
// the start of the paragraph.
await page.keyboard.press( 'Home' );
await page.keyboard.press( 'ArrowLeft' );

await page.keyboard.press( 'Delete' );

// The caret stays at the end of "inner" — Delete shouldn't
// move the cursor away from the block the user pressed it in.
await page.keyboard.type( '‸' );

await expect.poll( editor.getBlocks ).toMatchObject( [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'outer' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'inner‸' },
},
],
},
],
},
{
name: 'core/list-item',
attributes: { content: 'text' },
},
],
},
] );
} );

test( 'should merge a following sibling list into the outermost list with Delete from a nested item (#77245)', async ( {
editor,
page,
} ) => {
const startingContent = [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'a' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'b' },
},
],
},
],
},
],
},
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: '1' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: '2' },
},
],
},
],
},
],
},
];
for ( const block of startingContent ) {
await editor.insertBlock( block );
}

await page.keyboard.press( 'ArrowUp' );
await page.keyboard.press( 'ArrowRight' );

// Verify the setup: caret lands at end of the first list's
// inner "b".
await page.keyboard.type( '‸' );
await expect.poll( editor.getBlocks ).toMatchObject( [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'a' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'b‸' },
},
],
},
],
},
],
},
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: '1' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: '2' },
},
],
},
],
},
],
},
] );
await page.keyboard.press( 'Backspace' );

// Action under test.
await page.keyboard.press( 'Delete' );

// Caret stays at end of "b"; the second list's items are
// absorbed as outer-level siblings of "a".
await page.keyboard.type( '‸' );
await expect.poll( editor.getBlocks ).toMatchObject( [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'a' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: 'b‸' },
},
],
},
],
},
{
name: 'core/list-item',
attributes: { content: '1' },
innerBlocks: [
{
name: 'core/list',
innerBlocks: [
{
name: 'core/list-item',
attributes: { content: '2' },
},
],
},
],
},
],
},
] );
} );

test( 'should leave nested list intact when deleting the parent item', async ( {
editor,
page,
Expand Down
Loading