diff --git a/packages/block-editor/src/components/block-draggable/index.js b/packages/block-editor/src/components/block-draggable/index.js index 7191a0f1428a9..c86bc55e8ed07 100644 --- a/packages/block-editor/src/components/block-draggable/index.js +++ b/packages/block-editor/src/components/block-draggable/index.js @@ -17,12 +17,15 @@ import { __unstableUseBlockRef as useBlockRef } from '../block-list/use-block-pr import { isDropTargetValid } from '../use-block-drop-zone'; const BlockDraggable = ( { + appendToOwnerDocument, children, clientIds, cloneClassname, + elementId, onDragStart, onDragEnd, fadeWhenDisabled = false, + dragComponent, } ) => { const { srcRootClientId, @@ -181,6 +184,7 @@ const BlockDraggable = ( { return ( + // Check against `undefined` so that `null` can be used to disable + // the default drag component. + dragComponent !== undefined ? ( + dragComponent + ) : ( + + ) } + elementId={ elementId } > { ( { onDraggableStart, onDraggableEnd } ) => { return children( { diff --git a/packages/block-editor/src/components/list-view/block-contents.js b/packages/block-editor/src/components/list-view/block-contents.js index 8d5b03395f3e2..0537a4b48cbe4 100644 --- a/packages/block-editor/src/components/list-view/block-contents.js +++ b/packages/block-editor/src/components/list-view/block-contents.js @@ -74,7 +74,11 @@ const ListViewBlockContents = forwardRef( setInsertedBlock={ setInsertedBlock } /> ) } - + { ( { draggable, onDragStart, onDragEnd } ) => ( @@ -188,17 +206,20 @@ function ListViewBranch( props ) { selectBlock={ selectBlock } isSelected={ isSelected } isBranchSelected={ isSelectedBranch } - isDragged={ isDragged || isBranchDragged } + isDragged={ isDragged } level={ level } position={ position } rowCount={ rowCount } siblingBlockCount={ blockCount } showBlockMovers={ showBlockMovers } path={ updatedPath } - isExpanded={ shouldExpand } + isExpanded={ isDragged ? false : shouldExpand } listPosition={ nextPosition } selectedClientIds={ selectedClientIds } isSyncedBranch={ syncedBranch } + displacement={ displacement } + isAfterDraggedBlocks={ isAfterDraggedBlocks } + isNesting={ isNesting } /> ) } { ! showBlock && ( @@ -206,7 +227,7 @@ function ListViewBranch( props ) { ) } - { hasNestedBlocks && shouldExpand && ( + { hasNestedBlocks && shouldExpand && ! isDragged && ( { @@ -35,7 +60,7 @@ export default function ListViewDropIndicator( { : undefined; return [ _rootBlockElement, _blockElement ]; - }, [ rootClientId, clientId ] ); + }, [ listViewRef, rootClientId, clientId ] ); // The targetElement is the element that the drop indicator will appear // before or after. When dropping into an empty block list, blockElement @@ -44,27 +69,6 @@ export default function ListViewDropIndicator( { const rtl = isRTL(); - const getDropIndicatorIndent = useCallback( - ( targetElementRect ) => { - if ( ! rootBlockElement ) { - return 0; - } - - // Calculate the indent using the block icon of the root block. - // Using a classname selector here might be flaky and could be - // improved. - const rootBlockIconElement = rootBlockElement.querySelector( - '.block-editor-block-icon' - ); - const rootBlockIconRect = - rootBlockIconElement.getBoundingClientRect(); - return rtl - ? targetElementRect.right - rootBlockIconRect.left - : rootBlockIconRect.right - targetElementRect.left; - }, - [ rootBlockElement, rtl ] - ); - const getDropIndicatorWidth = useCallback( ( targetElementRect, indent ) => { if ( ! targetElement ) { @@ -143,12 +147,69 @@ export default function ListViewDropIndicator( { } const targetElementRect = targetElement.getBoundingClientRect(); - const indent = getDropIndicatorIndent( targetElementRect ); return { - width: getDropIndicatorWidth( targetElementRect, indent ), + width: getDropIndicatorWidth( targetElementRect, 0 ), }; - }, [ getDropIndicatorIndent, getDropIndicatorWidth, targetElement ] ); + }, [ getDropIndicatorWidth, targetElement ] ); + + const horizontalScrollOffsetStyle = useMemo( () => { + if ( ! targetElement ) { + return {}; + } + + const scrollContainer = getScrollContainer( targetElement ); + const ownerDocument = targetElement.ownerDocument; + const windowScroll = + scrollContainer === ownerDocument.body || + scrollContainer === ownerDocument.documentElement; + + if ( scrollContainer && ! windowScroll ) { + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const targetElementRect = targetElement.getBoundingClientRect(); + + const distanceBetweenContainerAndTarget = rtl + ? scrollContainerRect.right - targetElementRect.right + : targetElementRect.left - scrollContainerRect.left; + + if ( ! rtl && scrollContainerRect.left > targetElementRect.left ) { + return { + transform: `translateX( ${ distanceBetweenContainerAndTarget }px )`, + }; + } + + if ( rtl && scrollContainerRect.right < targetElementRect.right ) { + return { + transform: `translateX( ${ + distanceBetweenContainerAndTarget * -1 + }px )`, + }; + } + } + + return {}; + }, [ rtl, targetElement ] ); + + const ariaLevel = useMemo( () => { + if ( ! rootBlockElement ) { + return 1; + } + + const _ariaLevel = parseInt( + rootBlockElement.getAttribute( 'aria-level' ), + 10 + ); + + return _ariaLevel ? _ariaLevel + 1 : 1; + }, [ rootBlockElement ] ); + + const hasAdjacentSelectedBranch = useMemo( () => { + if ( ! targetElement ) { + return false; + } + + return targetElement.classList.contains( 'is-branch-selected' ); + }, [ targetElement ] ); const popoverAnchor = useMemo( () => { const isValidDropPosition = @@ -163,16 +224,15 @@ export default function ListViewDropIndicator( { contextElement: targetElement, getBoundingClientRect() { const rect = targetElement.getBoundingClientRect(); - const indent = getDropIndicatorIndent( rect ); // In RTL languages, the drop indicator should be positioned // to the left of the target element, with the width of the // indicator determining the indent at the right edge of the // target element. In LTR languages, the drop indicator should // end at the right edge of the target element, with the indent // added to the position of the left edge of the target element. - let left = rtl ? rect.left : rect.left + indent; + // let left = rtl ? rect.left : rect.left + indent; + let left = rect.left; let top = 0; - let bottom = 0; // In deeply nested lists, where a scrollbar is present, // the width of the drop indicator should be the width of @@ -212,27 +272,19 @@ export default function ListViewDropIndicator( { } if ( dropPosition === 'top' ) { - top = rect.top; - bottom = rect.top; + top = rect.top - rect.height * 2; } else { // `dropPosition` is either `bottom` or `inside` - top = rect.bottom; - bottom = rect.bottom; + top = rect.top; } - const width = getDropIndicatorWidth( rect, indent ); - const height = bottom - top; + const width = getDropIndicatorWidth( rect, 0 ); + const height = rect.height; return new window.DOMRect( left, top, width, height ); }, }; - }, [ - targetElement, - dropPosition, - getDropIndicatorIndent, - getDropIndicatorWidth, - rtl, - ] ); + }, [ targetElement, dropPosition, getDropIndicatorWidth, rtl ] ); if ( ! targetElement ) { return null; @@ -243,13 +295,54 @@ export default function ListViewDropIndicator( { animate={ false } anchor={ popoverAnchor } focusOnMount={ false } - className="block-editor-list-view-drop-indicator" + className="block-editor-list-view-drop-indicator--preview" variant="unstyled" + flip={ false } + resize={ true } >
+ className={ classnames( + 'block-editor-list-view-drop-indicator__line', + { + 'block-editor-list-view-drop-indicator__line--darker': + hasAdjacentSelectedBranch, + } + ) } + > +
+
+ {} } /> + + + + + { blockTitle } + + + +
+
+
+
); } diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 315a6153839d1..5270a7af3a296 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -28,8 +33,9 @@ import { __ } from '@wordpress/i18n'; */ import ListViewBranch from './branch'; import { ListViewContext } from './context'; -import ListViewDropIndicator from './drop-indicator'; +import ListViewDropIndicatorPreview from './drop-indicator'; import useBlockSelection from './use-block-selection'; +import useListViewBlockIndexes from './use-list-view-block-indexes'; import useListViewClientIds from './use-list-view-client-ids'; import useListViewDropZone from './use-list-view-drop-zone'; import useListViewExpandSelectedItem from './use-list-view-expand-selected-item'; @@ -105,6 +111,7 @@ function ListViewComponent( const instanceId = useInstanceId( ListViewComponent ); const { clientIdsTree, draggedClientIds, selectedClientIds } = useListViewClientIds( { blocks, rootClientId } ); + const blockIndexes = useListViewBlockIndexes( clientIdsTree ); const { getBlock } = useSelect( blockEditorStore ); const { visibleBlockCount, shouldShowInnerBlocks } = useSelect( @@ -132,6 +139,8 @@ function ListViewComponent( const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( { dropZoneElement, + expandedState, + setExpandedState, } ); const elementRef = useRef(); const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] ); @@ -210,11 +219,55 @@ function ListViewComponent( [ updateBlockSelection ] ); + const firstDraggedBlockClientId = draggedClientIds?.[ 0 ]; + + // Convert a blockDropTarget into indexes relative to the blocks in the list view. + // These values are used to determine which blocks should be displaced to make room + // for the drop indicator. See `ListViewBranch` and `getDragDisplacementValues`. + const { blockDropTargetIndex, blockDropPosition, firstDraggedBlockIndex } = + useMemo( () => { + let _blockDropTargetIndex, _firstDraggedBlockIndex; + + if ( blockDropTarget?.clientId ) { + const foundBlockIndex = + blockIndexes[ blockDropTarget.clientId ]; + // If dragging below or inside the block, treat the drop target as the next block. + _blockDropTargetIndex = + foundBlockIndex === undefined || + blockDropTarget?.dropPosition === 'top' + ? foundBlockIndex + : foundBlockIndex + 1; + } else if ( blockDropTarget === null ) { + // A `null` value is used to indicate that the user is dragging outside of the list view. + _blockDropTargetIndex = null; + } + + if ( firstDraggedBlockClientId ) { + const foundBlockIndex = + blockIndexes[ firstDraggedBlockClientId ]; + _firstDraggedBlockIndex = + foundBlockIndex === undefined || + blockDropTarget?.dropPosition === 'top' + ? foundBlockIndex + : foundBlockIndex + 1; + } + + return { + blockDropTargetIndex: _blockDropTargetIndex, + blockDropPosition: blockDropTarget?.dropPosition, + firstDraggedBlockIndex: _firstDraggedBlockIndex, + }; + }, [ blockDropTarget, blockIndexes, firstDraggedBlockClientId ] ); + const contextValue = useMemo( () => ( { + blockDropPosition, + blockDropTargetIndex, + blockIndexes, draggedClientIds, expandedState, expand, + firstDraggedBlockIndex, collapse, BlockSettingsMenu, listViewInstanceId: instanceId, @@ -225,9 +278,13 @@ function ListViewComponent( rootClientId, } ), [ + blockDropPosition, + blockDropTargetIndex, + blockIndexes, draggedClientIds, expandedState, expand, + firstDraggedBlockIndex, collapse, BlockSettingsMenu, instanceId, @@ -267,7 +324,8 @@ function ListViewComponent( return ( - @@ -278,7 +336,11 @@ function ListViewComponent( ) } 0 && + blockDropTargetIndex !== undefined, + } ) } aria-label={ __( 'Block navigation structure' ) } ref={ treeGridRef } onCollapseRow={ collapseRow } @@ -286,6 +348,15 @@ function ListViewComponent( onFocusRow={ focusRow } applicationAriaLabel={ __( 'Block navigation structure' ) } aria-describedby={ describedById } + style={ { + '--wp-admin--list-view-dragged-items-height': + draggedClientIds?.length + ? `${ + BLOCK_LIST_ITEM_HEIGHT * + ( draggedClientIds.length - 1 ) + }px` + : null, + } } > { const animationRef = useMovingAnimation( { - isSelected, - adjustScrolling: false, + clientId: props[ 'data-block' ], enableAnimation: true, triggerAnimationOnChange: path, } ); diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 42423328d19e4..11cf1fafa0e14 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -9,11 +9,22 @@ margin: (-$grid-unit-15) (-$grid-unit-15 * 0.5) 0; width: calc(100% + #{ $grid-unit-15 }); } + + // Without setting `pointer-events: none`, when dragging over list view items, + // Safari calls onDropZoneLeave causing flickering in the position of the drop indicator. + // https://bugs.webkit.org/show_bug.cgi?id=66547 + // See: https://github.com/WordPress/gutenberg/pull/56625 + &.is-dragging tbody { + pointer-events: none; + } } .block-editor-list-view-leaf { // Use position relative for row animation. position: relative; + // Set the initial translate position to ensure the UI in Safari doesn't jump + // when rows later receive the is-displacement-up or is-displacement-down class. + transform: translateY(0); &.is-draggable, &.is-draggable .block-editor-list-view-block-contents { @@ -128,6 +139,66 @@ border-radius: 0; } + // List view items will be displaced up or down relative to their original position + // when a user is dragging a block within the list view. This creates a space for a + // visual indicator of where the block will be placed when dropped. The `normal` state + // is used to allow rows to smoothly transition back into their original position, + // without attaching the transition to the list view leaf itself. This prevents rows + // from animating too much once the user has dropped the block. + &.is-displacement-normal { + transition: transform 0.2s; + transform: translateY(0); + @include reduce-motion("transition"); + } + + &.is-displacement-up { + transition: transform 0.2s; + transform: translateY(-36px); + @include reduce-motion("transition"); + } + + &.is-displacement-down { + transition: transform 0.2s; + transform: translateY(36px); + @include reduce-motion("transition"); + } + + // Collapse multi-selections down into a single row space while dragging. The following + // rules ensure that during a multi-selection, the space for additional blocks is factored in + // when displacing up and down. The result is that there should only ever be a single row's + // worth of space for the visual indicator of where a block will be placed when dropped. + &.is-after-dragged-blocks { + transition: transform 0.2s; + transform: translateY(calc(var(--wp-admin--list-view-dragged-items-height, 36px) * -1)); + @include reduce-motion("transition"); + } + + &.is-after-dragged-blocks.is-displacement-up { + transition: transform 0.2s; + transform: translateY(calc(-36px + var(--wp-admin--list-view-dragged-items-height, 36px) * -1)); + @include reduce-motion("transition"); + } + + &.is-after-dragged-blocks.is-displacement-down { + transition: transform 0.2s; + transform: translateY(calc(36px + var(--wp-admin--list-view-dragged-items-height, 36px) * -1)); + @include reduce-motion("transition"); + } + + // To ensure displaced rows behave correctly, ensure that blocks that are currently being dragged + // are visually hidden. The below rules are used in favor of `display: none` to ensure that + // there is no flicker when a user begins to drag a block. + &.is-dragging { + opacity: 0; + // The row's left position is set to 0 to ensure that the animation + // when dropping a block animates from the correct position. + left: 0; + // Ensure the dragged element does not otherwise affect dropping to + // other positions. + pointer-events: none; + z-index: -9999; + } + // List View renders a fixed number of items and relies on each item having a fixed height of 36px. // If this value changes, we should also change the itemHeight value set in useFixedWindowList. // See: https://github.com/WordPress/gutenberg/pull/35230 for additional context. @@ -159,6 +230,7 @@ } } + &.is-nesting .block-editor-list-view-block-contents, .block-editor-list-view-block-contents:focus { box-shadow: none; @@ -173,11 +245,6 @@ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); z-index: 2; pointer-events: none; - - // Hide focus styles while a user is dragging blocks/files etc. - .is-dragging-components-draggable & { - box-shadow: none; - } } } @@ -186,14 +253,10 @@ right: 0; } + &.is-nesting .block-editor-list-view__menu, .block-editor-list-view-block__menu:focus { box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); z-index: 1; - - // Hide focus styles while a user is dragging blocks/files etc. - .is-dragging-components-draggable & { - box-shadow: none; - } } &.is-visible .block-editor-list-view-block-contents { @@ -370,6 +433,14 @@ } } +// First level of indentation is aria-level 2, max indent is 8. +// Indent is a full icon size, plus 4px which optically aligns child icons to the text label above. +$block-navigation-max-indent: 8; + +.block-editor-list-view-draggable-chip { + opacity: 0.8; +} + .block-editor-list-view-block__contents-cell, .block-editor-list-view-appender__cell { .block-editor-list-view-block__contents-container, @@ -386,9 +457,6 @@ cursor: pointer; } -// First level of indentation is aria-level 2, max indent is 8. -// Indent is a full icon size, plus 4px which optically aligns child icons to the text label above. -$block-navigation-max-indent: 8; .block-editor-list-view-leaf[aria-level] .block-editor-list-view__expander { margin-left: ( $icon-size ) * $block-navigation-max-indent + 4 * ( $block-navigation-max-indent - 1 ); } @@ -443,6 +511,27 @@ $block-navigation-max-indent: 8; } } +.block-editor-list-view-drop-indicator--preview { + pointer-events: none; + + // If the drop indicator's popover ever extends to the edge of the screen, + // avoid any scrollbars by hiding the overflow. + .components-popover__content { + overflow: hidden !important; + } + + .block-editor-list-view-drop-indicator__line { + background: rgba(var(--wp-admin-theme-color--rgb), 0.04); + height: 36px; + border-radius: 4px; + overflow: hidden; + } + + .block-editor-list-view-drop-indicator__line--darker { + background: rgba(var(--wp-admin-theme-color--rgb), 0.09); + } +} + .block-editor-list-view-placeholder { padding: 0; margin: 0; 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 98c2b31132c60..f1180a9fa27ca 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 @@ -127,6 +127,7 @@ describe( 'getListViewDropTarget', () => { expect( target ).toEqual( { blockIndex: 0, + clientId: 'block-1', dropPosition: 'inside', rootClientId: 'block-1', } ); @@ -142,6 +143,7 @@ describe( 'getListViewDropTarget', () => { expect( target ).toEqual( { blockIndex: 0, + clientId: 'block-3', dropPosition: 'inside', rootClientId: 'block-3', } ); @@ -244,6 +246,7 @@ describe( 'getListViewDropTarget', () => { expect( target ).toEqual( { blockIndex: 1, + clientId: 'block-1', dropPosition: 'inside', rootClientId: 'block-1', } ); @@ -277,6 +280,7 @@ describe( 'getListViewDropTarget', () => { expect( target ).toEqual( { blockIndex: 0, + clientId: 'block-1', dropPosition: 'inside', rootClientId: 'block-1', } ); diff --git a/packages/block-editor/src/components/list-view/test/utils.js b/packages/block-editor/src/components/list-view/test/utils.js index 78d78a9d90069..d603d9ef1bb8b 100644 --- a/packages/block-editor/src/components/list-view/test/utils.js +++ b/packages/block-editor/src/components/list-view/test/utils.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { getCommonDepthClientIds } from '../utils'; +import { getCommonDepthClientIds, getDragDisplacementValues } from '../utils'; describe( 'getCommonDepthClientIds', () => { it( 'should return start and end when no depth is provided', () => { @@ -48,3 +48,220 @@ describe( 'getCommonDepthClientIds', () => { expect( result ).toEqual( { start: 'start-3', end: 'clicked-id' } ); } ); } ); + +describe( 'getDragDisplacementValues', () => { + const blockIndexes = { + 'block-a': 0, + 'block-b': 1, + 'block-c': 2, + 'block-d': 3, + 'block-e': 4, + 'block-f': 5, + 'block-g': 6, + 'block-h': 7, + }; + + it( 'should return displacement of normal when block is after dragged block and drop target', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 3, + blockDropPosition: 'bottom', + clientId: 'block-h', + firstDraggedBlockIndex: 5, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'normal', + isAfterDraggedBlocks: true, + isNesting: false, + } ); + } ); + + it( 'should return displacement of up when block is after dragged block and before the drop target', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 7, + blockDropPosition: 'bottom', + clientId: 'block-d', + firstDraggedBlockIndex: 2, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'up', + isAfterDraggedBlocks: true, + isNesting: false, + } ); + } ); + + it( 'should return displacement of down when block is before dragged block and after the drop target', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 1, + blockDropPosition: 'bottom', + clientId: 'block-d', + firstDraggedBlockIndex: 6, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'down', + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return isNesting of true when block is just before the drop target and drop position is inside', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 1, + blockDropPosition: 'inside', + clientId: 'block-a', + firstDraggedBlockIndex: 6, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'normal', + isAfterDraggedBlocks: false, + isNesting: true, + } ); + } ); + + it( 'should return isNesting of false when block is not just before the drop target and drop position is inside', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 1, + blockDropPosition: 'inside', + clientId: 'block-b', + firstDraggedBlockIndex: 6, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'down', + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return displacement of up when drop target index is null and block is after dragged block', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: null, + blockDropPosition: 'bottom', + clientId: 'block-h', + firstDraggedBlockIndex: 5, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'up', + isAfterDraggedBlocks: true, + isNesting: false, + } ); + } ); + + it( 'should return displacement of normal when drop target index is null and block is before dragged block', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: null, + blockDropPosition: 'bottom', + clientId: 'block-c', + firstDraggedBlockIndex: 5, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'normal', + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return displacement of normal when dragging a file (no dragged block) and the block is before the target index', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 3, + blockDropPosition: 'bottom', + clientId: 'block-b', + firstDraggedBlockIndex: undefined, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'normal', + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return displacement of down when dragging a file (no dragged block) and the block is after the target index', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 3, + blockDropPosition: 'bottom', + clientId: 'block-h', + firstDraggedBlockIndex: undefined, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'down', + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return displacement of normal when dragging a file (no dragged block) and dragging outside the list view (drop target of null)', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: null, + blockDropPosition: 'bottom', + clientId: 'block-h', + firstDraggedBlockIndex: undefined, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: 'normal', + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return undefined displacement if the target index is undefined', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: undefined, + blockDropPosition: 'bottom', + clientId: 'block-h', + firstDraggedBlockIndex: undefined, + isDragged: false, + } ); + + expect( result ).toEqual( { + displacement: undefined, + isAfterDraggedBlocks: false, + isNesting: false, + } ); + } ); + + it( 'should return all undefined values if the block is dragged', () => { + const result = getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex: 3, + blockDropPosition: 'bottom', + clientId: 'block-h', + firstDraggedBlockIndex: 7, + isDragged: true, + } ); + + expect( result ).toEqual( { + displacement: undefined, + isAfterDraggedBlocks: undefined, + isNesting: undefined, + } ); + } ); +} ); diff --git a/packages/block-editor/src/components/list-view/use-list-view-block-indexes.js b/packages/block-editor/src/components/list-view/use-list-view-block-indexes.js new file mode 100644 index 0000000000000..5890e366d7ce9 --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-list-view-block-indexes.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +export default function useListViewBlockIndexes( blocks ) { + const blockIndexes = useMemo( () => { + const indexes = {}; + + let currentGlobalIndex = 0; + + const traverseBlocks = ( blockList ) => { + blockList.forEach( ( block ) => { + indexes[ block.clientId ] = currentGlobalIndex; + currentGlobalIndex++; + + if ( block.innerBlocks.length > 0 ) { + traverseBlocks( block.innerBlocks ); + } + } ); + }; + + traverseBlocks( blocks ); + + return indexes; + }, [ blocks ] ); + + return blockIndexes; +} 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 a1a369d3f9408..3354b3f41d391 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 @@ -2,10 +2,11 @@ * WordPress dependencies */ import { useSelect } from '@wordpress/data'; -import { useState, useCallback } from '@wordpress/element'; +import { useState, useCallback, useEffect } from '@wordpress/element'; import { useThrottle, __experimentalUseDropZone as useDropZone, + usePrevious, } from '@wordpress/compose'; import { isRTL } from '@wordpress/i18n'; @@ -304,6 +305,7 @@ export function getListViewDropTarget( blocksData, position, rtl = false ) { return { rootClientId: candidateBlockData.clientId, + clientId: candidateBlockData.clientId, blockIndex: newBlockIndex, dropPosition: 'inside', }; @@ -396,15 +398,30 @@ export function getListViewDropTarget( blocksData, position, rtl = false ) { }; } +// Throttle options need to be defined outside of the hook to avoid +// re-creating the object on every render. This is due to a limitation +// of the `useThrottle` hook, where the options object is included +// in the dependency array for memoization. +const EXPAND_THROTTLE_OPTIONS = { + leading: false, // Don't call the function immediately on the first call. + trailing: true, // Do call the function on the last call. +}; + /** * 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. + * @param {Object} props Named parameters. + * @param {?HTMLElement} [props.dropZoneElement] Optional element to be used as the drop zone. + * @param {Object} [props.expandedState] The expanded state of the blocks in the list view. + * @param {Function} [props.setExpandedState] Function to set the expanded state of a list of block clientIds. * * @return {WPListViewDropZoneTarget} The drop target. */ -export default function useListViewDropZone( { dropZoneElement } ) { +export default function useListViewDropZone( { + dropZoneElement, + expandedState, + setExpandedState, +} ) { const { getBlockRootClientId, getBlockIndex, @@ -420,6 +437,55 @@ export default function useListViewDropZone( { dropZoneElement } ) { const rtl = isRTL(); + const previousRootClientId = usePrevious( targetRootClientId ); + + const maybeExpandBlock = useCallback( + ( _expandedState, _target ) => { + // If the user is attempting to drop a block inside a collapsed block, + // that is, using a nesting gesture flagged by 'inside' dropPosition, + // expand the block within the list view, if it isn't already. + const { rootClientId } = _target || {}; + if ( ! rootClientId ) { + return; + } + if ( + _target?.dropPosition === 'inside' && + ! _expandedState[ rootClientId ] + ) { + setExpandedState( { + type: 'expand', + clientIds: [ rootClientId ], + } ); + } + }, + [ setExpandedState ] + ); + + // Throttle the maybeExpandBlock function to avoid expanding the block + // too quickly when the user is dragging over the block. This is to + // avoid expanding the block when the user is just passing over it. + const throttledMaybeExpandBlock = useThrottle( + maybeExpandBlock, + 500, + EXPAND_THROTTLE_OPTIONS + ); + + useEffect( () => { + if ( + target?.dropPosition !== 'inside' || + previousRootClientId !== target?.rootClientId + ) { + throttledMaybeExpandBlock.cancel(); + return; + } + throttledMaybeExpandBlock( expandedState, target ); + }, [ + expandedState, + previousRootClientId, + target, + throttledMaybeExpandBlock, + ] ); + const draggedBlockClientIds = getDraggedBlockClientIds(); const throttled = useThrottle( useCallback( @@ -484,7 +550,7 @@ export default function useListViewDropZone( { dropZoneElement } ) { rtl, ] ), - 200 + 50 ); const ref = useDropZone( { @@ -496,6 +562,9 @@ export default function useListViewDropZone( { dropZoneElement } ) { }, onDragLeave() { throttled.cancel(); + // Use `null` value to indicate that the drop target is not valid, + // but that the drag is still active. This allows for styling rules + // that are active only when a user drags outside of the list view. setTarget( null ); }, onDragOver( event ) { @@ -506,7 +575,10 @@ export default function useListViewDropZone( { dropZoneElement } ) { }, onDragEnd() { throttled.cancel(); - setTarget( null ); + // Use `undefined` value to indicate that the drag has concluded. + // This allows styling rules that are active only when a user is + // dragging to be removed. + setTarget( undefined ); }, } ); diff --git a/packages/block-editor/src/components/list-view/utils.js b/packages/block-editor/src/components/list-view/utils.js index 632173e120691..ed7a321dea0c8 100644 --- a/packages/block-editor/src/components/list-view/utils.js +++ b/packages/block-editor/src/components/list-view/utils.js @@ -93,3 +93,119 @@ export function focusListItem( focusClientId, treeGridElementRef ) { } ); } } + +/** + * Get values for the block that flag whether the block should be displaced up or down, + * whether the block is being nested, and whether the block appears after the dragged + * blocks. These values are used to determine the class names to apply to the block. + * The list view rows are displaced visually via CSS rules. Displacement rules: + * - `normal`: no displacement — used to apply a translateY of `0` so that the block + * appears in its original position, and moves to that position smoothly when dragging + * outside of the list view area. + * - `up`: the block should be displaced up, creating room beneath the block for the drop indicator. + * - `down`: the block should be displaced down, creating room above the block for the drop indicator. + * + * @param {Object} props + * @param {Object} props.blockIndexes The indexes of all the blocks in the list view, keyed by clientId. + * @param {number|null|undefined} props.blockDropTargetIndex The index of the block that the user is dropping to. + * @param {?string} props.blockDropPosition The position relative to the block that the user is dropping to. + * @param {string} props.clientId The client id for the current block. + * @param {?number} props.firstDraggedBlockIndex The index of the first dragged block. + * @param {?boolean} props.isDragged Whether the current block is being dragged. Dragged blocks skip displacement. + * @return {Object} An object containing the `displacement`, `isAfterDraggedBlocks` and `isNesting` values. + */ +export function getDragDisplacementValues( { + blockIndexes, + blockDropTargetIndex, + blockDropPosition, + clientId, + firstDraggedBlockIndex, + isDragged, +} ) { + let displacement; + let isNesting; + let isAfterDraggedBlocks; + + if ( ! isDragged ) { + isNesting = false; + const thisBlockIndex = blockIndexes[ clientId ]; + isAfterDraggedBlocks = thisBlockIndex > firstDraggedBlockIndex; + + // Determine where to displace the position of the current block, relative + // to the blocks being dragged (in their original position) and the drop target + // (the position where a user is currently dragging the blocks to). + if ( + blockDropTargetIndex !== undefined && + blockDropTargetIndex !== null && + firstDraggedBlockIndex !== undefined + ) { + // If the block is being dragged and there is a valid drop target, + // determine if the block being rendered should be displaced up or down. + + if ( thisBlockIndex !== undefined ) { + if ( + thisBlockIndex >= firstDraggedBlockIndex && + thisBlockIndex < blockDropTargetIndex + ) { + // If the current block appears after the set of dragged blocks + // (in their original position), but is before the drop target, + // then the current block should be displaced up. + displacement = 'up'; + } else if ( + thisBlockIndex < firstDraggedBlockIndex && + thisBlockIndex >= blockDropTargetIndex + ) { + // If the current block appears before the set of dragged blocks + // (in their original position), but is after the drop target, + // then the current block should be displaced down. + displacement = 'down'; + } else { + displacement = 'normal'; + } + isNesting = + typeof blockDropTargetIndex === 'number' && + blockDropTargetIndex - 1 === thisBlockIndex && + blockDropPosition === 'inside'; + } + } else if ( + blockDropTargetIndex === null && + firstDraggedBlockIndex !== undefined + ) { + // A `null` value for `blockDropTargetIndex` indicates that the + // drop target is outside of the valid areas within the list view. + // In this case, the drag is still active, but as there is no + // valid drop target, we should remove the gap indicating where + // the block would be inserted. + if ( + thisBlockIndex !== undefined && + thisBlockIndex >= firstDraggedBlockIndex + ) { + displacement = 'up'; + } else { + displacement = 'normal'; + } + } else if ( + blockDropTargetIndex !== undefined && + blockDropTargetIndex !== null && + firstDraggedBlockIndex === undefined + ) { + // If the blockdrop target is defined, but there are no dragged blocks, + // then the block should be displaced relative to the drop target. + if ( thisBlockIndex !== undefined ) { + if ( thisBlockIndex < blockDropTargetIndex ) { + displacement = 'normal'; + } else { + displacement = 'down'; + } + } + } else if ( blockDropTargetIndex === null ) { + displacement = 'normal'; + } + } + + return { + displacement, + isNesting, + isAfterDraggedBlocks, + }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss index 88ff27a9c1d2f..908056d52af48 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -33,5 +33,5 @@ .edit-site-sidebar-navigation-screen__content .block-editor-list-view-block-select-button { cursor: grab; - padding: $grid-unit-10; + padding: $grid-unit-10 $grid-unit-10 $grid-unit-10 0; } diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index 674801cf94aba..00f21b4e51c5e 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -53,11 +53,40 @@ test.describe( 'List View', () => { name: 'Paragraph', exact: true, } ); + const imageBlockItem = listView.getByRole( 'gridcell', { + name: 'Image', + exact: true, + } ); const headingBlockItem = listView.getByRole( 'gridcell', { name: 'Heading', exact: true, } ); - await paragraphBlockItem.dragTo( headingBlockItem, { x: 0, y: 0 } ); + + await paragraphBlockItem.hover(); + await page.mouse.down(); + + // To work around a drag and drop bug in Safari, the list view applies + // `pointer-events: none` to the list view while dragging, so that + // `onDragLeave` is not fired when dragging within the list view. + // Without the `force: true` option, the `hover` action will fail + // as playwright will complain that pointer-events are intercepted. + // https://bugs.webkit.org/show_bug.cgi?id=66547 + // See: https://github.com/WordPress/gutenberg/pull/56625 + + // Hover over each block to mimic moving up the list view. + // Also, hover twice to ensure a dragover event is dispatched. + // See: https://playwright.dev/docs/input#dragging-manually + await imageBlockItem.hover( { force: true } ); + await imageBlockItem.hover( { force: true } ); + await headingBlockItem.hover( { force: true } ); + await headingBlockItem.hover( { force: true } ); + + // Disable reason: Need to wait until the throttle timeout of 250ms has passed. + /* eslint-disable playwright/no-wait-for-timeout */ + await editor.page.waitForTimeout( 300 ); + /* eslint-enable playwright/no-wait-for-timeout */ + + await page.mouse.up(); // Ensure the block was dropped correctly. await expect