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

Hide drop indicator where block isn't allowed to drop. #56843

Merged
merged 16 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
Expand All @@ -10,10 +15,20 @@ import { dragHandle } from '@wordpress/icons';
*/
import BlockIcon from '../block-icon';

export default function BlockDraggableChip( { count, icon, isPattern } ) {
export default function BlockDraggableChip( {
count,
icon,
isPattern,
className,
} ) {
const patternLabel = isPattern && __( 'Pattern' );
return (
<div className="block-editor-block-draggable-chip-wrapper">
<div
className={ classnames(
'block-editor-block-draggable-chip-wrapper',
className
) }
>
<div
className="block-editor-block-draggable-chip"
data-testid="block-draggable-chip"
Expand Down
102 changes: 98 additions & 4 deletions packages/block-editor/src/components/block-draggable/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,47 @@ import { store as blocksStore } from '@wordpress/blocks';
import { Draggable } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { useEffect, useRef } from '@wordpress/element';
import { throttle } from '@wordpress/compose';

/**
* Internal dependencies
*/
import BlockDraggableChip from './draggable-chip';
import useScrollWhenDragging from './use-scroll-when-dragging';
import { store as blockEditorStore } from '../../store';
import { __unstableUseBlockRef as useBlockRef } from '../block-list/use-block-props/use-block-refs';
import { isDropTargetValid } from '../use-block-drop-zone';

const BlockDraggable = ( {
children,
clientIds,
cloneClassname,
onDragStart,
onDragEnd,
fadeWhenDisabled = false,
} ) => {
const { srcRootClientId, isDraggable, icon } = useSelect(
const {
srcRootClientId,
isDraggable,
icon,
visibleInserter,
getBlockType,
getAllowedBlocks,
draggedBlockNames,
getBlockNamesByClientId,
} = useSelect(
( select ) => {
const {
canMoveBlocks,
getBlockRootClientId,
getBlockName,
getBlockAttributes,
isBlockInsertionPointVisible,
getDraggedBlockClientIds,
getBlockNamesByClientId: _getBlockNamesByClientId,
getAllowedBlocks: _getAllowedBlocks,
} = select( blockEditorStore );
const { getBlockType, getActiveBlockVariation } =
const { getBlockType: _getBlockType, getActiveBlockVariation } =
select( blocksStore );
const rootClientId = getBlockRootClientId( clientIds[ 0 ] );
const blockName = getBlockName( clientIds[ 0 ] );
Expand All @@ -40,11 +57,19 @@ const BlockDraggable = ( {
return {
srcRootClientId: rootClientId,
isDraggable: canMoveBlocks( clientIds, rootClientId ),
icon: variation?.icon || getBlockType( blockName )?.icon,
icon: variation?.icon || _getBlockType( blockName )?.icon,
visibleInserter: isBlockInsertionPointVisible(),
getBlockType: _getBlockType,
getAllowedBlocks: _getAllowedBlocks,
draggedBlockNames: _getBlockNamesByClientId(
getDraggedBlockClientIds()
),
getBlockNamesByClientId: _getBlockNamesByClientId,
};
},
[ clientIds ]
);

const isDragging = useRef( false );
const [ startScrolling, scrollOnDragOver, stopScrolling ] =
useScrollWhenDragging();
Expand All @@ -61,6 +86,68 @@ const BlockDraggable = ( {
};
}, [] );

// Find the root of the editor iframe.
const blockRef = useBlockRef( clientIds[ 0 ] );
const editorRoot = blockRef.current?.closest( 'body' );

// Add a dragover event listener to the editor root to track the blocks being dragged over.
// The listener has to be inside the editor iframe otherwise the target isn't accessible.
// Check if the dragged blocks are allowed inside the target. If not, grey out the draggable.
useEffect( () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this useEffect and its onDragOver still needed? Since the useBlockDropZone is hiding the insertion point, could we replace the whole useEffect with something like:

	useEffect( () => {
		if ( ! visibleInserter ) {
			window?.document?.body?.classList?.add(
				'block-draggable-invalid-drag-token'
			);
		} else {
			window?.document?.body?.classList?.remove(
				'block-draggable-invalid-drag-token'
			);
		}
	}, [ visibleInserter ] );

Then the logic of when to show and hide the insertion point could live in one place, so that the BlockDraggable doesn't have to also perform checks with its own drag event handler.

Or is there another reason why this component needs to perform its own separate checks?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, so the crucial bit here is finding the targetClientId, which tells us the container that the draggable is currently hovering over. To find that we need a dragover event listener attached to the inside of the editor iframe (if it's on the outside, it won't be able to access the elements inside the iframe). We need the targetClientId to check if the drop target is valid, which is necessary because if the chip is hovering over a valid container but there's no drop indicator currently showing (if we're moving between drop areas for instance), we don't want it to grey out.

Unfortunately there's not much that can be done to restrict how many times this runs without slowing down the responsiveness of the drag chip to what it's hovering over. The problem here is that, unlike with the drop indicators that exist statically between all blocks so we know where they're placed from the start, we need to find out what the draggable is being dragged over, and afaik the only way to do that is through the event target of a dragover listener.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the targetClientId to check if the drop target is valid, which is necessary because if the chip is hovering over a valid container but there's no drop indicator currently showing (if we're moving between drop areas for instance), we don't want it to grey out.

Ah, gotcha! Thanks for the explanation, that makes sense now 👍

Unfortunately there's not much that can be done to restrict how many times this runs without slowing down the responsiveness of the drag chip to what it's hovering over.

Yeah, it can be really tricky to get the right balance when slowing things down so that it's slow enough for performance reasons, but fast enough to feel responsive to a user! 😅

we need to find out what the draggable is being dragged over, and afaik the only way to do that is through the event target of a dragover listener.

That sounds about right to me, too. If it winds up being a bit of a rabbithole trying to get it working smoothly, would it be worth splitting out the useBlockDropZone change and landing that first? Totally cool if you want to keep it all together while figuring everything out of course!

if ( ! editorRoot ) {
return;
}

const onDragOver = ( event ) => {
Copy link
Contributor

@andrewserong andrewserong Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this callback might need to be throttled similar to the code in useBlockDropZone as this currently fires very frequently:

2023-12-14.10.37.55.mp4

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm yeah I'm wondering how best to do that given the inability to use hooks inside the effect callback 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I ran into this issue when testing, it seemed to crash in Safari and I couldn't move the chip any more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oo wait I see there's a throttle util in compose that we may be able to leverage, will try that!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok the throttle seems to make it better! I set it to the same as draggable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a little better, but it's still lagging a bit for me with the throttle at 16 (it's more visible when using CPU throttling in dev tools). I think a couple of things might be at play:

  • Since the useEffect doesn't use a dependency array, it seems that it's being called quite frequently, and potentially adding (and removing) event listeners too frequently? For example, if I increase the throttle time to 2000, it still appears to be firing rapidly
  • In draggable, 16ms is needed because it's updating the position of the drag chip. Here, I'm wondering if we can use a bigger delay / throttle, if that could smooth over the flicker when dragging between image blocks in a gallery block? I.e. if we had it at 200ms to match useBlockDropZone then if folks are dragging quickly between blocks, hopefully it shouldn't flicker as much?

Would it work to maybe move defining the onDragOver callback to be outside of the useEffect (possibly in a useCallback) so that the useEffect could have a dependency array and only attach the event listener once? Apologies if you've already tried that!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I had a think about this 😄 and realised that we actually don't need to use local state for targetClientId, and we can use a dependency array for the effect, which improves things a little, but it still has to re-render whenever either draggedBlockNames or visibleInserter change. Moving the callback out of the effect makes no difference, because it calls whichever version of the callback was attached to the event listener when the effect ran, so if we want the values inside the callback to change the effect has to re-run.

I also tried increasing the throttle time and it doesn't seem to make things any worse, so might leave it at 200 or so. I just pushed an update, let me know if it improves things at all!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! That appears to have fixed up the performance issues for me. Subjectively, without CPU throttling, it's feeling smooth now, and with CPU throttling, it seems much the same as trunk to me now 🎉

if ( ! event.target.closest( '[data-block]' ) ) {
return;
}

const targetClientId = event.target
.closest( '[data-block]' )
.getAttribute( 'data-block' );

const allowedBlocks = getAllowedBlocks( targetClientId );
const targetBlockName = getBlockNamesByClientId( [
targetClientId,
] )[ 0 ];
const dropTargetValid = isDropTargetValid(
getBlockType,
allowedBlocks,
draggedBlockNames,
targetBlockName
);

// Update the body class to reflect if drop target is valid.
// This has to be done on the document body because the draggable
// chip is rendered outside of the editor iframe.
if ( ! dropTargetValid && ! visibleInserter ) {
window?.document?.body?.classList?.add(
'block-draggable-invalid-drag-token'
);
} else {
window?.document?.body?.classList?.remove(
'block-draggable-invalid-drag-token'
);
}
};

const throttledOnDragOver = throttle( onDragOver, 200 );

editorRoot.addEventListener( 'dragover', throttledOnDragOver );

return () => {
editorRoot.removeEventListener( 'dragover', throttledOnDragOver );
};
}, [
draggedBlockNames,
editorRoot,
getAllowedBlocks,
getBlockNamesByClientId,
getBlockType,
visibleInserter,
] );

if ( ! isDraggable ) {
return children( { draggable: false } );
}
Expand Down Expand Up @@ -102,7 +189,14 @@ const BlockDraggable = ( {
}
} }
__experimentalDragComponent={
<BlockDraggableChip count={ clientIds.length } icon={ icon } />
<BlockDraggableChip
count={ clientIds.length }
icon={ icon }
className={
fadeWhenDisabled &&
'block-editor-block-draggable-chip-fade'
}
/>
}
>
{ ( { onDraggableStart, onDraggableEnd } ) => {
Expand Down
10 changes: 10 additions & 0 deletions packages/block-editor/src/components/block-draggable/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
padding: 0 ( $grid-unit-15 + $border-width );
user-select: none;
width: max-content;
transition:
background-color 0.5s linear,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest something much quicker, but with a tiny delay (to reduce the flashes):

background-color 0.1s linear 0.1s, opacity 0.1s linear 0.1s;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're here, a tiny tweak to the chip's shadow makes it feel nice :)

box-shadow: 0 4px 8px rgba($black, 0.2);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, added!

opacity 0.5s linear;

svg {
fill: currentColor;
Expand Down Expand Up @@ -45,3 +48,10 @@
font-size: $default-font-size;
}
}

.block-draggable-invalid-drag-token {
.block-editor-block-draggable-chip-fade .block-editor-block-draggable-chip {
background-color: $gray-600;
opacity: 0.8;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think opacity is making it clear enough (perhaps introducing confusion even). I suggest gray-700, without the opacity.

CleanShot 2023-12-15 at 17 35 20

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function BlockMover( { clientIds, hideDragHandle } ) {
} ) }
>
{ ! hideDragHandle && (
<BlockDraggable clientIds={ clientIds }>
<BlockDraggable clientIds={ clientIds } fadeWhenDisabled>
{ ( draggableProps ) => (
<Button
icon={ dragHandle }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
__experimentalUseDropZone as useDropZone,
} from '@wordpress/compose';
import { isRTL } from '@wordpress/i18n';
import { isUnmodifiedDefaultBlock as getIsUnmodifiedDefaultBlock } from '@wordpress/blocks';
import {
isUnmodifiedDefaultBlock as getIsUnmodifiedDefaultBlock,
store as blocksStore,
} from '@wordpress/blocks';

/**
* Internal dependencies
Expand Down Expand Up @@ -191,6 +194,50 @@ export function getDropTargetPosition(
];
}

/**
* Check if the dragged blocks can be dropped on the target.
* @param {Function} getBlockType
* @param {Object[]} allowedBlocks
* @param {string[]} draggedBlockNames
* @param {string} targetBlockName
* @return {boolean} Whether the dragged blocks can be dropped on the target.
*/
export function isDropTargetValid(
getBlockType,
allowedBlocks,
draggedBlockNames,
targetBlockName
) {
// At root level allowedBlocks is undefined and all blocks are allowed.
// Otherwise, check if all dragged blocks are allowed.
let areBlocksAllowed = true;
if ( allowedBlocks ) {
const allowedBlockNames = allowedBlocks?.map( ( { name } ) => name );

areBlocksAllowed = draggedBlockNames.every( ( name ) =>
allowedBlockNames?.includes( name )
);
}

// Work out if dragged blocks have an allowed parent and if so
// check target block matches the allowed parent.
const draggedBlockTypes = draggedBlockNames.map( ( name ) =>
getBlockType( name )
);
const targetMatchesDraggedBlockParents = draggedBlockTypes.every(
( block ) => {
const [ allowedParentName ] = block?.parent || [];
if ( ! allowedParentName ) {
return true;
}

return allowedParentName === targetBlockName;
}
);

return areBlocksAllowed && targetMatchesDraggedBlockParents;
}

/**
* @typedef {Object} WPBlockDropZoneConfig
* @property {?HTMLElement} dropZoneElement Optional element to be used as the drop zone.
Expand All @@ -216,15 +263,27 @@ export default function useBlockDropZone( {
operation: 'insert',
} );

const { isDisabled, parentBlockClientId, rootBlockIndex } = useSelect(
const {
parentBlockClientId,
rootBlockIndex,
isDisabled,
getBlockType,
allowedBlocks,
draggedBlockNames,
targetBlockName,
} = useSelect(
( select ) => {
const {
__unstableIsWithinBlockOverlay,
__unstableHasActiveBlockOverlayActive,
getBlockIndex,
getBlockParents,
getBlockEditingMode,
getDraggedBlockClientIds,
getBlockNamesByClientId,
getAllowedBlocks,
} = select( blockEditorStore );
const { getBlockType: _getBlockType } = select( blocksStore );
const blockEditingMode = getBlockEditingMode( targetRootClientId );
return {
parentBlockClientId:
Expand All @@ -236,6 +295,15 @@ export default function useBlockDropZone( {
targetRootClientId
) ||
__unstableIsWithinBlockOverlay( targetRootClientId ),

getBlockType: _getBlockType,
allowedBlocks: getAllowedBlocks( targetRootClientId ),
draggedBlockNames: getBlockNamesByClientId(
getDraggedBlockClientIds()
),
targetBlockName: getBlockNamesByClientId( [
targetRootClientId,
] )[ 0 ],
};
},
[ targetRootClientId ]
Expand All @@ -258,6 +326,16 @@ export default function useBlockDropZone( {
const throttled = useThrottle(
useCallback(
( event, ownerDocument ) => {
const isBlockDroppingAllowed = isDropTargetValid(
getBlockType,
allowedBlocks,
draggedBlockNames,
targetBlockName
);
if ( ! isBlockDroppingAllowed ) {
return;
}

const blocks = getBlocks( targetRootClientId );

// The block list is empty, don't show the insertion point but still allow dropping.
Expand Down Expand Up @@ -331,6 +409,10 @@ export default function useBlockDropZone( {
getBlockIndex,
parentBlockClientId,
rootBlockIndex,
getBlockType,
allowedBlocks,
draggedBlockNames,
targetBlockName,
]
),
200
Expand Down
Loading