diff --git a/packages/block-editor/src/components/block-draggable/index.js b/packages/block-editor/src/components/block-draggable/index.js index 8f960f4538a6c9..b75a3a1c2cecb0 100644 --- a/packages/block-editor/src/components/block-draggable/index.js +++ b/packages/block-editor/src/components/block-draggable/index.js @@ -4,8 +4,42 @@ import { Draggable } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { useEffect, useRef } from '@wordpress/element'; +import { getScrollContainer } from '@wordpress/dom'; -const BlockDraggable = ( { children, clientIds, cloneClassname } ) => { +const SCROLL_INACTIVE_DISTANCE_PX = 50; +const SCROLL_INTERVAL_MS = 25; +const PIXELS_PER_SECOND_PER_DISTANCE = 5; +const VELOCITY_MULTIPLIER = + PIXELS_PER_SECOND_PER_DISTANCE * ( SCROLL_INTERVAL_MS / 1000 ); + +function startScrollingY( nodeRef, velocityRef ) { + return setInterval( () => { + if ( nodeRef.current && velocityRef.current ) { + const newTop = nodeRef.current.scrollTop + velocityRef.current; + + nodeRef.current.scroll( { + top: newTop, + // behavior: 'smooth' // seems to hurt performance, better to use a small scroll interval + } ); + } + }, SCROLL_INTERVAL_MS ); +} + +function getVerticalScrollParent( node ) { + if ( node === null ) { + return null; + } + + return getScrollContainer( node ); +} + +const BlockDraggable = ( { + children, + clientIds, + cloneClassname, + onDragStart, + onDragEnd, +} ) => { const { srcRootClientId, index, isDraggable } = useSelect( ( select ) => { const { @@ -30,6 +64,14 @@ const BlockDraggable = ( { children, clientIds, cloneClassname } ) => { [ clientIds ] ); const isDragging = useRef( false ); + + // @todo - do this for horizontal scroll + const dragStartY = useRef( null ); + const velocityY = useRef( null ); + const scrollParentY = useRef( null ); + + const scrollEditorInterval = useRef( null ); + const { startDraggingBlocks, stopDraggingBlocks } = useDispatch( 'core/block-editor' ); @@ -40,6 +82,11 @@ const BlockDraggable = ( { children, clientIds, cloneClassname } ) => { if ( isDragging.current ) { stopDraggingBlocks(); } + + if ( scrollEditorInterval.current ) { + clearInterval( scrollEditorInterval.current ); + scrollEditorInterval.current = null; + } }; }, [] ); @@ -60,13 +107,51 @@ const BlockDraggable = ( { children, clientIds, cloneClassname } ) => { cloneClassname={ cloneClassname } elementId={ blockElementId } transferData={ transferData } - onDragStart={ () => { + onDragStart={ ( event ) => { startDraggingBlocks(); isDragging.current = true; + dragStartY.current = event.clientY; + + // find nearest parent(s) to scroll + scrollParentY.current = getVerticalScrollParent( + document.getElementById( blockElementId ) + ); + scrollEditorInterval.current = startScrollingY( + scrollParentY, + velocityY + ); + if ( onDragStart ) { + onDragStart(); + } + } } + onDragOver={ ( event ) => { + const distanceY = event.clientY - dragStartY.current; + if ( distanceY > SCROLL_INACTIVE_DISTANCE_PX ) { + velocityY.current = + VELOCITY_MULTIPLIER * + ( distanceY - SCROLL_INACTIVE_DISTANCE_PX ); + } else if ( distanceY < -SCROLL_INACTIVE_DISTANCE_PX ) { + velocityY.current = + VELOCITY_MULTIPLIER * + ( distanceY + SCROLL_INACTIVE_DISTANCE_PX ); + } else { + velocityY.current = 0; + } } } onDragEnd={ () => { stopDraggingBlocks(); isDragging.current = false; + dragStartY.current = null; + scrollParentY.current = null; + + if ( scrollEditorInterval.current ) { + clearInterval( scrollEditorInterval.current ); + scrollEditorInterval.current = null; + } + + if ( onDragEnd ) { + onDragEnd(); + } } } > { ( { onDraggableStart, onDraggableEnd } ) => { diff --git a/packages/block-editor/src/components/block-list/block-contextual-toolbar.js b/packages/block-editor/src/components/block-list/block-contextual-toolbar.js index 839cb84e87769d..c9faf2079a628d 100644 --- a/packages/block-editor/src/components/block-list/block-contextual-toolbar.js +++ b/packages/block-editor/src/components/block-list/block-contextual-toolbar.js @@ -39,7 +39,10 @@ function BlockContextualToolbar( { focusOnMount, ...props } ) { aria-label={ __( 'Block tools' ) } { ...props } > - + ); diff --git a/packages/block-editor/src/components/block-list/block-popover.js b/packages/block-editor/src/components/block-list/block-popover.js index fa29a157b762ab..2f7c4bb193b0c5 100644 --- a/packages/block-editor/src/components/block-list/block-popover.js +++ b/packages/block-editor/src/components/block-list/block-popover.js @@ -31,6 +31,7 @@ function selector( select ) { isCaretWithinFormattedText, getSettings, getLastMultiSelectedBlockClientId, + isDraggingBlocks, } = select( 'core/block-editor' ); return { isNavigationMode: isNavigationMode(), @@ -40,6 +41,7 @@ function selector( select ) { hasMultiSelection: hasMultiSelection(), hasFixedToolbar: getSettings().hasFixedToolbar, lastClientId: getLastMultiSelectedBlockClientId(), + isDragging: isDraggingBlocks(), }; } @@ -60,6 +62,7 @@ function BlockPopover( { hasMultiSelection, hasFixedToolbar, lastClientId, + isDragging, } = useSelect( selector, [] ); const isLargeViewport = useViewportMatch( 'medium' ); const [ isToolbarForced, setIsToolbarForced ] = useState( false ); @@ -96,7 +99,8 @@ function BlockPopover( { ! shouldShowBreadcrumb && ! shouldShowContextualToolbar && ! isToolbarForced && - ! showEmptyBlockSideInserter + ! showEmptyBlockSideInserter && + ! isDragging ) { return null; } @@ -136,6 +140,14 @@ function BlockPopover( { setIsInserterShown( false ); } + function onDragStart() { + setIsToolbarForced( true ); + } + + function onDragEnd() { + setIsToolbarForced( false ); + } + // Position above the anchor, pop out towards the right, and position in the // left corner. For the side inserter, pop out towards the left, and // position in the right corner. @@ -165,8 +177,10 @@ function BlockPopover( { onBlur={ () => setIsToolbarForced( false ) } shouldAnchorIncludePadding // Popover calculates the width once. Trigger a reset by remounting - // the component. - key={ shouldShowContextualToolbar } + // the component. We include both shouldShowContextualToolbar and isToolbarForced + // in the key to prevent the component being unmounted unexpectedly when isToolbarForced = true, + // e.g. during drag and drop + key={ shouldShowContextualToolbar || isToolbarForced } > { ( shouldShowContextualToolbar || isToolbarForced ) && (
) } { shouldShowBreadcrumb && ( diff --git a/packages/components/src/draggable/README.md b/packages/components/src/draggable/README.md index 28bdee89faf23e..adfe59d4d0b04b 100644 --- a/packages/components/src/draggable/README.md +++ b/packages/components/src/draggable/README.md @@ -24,7 +24,15 @@ Arbitrary data object attached to the drag and drop event. ### onDragStart -A function to be called when dragging starts. +A function called when dragging starts. This callback receives the `event` object from the `dragstart` event as its first parameter. + +- Type: `Function` +- Required: No +- Default: `noop` + +### onDragOver + +A function called when the element being dragged is dragged over a valid drop target. This callback receives the `event` object from the `dragover` event as its first parameter. - Type: `Function` - Required: No @@ -32,7 +40,7 @@ A function to be called when dragging starts. ### onDragEnd -A function to be called when dragging ends. +A function called when dragging ends. This callback receives the `event` object from the `dragend` event as its first parameter. - Type: `Function` - Required: No diff --git a/packages/components/src/draggable/index.js b/packages/components/src/draggable/index.js index 0a44ebeb70d8b6..7b74a253ea288e 100644 --- a/packages/components/src/draggable/index.js +++ b/packages/components/src/draggable/index.js @@ -38,7 +38,11 @@ class Draggable extends Component { event.preventDefault(); this.resetDragState(); - this.props.setTimeout( onDragEnd ); + + // Allow the Synthetic Event to be accessed from asynchronous code. + // https://reactjs.org/docs/events.html#event-pooling + event.persist(); + this.props.setTimeout( onDragEnd.bind( this, event ) ); } /** @@ -61,6 +65,12 @@ class Draggable extends Component { // Update cursor coordinates. this.cursorLeft = event.clientX; this.cursorTop = event.clientY; + + const { onDragOver = noop } = this.props; + + // The `event` from `onDragOver` is not a SyntheticEvent + // and so it doesn't require `event.persist()`. + this.props.setTimeout( onDragOver.bind( this, event ) ); } /** @@ -150,7 +160,10 @@ class Draggable extends Component { document.body.classList.add( 'is-dragging-components-draggable' ); document.addEventListener( 'dragover', this.onDragOver ); - this.props.setTimeout( onDragStart ); + // Allow the Synthetic Event to be accessed from asynchronous code. + // https://reactjs.org/docs/events.html#event-pooling + event.persist(); + this.props.setTimeout( onDragStart.bind( this, event ) ); } /** @@ -165,6 +178,9 @@ class Draggable extends Component { this.cloneWrapper = null; } + this.cursorLeft = null; + this.cursorTop = null; + // Reset cursor. document.body.classList.remove( 'is-dragging-components-draggable' ); } diff --git a/packages/components/src/draggable/style.scss b/packages/components/src/draggable/style.scss index 6cbc4ff09db4da..076207caed7fc0 100644 --- a/packages/components/src/draggable/style.scss +++ b/packages/components/src/draggable/style.scss @@ -16,6 +16,7 @@ body.is-dragging-components-draggable { background: transparent; pointer-events: none; z-index: z-index(".components-draggable__clone"); + opacity: 0.7; > * { // This needs specificity as a theme is meant to define these by default.