Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

List View: Allow dragging outside the immediate area by passing down a dropZoneElement #50726

Merged
Merged
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 @@ -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`.
Expand All @@ -73,6 +74,7 @@ function ListViewComponent(
{
id,
blocks,
dropZoneElement,
showBlockMovers = false,
isExpanded = false,
showAppender = false,
Expand Down Expand Up @@ -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 ] );

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