diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 3784f3be63eaa1..e56f7c4bb5a842 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -376,6 +376,9 @@ $block-navigation-max-indent: 8; } } +// When updating the margin for each indentation level, the corresponding +// indentation in `use-list-view-drop-zone.js` must be updated as well +// to ensure the drop zone is aligned with the indentation. @for $i from 0 to $block-navigation-max-indent { .block-editor-list-view-leaf[aria-level="#{ $i + 1 }"] .block-editor-list-view__expander { @if $i - 1 >= 0 { diff --git a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js index 0e5cdcf2e26c24..1e14417291a7a1 100644 --- a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js +++ b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js @@ -1,7 +1,10 @@ /** * Internal dependencies */ -import { getListViewDropTarget } from '../use-list-view-drop-zone'; +import { + getListViewDropTarget, + NESTING_LEVEL_INDENTATION, +} from '../use-list-view-drop-zone'; describe( 'getListViewDropTarget', () => { const blocksData = [ @@ -15,10 +18,10 @@ describe( 'getListViewDropTarget', () => { top: 50, bottom: 100, left: 10, - right: 100, + right: 300, x: 10, y: 50, - width: 90, + width: 290, height: 50, } ), }, @@ -26,6 +29,7 @@ describe( 'getListViewDropTarget', () => { isDraggedBlock: false, isExpanded: true, rootClientId: '', + nestingLevel: 1, }, { blockIndex: 0, @@ -37,32 +41,56 @@ describe( 'getListViewDropTarget', () => { top: 100, bottom: 150, left: 10, - right: 100, + right: 300, x: 10, y: 100, - width: 90, + width: 290, height: 50, } ), }, - innerBlockCount: 0, + innerBlockCount: 1, isDraggedBlock: false, - isExpanded: false, + isExpanded: true, rootClientId: 'block-1', + nestingLevel: 2, }, { - blockIndex: 1, + blockIndex: 0, canInsertDraggedBlocksAsChild: true, canInsertDraggedBlocksAsSibling: true, clientId: 'block-3', element: { getBoundingClientRect: () => ( { top: 150, - bottom: 150, + bottom: 200, left: 10, - right: 100, + right: 300, x: 10, y: 150, - width: 90, + width: 290, + height: 50, + } ), + }, + innerBlockCount: 0, + isDraggedBlock: false, + isExpanded: true, + rootClientId: 'block-2', + nestingLevel: 3, + }, + { + blockIndex: 1, + canInsertDraggedBlocksAsChild: true, + canInsertDraggedBlocksAsSibling: true, + clientId: 'block-4', + element: { + getBoundingClientRect: () => ( { + top: 200, + bottom: 250, + left: 10, + right: 300, + x: 10, + y: 200, + width: 290, height: 50, } ), }, @@ -70,6 +98,7 @@ describe( 'getListViewDropTarget', () => { isDraggedBlock: false, isExpanded: false, rootClientId: '', + nestingLevel: 1, }, ]; @@ -96,8 +125,55 @@ describe( 'getListViewDropTarget', () => { } ); } ); + it( 'should nest when dragging a block over the right side of the bottom half of a block nested to three levels', () => { + const position = { x: 250, y: 180 }; + const target = getListViewDropTarget( blocksData, position ); + + expect( target ).toEqual( { + blockIndex: 0, + dropPosition: 'inside', + rootClientId: 'block-3', + } ); + } ); + + it( 'should drag below when positioned at the bottom half of a block nested to three levels, and over the third level horizontally', () => { + const position = { x: 10 + NESTING_LEVEL_INDENTATION * 3, y: 180 }; + const target = getListViewDropTarget( blocksData, position ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-3', + dropPosition: 'bottom', + rootClientId: 'block-2', + } ); + } ); + + it( 'should drag one level up below when positioned at the bottom half of a block nested to three levels, and over the second level horizontally', () => { + const position = { x: 10 + NESTING_LEVEL_INDENTATION * 2, y: 180 }; + const target = getListViewDropTarget( blocksData, position ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-3', + dropPosition: 'bottom', + rootClientId: 'block-1', + } ); + } ); + + it( 'should drag two levels up below when positioned at the bottom half of a block nested to three levels, and over the first level horizontally', () => { + const position = { x: 10 + NESTING_LEVEL_INDENTATION, y: 180 }; + const target = getListViewDropTarget( blocksData, position ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-3', + dropPosition: 'bottom', + rootClientId: '', + } ); + } ); + it( 'should nest when dragging a block over the right side and bottom half of a collapsed block with children', () => { - const position = { x: 70, y: 90 }; + const position = { x: 160, y: 90 }; const collapsedBlockData = [ ...blocksData ]; diff --git a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js index e49a224e44efbe..ffcd15e529ab25 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js +++ b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js @@ -27,9 +27,9 @@ import { store as blockEditorStore } from '../../store'; */ /** - * An array representing data for blocks in the DOM used by drag and drop. + * An object representing data for blocks in the DOM used by drag and drop. * - * @typedef {Object} WPListViewDropZoneBlocks + * @typedef {Object} WPListViewDropZoneBlock * @property {string} clientId The client id for the block. * @property {string} rootClientId The root client id for the block. * @property {number} blockIndex The block's index. @@ -41,6 +41,12 @@ import { store as blockEditorStore } from '../../store'; * @property {boolean} canInsertDraggedBlocksAsChild Whether the dragged block can be a child of this block. */ +/** + * An array representing data for blocks in the DOM used by drag and drop. + * + * @typedef {WPListViewDropZoneBlock[]} WPListViewDropZoneBlocks + */ + /** * An object containing details of a drop target. * @@ -52,19 +58,110 @@ import { store as blockEditorStore } from '../../store'; * 'inside' refers to nesting as an inner block. */ +// When the indentation level, the corresponding left margin in `style.scss` +// must be updated as well to ensure the drop zone is aligned with the indentation. +export const NESTING_LEVEL_INDENTATION = 28; + +/** + * Determines whether the user is positioning the dragged block to be + * moved up to a parent level. + * + * Determined based on nesting level indentation of the current block. + * + * @param {WPPoint} point The point representing the cursor position when dragging. + * @param {DOMRect} rect The rectangle. + * @param {number} nestingLevel The nesting level of the block. + * @return {boolean} Whether the gesture is an upward gesture. + */ +function isUpGesture( point, rect, nestingLevel = 1 ) { + // If the block is nested, and the user is dragging to the bottom + // left of the block, then it is an upward gesture. + const blockIndentPosition = + rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; + return point.x < blockIndentPosition; +} + +/** + * Returns how many nesting levels up the user is attempting to drag to. + * + * The relative parent level is calculated based on how far + * the cursor is from the provided nesting level (e.g. of a candidate block + * that the user is hovering over). The nesting level is considered "desired" + * because it is not guaranteed that the user will be able to drag to the desired level. + * + * The returned integer can be used to access an ascending array + * of parent blocks, where the first item is the block the user + * is hovering over, and the last item is the root block. + * + * @param {WPPoint} point The point representing the cursor position when dragging. + * @param {DOMRect} rect The rectangle. + * @param {number} nestingLevel The nesting level of the block. + * @return {number} The desired relative parent level. + */ +function getDesiredRelativeParentLevel( point, rect, nestingLevel = 1 ) { + const blockIndentPosition = + rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; + const desiredParentLevel = Math.round( + ( point.x - blockIndentPosition ) / NESTING_LEVEL_INDENTATION + ); + return Math.abs( desiredParentLevel ); +} + +/** + * Returns an array of the parent blocks of the block the user is dropping to. + * + * @param {WPListViewDropZoneBlock} candidateBlockData The block the user is dropping to. + * @param {WPListViewDropZoneBlocks} blocksData Data about the blocks in list view. + * @return {WPListViewDropZoneBlocks} An array of block parents, including the block the user is dropping to. + */ +function getCandidateBlockParents( candidateBlockData, blocksData ) { + const candidateBlockParents = []; + let currentBlockData = candidateBlockData; + + while ( currentBlockData ) { + candidateBlockParents.push( { ...currentBlockData } ); + currentBlockData = blocksData.find( + ( blockData ) => + blockData.clientId === currentBlockData.rootClientId + ); + } + + return candidateBlockParents; +} + +/** + * Given a list of blocks data and a block index, return the next non-dragged + * block. This is used to determine the block that the user is dropping to, + * while ignoring the dragged block. + * + * @param {WPListViewDropZoneBlocks} blocksData Data about the blocks in list view. + * @param {number} index The index to begin searching from. + * @return {WPListViewDropZoneBlock | undefined} The next non-dragged block. + */ +function getNextNonDraggedBlock( blocksData, index ) { + const nextBlockData = blocksData[ index + 1 ]; + if ( nextBlockData && nextBlockData.isDraggedBlock ) { + return getNextNonDraggedBlock( blocksData, index + 1 ); + } + + return nextBlockData; +} + /** * Determines whether the user positioning the dragged block to nest as an * inner block. * - * Presently this is determined by whether the cursor is on the right hand side - * of the block. + * Determined based on nesting level indentation of the current block, plus + * the indentation of the next level of nesting. * - * @param {WPPoint} point The point representing the cursor position when dragging. - * @param {DOMRect} rect The rectangle. + * @param {WPPoint} point The point representing the cursor position when dragging. + * @param {DOMRect} rect The rectangle. + * @param {number} nestingLevel The nesting level of the block. */ -function isNestingGesture( point, rect ) { - const blockCenterX = rect.left + rect.width / 2; - return point.x > blockCenterX; +function isNestingGesture( point, rect, nestingLevel = 1 ) { + const blockIndentPosition = + rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; + return point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION; } // Block navigation is always a vertical list, so only allow dropping @@ -84,8 +181,10 @@ export function getListViewDropTarget( blocksData, position ) { let candidateBlockData; let candidateDistance; let candidateRect; + let candidateBlockIndex; - for ( const blockData of blocksData ) { + for ( let i = 0; i < blocksData.length; i++ ) { + const blockData = blocksData[ i ]; if ( blockData.isDraggedBlock ) { continue; } @@ -121,10 +220,12 @@ export function getListViewDropTarget( blocksData, position ) { candidateEdge = 'bottom'; candidateRect = previousBlockData.element.getBoundingClientRect(); + candidateBlockIndex = index - 1; } else { candidateBlockData = blockData; candidateEdge = edge; candidateRect = rect; + candidateBlockIndex = index; } // If the mouse position is within the block, break early @@ -143,8 +244,79 @@ export function getListViewDropTarget( blocksData, position ) { return; } + const candidateBlockParents = getCandidateBlockParents( + candidateBlockData, + blocksData + ); + const isDraggingBelow = candidateEdge === 'bottom'; + // If the user is dragging towards the bottom of the block check whether + // they might be trying to move the block to be at a parent level. + if ( + isDraggingBelow && + candidateBlockData.rootClientId && + isUpGesture( position, candidateRect, candidateBlockParents.length ) + ) { + const nextBlock = getNextNonDraggedBlock( + blocksData, + candidateBlockIndex + ); + const currentLevel = candidateBlockData.nestingLevel; + const nextLevel = nextBlock ? nextBlock.nestingLevel : 1; + + if ( currentLevel && nextLevel ) { + // Determine the desired relative level of the block to be dropped. + const desiredRelativeLevel = getDesiredRelativeParentLevel( + position, + candidateRect, + candidateBlockParents.length + ); + + const targetParentIndex = Math.max( + Math.min( desiredRelativeLevel, currentLevel - nextLevel ), + 0 + ); + + if ( candidateBlockParents[ targetParentIndex ] ) { + // Default to the block index of the candidate block. + let newBlockIndex = candidateBlockData.blockIndex; + + // If the next block is at the same level, use that as the default + // block index. This ensures that the block is dropped in the correct + // position when dragging to the bottom of a block. + if ( + candidateBlockParents[ targetParentIndex ].nestingLevel === + nextBlock?.nestingLevel + ) { + newBlockIndex = nextBlock?.blockIndex; + } else { + // Otherwise, search from the current block index back + // to find the last block index within the same target parent. + for ( let i = candidateBlockIndex; i >= 0; i-- ) { + const blockData = blocksData[ i ]; + if ( + blockData.rootClientId === + candidateBlockParents[ targetParentIndex ] + .rootClientId + ) { + newBlockIndex = blockData.blockIndex + 1; + break; + } + } + } + + return { + rootClientId: + candidateBlockParents[ targetParentIndex ].rootClientId, + clientId: candidateBlockData.clientId, + blockIndex: newBlockIndex, + dropPosition: candidateEdge, + }; + } + } + } + // If the user is dragging towards the bottom of the block check whether // they might be trying to nest the block as a child. // If the block already has inner blocks, and is expanded, this should be treated @@ -156,7 +328,11 @@ export function getListViewDropTarget( blocksData, position ) { candidateBlockData.canInsertDraggedBlocksAsChild && ( ( candidateBlockData.innerBlockCount > 0 && candidateBlockData.isExpanded ) || - isNestingGesture( position, candidateRect ) ) + isNestingGesture( + position, + candidateRect, + candidateBlockParents.length + ) ) ) { return { rootClientId: candidateBlockData.clientId, @@ -213,6 +389,12 @@ export default function useListViewDropZone() { const blocksData = blockElements.map( ( blockElement ) => { const clientId = blockElement.dataset.block; const isExpanded = blockElement.dataset.expanded === 'true'; + + // Get nesting level from `aria-level` attribute because Firefox does not support `element.ariaLevel`. + const nestingLevel = parseInt( + blockElement.getAttribute( 'aria-level' ), + 10 + ); const rootClientId = getBlockRootClientId( clientId ); return { @@ -221,6 +403,7 @@ export default function useListViewDropZone() { rootClientId, blockIndex: getBlockIndex( clientId ), element: blockElement, + nestingLevel: nestingLevel || undefined, isDraggedBlock: isBlockDrag ? draggedBlockClientIds.includes( clientId ) : false,