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 0537a4b48cbe4..c09dca2ac3e62 100644 --- a/packages/block-editor/src/components/list-view/block-contents.js +++ b/packages/block-editor/src/components/list-view/block-contents.js @@ -16,6 +16,7 @@ import ListViewBlockSelectButton from './block-select-button'; import BlockDraggable from '../block-draggable'; import { store as blockEditorStore } from '../../store'; import { useListViewContext } from './context'; +import useDragChip from './use-drag-chip'; const ListViewBlockContents = forwardRef( ( @@ -47,8 +48,21 @@ const ListViewBlockContents = forwardRef( [] ); - const { AdditionalBlockContent, insertedBlock, setInsertedBlock } = - useListViewContext(); + const { + AdditionalBlockContent, + blockDropTarget, + insertedBlock, + listViewInstanceId, + setInsertedBlock, + treeGridElementRef, + } = useListViewContext(); + + const { dragChipOnDragStart, dragChipOnDragEnd } = useDragChip( { + blockDropTarget, + cloneClassname: 'block-editor-list-view-draggable-chip', + listViewRef: treeGridElementRef, + elementId: `list-view-${ listViewInstanceId }-block-${ clientId }`, + } ); const isBlockMoveTarget = blockMovingClientId && selectedBlockInBlockEditor === clientId; @@ -77,7 +91,9 @@ const ListViewBlockContents = forwardRef( { ( { draggable, onDragStart, onDragEnd } ) => ( { + onDragStart( event ); + dragChipOnDragStart( event ); + } } + onDragEnd={ ( event ) => { + onDragEnd( event ); + dragChipOnDragEnd( event ); + } } isExpanded={ isExpanded } { ...props } /> diff --git a/packages/block-editor/src/components/list-view/drop-indicator.js b/packages/block-editor/src/components/list-view/drop-indicator.js index ed98922202293..e7208b0aeee39 100644 --- a/packages/block-editor/src/components/list-view/drop-indicator.js +++ b/packages/block-editor/src/components/list-view/drop-indicator.js @@ -6,35 +6,16 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - __experimentalHStack as HStack, - __experimentalTruncate as Truncate, - Popover, -} from '@wordpress/components'; +import { Popover } from '@wordpress/components'; import { getScrollContainer } from '@wordpress/dom'; import { useCallback, useMemo } from '@wordpress/element'; import { isRTL } from '@wordpress/i18n'; -/** - * Internal dependencies - */ -import BlockIcon from '../block-icon'; -import useBlockDisplayInformation from '../use-block-display-information'; -import useBlockDisplayTitle from '../block-title/use-block-display-title'; -import ListViewExpander from './expander'; - export default function ListViewDropIndicatorPreview( { - draggedBlockClientId, listViewRef, blockDropTarget, } ) { - const blockInformation = useBlockDisplayInformation( draggedBlockClientId ); - const blockTitle = useBlockDisplayTitle( { - clientId: draggedBlockClientId, - context: 'list-view', - } ); - const { rootClientId, clientId, dropPosition } = blockDropTarget || {}; const [ rootBlockElement, blockElement ] = useMemo( () => { @@ -153,56 +134,6 @@ export default function ListViewDropIndicatorPreview( { }; }, [ 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; @@ -309,40 +240,7 @@ export default function ListViewDropIndicatorPreview( { 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 895571755e4fa..d13399a479e89 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -275,6 +275,7 @@ function ListViewComponent( const contextValue = useMemo( () => ( { blockDropPosition, + blockDropTarget, blockDropTargetIndex, blockIndexes, draggedClientIds, @@ -292,6 +293,7 @@ function ListViewComponent( } ), [ blockDropPosition, + blockDropTarget, blockDropTargetIndex, blockIndexes, draggedClientIds, diff --git a/packages/block-editor/src/components/list-view/leaf.js b/packages/block-editor/src/components/list-view/leaf.js index ba6d528746829..debf837fc5f63 100644 --- a/packages/block-editor/src/components/list-view/leaf.js +++ b/packages/block-editor/src/components/list-view/leaf.js @@ -37,6 +37,9 @@ const ListViewLeaf = forwardRef( clientId: props[ 'data-block' ], enableAnimation: true, triggerAnimationOnChange: path, + elementSelector: isDragged + ? '.block-editor-list-view-draggable-chip .block-editor-list-view-leaf' + : undefined, } ); const mergedRef = useMergeRefs( [ ref, animationRef ] ); diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 11cf1fafa0e14..6b4c0fa6c4e38 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -437,8 +437,65 @@ // 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-default-draggable-chip { + // Hide the default draggable chip + display: none; +} + .block-editor-list-view-draggable-chip { - opacity: 0.8; + .block-editor-list-view-leaf { + background-color: $white; + border-radius: $radius-block-ui; + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + display: flex; + height: 36px; + // Where possible, restrict the width of the cloned row to the width of the list view. + max-width: 338px; + opacity: 1; + + // Reset colors to use the admin color. + td { + background: none !important; + .block-editor-list-view-block-select-button, + .block-editor-block-icon, + .components-button.has-icon { + color: var(--wp-admin-theme-color) !important; + } + } + + .block-editor-list-view__expander { + // Remove indent on the expander, as the dragged component offsets the entire row. + margin-left: 0 !important; + } + + // Apply a margin offset to account for nesting level. + &[aria-level] { + margin-left: ( $icon-size ) * $block-navigation-max-indent + 4 * ( $block-navigation-max-indent - 1 ); + } + + // When updating the margin for each indentation level, the corresponding + // indentation in `use-list-view-drop-zone.js` must be updated as well + // to ensure the drop zone is aligned with the indentation. + @for $i from 0 to $block-navigation-max-indent { + &[aria-level="#{ $i + 1 }"] { + @if $i - 1 >= 0 { + margin-left: ( $icon-size * $i ) + 4 * ($i - 1) !important; + } + @else { + margin-left: ( $icon-size * $i ) !important; + } + } + } + + .block-editor-list-view-block__contents-cell { + flex: 1; + } + + .block-editor-list-view-block__menu-cell { + display: flex; + align-items: center; + } + } } .block-editor-list-view-block__contents-cell, diff --git a/packages/block-editor/src/components/list-view/use-drag-chip.js b/packages/block-editor/src/components/list-view/use-drag-chip.js new file mode 100644 index 0000000000000..d4e537f6b2b65 --- /dev/null +++ b/packages/block-editor/src/components/list-view/use-drag-chip.js @@ -0,0 +1,231 @@ +/** + * WordPress dependencies + */ +import { throttle } from '@wordpress/compose'; +import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; + +const dragImageClass = 'components-draggable__invisible-drag-image'; +const cloneWrapperClass = 'components-draggable__clone'; +const clonePadding = 0; +const bodyClass = 'is-dragging-components-draggable'; + +export default function useDragChip( { + blockDropTarget, + cloneClassname, + listViewRef, + elementId, + transferData, + __experimentalTransferDataType: transferDataType = 'text', +} ) { + const { clientId, rootClientId } = blockDropTarget || {}; + // const horizontalOffset = useRef( 0 ); + const targetAriaLevelRef = useRef( 1 ); + const originalAriaLevelRef = useRef( 1 ); + const cleanup = useRef( () => {} ); + const [ isWithinListView, setIsWithinListView ] = useState( false ); + + // TODO: Add RTL support. + // const rtl = isRTL(); + + const [ rootBlockElement ] = useMemo( () => { + if ( ! listViewRef.current ) { + return []; + } + // The rootClientId will be defined whenever dropping into inner + // block lists, but is undefined when dropping at the root level. + const _rootBlockElement = rootClientId + ? listViewRef.current.querySelector( + `[data-block="${ rootClientId }"]` + ) + : undefined; + // The clientId represents the sibling block, the dragged block will + // usually be inserted adjacent to it. It will be undefined when + // dropping a block into an empty block list. + const _blockElement = clientId + ? listViewRef.current.querySelector( + `[data-block="${ clientId }"]` + ) + : undefined; + return [ _rootBlockElement, _blockElement ]; + }, [ listViewRef, rootClientId, clientId ] ); + + useEffect( () => { + let ariaLevel = 1; + + if ( rootBlockElement ) { + const _ariaLevel = parseInt( + rootBlockElement.getAttribute( 'aria-level' ), + 10 + ); + + ariaLevel = _ariaLevel ? _ariaLevel + 1 : 1; + } + + targetAriaLevelRef.current = ariaLevel; + }, [ rootBlockElement ] ); + + /** + * Removes the element clone, resets cursor, and removes drag listener. + * + * @param {DragEvent} event The non-custom DragEvent. + */ + function end( event ) { + event.preventDefault(); + cleanup.current(); + } + + /** + * This method does a couple of things: + * + * - Clones the current element and spawns clone over original element. + * - Adds a fake temporary drag image to avoid browser defaults. + * - Sets transfer data. + * - Adds dragover listener. + * + * @param {DragEvent} event The non-custom DragEvent. + */ + function start( event ) { + const { ownerDocument } = event.target; + + event.dataTransfer.setData( + transferDataType, + JSON.stringify( transferData ) + ); + + const cloneWrapper = ownerDocument.createElement( 'div' ); + // Reset position to 0,0. Natural stacking order will position this lower, even with a transform otherwise. + cloneWrapper.style.top = '0'; + cloneWrapper.style.left = '0'; + + const dragImage = ownerDocument.createElement( 'div' ); + + // Set a fake drag image to avoid browser defaults. Remove from DOM + // right after. event.dataTransfer.setDragImage is not supported yet in + // IE, we need to check for its existence first. + if ( 'function' === typeof event.dataTransfer.setDragImage ) { + dragImage.classList.add( dragImageClass ); + ownerDocument.body.appendChild( dragImage ); + event.dataTransfer.setDragImage( dragImage, 0, 0 ); + } + + cloneWrapper.classList.add( cloneWrapperClass ); + + if ( cloneClassname ) { + cloneWrapper.classList.add( cloneClassname ); + } + + let x = 0; + let y = 0; + + const element = ownerDocument.getElementById( elementId ); + + const _originalAriaLevel = element.getAttribute( 'aria-level' ); + + if ( _originalAriaLevel ) { + originalAriaLevelRef.current = parseInt( _originalAriaLevel, 10 ); + } + + // Prepare element clone and append to element wrapper. + const elementRect = element.getBoundingClientRect(); + const elementTopOffset = elementRect.top; + const elementLeftOffset = elementRect.left; + + cloneWrapper.style.width = `${ + elementRect.width + clonePadding * 2 + }px`; + + const clone = element.cloneNode( true ); + clone.id = `clone-${ elementId }`; + + // Position clone right over the original element (20px padding). + x = elementLeftOffset - clonePadding; + y = elementTopOffset - clonePadding; + cloneWrapper.style.transform = `translate( ${ x }px, ${ y }px )`; + + // Hack: Remove iFrames as it's causing the embeds drag clone to freeze. + Array.from( clone.querySelectorAll( 'iframe' ) ).forEach( ( child ) => + child.parentNode?.removeChild( child ) + ); + + cloneWrapper.appendChild( clone ); + + ownerDocument.body.appendChild( cloneWrapper ); + + // Mark the current cursor coordinates. + let cursorLeft = event.clientX; + let cursorTop = event.clientY; + + function over( e ) { + if ( listViewRef.current ) { + if ( + ! isWithinListView && + listViewRef.current.contains( e.target ) + ) { + setIsWithinListView( true ); + } else if ( + isWithinListView && + ! listViewRef.current.contains( e.target ) + ) { + setIsWithinListView( false ); + } + } + + // Skip doing any work if mouse has not moved. + if ( cursorLeft === e.clientX && cursorTop === e.clientY ) { + return; + } + + const horizontalOffset = + ( targetAriaLevelRef.current - originalAriaLevelRef.current ) * + 28; + + const nextY = y + e.clientY - cursorTop; + const nextX = x + horizontalOffset; + + cloneWrapper.style.transform = `translate( ${ nextX }px, ${ nextY }px )`; + cursorLeft = e.clientX; + cursorTop = e.clientY; + // x = nextX; + y = nextY; + } + + // Aim for 60fps (16 ms per frame) for now. We can potentially use requestAnimationFrame (raf) instead, + // note that browsers may throttle raf below 60fps in certain conditions. + // @ts-ignore + const throttledDragOver = throttle( over, 16 ); + + ownerDocument.addEventListener( 'dragover', throttledDragOver ); + + // Update cursor to 'grabbing', document wide. + ownerDocument.body.classList.add( bodyClass ); + + cleanup.current = () => { + // Remove drag clone. + if ( cloneWrapper && cloneWrapper.parentNode ) { + cloneWrapper.parentNode.removeChild( cloneWrapper ); + } + + if ( dragImage && dragImage.parentNode ) { + dragImage.parentNode.removeChild( dragImage ); + } + + // Reset cursor. + ownerDocument.body.classList.remove( bodyClass ); + + ownerDocument.removeEventListener( 'dragover', throttledDragOver ); + }; + } + + useEffect( + () => () => { + cleanup.current(); + }, + [] + ); + + return { + dragChipOnDragStart: start, + dragChipOnDragEnd: end, + isWithinListView, + }; +} diff --git a/packages/block-editor/src/components/use-moving-animation/index.js b/packages/block-editor/src/components/use-moving-animation/index.js index 4a66fe6fb6e63..92a61f16d4bcf 100644 --- a/packages/block-editor/src/components/use-moving-animation/index.js +++ b/packages/block-editor/src/components/use-moving-animation/index.js @@ -21,13 +21,6 @@ import { store as blockEditorStore } from '../../store'; */ const BLOCK_ANIMATION_THRESHOLD = 200; -function getAbsolutePosition( element ) { - return { - top: element.offsetTop, - left: element.offsetLeft, - }; -} - /** * Hook used to compute the styles required to move a div into a new position. * @@ -42,8 +35,13 @@ function getAbsolutePosition( element ) { * @param {Object} $1 Options * @param {*} $1.triggerAnimationOnChange Variable used to trigger the animation if it changes. * @param {string} $1.clientId + * @param {string} $1.elementSelector A CSS selector string used to find the position of an element to animate from. */ -function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { +function useMovingAnimation( { + triggerAnimationOnChange, + clientId, + elementSelector, +} ) { const ref = useRef(); const { isTyping, @@ -56,17 +54,30 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { // Whenever the trigger changes, we need to take a snapshot of the current // position of the block to use it as a destination point for the animation. - const { previous, prevRect } = useMemo( - () => ( { - previous: ref.current && getAbsolutePosition( ref.current ), - prevRect: ref.current && ref.current.getBoundingClientRect(), - } ), + const { prevRect } = useMemo( + () => { + let previousPosition; + + if ( ref.current && elementSelector ) { + const { ownerDocument } = ref.current; + const element = ownerDocument.querySelector( elementSelector ); + if ( element ) { + previousPosition = element.getBoundingClientRect(); + } + } else if ( ref.current ) { + previousPosition = ref.current.getBoundingClientRect(); + } + + return { + prevRect: previousPosition, + }; + }, // eslint-disable-next-line react-hooks/exhaustive-deps [ triggerAnimationOnChange ] ); useLayoutEffect( () => { - if ( ! previous || ! ref.current ) { + if ( ! prevRect || ! ref.current ) { return; } @@ -133,10 +144,10 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { } ); ref.current.style.transform = undefined; - const destination = getAbsolutePosition( ref.current ); + const destination = ref.current.getBoundingClientRect(); - const x = Math.round( previous.left - destination.left ); - const y = Math.round( previous.top - destination.top ); + const x = Math.round( prevRect.left - destination.left ); + const y = Math.round( prevRect.top - destination.top ); controller.start( { x: 0, y: 0, from: { x, y } } ); @@ -144,7 +155,6 @@ function useMovingAnimation( { triggerAnimationOnChange, clientId } ) { controller.stop(); }; }, [ - previous, prevRect, clientId, isTyping,