From af9c50fcdd9cc90af1cfd3f03a500c3f83a0bfde Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 23 Jan 2024 15:44:13 +1100 Subject: [PATCH 1/3] List View: Try a WYSIWYG drag chip --- .../components/list-view/block-contents.js | 10 +++- .../src/components/list-view/leaf.js | 3 + .../src/components/list-view/style.scss | 56 +++++++++++++++++++ .../list-view/use-list-view-drop-zone.js | 18 +++++- .../components/use-moving-animation/index.js | 46 +++++++++------ 5 files changed, 112 insertions(+), 21 deletions(-) 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..a3de14e61ef23 100644 --- a/packages/block-editor/src/components/list-view/block-contents.js +++ b/packages/block-editor/src/components/list-view/block-contents.js @@ -47,8 +47,12 @@ const ListViewBlockContents = forwardRef( [] ); - const { AdditionalBlockContent, insertedBlock, setInsertedBlock } = - useListViewContext(); + const { + AdditionalBlockContent, + insertedBlock, + listViewInstanceId, + setInsertedBlock, + } = useListViewContext(); const isBlockMoveTarget = blockMovingClientId && selectedBlockInBlockEditor === clientId; @@ -78,6 +82,8 @@ const ListViewBlockContents = forwardRef( appendToOwnerDocument clientIds={ draggableClientIds } cloneClassname={ 'block-editor-list-view-draggable-chip' } + dragComponent={ null } + elementId={ `list-view-${ listViewInstanceId }-block-${ clientId }` } > { ( { draggable, onDragStart, onDragEnd } ) => ( = 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-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js index 3354b3f41d391..978c7aa7a45d8 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 @@ -490,7 +490,7 @@ export default function useListViewDropZone( { const throttled = useThrottle( useCallback( ( event, currentTarget ) => { - const position = { x: event.clientX, y: event.clientY }; + let position = { x: event.clientX, y: event.clientY }; const isBlockDrag = !! draggedBlockClientIds?.length; const blockElements = Array.from( @@ -531,6 +531,22 @@ export default function useListViewDropZone( { }; } ); + const { ownerDocument } = currentTarget || {}; + const dragChipBlockElement = ownerDocument?.querySelector( + '.block-editor-list-view-draggable-chip .block-editor-block-icon' + ); + + if ( dragChipBlockElement ) { + const dragChipBlockRect = + dragChipBlockElement.getBoundingClientRect(); + position = { + x: rtl + ? dragChipBlockRect.right + : dragChipBlockRect.left, + y: dragChipBlockRect.top + dragChipBlockRect.height / 2, + }; + } + const newTarget = getListViewDropTarget( blocksData, position, 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, From 7ddb858c62da431c1152607759b3b26a0c6e8310 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:20:34 +1100 Subject: [PATCH 2/3] Try hiding the drop indicator preview, make the background opaque --- .../components/list-view/drop-indicator.js | 106 +----------------- .../src/components/list-view/style.scss | 6 +- 2 files changed, 3 insertions(+), 109 deletions(-) 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/style.scss b/packages/block-editor/src/components/list-view/style.scss index 1bc8b03ba147f..ac2c2993ecbcd 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -438,12 +438,8 @@ $block-navigation-max-indent: 8; .block-editor-list-view-draggable-chip { - opacity: 0.8; - .block-editor-list-view-leaf { - // The drag chip uses a transparent background to ensure that the nesting level - // for where a user drops the dragged block is visible. - background-color: rgba(255, 255, 255, 0.5); + 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; From efbee86e7ecf0b5be6d3b2f39e317dc90bcc4355 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 6 Feb 2024 17:32:29 +1100 Subject: [PATCH 3/3] Try locking the x-axis to the indent level --- .../components/list-view/block-contents.js | 26 +- .../src/components/list-view/index.js | 2 + .../src/components/list-view/style.scss | 5 + .../src/components/list-view/use-drag-chip.js | 231 ++++++++++++++++++ .../list-view/use-list-view-drop-zone.js | 18 +- 5 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 packages/block-editor/src/components/list-view/use-drag-chip.js 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 a3de14e61ef23..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( ( @@ -49,11 +50,20 @@ const ListViewBlockContents = forwardRef( 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; @@ -81,9 +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/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/style.scss b/packages/block-editor/src/components/list-view/style.scss index ac2c2993ecbcd..6b4c0fa6c4e38 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -437,6 +437,11 @@ // 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 { .block-editor-list-view-leaf { background-color: $white; 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/list-view/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js index 978c7aa7a45d8..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 @@ -490,7 +490,7 @@ export default function useListViewDropZone( { const throttled = useThrottle( useCallback( ( event, currentTarget ) => { - let position = { x: event.clientX, y: event.clientY }; + const position = { x: event.clientX, y: event.clientY }; const isBlockDrag = !! draggedBlockClientIds?.length; const blockElements = Array.from( @@ -531,22 +531,6 @@ export default function useListViewDropZone( { }; } ); - const { ownerDocument } = currentTarget || {}; - const dragChipBlockElement = ownerDocument?.querySelector( - '.block-editor-list-view-draggable-chip .block-editor-block-icon' - ); - - if ( dragChipBlockElement ) { - const dragChipBlockRect = - dragChipBlockElement.getBoundingClientRect(); - position = { - x: rtl - ? dragChipBlockRect.right - : dragChipBlockRect.left, - y: dragChipBlockRect.top + dragChipBlockRect.height / 2, - }; - } - const newTarget = getListViewDropTarget( blocksData, position,