UNPKG

@wordpress/block-editor

Version:
591 lines (532 loc) 16.7 kB
/** * WordPress dependencies */ import { useDispatch, useSelect, useRegistry } from '@wordpress/data'; import { useCallback, useState } from '@wordpress/element'; import { useThrottle, __experimentalUseDropZone as useDropZone, } from '@wordpress/compose'; import { isRTL } from '@wordpress/i18n'; import { isUnmodifiedDefaultBlock as getIsUnmodifiedDefaultBlock, store as blocksStore, } from '@wordpress/blocks'; /** * Internal dependencies */ import useOnBlockDrop from '../use-on-block-drop'; import { getDistanceToNearestEdge, isPointContainedByRect, isPointWithinTopAndBottomBoundariesOfRect, } from '../../utils/math'; import { store as blockEditorStore } from '../../store'; import { unlock } from '../../lock-unlock'; const THRESHOLD_DISTANCE = 30; const MINIMUM_HEIGHT_FOR_THRESHOLD = 120; const MINIMUM_WIDTH_FOR_THRESHOLD = 120; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ /** @typedef {import('../use-on-block-drop/types').WPDropOperation} WPDropOperation */ /** * The orientation of a block list. * * @typedef {'horizontal'|'vertical'|undefined} WPBlockListOrientation */ /** * The insert position when dropping a block. * * @typedef {'before'|'after'} WPInsertPosition */ /** * @typedef {Object} WPBlockData * @property {boolean} isUnmodifiedDefaultBlock Is the block unmodified default block. * @property {() => DOMRect} getBoundingClientRect Get the bounding client rect of the block. * @property {number} blockIndex The index of the block. */ /** * Get the drop target position from a given drop point and the orientation. * * @param {WPBlockData[]} blocksData The block data list. * @param {WPPoint} position The position of the item being dragged. * @param {WPBlockListOrientation} orientation The orientation of the block list. * @param {Object} options Additional options. * @return {[number, WPDropOperation]} The drop target position. */ export function getDropTargetPosition( blocksData, position, orientation = 'vertical', options = {} ) { const allowedEdges = orientation === 'horizontal' ? [ 'left', 'right' ] : [ 'top', 'bottom' ]; let nearestIndex = 0; let insertPosition = 'before'; let minDistance = Infinity; let targetBlockIndex = null; let nearestSide = 'right'; const { dropZoneElement, parentBlockOrientation, rootBlockIndex = 0, } = options; // Allow before/after when dragging over the top/bottom edges of the drop zone. if ( dropZoneElement && parentBlockOrientation !== 'horizontal' ) { const rect = dropZoneElement.getBoundingClientRect(); const [ distance, edge ] = getDistanceToNearestEdge( position, rect, [ 'top', 'bottom', ] ); // If dragging over the top or bottom of the drop zone, insert the block // before or after the parent block. This only applies to blocks that use // a drop zone element, typically container blocks such as Group or Cover. if ( rect.height > MINIMUM_HEIGHT_FOR_THRESHOLD && distance < THRESHOLD_DISTANCE ) { if ( edge === 'top' ) { return [ rootBlockIndex, 'before' ]; } if ( edge === 'bottom' ) { return [ rootBlockIndex + 1, 'after' ]; } } } const isRightToLeft = isRTL(); // Allow before/after when dragging over the left/right edges of the drop zone. if ( dropZoneElement && parentBlockOrientation === 'horizontal' ) { const rect = dropZoneElement.getBoundingClientRect(); const [ distance, edge ] = getDistanceToNearestEdge( position, rect, [ 'left', 'right', ] ); // If dragging over the left or right of the drop zone, insert the block // before or after the parent block. This only applies to blocks that use // a drop zone element, typically container blocks such as Group. if ( rect.width > MINIMUM_WIDTH_FOR_THRESHOLD && distance < THRESHOLD_DISTANCE ) { if ( ( isRightToLeft && edge === 'right' ) || ( ! isRightToLeft && edge === 'left' ) ) { return [ rootBlockIndex, 'before' ]; } if ( ( isRightToLeft && edge === 'left' ) || ( ! isRightToLeft && edge === 'right' ) ) { return [ rootBlockIndex + 1, 'after' ]; } } } blocksData.forEach( ( { isUnmodifiedDefaultBlock, getBoundingClientRect, blockIndex, blockOrientation, } ) => { const rect = getBoundingClientRect(); let [ distance, edge ] = getDistanceToNearestEdge( position, rect, allowedEdges ); // If the the point is close to a side, prioritize that side. const [ sideDistance, sideEdge ] = getDistanceToNearestEdge( position, rect, [ 'left', 'right' ] ); const isPointInsideRect = isPointContainedByRect( position, rect ); // Prioritize the element if the point is inside of an unmodified default block. if ( isUnmodifiedDefaultBlock && isPointInsideRect ) { distance = 0; } else if ( orientation === 'vertical' && blockOrientation !== 'horizontal' && ( ( isPointInsideRect && sideDistance < THRESHOLD_DISTANCE ) || ( ! isPointInsideRect && isPointWithinTopAndBottomBoundariesOfRect( position, rect ) ) ) ) { /** * This condition should only apply when the layout is vertical (otherwise there's * no need to create a Row) and dropzones should only activate when the block is * either within and close to the sides of the target block or on its outer sides. */ targetBlockIndex = blockIndex; nearestSide = sideEdge; } if ( distance < minDistance ) { // Where the dropped block will be inserted on the nearest block. insertPosition = edge === 'bottom' || ( ! isRightToLeft && edge === 'right' ) || ( isRightToLeft && edge === 'left' ) ? 'after' : 'before'; // Update the currently known best candidate. minDistance = distance; nearestIndex = blockIndex; } } ); const adjacentIndex = nearestIndex + ( insertPosition === 'after' ? 1 : -1 ); const isNearestBlockUnmodifiedDefaultBlock = !! blocksData[ nearestIndex ]?.isUnmodifiedDefaultBlock; const isAdjacentBlockUnmodifiedDefaultBlock = !! blocksData[ adjacentIndex ]?.isUnmodifiedDefaultBlock; // If the target index is set then group with the block at that index. if ( targetBlockIndex !== null ) { return [ targetBlockIndex, 'group', nearestSide ]; } // If both blocks are not unmodified default blocks then just insert between them. if ( ! isNearestBlockUnmodifiedDefaultBlock && ! isAdjacentBlockUnmodifiedDefaultBlock ) { // If the user is dropping to the trailing edge of the block // add 1 to the index to represent dragging after. const insertionIndex = insertPosition === 'after' ? nearestIndex + 1 : nearestIndex; return [ insertionIndex, 'insert' ]; } // Otherwise, replace the nearest unmodified default block. return [ isNearestBlockUnmodifiedDefaultBlock ? nearestIndex : adjacentIndex, 'replace', ]; } /** * Check if the dragged blocks can be dropped on the target. * @param {Function} getBlockType * @param {Object[]} allowedBlocks * @param {string[]} draggedBlockNames * @param {string} targetBlockName * @return {boolean} Whether the dragged blocks can be dropped on the target. */ export function isDropTargetValid( getBlockType, allowedBlocks, draggedBlockNames, targetBlockName ) { // At root level allowedBlocks is undefined and all blocks are allowed. // Otherwise, check if all dragged blocks are allowed. let areBlocksAllowed = true; if ( allowedBlocks ) { const allowedBlockNames = allowedBlocks?.map( ( { name } ) => name ); areBlocksAllowed = draggedBlockNames.every( ( name ) => allowedBlockNames?.includes( name ) ); } // Work out if dragged blocks have an allowed parent and if so // check target block matches the allowed parent. const draggedBlockTypes = draggedBlockNames.map( ( name ) => getBlockType( name ) ); const targetMatchesDraggedBlockParents = draggedBlockTypes.every( ( block ) => { const [ allowedParentName ] = block?.parent || []; if ( ! allowedParentName ) { return true; } return allowedParentName === targetBlockName; } ); return areBlocksAllowed && targetMatchesDraggedBlockParents; } /** * Checks if the given element is an insertion point. * * @param {EventTarget|null} targetToCheck - The element to check. * @param {Document} ownerDocument - The owner document of the element. * @return {boolean} True if the element is a insertion point, false otherwise. */ function isInsertionPoint( targetToCheck, ownerDocument ) { const { defaultView } = ownerDocument; return !! ( defaultView && targetToCheck instanceof defaultView.HTMLElement && targetToCheck.closest( '[data-is-insertion-point]' ) ); } /** * @typedef {Object} WPBlockDropZoneConfig * @property {?HTMLElement} dropZoneElement Optional element to be used as the drop zone. * @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. */ export default function useBlockDropZone( { dropZoneElement, // An undefined value represents a top-level block. Default to an empty // string for this so that `targetRootClientId` can be easily compared to // values returned by the `getRootBlockClientId` selector, which also uses // an empty string to represent top-level blocks. rootClientId: targetRootClientId = '', parentClientId: parentBlockClientId = '', isDisabled = false, } = {} ) { const registry = useRegistry(); const [ dropTarget, setDropTarget ] = useState( { index: null, operation: 'insert', } ); const { getBlockType, getBlockVariations, getGroupingBlockName } = useSelect( blocksStore ); const { canInsertBlockType, getBlockListSettings, getBlocks, getBlockIndex, getDraggedBlockClientIds, getBlockNamesByClientId, getAllowedBlocks, isDragging, isGroupable, isZoomOut, getSectionRootClientId, getBlockParents, } = unlock( useSelect( blockEditorStore ) ); const { showInsertionPoint, hideInsertionPoint, startDragging, stopDragging, } = unlock( useDispatch( blockEditorStore ) ); const onBlockDrop = useOnBlockDrop( dropTarget.operation === 'before' || dropTarget.operation === 'after' ? parentBlockClientId : targetRootClientId, dropTarget.index, { operation: dropTarget.operation, nearestSide: dropTarget.nearestSide, } ); const throttled = useThrottle( useCallback( ( event, ownerDocument ) => { if ( ! isDragging() ) { // When dragging from the desktop, no drag start event is fired. // So, ensure that the drag state is set when the user drags over a drop zone. startDragging(); } const draggedBlockClientIds = getDraggedBlockClientIds(); const targetParents = [ targetRootClientId, ...getBlockParents( targetRootClientId, true ), ]; // Check if the target is within any of the dragged blocks. const isTargetWithinDraggedBlocks = draggedBlockClientIds.some( ( clientId ) => targetParents.includes( clientId ) ); if ( isTargetWithinDraggedBlocks ) { return; } const allowedBlocks = getAllowedBlocks( targetRootClientId ); const targetBlockName = getBlockNamesByClientId( [ targetRootClientId, ] )[ 0 ]; const draggedBlockNames = getBlockNamesByClientId( draggedBlockClientIds ); const isBlockDroppingAllowed = isDropTargetValid( getBlockType, allowedBlocks, draggedBlockNames, targetBlockName ); if ( ! isBlockDroppingAllowed ) { return; } const sectionRootClientId = getSectionRootClientId(); // In Zoom Out mode, if the target is not the section root provided by settings then // do not allow dropping as the drop target is not within the root (that which is // treated as "the content" by Zoom Out Mode). if ( isZoomOut() && sectionRootClientId !== targetRootClientId ) { return; } const blocks = getBlocks( targetRootClientId ); // The block list is empty, don't show the insertion point but still allow dropping. if ( blocks.length === 0 ) { registry.batch( () => { setDropTarget( { index: 0, operation: 'insert', } ); showInsertionPoint( targetRootClientId, 0, { operation: 'insert', } ); } ); return; } const blocksData = blocks.map( ( block ) => { const clientId = block.clientId; return { isUnmodifiedDefaultBlock: getIsUnmodifiedDefaultBlock( block ), getBoundingClientRect: () => ownerDocument .getElementById( `block-${ clientId }` ) .getBoundingClientRect(), blockIndex: getBlockIndex( clientId ), blockOrientation: getBlockListSettings( clientId )?.orientation, }; } ); const dropTargetPosition = getDropTargetPosition( blocksData, { x: event.clientX, y: event.clientY }, getBlockListSettings( targetRootClientId )?.orientation, { dropZoneElement, parentBlockClientId, parentBlockOrientation: parentBlockClientId ? getBlockListSettings( parentBlockClientId ) ?.orientation : undefined, rootBlockIndex: getBlockIndex( targetRootClientId ), } ); const [ targetIndex, operation, nearestSide ] = dropTargetPosition; const isTargetIndexEmptyDefaultBlock = blocksData[ targetIndex ]?.isUnmodifiedDefaultBlock; if ( isZoomOut() && ! isTargetIndexEmptyDefaultBlock && operation !== 'insert' ) { return; } if ( operation === 'group' ) { const targetBlock = blocks[ targetIndex ]; const areAllImages = [ targetBlock.name, ...draggedBlockNames, ].every( ( name ) => name === 'core/image' ); const canInsertGalleryBlock = canInsertBlockType( 'core/gallery', targetRootClientId ); const areGroupableBlocks = isGroupable( [ targetBlock.clientId, getDraggedBlockClientIds(), ] ); const groupBlockVariations = getBlockVariations( getGroupingBlockName(), 'block' ); const canInsertRow = groupBlockVariations && groupBlockVariations.find( ( { name } ) => name === 'group-row' ); // If the dragged blocks and the target block are all images, // check if it is creatable either a Row variation or a Gallery block. if ( areAllImages && ! canInsertGalleryBlock && ( ! areGroupableBlocks || ! canInsertRow ) ) { return; } // If the dragged blocks and the target block are not all images, // check if it is creatable a Row variation. if ( ! areAllImages && ( ! areGroupableBlocks || ! canInsertRow ) ) { return; } } registry.batch( () => { setDropTarget( { index: targetIndex, operation, nearestSide, } ); const insertionPointClientId = [ 'before', 'after', ].includes( operation ) ? parentBlockClientId : targetRootClientId; showInsertionPoint( insertionPointClientId, targetIndex, { operation, nearestSide, } ); } ); }, [ isDragging, getAllowedBlocks, targetRootClientId, getBlockNamesByClientId, getDraggedBlockClientIds, getBlockType, getSectionRootClientId, isZoomOut, getBlocks, getBlockListSettings, dropZoneElement, parentBlockClientId, getBlockIndex, registry, startDragging, showInsertionPoint, canInsertBlockType, isGroupable, getBlockVariations, getGroupingBlockName, ] ), 200 ); return useDropZone( { dropZoneElement, isDisabled, onDrop: onBlockDrop, 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.ownerDocument ); }, onDragLeave( event ) { const { ownerDocument } = event.currentTarget; // If the drag event is leaving the drop zone and entering an insertion point, // do not hide the insertion point as it is conceptually within the dropzone. if ( isInsertionPoint( event.relatedTarget, ownerDocument ) || isInsertionPoint( event.target, ownerDocument ) ) { return; } throttled.cancel(); hideInsertionPoint(); }, onDragEnd() { throttled.cancel(); stopDragging(); hideInsertionPoint(); }, } ); }