Skip to content

Commit

Permalink
List View: Allow dragging outside the immediate area by passing down …
Browse files Browse the repository at this point in the history
…a dropZoneElement (#50726)

* List View: Allow dragging outside the immediate area by passing down a dropZoneRef from outside the list view

* Fix nesting logic so it will only nest if hovering over the bottom half of the item

* Update widget sidebar, too

* Update comments

* Update comments and types

* Use setTarget directly in the callback

* Fix issue where sometimes the expanded drop zone area was not being respected

* Add tests for useDropZone

* Update dependency array, we only need to look at the current key

* Swap out dropZoneRef for dropZoneElement

* Fix typo

* Add README file

* Update packages/compose/src/hooks/use-drop-zone/README.md

Co-authored-by: Daniel Richards <daniel.richards@automattic.com>

---------

Co-authored-by: Daniel Richards <daniel.richards@automattic.com>
  • Loading branch information
andrewserong and talldan authored May 24, 2023
1 parent 19594cd commit 66f94d8
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 17 deletions.
6 changes: 5 additions & 1 deletion packages/block-editor/src/components/list-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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`.
Expand All @@ -74,6 +75,7 @@ function ListViewComponent(
{
id,
blocks,
dropZoneElement,
showBlockMovers = false,
isExpanded = false,
showAppender = false,
Expand Down Expand Up @@ -124,7 +126,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 ] );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
} );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions packages/compose/src/hooks/use-drop-zone/README.md
Original file line number Diff line number Diff line change
@@ -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 (
<div className="outer-wrapper" ref={ setDropZoneElement }>
<div ref={ dropZoneRef }>
<p>Drop Zone</p>
</div>
</div>
);
};

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

return (
<div ref={ dropZoneRef }>
<p>Drop Zone</p>
</div>
);
};
```

## 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.
27 changes: 17 additions & 10 deletions packages/compose/src/hooks/use-drop-zone/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>} Ref callback to be passed to the drop zone element.
*/
export default function useDropZone( {
dropZoneElement,
isDisabled,
onDrop: _onDrop,
onDragStart: _onDragStart,
Expand All @@ -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;
Expand Down Expand Up @@ -228,6 +235,6 @@ export default function useDropZone( {
);
};
},
[ isDisabled ]
[ isDisabled, dropZoneElement ] // Refresh when the passed in dropZoneElement changes.
);
}
63 changes: 63 additions & 0 deletions packages/compose/src/hooks/use-drop-zone/test/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<div role="main" ref={ setDropZoneElement }>
<div role="region" ref={ dropZoneRef }>
<div>Drop Zone</div>
</div>
</div>
);
};

const ComponentWithoutWrapperDropZone = () => {
const dropZoneRef = useDropZone( {} );

return (
<div role="main">
<div role="region" ref={ dropZoneRef }>
<div>Drop Zone</div>
</div>
</div>
);
};

it( 'will attach dropzone to outer wrapper', () => {
const { rerender } = render( <ComponentWithWrapperDropZone /> );
// Ensure `useEffect` has run.
rerender( <ComponentWithWrapperDropZone /> );

expect( screen.getByRole( 'main' ) ).toHaveAttribute(
'data-is-drop-zone'
);
} );

it( 'will attach dropzone to element with dropZoneRef attached', () => {
const { rerender } = render( <ComponentWithoutWrapperDropZone /> );
// Ensure `useEffect` has run.
rerender( <ComponentWithoutWrapperDropZone /> );

expect( screen.getByRole( 'region' ) ).toHaveAttribute(
'data-is-drop-zone'
);
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -147,12 +151,13 @@ export default function ListViewSidebar() {
contentFocusReturnRef,
focusOnMountRef,
listViewRef,
setDropZoneElement,
] ) }
className="edit-post-editor__list-view-container"
>
{ tab === 'list-view' && (
<div className="edit-post-editor__list-view-panel-content">
<ListView />
<ListView dropZoneElement={ dropZoneElement } />
</div>
) }
{ tab === 'outline' && <ListViewOutline /> }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 );
Expand Down Expand Up @@ -55,9 +61,10 @@ export default function ListViewSidebar() {
ref={ useMergeRefs( [
contentFocusReturnRef,
focusOnMountRef,
setDropZoneElement,
] ) }
>
<PrivateListView />
<PrivateListView dropZoneElement={ dropZoneElement } />
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -53,9 +59,10 @@ export default function ListViewSidebar() {
ref={ useMergeRefs( [
contentFocusReturnRef,
focusOnMountRef,
setDropZoneElement,
] ) }
>
<ListView />
<ListView dropZoneElement={ dropZoneElement } />
</div>
</div>
);
Expand Down

1 comment on commit 66f94d8

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected in 66f94d8.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/5074167024
📝 Reported issues:

Please sign in to comment.