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 6e9d05d7f0d72d..e04c2cc5bd90a5 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 @@ -7,13 +7,13 @@ import { difference } from 'lodash'; * WordPress dependencies */ import { __unstableUseDropZone as useDropZone } from '@wordpress/components'; -import { - pasteHandler, - getBlockTransforms, - findTransform, -} from '@wordpress/blocks'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect, useCallback, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useOnBlockDrop from '../use-on-block-drop'; /** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */ @@ -23,6 +23,8 @@ import { useEffect, useCallback, useState } from '@wordpress/element'; * @property {number} y The vertical position of the block being dragged. */ +/** @typedef {import('@wordpress/dom').WPPoint} WPPoint */ + /** * The orientation of a block list. * @@ -126,36 +128,6 @@ export function getNearestBlockIndex( elements, position, orientation ) { 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, - srcClientIds: null, - type: null, - }; - - if ( ! event.dataTransfer ) { - return result; - } - - try { - result = Object.assign( - result, - JSON.parse( event.dataTransfer.getData( 'text' ) ) - ); - } catch ( err ) { - return result; - } - - return result; -} - /** * @typedef {Object} WPBlockDropZoneConfig * @property {Object} element A React ref object pointing to the block list's DOM element. @@ -179,149 +151,30 @@ export default function useBlockDropZone( { } ) { const [ targetBlockIndex, setTargetBlockIndex ] = useState( null ); - const { - getClientIdsOfDescendants, - getBlockIndex, - hasUploadPermissions, - isLockedAll, - orientation, - } = useSelect( + const { isLockedAll, orientation } = useSelect( ( select ) => { - const { - getBlockListSettings, - getClientIdsOfDescendants: _getClientIdsOfDescendants, - getBlockIndex: _getBlockIndex, - getSettings, - getTemplateLock, - } = select( 'core/block-editor' ); + const { getBlockListSettings, getTemplateLock } = select( + 'core/block-editor' + ); return { + isLockedAll: getTemplateLock( targetRootClientId ) === 'all', orientation: getBlockListSettings( targetRootClientId ) ?.orientation, - getClientIdsOfDescendants: _getClientIdsOfDescendants, - getBlockIndex: _getBlockIndex, - hasUploadPermissions: !! getSettings().mediaUpload, - isLockedAll: getTemplateLock( targetRootClientId ) === 'all', }; }, [ targetRootClientId ] ); - const { - insertBlocks, - updateBlockAttributes, - moveBlocksToPosition, - } = useDispatch( 'core/block-editor' ); - const onFilesDrop = useCallback( - ( files ) => { - if ( ! hasUploadPermissions ) { - return; - } - - const transformation = findTransform( - getBlockTransforms( 'from' ), - ( transform ) => - transform.type === 'files' && transform.isMatch( files ) - ); - - if ( transformation ) { - const blocks = transformation.transform( - files, - updateBlockAttributes - ); - insertBlocks( blocks, targetBlockIndex, targetRootClientId ); - } - }, - [ - hasUploadPermissions, - updateBlockAttributes, - insertBlocks, - targetBlockIndex, - targetRootClientId, - ] - ); - - const onHTMLDrop = useCallback( - ( HTML ) => { - const blocks = pasteHandler( { HTML, mode: 'BLOCKS' } ); - - if ( blocks.length ) { - insertBlocks( blocks, targetBlockIndex, targetRootClientId ); - } - }, - [ insertBlocks, targetBlockIndex, targetRootClientId ] - ); - - const onDrop = useCallback( - ( event ) => { - const { - srcRootClientId: sourceRootClientId, - srcClientIds: sourceClientIds, - type: dropType, - } = parseDropEvent( event ); - - // If the user isn't dropping a block, return early. - if ( dropType !== 'block' ) { - return; - } - - const sourceBlockIndex = getBlockIndex( - sourceClientIds[ 0 ], - sourceRootClientId - ); - - // 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 ( - sourceClientIds.includes( targetRootClientId ) || - getClientIdsOfDescendants( sourceClientIds ).some( - ( id ) => id === targetRootClientId - ) - ) { - return; - } - - // If the block is kept at the same level and moved downwards, - // subtract to take into account that the blocks being dragged - // were removed from the block list. - const isAtSameLevel = sourceRootClientId === targetRootClientId; - const draggedBlockCount = sourceClientIds.length; - const insertIndex = - isAtSameLevel && sourceBlockIndex < targetBlockIndex - ? targetBlockIndex - draggedBlockCount - : targetBlockIndex; - - moveBlocksToPosition( - sourceClientIds, - sourceRootClientId, - targetRootClientId, - insertIndex - ); - }, - [ - getClientIdsOfDescendants, - getBlockIndex, - targetBlockIndex, - moveBlocksToPosition, - targetRootClientId, - ] + const dropEventHandlers = useOnBlockDrop( + targetRootClientId, + targetBlockIndex ); const { position } = useDropZone( { element, - onFilesDrop, - onHTMLDrop, - onDrop, isDisabled: isLockedAll, withPosition: true, + ...dropEventHandlers, } ); useEffect( () => { diff --git a/packages/block-editor/src/components/use-on-block-drop/index.js b/packages/block-editor/src/components/use-on-block-drop/index.js new file mode 100644 index 00000000000000..eda98177563ef9 --- /dev/null +++ b/packages/block-editor/src/components/use-on-block-drop/index.js @@ -0,0 +1,235 @@ +/** + * WordPress dependencies + */ +import { + findTransform, + getBlockTransforms, + pasteHandler, +} from '@wordpress/blocks'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */ + +/** + * Retrieve the data for a block drop event. + * + * @param {WPSyntheticEvent} event The drop event. + * + * @return {Object} An object with block drag and drop data. + */ +export function parseDropEvent( event ) { + let result = { + srcRootClientId: null, + srcClientIds: null, + srcIndex: null, + type: null, + }; + + if ( ! event.dataTransfer ) { + return result; + } + + try { + result = Object.assign( + result, + JSON.parse( event.dataTransfer.getData( 'text' ) ) + ); + } catch ( err ) { + return result; + } + + return result; +} + +/** + * A function that returns an event handler function for block drop events. + * + * @param {string} targetRootClientId The root client id where the block(s) will be inserted. + * @param {number} targetBlockIndex The index where the block(s) will be inserted. + * @param {Function} getBlockIndex A function that gets the index of a block. + * @param {Function} getClientIdsOfDescendants A function that gets the client ids of descendant blocks. + * @param {Function} moveBlocksToPosition A function that moves blocks. + * + * @return {Function} The event handler for a block drop event. + */ +export function onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition +) { + return ( event ) => { + const { + srcRootClientId: sourceRootClientId, + srcClientIds: sourceClientIds, + type: dropType, + } = parseDropEvent( event ); + + // If the user isn't dropping a block, return early. + if ( dropType !== 'block' ) { + return; + } + + const sourceBlockIndex = getBlockIndex( + sourceClientIds[ 0 ], + sourceRootClientId + ); + + // 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 ( + sourceClientIds.includes( targetRootClientId ) || + getClientIdsOfDescendants( sourceClientIds ).some( + ( id ) => id === targetRootClientId + ) + ) { + return; + } + + const isAtSameLevel = sourceRootClientId === targetRootClientId; + const draggedBlockCount = sourceClientIds.length; + + // If the block is kept at the same level and moved downwards, + // subtract to take into account that the blocks being dragged + // were removed from the block list above the insertion point. + const insertIndex = + isAtSameLevel && sourceBlockIndex < targetBlockIndex + ? targetBlockIndex - draggedBlockCount + : targetBlockIndex; + + moveBlocksToPosition( + sourceClientIds, + sourceRootClientId, + targetRootClientId, + insertIndex + ); + }; +} + +/** + * A function that returns an event handler function for block-related file drop events. + * + * @param {string} targetRootClientId The root client id where the block(s) will be inserted. + * @param {number} targetBlockIndex The index where the block(s) will be inserted. + * @param {boolean} hasUploadPermissions Whether the user has upload permissions. + * @param {Function} updateBlockAttributes A function that updates a block's attributes. + * @param {Function} insertBlocks A function that inserts blocks. + * + * @return {Function} The event handler for a block-related file drop event. + */ +export function onFilesDrop( + targetRootClientId, + targetBlockIndex, + hasUploadPermissions, + updateBlockAttributes, + insertBlocks +) { + return ( files ) => { + if ( ! hasUploadPermissions ) { + return; + } + + const transformation = findTransform( + getBlockTransforms( 'from' ), + ( transform ) => + transform.type === 'files' && transform.isMatch( files ) + ); + + if ( transformation ) { + const blocks = transformation.transform( + files, + updateBlockAttributes + ); + insertBlocks( blocks, targetBlockIndex, targetRootClientId ); + } + }; +} + +/** + * A function that returns an event handler function for block-related HTML drop events. + * + * @param {string} targetRootClientId The root client id where the block(s) will be inserted. + * @param {number} targetBlockIndex The index where the block(s) will be inserted. + * @param {Function} insertBlocks A function that inserts blocks. + * + * @return {Function} The event handler for a block-related HTML drop event. + */ +export function onHTMLDrop( + targetRootClientId, + targetBlockIndex, + insertBlocks +) { + return ( HTML ) => { + const blocks = pasteHandler( { HTML, mode: 'BLOCKS' } ); + + if ( blocks.length ) { + insertBlocks( blocks, targetBlockIndex, targetRootClientId ); + } + }; +} + +/** + * A React hook for handling block drop events. + * + * @param {string} targetRootClientId The root client id where the block(s) will be inserted. + * @param {number} targetBlockIndex The index where the block(s) will be inserted. + * + * @return {Object} An object that contains the event handlers `onDrop`, `onFilesDrop` and `onHTMLDrop`. + */ +export default function useOnBlockDrop( targetRootClientId, targetBlockIndex ) { + const { + getBlockIndex, + getClientIdsOfDescendants, + hasUploadPermissions, + } = useSelect( ( select ) => { + const { + getBlockIndex: _getBlockIndex, + getClientIdsOfDescendants: _getClientIdsOfDescendants, + getSettings, + } = select( 'core/block-editor' ); + + return { + getBlockIndex: _getBlockIndex, + getClientIdsOfDescendants: _getClientIdsOfDescendants, + hasUploadPermissions: getSettings().mediaUpload, + }; + }, [] ); + + const { + insertBlocks, + moveBlocksToPosition, + updateBlockAttributes, + } = useDispatch( 'core/block-editor' ); + + return { + onDrop: onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ), + onFilesDrop: onFilesDrop( + targetRootClientId, + targetBlockIndex, + hasUploadPermissions, + updateBlockAttributes, + insertBlocks + ), + onHTMLDrop: onHTMLDrop( + targetRootClientId, + targetBlockIndex, + insertBlocks + ), + }; +} diff --git a/packages/block-editor/src/components/use-on-block-drop/test/index.js b/packages/block-editor/src/components/use-on-block-drop/test/index.js new file mode 100644 index 00000000000000..beae8362fbfcb3 --- /dev/null +++ b/packages/block-editor/src/components/use-on-block-drop/test/index.js @@ -0,0 +1,418 @@ +/** + * Internal dependencies + */ +import { parseDropEvent, onFilesDrop, onHTMLDrop, onBlockDrop } from '..'; +/** + * WordPress dependencies + */ +import { findTransform, pasteHandler } from '@wordpress/blocks'; + +const noop = () => {}; + +jest.mock( '@wordpress/blocks/src/api/factory', () => ( { + findTransform: jest.fn(), + getBlockTransforms: jest.fn(), +} ) ); + +jest.mock( '@wordpress/blocks/src/api/raw-handling', () => ( { + pasteHandler: jest.fn(), +} ) ); + +describe( 'parseDropEvent', () => { + it( 'converts an event dataTransfer property from JSON to an object', () => { + const rawDataTransfer = { + srcRootClientId: '123', + srcClientIds: [ 'abc' ], + srcIndex: 1, + type: 'block', + }; + const event = { + dataTransfer: { + getData() { + return JSON.stringify( rawDataTransfer ); + }, + }, + }; + + expect( parseDropEvent( event ) ).toEqual( rawDataTransfer ); + } ); + + it( 'defaults any missing values to null', () => { + const rawDataTransfer = { + srcClientIds: [ 'abc' ], + type: 'block', + }; + const event = { + dataTransfer: { + getData() { + return JSON.stringify( rawDataTransfer ); + }, + }, + }; + + expect( parseDropEvent( event ) ).toEqual( { + srcRootClientId: null, + srcIndex: null, + ...rawDataTransfer, + } ); + } ); + + it( 'returns an object with null values if the event dataTransfer can not be parsed', () => { + const expected = { + srcRootClientId: null, + srcClientIds: null, + srcIndex: null, + type: null, + }; + const event = { + dataTransfer: { + getData() { + return '{ something_that_cannot_be_parsed_as_json }'; + }, + }, + }; + + expect( parseDropEvent( event ) ).toEqual( expected ); + } ); + + it( 'returns an object with null values if the event has no dataTransfer property', () => { + const expected = { + srcRootClientId: null, + srcClientIds: null, + srcIndex: null, + type: null, + }; + const event = {}; + + expect( parseDropEvent( event ) ).toEqual( expected ); + } ); +} ); + +describe( 'onBlockDrop', () => { + it( 'does nothing if the event is not a block type drop', () => { + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const getBlockIndex = noop; + const getClientIdsOfDescendants = noop; + const moveBlocksToPosition = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'not-a-block-type-drop', + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ); + eventHandler( event ); + + expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if the block is dropped to the same place it was dragged from', () => { + const targetRootClientId = '1'; + const targetBlockIndex = 0; + // Target and source block index is the same. + const getBlockIndex = jest.fn( () => targetBlockIndex ); + const getClientIdsOfDescendants = noop; + const moveBlocksToPosition = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + // Target and source root client id is the same. + srcRootClientId: targetRootClientId, + srcClientIds: [ '2' ], + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ); + eventHandler( event ); + + expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if the block is dropped as a child of itself', () => { + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const getBlockIndex = jest.fn( () => 6 ); + const getClientIdsOfDescendants = noop; + const moveBlocksToPosition = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + srcRootClientId: '0', + // The dragged block is being dropped as a child of itself. + srcClientIds: [ targetRootClientId ], + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ); + eventHandler( event ); + + expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if the block is dropped as a descendant of itself', () => { + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const getBlockIndex = jest.fn( () => 1 ); + // Dragged block is being dropped as a descendant of itself. + const getClientIdsOfDescendants = jest.fn( () => [ + targetRootClientId, + ] ); + const moveBlocksToPosition = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + srcRootClientId: '0', + srcClientIds: [ '5' ], + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ); + eventHandler( event ); + + expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + } ); + + it( 'inserts blocks if the drop is valid', () => { + const sourceClientIds = [ '5' ]; + const sourceRootClientId = '0'; + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const getBlockIndex = jest.fn( () => 1 ); + const getClientIdsOfDescendants = () => []; + const moveBlocksToPosition = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + srcRootClientId: sourceRootClientId, + srcClientIds: sourceClientIds, + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ); + eventHandler( event ); + + expect( moveBlocksToPosition ).toHaveBeenCalledWith( + sourceClientIds, + sourceRootClientId, + targetRootClientId, + targetBlockIndex + ); + } ); + + it( 'adjusts the index blocks are dropped at when moved down under the same root block', () => { + const sourceClientIds = [ '5', '6', '7' ]; + const sourceRootClientId = '0'; + const targetRootClientId = sourceRootClientId; + const targetBlockIndex = 5; + const getBlockIndex = jest.fn( () => 1 ); + // Dragged block is being dropped as a descendant of itself. + const getClientIdsOfDescendants = () => []; + const moveBlocksToPosition = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + srcRootClientId: sourceRootClientId, + // The dragged block is being dropped as a child of itself. + srcClientIds: sourceClientIds, + } ); + }, + }, + }; + + const insertIndex = targetBlockIndex - sourceClientIds.length; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocksToPosition + ); + eventHandler( event ); + + expect( moveBlocksToPosition ).toHaveBeenCalledWith( + sourceClientIds, + sourceRootClientId, + targetRootClientId, + insertIndex + ); + } ); +} ); + +describe( 'onFilesDrop', () => { + it( 'does nothing if hasUploadPermissions is false', () => { + const updateBlockAttributes = jest.fn(); + const insertBlocks = jest.fn(); + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const uploadPermissions = false; + + const onFileDropHandler = onFilesDrop( + targetRootClientId, + targetBlockIndex, + uploadPermissions, + updateBlockAttributes, + insertBlocks + ); + onFileDropHandler(); + + expect( findTransform ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if the block has no matching file transforms', () => { + // Test the scenario of 'no transforms' by mocking findTransform + // to have no return value. + findTransform.mockImplementation( noop ); + const updateBlockAttributes = noop; + const insertBlocks = jest.fn(); + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const uploadPermissions = true; + + const onFileDropHandler = onFilesDrop( + targetRootClientId, + targetBlockIndex, + uploadPermissions, + updateBlockAttributes, + insertBlocks + ); + onFileDropHandler(); + + expect( findTransform ).toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); + } ); + + it( 'inserts blocks if a valid transform can be found', () => { + // Mock findTransform to return a valid transform. The implementation + // of the transform isn't important just that there is a callable 'transform' + // function that returns a value. + const blocks = 'blocks'; + const transformation = { transform: jest.fn( () => blocks ) }; + findTransform.mockImplementation( () => transformation ); + const updateBlockAttributes = noop; + const insertBlocks = jest.fn(); + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const uploadPermissions = true; + + const onFileDropHandler = onFilesDrop( + targetRootClientId, + targetBlockIndex, + uploadPermissions, + updateBlockAttributes, + insertBlocks + ); + const files = 'test'; + onFileDropHandler( files ); + + expect( findTransform ).toHaveBeenCalled(); + expect( transformation.transform ).toHaveBeenCalledWith( + files, + updateBlockAttributes + ); + expect( insertBlocks ).toHaveBeenCalledWith( + blocks, + targetBlockIndex, + targetRootClientId + ); + } ); +} ); + +describe( 'onHTMLDrop', () => { + it( 'does nothing if the HTML cannot be converted into blocks', () => { + pasteHandler.mockImplementation( () => [] ); + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const insertBlocks = jest.fn(); + + const eventHandler = onHTMLDrop( + targetRootClientId, + targetBlockIndex, + insertBlocks + ); + eventHandler(); + + expect( insertBlocks ).not.toHaveBeenCalled(); + } ); + + it( 'inserts blocks if the HTML can be converted into blocks', () => { + const blocks = [ 'blocks' ]; + pasteHandler.mockImplementation( () => blocks ); + const targetRootClientId = '1'; + const targetBlockIndex = 0; + const insertBlocks = jest.fn(); + + const eventHandler = onHTMLDrop( + targetRootClientId, + targetBlockIndex, + insertBlocks + ); + eventHandler(); + + expect( insertBlocks ).toHaveBeenCalledWith( + blocks, + targetBlockIndex, + targetRootClientId + ); + } ); +} );