diff --git a/packages/block-editor/src/components/block-draggable/draggable-chip.js b/packages/block-editor/src/components/block-draggable/draggable-chip.js index 4deed6aaef7d0..4ab64ad57dc68 100644 --- a/packages/block-editor/src/components/block-draggable/draggable-chip.js +++ b/packages/block-editor/src/components/block-draggable/draggable-chip.js @@ -23,7 +23,7 @@ export default function BlockDraggableChip( { clientIds } ) { const [ firstId ] = clientIds; const blockName = getBlockName( firstId ); - return getBlockType( blockName ).icon; + return getBlockType( blockName )?.icon; }, [ clientIds ] ); diff --git a/packages/block-editor/src/components/block-draggable/index.js b/packages/block-editor/src/components/block-draggable/index.js index a947f4ea858f1..238a86c8937ea 100644 --- a/packages/block-editor/src/components/block-draggable/index.js +++ b/packages/block-editor/src/components/block-draggable/index.js @@ -17,6 +17,7 @@ const BlockDraggable = ( { cloneClassname, onDragStart, onDragEnd, + elementId, } ) => { const { srcRootClientId, isDraggable } = useSelect( ( select ) => { @@ -68,7 +69,7 @@ const BlockDraggable = ( { return ( { startDraggingBlocks( clientIds ); diff --git a/packages/block-editor/src/components/block-navigation/appender.js b/packages/block-editor/src/components/block-navigation/appender.js index 2d4db4e5f6b88..58bdb381e10e6 100644 --- a/packages/block-editor/src/components/block-navigation/appender.js +++ b/packages/block-editor/src/components/block-navigation/appender.js @@ -1,9 +1,15 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ import { __experimentalTreeGridCell as TreeGridCell } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { __, sprintf } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -20,6 +26,19 @@ export default function BlockNavigationAppender( { terminatedLevels, path, } ) { + const isDragging = useSelect( + ( select ) => { + const { isBlockBeingDragged, isAncestorBeingDragged } = select( + 'core/block-editor' + ); + + return ( + isBlockBeingDragged( parentBlockClientId ) || + isAncestorBeingDragged( parentBlockClientId ) + ); + }, + [ parentBlockClientId ] + ); const instanceId = useInstanceId( BlockNavigationAppender ); const descriptionId = `block-navigation-appender-row__description_${ instanceId }`; @@ -32,6 +51,7 @@ export default function BlockNavigationAppender( { return ( { const { - __experimentalFeatures: withBlockNavigationSlots, + __experimentalFeatures, + blockDropTarget = {}, } = useBlockNavigationContext(); - return withBlockNavigationSlots ? ( - - ) : ( - + const { clientId } = block; + + const rootClientId = useSelect( + ( select ) => + select( 'core/block-editor' ).getBlockRootClientId( + clientId + ) || '', + [ clientId ] + ); + + const { + rootClientId: dropTargetRootClientId, + clientId: dropTargetClientId, + dropPosition, + } = blockDropTarget; + + const isDroppingBefore = + dropTargetRootClientId === rootClientId && + dropTargetClientId === clientId && + dropPosition === 'top'; + const isDroppingAfter = + dropTargetRootClientId === rootClientId && + dropTargetClientId === clientId && + dropPosition === 'bottom'; + const isDroppingToInnerBlocks = + dropTargetRootClientId === clientId && dropPosition === 'inside'; + + const className = classnames( + 'block-editor-block-navigation-block-contents', + { + 'is-dropping-before': isDroppingBefore, + 'is-dropping-after': isDroppingAfter, + 'is-dropping-to-inner-blocks': isDroppingToInnerBlocks, + } + ); + + return ( + + { ( { isDraggable, onDraggableStart, onDraggableEnd } ) => + __experimentalFeatures ? ( + + ) : ( + + ) + } + ); } ); diff --git a/packages/block-editor/src/components/block-navigation/block-select-button.js b/packages/block-editor/src/components/block-navigation/block-select-button.js index 17042e3951d57..2e9956d5387e1 100644 --- a/packages/block-editor/src/components/block-navigation/block-select-button.js +++ b/packages/block-editor/src/components/block-navigation/block-select-button.js @@ -32,6 +32,9 @@ function BlockNavigationBlockSelectButton( level, tabIndex, onFocus, + onDragStart, + onDragEnd, + draggable, }, ref ) { @@ -59,6 +62,9 @@ function BlockNavigationBlockSelectButton( ref={ ref } tabIndex={ tabIndex } onFocus={ onFocus } + onDragStart={ onDragStart } + onDragEnd={ onDragEnd } + draggable={ draggable } > { blockDisplayName } diff --git a/packages/block-editor/src/components/block-navigation/block.js b/packages/block-editor/src/components/block-navigation/block.js index 62ad355da94fd..da24893d49d0d 100644 --- a/packages/block-editor/src/components/block-navigation/block.js +++ b/packages/block-editor/src/components/block-navigation/block.js @@ -15,7 +15,7 @@ import { import { __ } from '@wordpress/i18n'; import { moreVertical } from '@wordpress/icons'; import { useState, useRef, useEffect } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -45,10 +45,24 @@ export default function BlockNavigationBlock( { const cellRef = useRef( null ); const [ isHovered, setIsHovered ] = useState( false ); const [ isFocused, setIsFocused ] = useState( false ); + const { clientId } = block; + const isDragging = useSelect( + ( select ) => { + const { isBlockBeingDragged, isAncestorBeingDragged } = select( + 'core/block-editor' + ); + + return ( + isBlockBeingDragged( clientId ) || + isAncestorBeingDragged( clientId ) + ); + }, + [ clientId ] + ); + const { selectBlock: selectEditorBlock } = useDispatch( 'core/block-editor' ); - const { clientId } = block; const hasSiblings = siblingBlockCount > 0; const hasRenderedMovers = showBlockMovers && hasSiblings; @@ -74,6 +88,7 @@ export default function BlockNavigationBlock( { setIsHovered( true ) } onMouseLeave={ () => setIsHovered( false ) } @@ -83,6 +98,8 @@ export default function BlockNavigationBlock( { position={ position } rowCount={ rowCount } path={ path } + id={ `block-navigation-block-${ clientId }` } + data-block={ clientId } > ( { __experimentalFeatures, + blockDropTarget, } ), - [ __experimentalFeatures ] + [ __experimentalFeatures, blockDropTarget ] ); return ( diff --git a/packages/block-editor/src/components/block-navigation/use-block-navigation-drop-zone.js b/packages/block-editor/src/components/block-navigation/use-block-navigation-drop-zone.js new file mode 100644 index 0000000000000..02f541ab63917 --- /dev/null +++ b/packages/block-editor/src/components/block-navigation/use-block-navigation-drop-zone.js @@ -0,0 +1,318 @@ +/** + * WordPress dependencies + */ +import { __unstableUseDropZone as useDropZone } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getDistanceToNearestEdge } from '../../utils/math'; +import useOnBlockDrop from '../use-on-block-drop'; + +/** @typedef {import('../../utils/math').WPPoint} WPPoint */ +/** @typedef {import('@wordpress/element').RefObject} RefObject */ + +/** + * The type of a drag event. + * + * @typedef {'default'|'file'|'html'} WPDragEventType + */ + +/** + * An array representing data for blocks in the DOM used by drag and drop. + * + * @typedef {Object} WPBlockNavigationDropZoneBlocks + * @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. + * @property {Element} element The DOM element representing the block. + * @property {number} innerBlockCount The number of inner blocks the block has. + * @property {boolean} isDraggedBlock Whether the block is currently being dragged. + * @property {boolean} canInsertDraggedBlocksAsSibling Whether the dragged block can be a sibling of this block. + * @property {boolean} canInsertDraggedBlocksAsChild Whether the dragged block can be a child of this block. + */ + +/** + * An object containing details of a drop target. + * + * @typedef {Object} WPBlockNavigationDropZoneTarget + * @property {string} blockIndex The insertion index. + * @property {string} rootClientId The root client id for the block. + * @property {string|undefined} clientId The client id for the block. + * @property {'top'|'bottom'|'inside'} dropPosition The position relative to the block that the user is dropping to. + * 'inside' refers to nesting as an inner block. + */ + +/** + * A react hook that returns data about blocks used for computing where a user + * can drop to when dragging and dropping blocks. + * + * @param {Object} ref A React ref of a containing element for block navigation. + * @param {WPPoint} position The current drag position. + * @param {WPDragEventType} dragEventType The drag event type. + * + * @return {RefObject} A React ref containing the blocks data. + */ +function useDropTargetBlocksData( ref, position, dragEventType ) { + const { + getBlockRootClientId, + getBlockIndex, + getBlockCount, + getDraggedBlockClientIds, + canInsertBlocks, + } = useSelect( ( select ) => { + const selectors = select( 'core/block-editor' ); + return { + canInsertBlocks: selectors.canInsertBlocks, + getBlockRootClientId: selectors.getBlockRootClientId, + getBlockIndex: selectors.getBlockIndex, + getBlockCount: selectors.getBlockCount, + getDraggedBlockClientIds: selectors.getDraggedBlockClientIds, + }; + }, [] ); + const blocksData = useRef(); + + // Compute data about blocks only when the user + // starts dragging, as determined by `hasPosition`. + const hasPosition = !! position; + + useEffect( () => { + if ( ! ref.current || ! hasPosition ) { + return; + } + + const isBlockDrag = dragEventType === 'default'; + + const draggedBlockClientIds = isBlockDrag + ? getDraggedBlockClientIds() + : undefined; + + const blockElements = Array.from( + ref.current.querySelectorAll( '[data-block]' ) + ); + + blocksData.current = blockElements.map( ( blockElement ) => { + const clientId = blockElement.dataset.block; + const rootClientId = getBlockRootClientId( clientId ); + + return { + clientId, + rootClientId, + blockIndex: getBlockIndex( clientId, rootClientId ), + element: blockElement, + isDraggedBlock: isBlockDrag + ? draggedBlockClientIds.includes( clientId ) + : false, + innerBlockCount: getBlockCount( clientId ), + canInsertDraggedBlocksAsSibling: isBlockDrag + ? canInsertBlocks( draggedBlockClientIds, rootClientId ) + : true, + canInsertDraggedBlocksAsChild: isBlockDrag + ? canInsertBlocks( draggedBlockClientIds, clientId ) + : true, + }; + } ); + }, [ + // `ref` shouldn't actually change during a drag operation, but + // is specified for completeness as it's used within the hook. + ref, + hasPosition, + dragEventType, + canInsertBlocks, + getBlockCount, + getBlockIndex, + getBlockRootClientId, + getDraggedBlockClientIds, + ] ); + + return blocksData; +} + +/** + * Is the point contained by the rectangle. + * + * @param {WPPoint} point The point. + * @param {DOMRect} rect The rectangle. + * + * @return {boolean} True if the point is contained by the rectangle, false otherwise. + */ +function isPointContainedByRect( point, rect ) { + return ( + rect.left <= point.x && + rect.right >= point.x && + rect.top <= point.y && + rect.bottom >= point.y + ); +} + +/** + * 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. + * + * @param {WPPoint} point The point representing the cursor position when dragging. + * @param {DOMRect} rect The rectangle. + */ +function isNestingGesture( point, rect ) { + const blockCenterX = rect.left + rect.width / 2; + return point.x > blockCenterX; +} + +// Block navigation is always a vertical list, so only allow dropping +// to the above or below a block. +const ALLOWED_DROP_EDGES = [ 'top', 'bottom' ]; + +/** + * Given blocks data and the cursor position, compute the drop target. + * + * @param {WPBlockNavigationDropZoneBlocks} blocksData Data about the blocks in block navigation. + * @param {WPPoint} position The point representing the cursor position when dragging. + * + * @return {WPBlockNavigationDropZoneTarget} An object containing data about the drop target. + */ +function getBlockNavigationDropTarget( blocksData, position ) { + let candidateEdge; + let candidateBlockData; + let candidateDistance; + let candidateRect; + + for ( const blockData of blocksData ) { + if ( blockData.isDraggedBlock ) { + continue; + } + + const rect = blockData.element.getBoundingClientRect(); + const [ distance, edge ] = getDistanceToNearestEdge( + position, + rect, + ALLOWED_DROP_EDGES + ); + + const isCursorWithinBlock = isPointContainedByRect( position, rect ); + if ( + candidateDistance === undefined || + distance < candidateDistance || + isCursorWithinBlock + ) { + candidateDistance = distance; + + const index = blocksData.indexOf( blockData ); + const previousBlockData = blocksData[ index - 1 ]; + + // If dragging near the top of a block and the preceding block + // is at the same level, use the preceding block as the candidate + // instead, as later it makes determining a nesting drop easier. + if ( + edge === 'top' && + previousBlockData && + previousBlockData.rootClientId === blockData.rootClientId && + ! previousBlockData.isDraggedBlock + ) { + candidateBlockData = previousBlockData; + candidateEdge = 'bottom'; + candidateRect = previousBlockData.element.getBoundingClientRect(); + } else { + candidateBlockData = blockData; + candidateEdge = edge; + candidateRect = rect; + } + + // If the mouse position is within the block, break early + // as the user would intend to drop either before or after + // this block. + // + // This solves an issue where some rows in the block navigation + // tree overlap slightly due to sub-pixel rendering. + if ( isCursorWithinBlock ) { + break; + } + } + } + + if ( ! candidateBlockData ) { + return; + } + + const isDraggingBelow = candidateEdge === 'bottom'; + + // 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, this should always be treated + // as nesting since the next block in the tree will be the first child. + if ( + isDraggingBelow && + candidateBlockData.canInsertDraggedBlocksAsChild && + ( candidateBlockData.innerBlockCount > 0 || + isNestingGesture( position, candidateRect ) ) + ) { + return { + rootClientId: candidateBlockData.clientId, + blockIndex: 0, + dropPosition: 'inside', + }; + } + + // If dropping as a sibling, but block cannot be inserted in + // this context, return early. + if ( ! candidateBlockData.canInsertDraggedBlocksAsSibling ) { + return; + } + + const offset = isDraggingBelow ? 1 : 0; + return { + rootClientId: candidateBlockData.rootClientId, + clientId: candidateBlockData.clientId, + blockIndex: candidateBlockData.blockIndex + offset, + dropPosition: candidateEdge, + }; +} + +/** + * A react hook for implementing a drop zone in block navigation. + * + * @param {Object} ref A React ref of a containing element for block navigation. + * + * @return {WPBlockNavigationDropZoneTarget} The drop target. + */ +export default function useBlockNavigationDropZone( ref ) { + const [ target = {}, setTarget ] = useState(); + const { + rootClientId: targetRootClientId, + blockIndex: targetBlockIndex, + } = target; + + const dropEventHandlers = useOnBlockDrop( + targetRootClientId, + targetBlockIndex + ); + + const { position, type: dragEventType } = useDropZone( { + element: ref, + withPosition: true, + ...dropEventHandlers, + } ); + + const blocksData = useDropTargetBlocksData( ref, position, dragEventType ); + + // Calculate the drop target based on the drag position. + useEffect( () => { + if ( position ) { + const newTarget = getBlockNavigationDropTarget( + blocksData.current, + position + ); + + if ( newTarget ) { + setTarget( newTarget ); + } + } + }, [ blocksData, position ] ); + + if ( position ) { + return target; + } +} diff --git a/packages/components/src/tree-grid/index.js b/packages/components/src/tree-grid/index.js index 8221efaac3366..cd5d6bb2a3cd3 100644 --- a/packages/components/src/tree-grid/index.js +++ b/packages/components/src/tree-grid/index.js @@ -7,7 +7,7 @@ import { includes } from 'lodash'; * WordPress dependencies */ import { focus } from '@wordpress/dom'; -import { useCallback } from '@wordpress/element'; +import { forwardRef, useCallback } from '@wordpress/element'; import { UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; /** @@ -39,11 +39,11 @@ function getRowFocusables( rowElement ) { * Renders both a table and tbody element, used to create a tree hierarchy. * * @see https://github.com/WordPress/gutenberg/blob/master/packages/components/src/tree-grid/README.md - * * @param {Object} props Component props. - * @param {WPElement} props.children Children to be rendered + * @param {WPElement} props.children Children to be rendered. + * @param {Object} ref A ref to the underlying DOM table element. */ -export default function TreeGrid( { children, ...props } ) { +function TreeGrid( { children, ...props }, ref ) { const onKeyDown = useCallback( ( event ) => { const { keyCode, metaKey, ctrlKey, altKey, shiftKey } = event; @@ -147,17 +147,24 @@ export default function TreeGrid( { children, ...props } ) { } }, [] ); + /* Disable reason: A treegrid is implemented using a table element. */ + /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */ return ( - { /* Disable reason: A treegrid is implemented using a table element. */ } - { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role */ } - +
{ children }
); + /* eslint-enable jsx-a11y/no-noninteractive-element-to-interactive-role */ } +export default forwardRef( TreeGrid ); export { default as TreeGridRow } from './row'; export { default as TreeGridCell } from './cell'; export { default as TreeGridItem } from './item';