diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index cfb95389ba0391..fc2037333026d8 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -1573,6 +1573,7 @@ Returns an action object used in signalling that the user has begun to drag bloc _Parameters_ - _clientIds_ `string[]`: An array of client ids being dragged +- _targets_ `Array`: the names of target dropzones into which this draggable can be dropped. _Returns_ diff --git a/packages/block-editor/src/components/block-draggable/README.md b/packages/block-editor/src/components/block-draggable/README.md new file mode 100644 index 00000000000000..87a7eff8246ad0 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/README.md @@ -0,0 +1,71 @@ +# Block Draggable + +Block Draggable is a block-specific implementation of a `` which defines behaviour for dragged elements in a block editor context, including (but not limited to): + +- determining whether the given block(s) can be moved. +- setting information on the [`transferData` object](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer). +- (optionally) setting the `target` dropzones with which this draggable is compatible. +- displaying a suitable "chip" during the drag operation. +- managing scrolling during the drag. + +Note that the majority of the behaviour is delegated to `Draggable` from `@wordpress/components`. + +## Usage + +```js +import { BlockDraggable } from '@wordpress/block-editor'; + +function MyComponent() { + return ; +} +``` + +## Props + +### clientIds + +- Type: `Array` +- Required: Yes + +Blocks IDs of candidates to be dragged. + +### targets + +- Type: `Array[string]` +- Required: No + +A list of dropzone names that this draggable considers valid drop targets. If provided then it will only be possible to drop the draggable in a _named_ dropzone that is included in the list. + +### `children` + +- Type: Function +- Required: No + +Component children as a function. The function receives the following arguments: + +- `draggable` - whether or not the block(s) are deemed to be draggable. +- `onDragStart` (optional) - an event handler to be passed as the `onDragStart` event prop of any child node. +- `onDragEnd` - an event handler to be passed as the `onDragEnd` event prop of any child node. + +See [`Draggable`](./packages/components/src/draggable/README.md) for more information. + +### cloneClassname + +- Type: `string` +- Required: No + +A className to be passed to the clone of the draggable. + +### `onDragStart` + +- Type: Function +- Required: No + +A function to be called on the `ondragstart` event. + +### `onDragEnd` + +- Type: Function +- Required: No + +A function to be called on the `ondragend` event. diff --git a/packages/block-editor/src/components/block-draggable/index.js b/packages/block-editor/src/components/block-draggable/index.js index 40f61bfc79ce4d..c2fd52c23f6bcc 100644 --- a/packages/block-editor/src/components/block-draggable/index.js +++ b/packages/block-editor/src/components/block-draggable/index.js @@ -19,6 +19,7 @@ const BlockDraggable = ( { cloneClassname, onDragStart, onDragEnd, + targets = [], } ) => { const { srcRootClientId, isDraggable, icon } = useSelect( ( select ) => { @@ -59,6 +60,7 @@ const BlockDraggable = ( { type: 'block', srcClientIds: clientIds, srcRootClientId, + targets, }; return ( @@ -67,7 +69,7 @@ const BlockDraggable = ( { __experimentalTransferDataType="wp-blocks" transferData={ transferData } onDragStart={ ( event ) => { - startDraggingBlocks( clientIds ); + startDraggingBlocks( clientIds, targets ); isDragging.current = true; startScrolling( event ); diff --git a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js index 680beafd3c07cd..28550ceb17a188 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js +++ b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js @@ -17,6 +17,7 @@ import { } from '../../utils/math'; import useOnBlockDrop from '../use-on-block-drop'; import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../experiments'; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ @@ -179,26 +180,39 @@ function getListViewDropTarget( blocksData, position ) { /** * A react hook for implementing a drop zone in list view. * + * @param {string} dropZoneName the name of the drop zone. * @return {WPListViewDropZoneTarget} The drop target. */ -export default function useListViewDropZone() { +export default function useListViewDropZone( dropZoneName ) { const { getBlockRootClientId, getBlockIndex, getBlockCount, getDraggedBlockClientIds, canInsertBlocks, - } = useSelect( blockEditorStore ); + getDraggedBlocksTargets, + } = unlock( useSelect( blockEditorStore ) ); const [ target, setTarget ] = useState(); const { rootClientId: targetRootClientId, blockIndex: targetBlockIndex } = target || {}; - const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex ); + const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex, { + dropZoneName, + } ); const draggedBlockClientIds = getDraggedBlockClientIds(); const throttled = useThrottle( useCallback( ( event, currentTarget ) => { + const draggedBlocksTargets = getDraggedBlocksTargets(); + + if ( + draggedBlocksTargets?.length && + ! draggedBlocksTargets.includes( dropZoneName ) + ) { + // If drag targets are defined and the drop zone doesn't match, don't allow dropping. + return; + } const position = { x: event.clientX, y: event.clientY }; const isBlockDrag = !! draggedBlockClientIds?.length; diff --git a/packages/block-editor/src/components/off-canvas-editor/block-contents.js b/packages/block-editor/src/components/off-canvas-editor/block-contents.js index 4048f25b49c989..e0480170e215b9 100644 --- a/packages/block-editor/src/components/off-canvas-editor/block-contents.js +++ b/packages/block-editor/src/components/off-canvas-editor/block-contents.js @@ -123,7 +123,10 @@ const ListViewBlockContents = forwardRef( } } /> ) } - + { ( { draggable, onDragStart, onDragEnd } ) => ( { + const draggedBlocksTargets = getDraggedBlocksTargets(); + + if ( + draggedBlocksTargets?.length && + ! draggedBlocksTargets.includes( dropZoneName ) + ) { + // If drag targets are defined and the drop zone doesn't match, don't allow dropping. + return; + } const position = { x: event.clientX, y: event.clientY }; const isBlockDrag = !! draggedBlockClientIds?.length; diff --git a/packages/block-editor/src/components/use-block-drop-zone/README.md b/packages/block-editor/src/components/use-block-drop-zone/README.md index 8aa7a98cf89d4a..d122aa8d0e2643 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/README.md +++ b/packages/block-editor/src/components/use-block-drop-zone/README.md @@ -1,3 +1,12 @@ # useBlockDropZone `useBlockDropZone` is a React hook used to specify a drop zone for a block. This drop zone supports the drag and drop of media into the editor. + +## Props + +### dropZoneName + +- Type: `string` +- Required: No + +An optional name for the dropzone. Note that if a name is provided then only ``s that specify this name in their `targets` prop will be able to be dropped. 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 d0700bd8d05abb..fd6eb588924ec0 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 @@ -19,6 +19,7 @@ import { isPointContainedByRect, } from '../../utils/math'; import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../experiments'; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ /** @typedef {import('../use-on-block-drop/types').WPDropOperation} WPDropOperation */ @@ -141,6 +142,7 @@ export default function useBlockDropZone( { // values returned by the `getRootBlockClientId` selector, which also uses // an empty string to represent top-level blocks. rootClientId: targetRootClientId = '', + dropZoneName, } = {} ) { const [ dropTarget, setDropTarget ] = useState( { index: null, @@ -166,17 +168,33 @@ export default function useBlockDropZone( { [ targetRootClientId ] ); - const { getBlockListSettings, getBlocks, getBlockIndex } = - useSelect( blockEditorStore ); + const { + getBlockListSettings, + getBlocks, + getBlockIndex, + getDraggedBlocksTargets, + } = unlock( useSelect( blockEditorStore ) ); + const { showInsertionPoint, hideInsertionPoint } = useDispatch( blockEditorStore ); const onBlockDrop = useOnBlockDrop( targetRootClientId, dropTarget.index, { operation: dropTarget.operation, + dropZoneName, } ); const throttled = useThrottle( useCallback( ( event, ownerDocument ) => { + const draggedBlocksTargets = getDraggedBlocksTargets(); + + if ( + draggedBlocksTargets?.length && + ! draggedBlocksTargets.includes( dropZoneName ) + ) { + // If drag targets are defined and the drop zone doesn't match, don't allow dropping. + return; + } + const blocks = getBlocks( targetRootClientId ); // The block list is empty, don't show the insertion point but still allow dropping. 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 index 6b0a9300124493..17958aea76e489 100644 --- a/packages/block-editor/src/components/use-on-block-drop/index.js +++ b/packages/block-editor/src/components/use-on-block-drop/index.js @@ -33,6 +33,7 @@ export function parseDropEvent( event ) { srcIndex: null, type: null, blocks: null, + targets: null, }; if ( ! event.dataTransfer ) { @@ -61,6 +62,7 @@ export function parseDropEvent( event ) { * @param {Function} moveBlocks A function that moves blocks. * @param {Function} insertOrReplaceBlocks A function that inserts or replaces blocks. * @param {Function} clearSelectedBlock A function that clears block selection. + * @param {string} dropZoneName The dropZoneName where the drop event will occur (e.g. 'list-view', 'canvas) * @return {Function} The event handler for a block drop event. */ export function onBlockDrop( @@ -70,7 +72,8 @@ export function onBlockDrop( getClientIdsOfDescendants, moveBlocks, insertOrReplaceBlocks, - clearSelectedBlock + clearSelectedBlock, + dropZoneName ) { return ( event ) => { const { @@ -78,6 +81,7 @@ export function onBlockDrop( srcClientIds: sourceClientIds, type: dropType, blocks, + targets: draggableTargets, } = parseDropEvent( event ); // If the user is inserting a block. @@ -91,6 +95,12 @@ export function onBlockDrop( // If the user is moving a block. if ( dropType === 'block' ) { + if ( + draggableTargets?.length && + ! draggableTargets.includes( dropZoneName ) + ) { + return; + } const sourceBlockIndex = getBlockIndex( sourceClientIds[ 0 ] ); // If the user is dropping to the same position, return early. @@ -210,7 +220,7 @@ export default function useOnBlockDrop( targetBlockIndex, options = {} ) { - const { operation = 'insert' } = options; + const { operation = 'insert', dropZoneName } = options; const hasUploadPermissions = useSelect( ( select ) => select( blockEditorStore ).getSettings().mediaUpload, [] @@ -307,7 +317,8 @@ export default function useOnBlockDrop( getClientIdsOfDescendants, moveBlocks, insertOrReplaceBlocks, - clearSelectedBlock + clearSelectedBlock, + dropZoneName ); const _onFilesDrop = onFilesDrop( targetRootClientId, 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 index 1b95cc0085a79e..92690d7a00b619 100644 --- 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 @@ -26,6 +26,7 @@ describe( 'parseDropEvent', () => { srcClientIds: [ 'abc' ], srcIndex: 1, type: 'block', + targets: [ 'canvas' ], }; const event = { dataTransfer: { @@ -55,6 +56,7 @@ describe( 'parseDropEvent', () => { srcRootClientId: null, srcIndex: null, blocks: null, + targets: null, ...rawDataTransfer, } ); } ); @@ -66,6 +68,7 @@ describe( 'parseDropEvent', () => { srcClientIds: null, srcIndex: null, type: null, + targets: null, }; const event = { dataTransfer: { @@ -85,6 +88,7 @@ describe( 'parseDropEvent', () => { srcClientIds: null, srcIndex: null, type: null, + targets: null, }; const event = {}; @@ -298,6 +302,94 @@ describe( 'onBlockDrop', () => { insertIndex ); } ); + + it( 'does nothing if the block is dropped into a drop zone that is not included in a list of valid targets', () => { + const targetRootClientId = '1'; + const targetBlockIndex = 0; + + const dropZoneName = 'canvas'; + const targets = [ 'list-view' ]; + + const getBlockIndex = jest.fn( () => 1 ); + // Dragged block is being dropped as a descendant of itself. + const getClientIdsOfDescendants = jest.fn( () => [ + targetRootClientId, + ] ); + const moveBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); + const clearSelectedBlock = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + srcRootClientId: '0', + srcClientIds: [ '5' ], + targets, + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocks, + insertOrReplaceBlocks, + clearSelectedBlock, + dropZoneName + ); + eventHandler( event ); + + expect( moveBlocks ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing if the block with targets is dropped into an unnamed dropzone', () => { + const targetRootClientId = '1'; + const targetBlockIndex = 0; + + const dropZoneName = ''; + const targets = [ 'list-view' ]; + + const getBlockIndex = jest.fn( () => 1 ); + // Dragged block is being dropped as a descendant of itself. + const getClientIdsOfDescendants = jest.fn( () => [ + targetRootClientId, + ] ); + const moveBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); + const clearSelectedBlock = jest.fn(); + + const event = { + dataTransfer: { + getData() { + return JSON.stringify( { + type: 'block', + srcRootClientId: '0', + srcClientIds: [ '5' ], + targets, + } ); + }, + }, + }; + + const eventHandler = onBlockDrop( + targetRootClientId, + targetBlockIndex, + getBlockIndex, + getClientIdsOfDescendants, + moveBlocks, + insertOrReplaceBlocks, + clearSelectedBlock, + dropZoneName + ); + eventHandler( event ); + + expect( moveBlocks ).not.toHaveBeenCalled(); + } ); } ); describe( 'onFilesDrop', () => { diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 98bdf1bffc78c0..e22964043c2917 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1291,12 +1291,14 @@ export function stopTyping() { * * @param {string[]} clientIds An array of client ids being dragged * + * @param {Array} targets the names of target dropzones into which this draggable can be dropped. * @return {Object} Action object. */ -export function startDraggingBlocks( clientIds = [] ) { +export function startDraggingBlocks( clientIds = [], targets ) { return { type: 'START_DRAGGING_BLOCKS', clientIds, + targets, }; } diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 60712e6b8eb6e0..e48d3913caf4b7 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -18,3 +18,13 @@ export function isBlockInterfaceHidden( state ) { export function getLastInsertedBlocksClientIds( state ) { return state?.lastBlockInserted?.clientIds; } + +/** + * Returns the origin of the dragged blocks (if any). + * + * @param {Object} state Global application state. + * @return {string} The origin of the dragged blocks. + */ +export function getDraggedBlocksTargets( state ) { + return state.draggedBlocksTargets; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index c207df38692b2a..b4c09edba3d875 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1247,6 +1247,17 @@ export function draggedBlocks( state = [], action ) { return state; } +export function draggedBlocksTargets( state = [], action ) { + switch ( action.type ) { + case 'START_DRAGGING_BLOCKS': + return action.targets; + case 'STOP_DRAGGING_BLOCKS': + return []; + } + + return state; +} + /** * Reducer tracking the visible blocks. * @@ -1886,4 +1897,5 @@ export default combineReducers( { lastBlockInserted, temporarilyEditingAsBlocks, blockVisibility, + draggedBlocksTargets, } ); diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 45e432ad8bf3f8..a6c746bd9cd54a 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -799,6 +799,17 @@ describe( 'actions', () => { clientIds, } ); } ); + + it( 'should optionally return a drag origin with START_DRAGGING_BLOCKS action', () => { + const clientIds = [ 'block-1', 'block-2', 'block-3' ]; + expect( + startDraggingBlocks( clientIds, [ 'inner-blocks' ] ) + ).toEqual( { + type: 'START_DRAGGING_BLOCKS', + clientIds, + targets: [ 'inner-blocks' ], + } ); + } ); } ); describe( 'stopDraggingBlocks', () => { diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index c5df265f75db35..6bca596684bce1 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -4,6 +4,7 @@ import { isBlockInterfaceHidden, getLastInsertedBlocksClientIds, + getDraggedBlocksTargets, } from '../private-selectors'; describe( 'private selectors', () => { @@ -49,4 +50,14 @@ describe( 'private selectors', () => { ] ); } ); } ); + + describe( 'getDraggedBlocksTargets', () => { + it( 'returns the draggedBlocks origin', () => { + const targets = [ 'inner-blocks', 'list-view' ]; + const state = { + draggedBlocksTargets: targets, + }; + expect( getDraggedBlocksTargets( state ) ).toBe( targets ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 609cbb59c6e54b..3eb029be4705fe 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -32,6 +32,7 @@ import { blockListSettings, lastBlockAttributesChange, lastBlockInserted, + draggedBlocksTargets, } from '../reducer'; const noop = () => {}; @@ -2464,6 +2465,33 @@ describe( 'state', () => { } ); } ); + describe( 'draggedBlocksTargets', () => { + it.each( [ [ '' ], [ 'inner-blocks', 'list-view' ] ] )( + `should store the dragged blocks' targets as "%s" when a user starts dragging blocks`, + ( targets ) => { + const clientIds = [ 'block-1', 'block-2', 'block-3' ]; + const state = draggedBlocksTargets( [], { + type: 'START_DRAGGING_BLOCKS', + clientIds, + targets, + } ); + + expect( state ).toEqual( targets ); + } + ); + + it( `should reset the state to an empty string when a user stops dragging blocks`, () => { + const state = draggedBlocksTargets( + [ 'inner-blocks', 'list-view' ], + { + type: 'STOP_DRAGGING_BLOCKS', + } + ); + + expect( state ).toEqual( [] ); + } ); + } ); + describe( 'initialPosition()', () => { it( 'should return with block clientId as selected', () => { const state = initialPosition( undefined, {