diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 60c08abaf9089..ff56484dc7613 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -59,6 +59,7 @@ export const BLOCK_LIST_ITEM_HEIGHT = 36; * @param {Object} props Components props. * @param {string} props.id An HTML element id for the root element of ListView. * @param {Array} props.blocks _deprecated_ Custom subset of block client IDs to be used instead of the default hierarchy. + * @param {?HTMLElement} props.dropZoneElement Optional element to be used as the drop zone. * @param {?boolean} props.showBlockMovers Flag to enable block movers. Defaults to `false`. * @param {?boolean} props.isExpanded Flag to determine whether nested levels are expanded by default. Defaults to `false`. * @param {?boolean} props.showAppender Flag to show or hide the block appender. Defaults to `false`. @@ -73,6 +74,7 @@ function ListViewComponent( { id, blocks, + dropZoneElement, showBlockMovers = false, isExpanded = false, showAppender = false, @@ -123,7 +125,9 @@ function ListViewComponent( const [ expandedState, setExpandedState ] = useReducer( expanded, {} ); - const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone(); + const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( { + dropZoneElement, + } ); const elementRef = useRef(); const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] ); diff --git a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js index 1e14417291a7a..c77feae4d291f 100644 --- a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js +++ b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js @@ -261,4 +261,21 @@ describe( 'getListViewDropTarget', () => { expect( target ).toBeUndefined(); } ); + + it( 'should move below, and not nest when dragging lower than the bottom-most block', () => { + const singleBlock = [ { ...blocksData[ 0 ], innerBlockCount: 0 } ]; + + // This position is to the right of the block, but below the bottom of the block. + // This should result in the block being moved below the bottom-most block, and + // not being treated as a nesting gesture. + const position = { x: 160, y: 250 }; + const target = getListViewDropTarget( singleBlock, position ); + + expect( target ).toEqual( { + blockIndex: 1, + clientId: 'block-1', + dropPosition: 'bottom', + rootClientId: '', + } ); + } ); } ); 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 ffcd15e529ab2..ba98da7843356 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 @@ -152,7 +152,8 @@ function getNextNonDraggedBlock( blocksData, index ) { * inner block. * * Determined based on nesting level indentation of the current block, plus - * the indentation of the next level of nesting. + * 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. @@ -161,7 +162,10 @@ function getNextNonDraggedBlock( blocksData, index ) { function isNestingGesture( point, rect, nestingLevel = 1 ) { const blockIndentPosition = rect.left + nestingLevel * NESTING_LEVEL_INDENTATION; - return point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION; + return ( + point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION && + point.y < rect.bottom + ); } // Block navigation is always a vertical list, so only allow dropping @@ -359,9 +363,12 @@ export function getListViewDropTarget( blocksData, position ) { /** * 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. + * * @return {WPListViewDropZoneTarget} The drop target. */ -export default function useListViewDropZone() { +export default function useListViewDropZone( { dropZoneElement } ) { const { getBlockRootClientId, getBlockIndex, @@ -432,7 +439,12 @@ export default function useListViewDropZone() { ); const ref = useDropZone( { + dropZoneElement, onDrop: onBlockDrop, + onDragLeave() { + throttled.cancel(); + 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. diff --git a/packages/compose/src/hooks/use-drop-zone/README.md b/packages/compose/src/hooks/use-drop-zone/README.md new file mode 100644 index 0000000000000..558a5f6866b54 --- /dev/null +++ b/packages/compose/src/hooks/use-drop-zone/README.md @@ -0,0 +1,71 @@ +# useDropZone (experimental) + +A hook to facilitate drag and drop handling within a designated drop zone area. An optional `dropZoneElement` can be provided, however by default the drop zone is bound by the area where the returned `ref` is assigned. + +When using a `dropZoneElement`, it is expected that the `ref` will be attached to a node that is a descendent of the `dropZoneElement`. Additionally, the element passed to `dropZoneElement` should be stored in state rather than a plain ref to ensure reactive updating when it changes. + +## Usage + +```js +import { useDropZone } from '@wordpress/compose'; +import { useState } from '@wordpress/element'; + +const WithWrapperDropZoneElement = () => { + const [ dropZoneElement, setDropZoneElement ] = useState( null ); + + const dropZoneRef = useDropZone( + { + dropZoneElement, + onDrop() => { + console.log( 'Dropped within the drop zone.' ); + }, + onDragEnter() => { + console.log( 'Dragging within the drop zone' ); + } + } + ) + + return ( +
+
+

Drop Zone

+
+
+ ); +}; + +const WithoutWrapperDropZoneElement = () => { + const dropZoneRef = useDropZone( + { + onDrop() => { + console.log( 'Dropped within the drop zone.' ); + }, + onDragEnter() => { + console.log( 'Dragging within the drop zone' ); + } + } + ) + + return ( +
+

Drop Zone

+
+ ); +}; +``` + +## Parameters + +- _props_ `Object`: Named parameters. +- _props.dropZoneElement_ `HTMLElement`: Optional element to be used as the drop zone. +- _props.isDisabled_ `boolean`: Whether or not to disable the drop zone. +- _props.onDragStart_ `( e: DragEvent ) => void`: Called when dragging has started. +- _props.onDragEnter_ `( e: DragEvent ) => void`: Called when the zone is entered. +- _props.onDragOver_ `( e: DragEvent ) => void`: Called when the zone is moved within. +- _props.onDragLeave_ `( e: DragEvent ) => void`: Called when the zone is left. +- _props.onDragEnd_ `( e: MouseEvent ) => void`: Called when dragging has ended. +- _props.onDrop_ `( e: DragEvent ) => void`: Called when dropping in the zone. + +_Returns_ + +- `RefCallback< HTMLElement >`: Ref callback to be passed to the drop zone element. diff --git a/packages/compose/src/hooks/use-drop-zone/index.js b/packages/compose/src/hooks/use-drop-zone/index.js index d6388e09f0bf0..b537f0a5ab622 100644 --- a/packages/compose/src/hooks/use-drop-zone/index.js +++ b/packages/compose/src/hooks/use-drop-zone/index.js @@ -33,18 +33,20 @@ function useFreshRef( value ) { /** * A hook to facilitate drag and drop handling. * - * @param {Object} props Named parameters. - * @param {boolean} [props.isDisabled] Whether or not to disable the drop zone. - * @param {(e: DragEvent) => void} [props.onDragStart] Called when dragging has started. - * @param {(e: DragEvent) => void} [props.onDragEnter] Called when the zone is entered. - * @param {(e: DragEvent) => void} [props.onDragOver] Called when the zone is moved within. - * @param {(e: DragEvent) => void} [props.onDragLeave] Called when the zone is left. - * @param {(e: MouseEvent) => void} [props.onDragEnd] Called when dragging has ended. - * @param {(e: DragEvent) => void} [props.onDrop] Called when dropping in the zone. + * @param {Object} props Named parameters. + * @param {?HTMLElement} [props.dropZoneElement] Optional element to be used as the drop zone. + * @param {boolean} [props.isDisabled] Whether or not to disable the drop zone. + * @param {(e: DragEvent) => void} [props.onDragStart] Called when dragging has started. + * @param {(e: DragEvent) => void} [props.onDragEnter] Called when the zone is entered. + * @param {(e: DragEvent) => void} [props.onDragOver] Called when the zone is moved within. + * @param {(e: DragEvent) => void} [props.onDragLeave] Called when the zone is left. + * @param {(e: MouseEvent) => void} [props.onDragEnd] Called when dragging has ended. + * @param {(e: DragEvent) => void} [props.onDrop] Called when dropping in the zone. * * @return {import('react').RefCallback} Ref callback to be passed to the drop zone element. */ export default function useDropZone( { + dropZoneElement, isDisabled, onDrop: _onDrop, onDragStart: _onDragStart, @@ -61,11 +63,16 @@ export default function useDropZone( { const onDragOverRef = useFreshRef( _onDragOver ); return useRefEffect( - ( element ) => { + ( elem ) => { if ( isDisabled ) { return; } + // If a custom dropZoneRef is passed, use that instead of the element. + // This allows the dropzone to cover an expanded area, rather than + // be restricted to the area of the ref returned by this hook. + const element = dropZoneElement ?? elem; + let isDragging = false; const { ownerDocument } = element; @@ -228,6 +235,6 @@ export default function useDropZone( { ); }; }, - [ isDisabled ] + [ isDisabled, dropZoneElement ] // Refresh when the passed in dropZoneElement changes. ); } diff --git a/packages/compose/src/hooks/use-drop-zone/test/index.js b/packages/compose/src/hooks/use-drop-zone/test/index.js new file mode 100644 index 0000000000000..260d5cbbbdceb --- /dev/null +++ b/packages/compose/src/hooks/use-drop-zone/test/index.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useDropZone from '../'; + +describe( 'useDropZone', () => { + const ComponentWithWrapperDropZone = () => { + const [ dropZoneElement, setDropZoneElement ] = useState( null ); + const dropZoneRef = useDropZone( { + dropZoneElement, + } ); + + return ( +
+
+
Drop Zone
+
+
+ ); + }; + + const ComponentWithoutWrapperDropZone = () => { + const dropZoneRef = useDropZone( {} ); + + return ( +
+
+
Drop Zone
+
+
+ ); + }; + + it( 'will attach dropzone to outer wrapper', () => { + const { rerender } = render( ); + // Ensure `useEffect` has run. + rerender( ); + + expect( screen.getByRole( 'main' ) ).toHaveAttribute( + 'data-is-drop-zone' + ); + } ); + + it( 'will attach dropzone to element with dropZoneRef attached', () => { + const { rerender } = render( ); + // Ensure `useEffect` has run. + rerender( ); + + expect( screen.getByRole( 'region' ) ).toHaveAttribute( + 'data-is-drop-zone' + ); + } ); +} ); diff --git a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js index 67cb2ad11c4cb..69aaf573b840e 100644 --- a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js @@ -41,6 +41,10 @@ export default function ListViewSidebar() { } } + // Use internal state instead of a ref to make sure that the component + // re-renders when the dropZoneElement updates. + const [ dropZoneElement, setDropZoneElement ] = useState( null ); + const [ tab, setTab ] = useState( 'list-view' ); // This ref refers to the sidebar as a whole. @@ -147,12 +151,13 @@ export default function ListViewSidebar() { contentFocusReturnRef, focusOnMountRef, listViewRef, + setDropZoneElement, ] ) } className="edit-post-editor__list-view-container" > { tab === 'list-view' && (
- +
) } { tab === 'outline' && } diff --git a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js index 37c85216b9a35..e4eff5ad037dd 100644 --- a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js @@ -9,6 +9,7 @@ import { useMergeRefs, } from '@wordpress/compose'; import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { ESCAPE } from '@wordpress/keycodes'; @@ -24,9 +25,14 @@ const { PrivateListView } = unlock( blockEditorPrivateApis ); export default function ListViewSidebar() { const { setIsListViewOpened } = useDispatch( editSiteStore ); + // Use internal state instead of a ref to make sure that the component + // re-renders when the dropZoneElement updates. + const [ dropZoneElement, setDropZoneElement ] = useState( null ); + const focusOnMountRef = useFocusOnMount( 'firstElement' ); const headerFocusReturnRef = useFocusReturn(); const contentFocusReturnRef = useFocusReturn(); + function closeOnEscape( event ) { if ( event.keyCode === ESCAPE && ! event.defaultPrevented ) { setIsListViewOpened( false ); @@ -55,9 +61,10 @@ export default function ListViewSidebar() { ref={ useMergeRefs( [ contentFocusReturnRef, focusOnMountRef, + setDropZoneElement, ] ) } > - + ); diff --git a/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js index 6e828533ddb6a..eeff1ea0bf0f3 100644 --- a/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js +++ b/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js @@ -9,6 +9,7 @@ import { useMergeRefs, } from '@wordpress/compose'; import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { ESCAPE } from '@wordpress/keycodes'; @@ -21,9 +22,14 @@ import { store as editWidgetsStore } from '../../store'; export default function ListViewSidebar() { const { setIsListViewOpened } = useDispatch( editWidgetsStore ); + // Use internal state instead of a ref to make sure that the component + // re-renders when the dropZoneElement updates. + const [ dropZoneElement, setDropZoneElement ] = useState( null ); + const focusOnMountRef = useFocusOnMount( 'firstElement' ); const headerFocusReturnRef = useFocusReturn(); const contentFocusReturnRef = useFocusReturn(); + function closeOnEscape( event ) { if ( event.keyCode === ESCAPE && ! event.defaultPrevented ) { event.preventDefault(); @@ -53,9 +59,10 @@ export default function ListViewSidebar() { ref={ useMergeRefs( [ contentFocusReturnRef, focusOnMountRef, + setDropZoneElement, ] ) } > - + );