@wordpress/block-editor
Version:
591 lines (532 loc) • 16.7 kB
JavaScript
/**
* 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();
},
} );
}