diff --git a/packages/block-editor/src/components/block-list-appender/style.scss b/packages/block-editor/src/components/block-list-appender/style.scss index e382d17507063..f5eb38c57f8f4 100644 --- a/packages/block-editor/src/components/block-list-appender/style.scss +++ b/packages/block-editor/src/components/block-list-appender/style.scss @@ -19,17 +19,6 @@ } } -.block-list-appender.is-drop-target > div::before { - content: ""; - position: absolute; - right: -$grid-unit-10; - left: -$grid-unit-10; - top: -$grid-unit-10; - bottom: -$grid-unit-10; - border-radius: $radius-block-ui; - border: 3px solid var(--wp-admin-theme-color); -} - .block-list-appender > .block-editor-inserter { display: block; } diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index da198a7deba26..0f60351182632 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -37,6 +37,7 @@ function BlockList( function selector( select ) { const { getBlockOrder, + getBlockListSettings, isMultiSelecting, getSelectedBlockClientId, getMultiSelectedBlockClientIds, @@ -50,6 +51,8 @@ function BlockList( isMultiSelecting: isMultiSelecting(), selectedBlockClientId: getSelectedBlockClientId(), multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(), + moverDirection: getBlockListSettings( rootClientId ) + ?.__experimentalMoverDirection, hasMultiSelection: hasMultiSelection(), enableAnimation: ! isTyping() && @@ -62,16 +65,19 @@ function BlockList( isMultiSelecting, selectedBlockClientId, multiSelectedBlockClientIds, + moverDirection, hasMultiSelection, enableAnimation, } = useSelect( selector, [ rootClientId ] ); const Container = rootClientId ? __experimentalTagName : RootContainer; - const targetClientId = useBlockDropZone( { + const dropTargetIndex = useBlockDropZone( { element: ref, rootClientId, } ); + const isAppenderDropTarget = dropTargetIndex === blockClientIds.length; + return ( ); @@ -114,9 +123,11 @@ function BlockList( tagName={ __experimentalAppenderTagName } rootClientId={ rootClientId } renderAppender={ renderAppender } - className={ - targetClientId === null ? 'is-drop-target' : undefined - } + className={ classnames( { + 'is-drop-target': isAppenderDropTarget, + 'is-dropping-horizontally': + isAppenderDropTarget && moverDirection === 'horizontal', + } ) } /> ); diff --git a/packages/block-editor/src/components/block-list/style.scss b/packages/block-editor/src/components/block-list/style.scss index 0ffd059aacdfb..932115b43f760 100644 --- a/packages/block-editor/src/components/block-list/style.scss +++ b/packages/block-editor/src/components/block-list/style.scss @@ -96,6 +96,11 @@ opacity: 1; } } +} + +.block-editor-block-list__layout .block-editor-block-list__block, +.block-editor-block-list__layout .block-list-appender { + position: relative; // Between-blocks dropzone line indicator. &.is-drop-target::before { @@ -104,13 +109,21 @@ z-index: 0; pointer-events: none; transition: border-color 0.1s linear, border-style 0.1s linear, box-shadow 0.1s linear; + top: -$default-block-margin / 2; right: 0; left: 0; - top: -$default-block-margin / 2; border-top: 4px solid var(--wp-admin-theme-color); } -} + &.is-drop-target.is-dropping-horizontally::before { + top: 0; + bottom: 0; + // Drop target border-width plus a couple of pixels so that the border looks between blocks. + left: -6px; + border-top: none; + border-left: 4px solid var(--wp-admin-theme-color); + } +} /** * Cross-Block Selection diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index 6f15cb0284af0..77861a19d63f6 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -8,9 +8,119 @@ import { findTransform, } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect, useState, useCallback } from '@wordpress/element'; +import { useEffect, useCallback, useState } from '@wordpress/element'; -const parseDropEvent = ( event ) => { +/** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */ + +/** + * @typedef {Object} WPBlockDragPosition + * @property {number} x The horizontal position of a the block being dragged. + * @property {number} y The vertical position of the block being dragged. + */ + +/** + * The orientation of a block list. + * + * @typedef {'horizontal'|'vertical'|undefined} WPBlockListOrientation + */ + +/** + * Given a list of block DOM elements finds the index that a block should be dropped + * at. + * + * This function works for both horizontal and vertical block lists and uses the following + * terms for its variables: + * + * - Lateral, meaning the axis running horizontally when a block list is vertical and vertically when a block list is horizontal. + * - Forward, meaning the axis running vertically when a block list is vertical and horizontally + * when a block list is horizontal. + * + * + * @param {Element[]} elements Array of DOM elements that represent each block in a block list. + * @param {WPBlockDragPosition} position The position of the item being dragged. + * @param {WPBlockListOrientation} orientation The orientation of a block list. + * + * @return {number|undefined} The block index that's closest to the drag position. + */ +export function getNearestBlockIndex( elements, position, orientation ) { + const { x, y } = position; + const isHorizontal = orientation === 'horizontal'; + + let candidateIndex; + let candidateDistance; + + elements.forEach( ( element, index ) => { + // Ensure the element is a block. It should have the `data-block` attribute. + if ( ! element.dataset.block ) { + return; + } + + const rect = element.getBoundingClientRect(); + const cursorLateralPosition = isHorizontal ? y : x; + const cursorForwardPosition = isHorizontal ? x : y; + const edgeLateralStart = isHorizontal ? rect.top : rect.left; + const edgeLateralEnd = isHorizontal ? rect.bottom : rect.right; + + // When the cursor position is within the lateral bounds of the block, + // measure the straight line distance to the nearest point on the + // block's edge, else measure diagonal distance to the nearest corner. + let edgeLateralPosition; + if ( + cursorLateralPosition >= edgeLateralStart && + cursorLateralPosition <= edgeLateralEnd + ) { + edgeLateralPosition = cursorLateralPosition; + } else if ( cursorLateralPosition < edgeLateralStart ) { + edgeLateralPosition = edgeLateralStart; + } else { + edgeLateralPosition = edgeLateralEnd; + } + const leadingEdgeForwardPosition = isHorizontal ? rect.left : rect.top; + const trailingEdgeForwardPosition = isHorizontal + ? rect.right + : rect.bottom; + + // First measure the distance to the leading edge of the block. + const leadingEdgeDistance = Math.sqrt( + ( cursorLateralPosition - edgeLateralPosition ) ** 2 + + ( cursorForwardPosition - leadingEdgeForwardPosition ) ** 2 + ); + + // If no candidate has been assigned yet or this is the nearest + // block edge to the cursor, then assign it as the candidate. + if ( + candidateDistance === undefined || + Math.abs( leadingEdgeDistance ) < candidateDistance + ) { + candidateDistance = leadingEdgeDistance; + candidateIndex = index; + } + + // Next measure the distance to the trailing edge of the block. + const trailingEdgeDistance = Math.sqrt( + ( cursorLateralPosition - edgeLateralPosition ) ** 2 + + ( cursorForwardPosition - trailingEdgeForwardPosition ) ** 2 + ); + + // If no candidate has been assigned yet or this is the nearest + // block edge to the cursor, then assign it as the candidate. + if ( Math.abs( trailingEdgeDistance ) < candidateDistance ) { + candidateDistance = trailingEdgeDistance; + candidateIndex = index + 1; + } + } ); + + return candidateIndex; +} + +/** + * Retrieve the data for a block drop event. + * + * @param {WPSyntheticEvent} event The drop event. + * + * @return {Object} An object with block drag and drop data. + */ +function parseDropEvent( event ) { let result = { srcRootClientId: null, srcClientId: null, @@ -32,34 +142,52 @@ const parseDropEvent = ( event ) => { } return result; -}; +} -export default function useBlockDropZone( { element, rootClientId } ) { - const [ clientId, setClientId ] = useState( null ); +/** + * @typedef {Object} WPBlockDropZoneConfig + * @property {Object} element A React ref object pointing to the block list's DOM element. + * @property {string} rootClientId The root client id for the block list. + */ + +/** + * A React hook that can be used to make a block list handle drag and drop. + * + * @param {WPBlockDropZoneConfig} dropZoneConfig configuration data for the drop zone. + * + * @return {number|undefined} The block index that's closest to the drag position. + */ +export default function useBlockDropZone( { + element, + rootClientId: targetRootClientId, +} ) { + const [ targetBlockIndex, setTargetBlockIndex ] = useState( null ); function selector( select ) { const { getBlockIndex, + getBlockListSettings, getClientIdsOfDescendants, getSettings, getTemplateLock, } = select( 'core/block-editor' ); return { getBlockIndex, - blockIndex: getBlockIndex( clientId, rootClientId ), + moverDirection: getBlockListSettings( targetRootClientId ) + ?.__experimentalMoverDirection, getClientIdsOfDescendants, hasUploadPermissions: !! getSettings().mediaUpload, - isLockedAll: getTemplateLock( rootClientId ) === 'all', + isLockedAll: getTemplateLock( targetRootClientId ) === 'all', }; } const { getBlockIndex, - blockIndex, getClientIdsOfDescendants, hasUploadPermissions, isLockedAll, - } = useSelect( selector, [ rootClientId, clientId ] ); + moverDirection, + } = useSelect( selector, [ targetRootClientId ] ); const { insertBlocks, updateBlockAttributes, @@ -83,15 +211,15 @@ export default function useBlockDropZone( { element, rootClientId } ) { files, updateBlockAttributes ); - insertBlocks( blocks, blockIndex, rootClientId ); + insertBlocks( blocks, targetBlockIndex, targetRootClientId ); } }, [ hasUploadPermissions, updateBlockAttributes, insertBlocks, - blockIndex, - rootClientId, + targetBlockIndex, + targetRootClientId, ] ); @@ -100,73 +228,71 @@ export default function useBlockDropZone( { element, rootClientId } ) { const blocks = pasteHandler( { HTML, mode: 'BLOCKS' } ); if ( blocks.length ) { - insertBlocks( blocks, blockIndex, rootClientId ); + insertBlocks( blocks, targetBlockIndex, targetRootClientId ); } }, - [ insertBlocks, blockIndex, rootClientId ] + [ insertBlocks, targetBlockIndex, targetRootClientId ] ); const onDrop = useCallback( ( event ) => { const { - srcRootClientId, - srcClientId, - srcIndex, - type, + srcRootClientId: sourceRootClientId, + srcClientId: sourceClientId, + srcIndex: sourceBlockIndex, + type: dropType, } = parseDropEvent( event ); - const isBlockDropType = ( dropType ) => dropType === 'block'; - const isSameLevel = ( srcRoot, dstRoot ) => { - // Note that rootClientId of top-level blocks will be undefined OR a void string, - // so we also need to account for that case separately. - return ( - srcRoot === dstRoot || - ( ! srcRoot === true && ! dstRoot === true ) - ); - }; - const isSameBlock = ( src, dst ) => src === dst; - const isSrcBlockAnAncestorOfDstBlock = ( src, dst ) => - getClientIdsOfDescendants( [ src ] ).some( - ( id ) => id === dst - ); + // If the user isn't dropping a block, return early. + if ( dropType !== 'block' ) { + return; + } + + // If the user is dropping to the same position, return early. + if ( + sourceRootClientId === targetRootClientId && + sourceBlockIndex === targetBlockIndex + ) { + return; + } + // If the user is attempting to drop a block within its own + // nested blocks, return early as this would create infinite + // recursion. if ( - ! isBlockDropType( type ) || - isSameBlock( srcClientId, clientId ) || - isSrcBlockAnAncestorOfDstBlock( - srcClientId, - clientId || rootClientId + targetRootClientId === sourceClientId || + getClientIdsOfDescendants( [ sourceClientId ] ).some( + ( id ) => id === targetRootClientId ) ) { return; } - const dstIndex = clientId - ? getBlockIndex( clientId, rootClientId ) - : undefined; - const positionIndex = blockIndex; + const isAtSameLevel = + sourceRootClientId === targetRootClientId || + ( sourceRootClientId === '' && + targetRootClientId === undefined ); + // If the block is kept at the same level and moved downwards, // subtract to account for blocks shifting upward to occupy its old position. const insertIndex = - dstIndex && - srcIndex < dstIndex && - isSameLevel( srcRootClientId, rootClientId ) - ? positionIndex - 1 - : positionIndex; + isAtSameLevel && sourceBlockIndex < targetBlockIndex + ? targetBlockIndex - 1 + : targetBlockIndex; + moveBlockToPosition( - srcClientId, - srcRootClientId, - rootClientId, + sourceClientId, + sourceRootClientId, + targetRootClientId, insertIndex ); }, [ getClientIdsOfDescendants, getBlockIndex, - clientId, - blockIndex, + targetBlockIndex, moveBlockToPosition, - rootClientId, + targetRootClientId, ] ); @@ -181,33 +307,22 @@ export default function useBlockDropZone( { element, rootClientId } ) { useEffect( () => { if ( position ) { - const { y } = position; - const rect = element.current.getBoundingClientRect(); - - const offset = y - rect.top; - const target = Array.from( element.current.children ).find( - ( blockEl ) => { - return ( - blockEl.offsetTop + blockEl.offsetHeight / 2 > offset - ); - } + const blockElements = Array.from( element.current.children ); + const targetIndex = getNearestBlockIndex( + blockElements, + position, + moverDirection ); - if ( ! target ) { - return; - } - - const targetClientId = target.id.slice( 'block-'.length ); - - if ( ! targetClientId ) { + if ( targetIndex === undefined ) { return; } - setClientId( targetClientId ); + setTargetBlockIndex( targetIndex ); } }, [ position ] ); if ( position ) { - return clientId; + return targetBlockIndex; } } diff --git a/packages/block-editor/src/components/use-block-drop-zone/test/index.js b/packages/block-editor/src/components/use-block-drop-zone/test/index.js new file mode 100644 index 0000000000000..a135f18285ebe --- /dev/null +++ b/packages/block-editor/src/components/use-block-drop-zone/test/index.js @@ -0,0 +1,314 @@ +/** + * Internal dependencies + */ +import { getNearestBlockIndex } from '..'; + +const elementData = [ + { + top: 0, + left: 0, + bottom: 200, + right: 400, + }, + { + top: 200, + left: 0, + bottom: 500, + right: 400, + }, + { + top: 500, + left: 0, + bottom: 900, + right: 400, + }, + // Fourth block wraps to the next row/column + { + top: 0, + left: 400, + bottom: 300, + right: 800, + }, +]; + +const mapElements = ( orientation ) => ( + { top, right, bottom, left }, + index +) => { + return { + dataset: { block: index + 1 }, + getBoundingClientRect() { + return orientation === 'vertical' + ? { + top, + right, + bottom, + left, + } + : { + top: left, + bottom: right, + left: top, + right: bottom, + }; + }, + }; +}; + +const verticalElements = elementData.map( mapElements( 'vertical' ) ); +// Flip the elementData to make a horizontal block list. +const horizontalElements = elementData.map( mapElements( 'horizontal' ) ); + +describe( 'getNearestBlockIndex', () => { + it( 'returns `undefined` for an empty list of elements', () => { + const emptyElementList = []; + const position = { x: 0, y: 0 }; + const orientation = 'horizontal'; + + const result = getNearestBlockIndex( + emptyElementList, + position, + orientation + ); + + expect( result ).toBeUndefined(); + } ); + + it( 'returns `undefined` if the elements do not have the `data-block` attribute', () => { + const nonBlockElements = [ { dataset: {} } ]; + const position = { x: 0, y: 0 }; + const orientation = 'horizontal'; + + const result = getNearestBlockIndex( + nonBlockElements, + position, + orientation + ); + + expect( result ).toBeUndefined(); + } ); + + describe( 'Vertical block lists', () => { + const orientation = 'vertical'; + + it( 'returns `0` when the position is nearest to the start of the first block', () => { + const position = { x: 0, y: 0 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 0 ); + } ); + + it( 'returns `1` when the position is nearest to the end of the first block', () => { + const position = { x: 0, y: 190 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 1 ); + } ); + + it( 'returns `1` when the position is nearest to the start of the second block', () => { + const position = { x: 0, y: 210 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 1 ); + } ); + + it( 'returns `2` when the position is nearest to the end of the second block', () => { + const position = { x: 0, y: 450 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 2 ); + } ); + + it( 'returns `2` when the position is nearest to the start of the third block', () => { + const position = { x: 0, y: 510 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 2 ); + } ); + + it( 'returns `3` when the position is nearest to the end of the third block', () => { + const position = { x: 0, y: 880 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 3 ); + } ); + + it( 'returns `3` when the position is past the end of the third block', () => { + const position = { x: 0, y: 920 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 3 ); + } ); + + it( 'returns `3` when the position is nearest to the start of the fourth block', () => { + const position = { x: 401, y: 0 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 3 ); + } ); + + it( 'returns `4` when the position is nearest to the end of the fourth block', () => { + const position = { x: 401, y: 300 }; + + const result = getNearestBlockIndex( + verticalElements, + position, + orientation + ); + + expect( result ).toBe( 4 ); + } ); + } ); + + describe( 'Horizontal block lists', () => { + const orientation = 'horizontal'; + + it( 'returns `0` when the position is nearest to the start of the first block', () => { + const position = { x: 0, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 0 ); + } ); + + it( 'returns `1` when the position is nearest to the end of the first block', () => { + const position = { x: 190, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 1 ); + } ); + + it( 'returns `1` when the position is nearest to the start of the second block', () => { + const position = { x: 210, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 1 ); + } ); + + it( 'returns `2` when the position is nearest to the end of the second block', () => { + const position = { x: 450, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 2 ); + } ); + + it( 'returns `2` when the position is nearest to the start of the third block', () => { + const position = { x: 510, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 2 ); + } ); + + it( 'returns `3` when the position is nearest to the end of the third block', () => { + const position = { x: 880, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 3 ); + } ); + + it( 'returns `3` when the position is past the end of the third block', () => { + const position = { x: 920, y: 0 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 3 ); + } ); + + it( 'returns `3` when the position is nearest to the start of the fourth block', () => { + const position = { x: 0, y: 401 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 3 ); + } ); + + it( 'returns `4` when the position is nearest to the end of the fourth block', () => { + const position = { x: 300, y: 401 }; + + const result = getNearestBlockIndex( + horizontalElements, + position, + orientation + ); + + expect( result ).toBe( 4 ); + } ); + } ); +} );