UNPKG

@wordpress/block-editor

Version:
592 lines (533 loc) 19.6 kB
/** * WordPress dependencies */ import { useSelect } from '@wordpress/data'; import { useState, useCallback, useEffect } from '@wordpress/element'; import { useThrottle, __experimentalUseDropZone as useDropZone, usePrevious, } from '@wordpress/compose'; import { isRTL } from '@wordpress/i18n'; /** * Internal dependencies */ import { getDistanceToNearestEdge, isPointContainedByRect, } from '../../utils/math'; import useOnBlockDrop from '../use-on-block-drop'; import { store as blockEditorStore } from '../../store'; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ /** * The type of a drag event. * * @typedef {'default'|'file'|'html'} WPDragEventType */ /** * An object representing data for blocks in the DOM used by drag and drop. * * @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. * @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} isExpanded Whether the block is expanded in the UI. * @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 array representing data for blocks in the DOM used by drag and drop. * * @typedef {WPListViewDropZoneBlock[]} WPListViewDropZoneBlocks */ /** * An object containing details of a drop target. * * @typedef {Object} WPListViewDropZoneTarget * @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. */ // 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 = 24; /** * 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. * @param {boolean} rtl Whether the editor is in RTL mode. * @return {boolean} Whether the gesture is an upward gesture. */ function isUpGesture( point, rect, nestingLevel = 1, rtl = false ) { // If the block is nested, and the user is dragging to the bottom // left of the block (or bottom right in RTL languages), then it is an upward gesture. const blockIndentPosition = rtl ? rect.right - nestingLevel * NESTING_LEVEL_INDENTATION : rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; return rtl ? point.x > blockIndentPosition : 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. * @param {boolean} rtl Whether the editor is in RTL mode. * @return {number} The desired relative parent level. */ function getDesiredRelativeParentLevel( point, rect, nestingLevel = 1, rtl = false ) { // In RTL languages, the block indent position is from the right edge of the block. // In LTR languages, the block indent position is from the left edge of the block. const blockIndentPosition = rtl ? rect.right - nestingLevel * NESTING_LEVEL_INDENTATION : rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; const distanceBetweenPointAndBlockIndentPosition = rtl ? blockIndentPosition - point.x : point.x - blockIndentPosition; const desiredParentLevel = Math.round( distanceBetweenPointAndBlockIndentPosition / 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. * * Determined based on nesting level indentation of the current block, plus * the indentation of the next level of nesting. The vertical position of the * cursor must also be within the 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. * @param {boolean} rtl Whether the editor is in RTL mode. */ function isNestingGesture( point, rect, nestingLevel = 1, rtl = false ) { const blockIndentPosition = rtl ? rect.right - nestingLevel * NESTING_LEVEL_INDENTATION : rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; const isNestingHorizontalGesture = rtl ? point.x < blockIndentPosition - NESTING_LEVEL_INDENTATION : point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION; return isNestingHorizontalGesture && point.y < rect.bottom; } // 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 {WPListViewDropZoneBlocks} blocksData Data about the blocks in list view. * @param {WPPoint} position The point representing the cursor position when dragging. * @param {boolean} rtl Whether the editor is in RTL mode. * * @return {WPListViewDropZoneTarget | undefined} An object containing data about the drop target. */ export function getListViewDropTarget( blocksData, position, rtl = false ) { let candidateEdge; let candidateBlockData; let candidateDistance; let candidateRect; let candidateBlockIndex; for ( let i = 0; i < blocksData.length; i++ ) { const blockData = blocksData[ i ]; 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(); candidateBlockIndex = index - 1; } else { candidateBlockData = blockData; candidateEdge = edge; candidateRect = rect; candidateBlockIndex = index; } // 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 list view // tree overlap slightly due to sub-pixel rendering. if ( isCursorWithinBlock ) { break; } } } if ( ! candidateBlockData ) { 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 nest the block as a child. // If the block already has inner blocks, and is expanded, this should be treated // as nesting since the next block in the tree will be the first child. // However, if the block is collapsed, dragging beneath the block should // still be allowed, as the next visible block in the tree will be a sibling. if ( isDraggingBelow && candidateBlockData.canInsertDraggedBlocksAsChild && ( ( candidateBlockData.innerBlockCount > 0 && candidateBlockData.isExpanded ) || isNestingGesture( position, candidateRect, candidateBlockParents.length, rtl ) ) ) { // If the block is expanded, insert the block as the first child. // Otherwise, for collapsed blocks, insert the block as the last child. const newBlockIndex = candidateBlockData.isExpanded ? 0 : candidateBlockData.innerBlockCount || 0; return { rootClientId: candidateBlockData.clientId, clientId: candidateBlockData.clientId, blockIndex: newBlockIndex, dropPosition: 'inside', }; } // 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, rtl ) ) { 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, rtl ); 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 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, }; } // Throttle options need to be defined outside of the hook to avoid // re-creating the object on every render. This is due to a limitation // of the `useThrottle` hook, where the options object is included // in the dependency array for memoization. const EXPAND_THROTTLE_OPTIONS = { leading: false, // Don't call the function immediately on the first call. trailing: true, // Do call the function on the last call. }; /** * A react hook for implementing a drop zone in list view. * * @param {Object} props Named parameters. * @param {?HTMLElement} [props.dropZoneElement] Optional element to be used as the drop zone. * @param {Object} [props.expandedState] The expanded state of the blocks in the list view. * @param {Function} [props.setExpandedState] Function to set the expanded state of a list of block clientIds. * * @return {WPListViewDropZoneTarget} The drop target. */ export default function useListViewDropZone( { dropZoneElement, expandedState, setExpandedState, } ) { const { getBlockRootClientId, getBlockIndex, getBlockCount, getDraggedBlockClientIds, canInsertBlocks, } = useSelect( blockEditorStore ); const [ target, setTarget ] = useState(); const { rootClientId: targetRootClientId, blockIndex: targetBlockIndex } = target || {}; const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex ); const rtl = isRTL(); const previousRootClientId = usePrevious( targetRootClientId ); const maybeExpandBlock = useCallback( ( _expandedState, _target ) => { // If the user is attempting to drop a block inside a collapsed block, // that is, using a nesting gesture flagged by 'inside' dropPosition, // expand the block within the list view, if it isn't already. const { rootClientId } = _target || {}; if ( ! rootClientId ) { return; } if ( _target?.dropPosition === 'inside' && ! _expandedState[ rootClientId ] ) { setExpandedState( { type: 'expand', clientIds: [ rootClientId ], } ); } }, [ setExpandedState ] ); // Throttle the maybeExpandBlock function to avoid expanding the block // too quickly when the user is dragging over the block. This is to // avoid expanding the block when the user is just passing over it. const throttledMaybeExpandBlock = useThrottle( maybeExpandBlock, 500, EXPAND_THROTTLE_OPTIONS ); useEffect( () => { if ( target?.dropPosition !== 'inside' || previousRootClientId !== target?.rootClientId ) { throttledMaybeExpandBlock.cancel(); return; } throttledMaybeExpandBlock( expandedState, target ); }, [ expandedState, previousRootClientId, target, throttledMaybeExpandBlock, ] ); const draggedBlockClientIds = getDraggedBlockClientIds(); const throttled = useThrottle( useCallback( ( event, currentTarget ) => { const position = { x: event.clientX, y: event.clientY }; const isBlockDrag = !! draggedBlockClientIds?.length; const blockElements = Array.from( currentTarget.querySelectorAll( '[data-block]' ) ); const blocksData = blockElements.map( ( blockElement ) => { const clientId = blockElement.dataset.block; const isExpanded = blockElement.dataset.expanded === 'true'; const isDraggedBlock = blockElement.classList.contains( 'is-dragging' ); // 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 { clientId, isExpanded, rootClientId, blockIndex: getBlockIndex( clientId ), element: blockElement, nestingLevel: nestingLevel || undefined, isDraggedBlock: isBlockDrag ? isDraggedBlock : false, innerBlockCount: getBlockCount( clientId ), canInsertDraggedBlocksAsSibling: isBlockDrag ? canInsertBlocks( draggedBlockClientIds, rootClientId ) : true, canInsertDraggedBlocksAsChild: isBlockDrag ? canInsertBlocks( draggedBlockClientIds, clientId ) : true, }; } ); const newTarget = getListViewDropTarget( blocksData, position, rtl ); if ( newTarget ) { setTarget( newTarget ); } }, [ canInsertBlocks, draggedBlockClientIds, getBlockCount, getBlockIndex, getBlockRootClientId, rtl, ] ), 50 ); const ref = useDropZone( { dropZoneElement, onDrop( event ) { throttled.cancel(); if ( target ) { onBlockDrop( event ); } // Use `undefined` value to indicate that the drag has concluded. // This allows styling rules that are active only when a user is // dragging to be removed. setTarget( undefined ); }, onDragLeave() { throttled.cancel(); // Use `null` value to indicate that the drop target is not valid, // but that the drag is still active. This allows for styling rules // that are active only when a user drags outside of the list view. setTarget( null ); }, onDragOver( event ) { // `currentTarget` is only available while the event is being // handled, so get it now and pass it to the thottled function. // https://developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget throttled( event, event.currentTarget ); }, onDragEnd() { throttled.cancel(); // Use `undefined` value to indicate that the drag has concluded. // This allows styling rules that are active only when a user is // dragging to be removed. setTarget( undefined ); }, } ); return { ref, target }; }