diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 31e4718500018e..8d7bceb5b44317 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -1510,12 +1510,17 @@ _Parameters_ - _rootClientId_ `?string`: Optional root client ID of block list on which to insert. - _index_ `?number`: Index at which block should be inserted. -- _\_\_unstableOptions_ `Object`: Whether or not to show an inserter button. +- _\_\_unstableOptions_ `?Object`: Additional options. _Returns_ - `Object`: Action object. +_Properties_ + +- _\_\_unstableWithInserter_ `boolean`: Whether or not to show an inserter button. +- _operation_ `WPDropOperation`: The operation to perform when applied, either 'insert' or 'replace' for now. + ### startDraggingBlocks Returns an action object used in signalling that the user has begun to drag blocks. diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index cc14eb7d867897..827b9f2e2211e4 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -7,6 +7,10 @@ - `FontSizePicker`: Update fluid utils so that only string, floats and integers are treated as valid font sizes for the purposes of fluid typography ([#44847](https://github.com/WordPress/gutenberg/pull/44847)) - `getTypographyClassesAndStyles()`: Ensure that font sizes are transformed into fluid values if fluid typography is activated ([#44852](https://github.com/WordPress/gutenberg/pull/44852)) +### New features + +- You can now drop files/blocks/HTML on unmodified default blocks to transform them into corresponding blocks ([#44647](https://github.com/WordPress/gutenberg/pull/44647)). + ## 10.2.0 (2022-10-05) ## 10.1.0 (2022-09-21) diff --git a/packages/block-editor/src/components/block-popover/drop-zone.js b/packages/block-editor/src/components/block-popover/drop-zone.js new file mode 100644 index 00000000000000..c26f28127022d6 --- /dev/null +++ b/packages/block-editor/src/components/block-popover/drop-zone.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useReducedMotion } from '@wordpress/compose'; +import { __unstableMotion as motion } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +import BlockPopover from './index'; + +const animateVariants = { + hide: { opacity: 0, scaleY: 0.75 }, + show: { opacity: 1, scaleY: 1 }, + exit: { opacity: 0, scaleY: 0.9 }, +}; + +function BlockDropZonePopover( { + __unstablePopoverSlot, + __unstableContentRef, +} ) { + const { clientId } = useSelect( ( select ) => { + const { getBlockOrder, getBlockInsertionPoint } = + select( blockEditorStore ); + const insertionPoint = getBlockInsertionPoint(); + const order = getBlockOrder( insertionPoint.rootClientId ); + + if ( ! order.length ) { + return {}; + } + + return { + clientId: order[ insertionPoint.index ], + }; + }, [] ); + const reducedMotion = useReducedMotion(); + + return ( + + + + ); +} + +export default BlockDropZonePopover; diff --git a/packages/block-editor/src/components/block-popover/style.scss b/packages/block-editor/src/components/block-popover/style.scss index 1ed4774a56c010..1744506179e670 100644 --- a/packages/block-editor/src/components/block-popover/style.scss +++ b/packages/block-editor/src/components/block-popover/style.scss @@ -21,7 +21,7 @@ } // Enable pointer events for the toolbar's content. - &:not(.block-editor-block-popover__inbetween) .components-popover__content { + &:not(.block-editor-block-popover__inbetween, .block-editor-block-popover__drop-zone) .components-popover__content { * { pointer-events: all; } @@ -48,3 +48,19 @@ } } } + + +.components-popover.block-editor-block-popover__drop-zone { + // Disable pointer events for dragging and dropping. + // This drop zone is fully presentational, the actual DnD implementation is handled elsewhere. + * { + pointer-events: none; + } + + .block-editor-block-popover__drop-zone-foreground { + position: absolute; + inset: 0; + background-color: var(--wp-admin-theme-color); + border-radius: 2px; + } +} diff --git a/packages/block-editor/src/components/block-tools/insertion-point.js b/packages/block-editor/src/components/block-tools/insertion-point.js index 4f98a29a6e0efb..3b950ef87a7544 100644 --- a/packages/block-editor/src/components/block-tools/insertion-point.js +++ b/packages/block-editor/src/components/block-tools/insertion-point.js @@ -17,10 +17,11 @@ import { useReducedMotion } from '@wordpress/compose'; import Inserter from '../inserter'; import { store as blockEditorStore } from '../../store'; import BlockPopoverInbetween from '../block-popover/inbetween'; +import BlockDropZonePopover from '../block-popover/drop-zone'; export const InsertionPointOpenRef = createContext(); -function InsertionPointPopover( { +function InbetweenInsertionPointPopover( { __unstablePopoverSlot, __unstableContentRef, } ) { @@ -232,9 +233,30 @@ function InsertionPointPopover( { } export default function InsertionPoint( props ) { - const isVisible = useSelect( ( select ) => { - return select( blockEditorStore ).isBlockInsertionPointVisible(); + const { insertionPoint, isVisible } = useSelect( ( select ) => { + const { getBlockInsertionPoint, isBlockInsertionPointVisible } = + select( blockEditorStore ); + return { + insertionPoint: getBlockInsertionPoint(), + isVisible: isBlockInsertionPointVisible(), + }; }, [] ); - return isVisible && ; + if ( ! isVisible ) { + return null; + } + + /** + * Render a popover that overlays the block when the desired operation is to replace it. + * Otherwise, render a popover in between blocks for the indication of inserting between them. + */ + return insertionPoint.operation === 'replace' ? ( + + ) : ( + + ); } diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 0ad7de536282e2..6b7a24887b3423 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -155,7 +155,6 @@ export { export { default as __experimentalBlockPatternsList } from './block-patterns-list'; export { default as __experimentalPublishDateTimePicker } from './publish-date-time-picker'; export { default as __experimentalInspectorPopoverHeader } from './inspector-popover-header'; -export { default as __experimentalUseOnBlockDrop } from './use-on-block-drop'; /* * State Related Components 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 731ae2530ec114..346631667c2544 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 @@ -11,7 +11,10 @@ import { /** * Internal dependencies */ -import { getDistanceToNearestEdge } from '../../utils/math'; +import { + getDistanceToNearestEdge, + isPointContainedByRect, +} from '../../utils/math'; import useOnBlockDrop from '../use-on-block-drop'; import { store as blockEditorStore } from '../../store'; @@ -48,23 +51,6 @@ import { store as blockEditorStore } from '../../store'; * 'inside' refers to nesting as an inner block. */ -/** - * Is the point contained by the rectangle. - * - * @param {WPPoint} point The point. - * @param {DOMRect} rect The rectangle. - * - * @return {boolean} True if the point is contained by the rectangle, false otherwise. - */ -function isPointContainedByRect( point, rect ) { - return ( - rect.left <= point.x && - rect.right >= point.x && - rect.top <= point.y && - rect.bottom >= point.y - ); -} - /** * Determines whether the user positioning the dragged block to nest as an * inner block. diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index 3bef31546c711e..d0700bd8d05abb 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -8,15 +8,20 @@ import { __experimentalUseDropZone as useDropZone, } from '@wordpress/compose'; import { isRTL } from '@wordpress/i18n'; +import { isUnmodifiedDefaultBlock as getIsUnmodifiedDefaultBlock } from '@wordpress/blocks'; /** * Internal dependencies */ import useOnBlockDrop from '../use-on-block-drop'; -import { getDistanceToNearestEdge } from '../../utils/math'; +import { + getDistanceToNearestEdge, + isPointContainedByRect, +} from '../../utils/math'; import { store as blockEditorStore } from '../../store'; /** @typedef {import('../../utils/math').WPPoint} WPPoint */ +/** @typedef {import('../use-on-block-drop/types').WPDropOperation} WPDropOperation */ /** * The orientation of a block list. @@ -25,16 +30,31 @@ import { store as blockEditorStore } from '../../store'; */ /** - * Given a list of block DOM elements finds the index that a block should be dropped - * at. + * The insert position when dropping a block. * - * @param {Element[]} elements Array of DOM elements that represent each block in a block list. - * @param {WPPoint} position The position of the item being dragged. - * @param {WPBlockListOrientation} orientation The orientation of a block list. + * @typedef {'before'|'after'} WPInsertPosition + */ + +/** + * @typedef {Object} WPBlockData + * @property {boolean} isUnmodifiedDefaultBlock Is the block unmodified default block. + * @property {() => DOMRect} getBoundingClientRect Get the bounding client rect of the block. + * @property {number} blockIndex The index of the block. + */ + +/** + * Get the drop target position from a given drop point and the orientation. * - * @return {number|undefined} The block index that's closest to the drag position. + * @param {WPBlockData[]} blocksData The block data list. + * @param {WPPoint} position The position of the item being dragged. + * @param {WPBlockListOrientation} orientation The orientation of the block list. + * @return {[number, WPDropOperation]} The drop target position. */ -export function getNearestBlockIndex( elements, position, orientation ) { +export function getDropTargetPosition( + blocksData, + position, + orientation = 'vertical' +) { const allowedEdges = orientation === 'horizontal' ? [ 'left', 'right' ] @@ -42,49 +62,67 @@ export function getNearestBlockIndex( elements, position, orientation ) { const isRightToLeft = isRTL(); - let candidateIndex; - let candidateDistance; - - elements.forEach( ( element, index ) => { - const rect = element.getBoundingClientRect(); - const [ distance, edge ] = getDistanceToNearestEdge( - position, - rect, - allowedEdges - ); - - if ( candidateDistance === undefined || distance < candidateDistance ) { - // If the user is dropping to the trailing edge of the block - // add 1 to the index to represent dragging after. - // Take RTL languages into account where the left edge is - // the trailing edge. - const isTrailingEdge = - edge === 'bottom' || - ( ! isRightToLeft && edge === 'right' ) || - ( isRightToLeft && edge === 'left' ); - const offset = isTrailingEdge ? 1 : 0; - - // Update the currently known best candidate. - candidateDistance = distance; - candidateIndex = index + offset; - } - } ); + let nearestIndex = 0; + let insertPosition = 'before'; + let minDistance = Infinity; - return candidateIndex; -} + blocksData.forEach( + ( { isUnmodifiedDefaultBlock, getBoundingClientRect, blockIndex } ) => { + const rect = getBoundingClientRect(); -/** - * Determine if the element is an empty paragraph block. - * - * @param {?HTMLElement} element The element being tested. - * @return {boolean} True or False. - */ -function isEmptyParagraph( element ) { - return ( - !! element && - element.dataset.type === 'core/paragraph' && - element.dataset.empty === 'true' + let [ distance, edge ] = getDistanceToNearestEdge( + position, + rect, + allowedEdges + ); + // Prioritize the element if the point is inside of an unmodified default block. + if ( + isUnmodifiedDefaultBlock && + isPointContainedByRect( position, rect ) + ) { + distance = 0; + } + + if ( distance < minDistance ) { + // Where the dropped block will be inserted on the nearest block. + insertPosition = + edge === 'bottom' || + ( ! isRightToLeft && edge === 'right' ) || + ( isRightToLeft && edge === 'left' ) + ? 'after' + : 'before'; + + // Update the currently known best candidate. + minDistance = distance; + nearestIndex = blockIndex; + } + } ); + + const adjacentIndex = + nearestIndex + ( insertPosition === 'after' ? 1 : -1 ); + const isNearestBlockUnmodifiedDefaultBlock = + !! blocksData[ nearestIndex ]?.isUnmodifiedDefaultBlock; + const isAdjacentBlockUnmodifiedDefaultBlock = + !! blocksData[ adjacentIndex ]?.isUnmodifiedDefaultBlock; + + // If both blocks are not unmodified default blocks then just insert between them. + if ( + ! isNearestBlockUnmodifiedDefaultBlock && + ! isAdjacentBlockUnmodifiedDefaultBlock + ) { + // If the user is dropping to the trailing edge of the block + // add 1 to the index to represent dragging after. + const insertionIndex = + insertPosition === 'after' ? nearestIndex + 1 : nearestIndex; + return [ insertionIndex, 'insert' ]; + } + + // Otherwise, replace the nearest unmodified default block. + return [ + isNearestBlockUnmodifiedDefaultBlock ? nearestIndex : adjacentIndex, + 'replace', + ]; } /** @@ -104,7 +142,10 @@ export default function useBlockDropZone( { // an empty string to represent top-level blocks. rootClientId: targetRootClientId = '', } = {} ) { - const [ targetBlockIndex, setTargetBlockIndex ] = useState( null ); + const [ dropTarget, setDropTarget ] = useState( { + index: null, + operation: 'insert', + } ); const isDisabled = useSelect( ( select ) => { @@ -125,40 +166,58 @@ export default function useBlockDropZone( { [ targetRootClientId ] ); - const { getBlockListSettings } = useSelect( blockEditorStore ); + const { getBlockListSettings, getBlocks, getBlockIndex } = + useSelect( blockEditorStore ); const { showInsertionPoint, hideInsertionPoint } = useDispatch( blockEditorStore ); - const onBlockDrop = useOnBlockDrop( targetRootClientId, targetBlockIndex ); + const onBlockDrop = useOnBlockDrop( targetRootClientId, dropTarget.index, { + operation: dropTarget.operation, + } ); const throttled = useThrottle( - useCallback( ( event, currentTarget ) => { - const blockElements = Array.from( currentTarget.children ).filter( - // Ensure the element is a block. It should have the `wp-block` class. - ( element ) => element.classList.contains( 'wp-block' ) - ); - const targetIndex = getNearestBlockIndex( - blockElements, - { x: event.clientX, y: event.clientY }, - getBlockListSettings( targetRootClientId )?.orientation - ); - - setTargetBlockIndex( targetIndex === undefined ? 0 : targetIndex ); - - if ( targetIndex !== undefined ) { - const nextBlock = blockElements[ targetIndex ]; - const previousBlock = blockElements[ targetIndex - 1 ]; - - // Don't show the insertion point when it's near an empty paragraph block. - if ( - isEmptyParagraph( nextBlock ) || - isEmptyParagraph( previousBlock ) - ) { + useCallback( + ( event, ownerDocument ) => { + const blocks = getBlocks( targetRootClientId ); + + // The block list is empty, don't show the insertion point but still allow dropping. + if ( blocks.length === 0 ) { + setDropTarget( { + index: 0, + operation: 'insert', + } ); return; } - showInsertionPoint( targetRootClientId, targetIndex ); - } - }, [] ), + const blocksData = blocks.map( ( block ) => { + const clientId = block.clientId; + + return { + isUnmodifiedDefaultBlock: + getIsUnmodifiedDefaultBlock( block ), + getBoundingClientRect: () => + ownerDocument + .getElementById( `block-${ clientId }` ) + .getBoundingClientRect(), + blockIndex: getBlockIndex( clientId ), + }; + } ); + + const [ targetIndex, operation ] = getDropTargetPosition( + blocksData, + { x: event.clientX, y: event.clientY }, + getBlockListSettings( targetRootClientId )?.orientation + ); + + setDropTarget( { + index: targetIndex, + operation, + } ); + showInsertionPoint( targetRootClientId, targetIndex, { + operation, + } ); + }, + [ targetRootClientId ] + ), 200 ); @@ -169,17 +228,15 @@ export default function useBlockDropZone( { // `currentTarget` is only available while the event is being // handled, so get it now and pass it to the thottled function. // https://developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget - throttled( event, event.currentTarget ); + throttled( event, event.currentTarget.ownerDocument ); }, onDragLeave() { throttled.cancel(); hideInsertionPoint(); - setTargetBlockIndex( null ); }, onDragEnd() { throttled.cancel(); hideInsertionPoint(); - setTargetBlockIndex( null ); }, } ); } diff --git a/packages/block-editor/src/components/use-block-drop-zone/test/index.js b/packages/block-editor/src/components/use-block-drop-zone/test/index.js index 492f89ff7467a0..f5560c1cfdf13a 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/test/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/test/index.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { getNearestBlockIndex } from '..'; +import { getDropTargetPosition } from '..'; const elementData = [ { @@ -31,19 +31,12 @@ const elementData = [ }, ]; -const createMockClassList = ( classes ) => { - return { - contains( textToMatch ) { - return classes.includes( textToMatch ); - }, - }; -}; - const mapElements = ( orientation ) => - ( { top, right, bottom, left }, index ) => { + ( { top, right, bottom, left, isUnmodifiedDefaultBlock }, index ) => { return { - dataset: { block: index + 1 }, + isUnmodifiedDefaultBlock: !! isUnmodifiedDefaultBlock, + blockIndex: index, getBoundingClientRect() { return orientation === 'vertical' ? { @@ -59,27 +52,21 @@ const mapElements = right: bottom, }; }, - classList: createMockClassList( 'wp-block' ), }; }; -const verticalElements = elementData.map( mapElements( 'vertical' ) ); +const verticalBlocksData = elementData.map( mapElements( 'vertical' ) ); // Flip the elementData to make a horizontal block list. -const horizontalElements = elementData.map( mapElements( 'horizontal' ) ); +const horizontalBlocksData = elementData.map( mapElements( 'horizontal' ) ); -describe( 'getNearestBlockIndex', () => { - it( 'returns `undefined` for an empty list of elements', () => { - const emptyElementList = []; +describe( 'getDropTargetPosition', () => { + it( 'returns `0` for an empty list of elements', () => { const position = { x: 0, y: 0 }; const orientation = 'horizontal'; - const result = getNearestBlockIndex( - emptyElementList, - position, - orientation - ); + const result = getDropTargetPosition( [], position, orientation ); - expect( result ).toBeUndefined(); + expect( result ).toEqual( [ 0, 'insert' ] ); } ); describe( 'Vertical block lists', () => { @@ -88,109 +75,109 @@ describe( 'getNearestBlockIndex', () => { it( 'returns `0` when the position is nearest to the start of the first block', () => { const position = { x: 0, y: 0 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 0 ); + expect( result ).toEqual( [ 0, 'insert' ] ); } ); it( 'returns `1` when the position is nearest to the end of the first block', () => { const position = { x: 0, y: 190 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 1 ); + expect( result ).toEqual( [ 1, 'insert' ] ); } ); it( 'returns `1` when the position is nearest to the start of the second block', () => { const position = { x: 0, y: 210 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 1 ); + expect( result ).toEqual( [ 1, 'insert' ] ); } ); it( 'returns `2` when the position is nearest to the end of the second block', () => { const position = { x: 0, y: 450 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 2 ); + expect( result ).toEqual( [ 2, 'insert' ] ); } ); it( 'returns `2` when the position is nearest to the start of the third block', () => { const position = { x: 0, y: 510 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 2 ); + expect( result ).toEqual( [ 2, 'insert' ] ); } ); it( 'returns `3` when the position is nearest to the end of the third block', () => { const position = { x: 0, y: 880 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 3 ); + expect( result ).toEqual( [ 3, 'insert' ] ); } ); it( 'returns `3` when the position is past the end of the third block', () => { const position = { x: 0, y: 920 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 3 ); + expect( result ).toEqual( [ 3, 'insert' ] ); } ); - it( 'returns `3` when the position is nearest to the start of the fourth block', () => { + it( 'returns `4` when the position is nearest to the start of the fourth block', () => { const position = { x: 401, y: 0 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 3 ); + expect( result ).toEqual( [ 3, 'insert' ] ); } ); - it( 'returns `4` when the position is nearest to the end of the fourth block', () => { + it( 'returns `5` when the position is nearest to the end of the fourth block', () => { const position = { x: 401, y: 300 }; - const result = getNearestBlockIndex( - verticalElements, + const result = getDropTargetPosition( + verticalBlocksData, position, orientation ); - expect( result ).toBe( 4 ); + expect( result ).toEqual( [ 4, 'insert' ] ); } ); } ); @@ -200,109 +187,374 @@ describe( 'getNearestBlockIndex', () => { it( 'returns `0` when the position is nearest to the start of the first block', () => { const position = { x: 0, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 0 ); + expect( result ).toEqual( [ 0, 'insert' ] ); } ); it( 'returns `1` when the position is nearest to the end of the first block', () => { const position = { x: 190, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 1 ); + expect( result ).toEqual( [ 1, 'insert' ] ); } ); it( 'returns `1` when the position is nearest to the start of the second block', () => { const position = { x: 210, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 1 ); + expect( result ).toEqual( [ 1, 'insert' ] ); } ); it( 'returns `2` when the position is nearest to the end of the second block', () => { const position = { x: 450, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 2 ); + expect( result ).toEqual( [ 2, 'insert' ] ); } ); it( 'returns `2` when the position is nearest to the start of the third block', () => { const position = { x: 510, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 2 ); + expect( result ).toEqual( [ 2, 'insert' ] ); } ); it( 'returns `3` when the position is nearest to the end of the third block', () => { const position = { x: 880, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 3 ); + expect( result ).toEqual( [ 3, 'insert' ] ); } ); it( 'returns `3` when the position is past the end of the third block', () => { const position = { x: 920, y: 0 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 3 ); + expect( result ).toEqual( [ 3, 'insert' ] ); } ); - it( 'returns `3` when the position is nearest to the start of the fourth block', () => { + it( 'returns `3` when the position is nearest to the start of the last block', () => { const position = { x: 0, y: 401 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 3 ); + expect( result ).toEqual( [ 3, 'insert' ] ); } ); - it( 'returns `4` when the position is nearest to the end of the fourth block', () => { + it( 'returns `4` when the position is nearest to the end of the last block', () => { const position = { x: 300, y: 401 }; - const result = getNearestBlockIndex( - horizontalElements, + const result = getDropTargetPosition( + horizontalBlocksData, position, orientation ); - expect( result ).toBe( 4 ); + expect( result ).toEqual( [ 4, 'insert' ] ); + } ); + } ); + + describe( 'Unmodified default blocks', () => { + const orientation = 'vertical'; + + it( 'handles replacement index when only the first block is an unmodified default block', () => { + const blocksData = [ + { + left: 0, + top: 10, + right: 400, + bottom: 210, + isUnmodifiedDefaultBlock: true, + }, + { + left: 0, + top: 220, + right: 400, + bottom: 420, + isUnmodifiedDefaultBlock: false, + }, + ].map( mapElements( 'vertical' ) ); + + // Dropping above the first block. + expect( + getDropTargetPosition( blocksData, { x: 0, y: 0 }, orientation ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping on the top half of the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 20 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping on the bottom half of the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 200 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping slightly after the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 211 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping slightly above the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 219 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping on the top half of the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 230 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping on the bottom half of the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 410 }, + orientation + ) + ).toEqual( [ 2, 'insert' ] ); + + // Dropping below the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 421 }, + orientation + ) + ).toEqual( [ 2, 'insert' ] ); + } ); + + it( 'handles replacement index when only the second block is an unmodified default block', () => { + const blocksData = [ + { + left: 0, + top: 10, + right: 400, + bottom: 210, + isUnmodifiedDefaultBlock: false, + }, + { + left: 0, + top: 220, + right: 400, + bottom: 420, + isUnmodifiedDefaultBlock: true, + }, + ].map( mapElements( 'vertical' ) ); + + // Dropping above the first block. + expect( + getDropTargetPosition( blocksData, { x: 0, y: 0 }, orientation ) + ).toEqual( [ 0, 'insert' ] ); + + // Dropping on the top half of the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 20 }, + orientation + ) + ).toEqual( [ 0, 'insert' ] ); + + // Dropping on the bottom half of the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 200 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping slightly after the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 211 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping slightly above the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 219 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping on the top half of the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 230 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping on the bottom half of the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 410 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping below the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 421 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + } ); + + it( 'returns replacement index when both blocks are unmodified default blocks', () => { + const blocksData = [ + { + left: 0, + top: 10, + right: 400, + bottom: 210, + isUnmodifiedDefaultBlock: true, + }, + { + left: 0, + top: 220, + right: 400, + bottom: 420, + isUnmodifiedDefaultBlock: true, + }, + ].map( mapElements( 'vertical' ) ); + + // Dropping above the first block. + expect( + getDropTargetPosition( blocksData, { x: 0, y: 0 }, orientation ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping on the top half of the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 20 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping on the bottom half of the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 200 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping slightly after the first block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 211 }, + orientation + ) + ).toEqual( [ 0, 'replace' ] ); + + // Dropping slightly above the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 219 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping on the top half of the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 230 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping on the bottom half of the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 410 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); + + // Dropping below the second block. + expect( + getDropTargetPosition( + blocksData, + { x: 0, y: 421 }, + orientation + ) + ).toEqual( [ 1, 'replace' ] ); } ); } ); } ); diff --git a/packages/block-editor/src/components/use-on-block-drop/index.js b/packages/block-editor/src/components/use-on-block-drop/index.js index 245f912bcf4da7..6b0a9300124493 100644 --- a/packages/block-editor/src/components/use-on-block-drop/index.js +++ b/packages/block-editor/src/components/use-on-block-drop/index.js @@ -17,6 +17,7 @@ import { getFilesFromDataTransfer } from '@wordpress/dom'; import { store as blockEditorStore } from '../../store'; /** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */ +/** @typedef {import('./types').WPDropOperation} WPDropOperation */ /** * Retrieve the data for a block drop event. @@ -197,21 +198,19 @@ export function onHTMLDrop( /** * A React hook for handling block drop events. * - * @typedef {'insert'|'replace'} DropAction The type of action to perform on drop. + * @param {string} targetRootClientId The root client id where the block(s) will be inserted. + * @param {number} targetBlockIndex The index where the block(s) will be inserted. + * @param {Object} options The optional options. + * @param {WPDropOperation} [options.operation] The type of operation to perform on drop. Could be `insert` or `replace` for now. * - * @param {string} targetRootClientId The root client id where the block(s) will be inserted. - * @param {number} targetBlockIndex The index where the block(s) will be inserted. - * @param {Object} options The optional options. - * @param {DropAction} options.action The type of action to perform on drop. Could be `insert` or `replace` for now. - * - * @return {Object} An object that contains the event handlers `onDrop`, `onFilesDrop` and `onHTMLDrop`. + * @return {Function} A function to be passed to the onDrop handler. */ export default function useOnBlockDrop( targetRootClientId, targetBlockIndex, options = {} ) { - const { action = 'insert' } = options; + const { operation = 'insert' } = options; const hasUploadPermissions = useSelect( ( select ) => select( blockEditorStore ).getSettings().mediaUpload, [] @@ -235,7 +234,7 @@ export default function useOnBlockDrop( const insertOrReplaceBlocks = useCallback( ( blocks, updateSelection = true, initialPosition = 0 ) => { - if ( action === 'replace' ) { + if ( operation === 'replace' ) { const clientIds = getBlockOrder( targetRootClientId ); const clientId = clientIds[ targetBlockIndex ]; @@ -251,7 +250,7 @@ export default function useOnBlockDrop( } }, [ - action, + operation, getBlockOrder, insertBlocks, replaceBlocks, @@ -262,7 +261,7 @@ export default function useOnBlockDrop( const moveBlocks = useCallback( ( sourceClientIds, sourceRootClientId, insertIndex ) => { - if ( action === 'replace' ) { + if ( operation === 'replace' ) { const sourceBlocks = getBlocksByClientId( sourceClientIds ); const targetBlockClientIds = getBlockOrder( targetRootClientId ); @@ -290,7 +289,7 @@ export default function useOnBlockDrop( } }, [ - action, + operation, getBlockOrder, getBlocksByClientId, insertBlocks, diff --git a/packages/block-editor/src/components/use-on-block-drop/types.ts b/packages/block-editor/src/components/use-on-block-drop/types.ts new file mode 100644 index 00000000000000..1b24ea7e90f9a6 --- /dev/null +++ b/packages/block-editor/src/components/use-on-block-drop/types.ts @@ -0,0 +1 @@ +export type WPDropOperation = 'insert' | 'replace'; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 68b6f61ca89d63..971525c94dfd65 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -26,6 +26,8 @@ import { START_OF_SELECTED_AREA, } from '../utils/selection'; +/** @typedef {import('../components/use-on-block-drop/types').WPDropOperation} WPDropOperation */ + const castArray = ( maybeArray ) => Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ]; @@ -624,10 +626,13 @@ export const insertBlocks = /** * Action that shows the insertion point. * - * @param {?string} rootClientId Optional root client ID of block list on - * which to insert. - * @param {?number} index Index at which block should be inserted. - * @param {Object} __unstableOptions Whether or not to show an inserter button. + * @param {?string} rootClientId Optional root client ID of block list on + * which to insert. + * @param {?number} index Index at which block should be inserted. + * @param {?Object} __unstableOptions Additional options. + * @property {boolean} __unstableWithInserter Whether or not to show an inserter button. + * @property {WPDropOperation} operation The operation to perform when applied, + * either 'insert' or 'replace' for now. * * @return {Object} Action object. */ @@ -636,12 +641,13 @@ export function showInsertionPoint( index, __unstableOptions = {} ) { - const { __unstableWithInserter } = __unstableOptions; + const { __unstableWithInserter, operation } = __unstableOptions; return { type: 'SHOW_INSERTION_POINT', rootClientId, index, __unstableWithInserter, + operation, }; } /** diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index e306829c22ed55..3c04134f8ffd83 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1475,9 +1475,19 @@ export function blocksMode( state = {}, action ) { */ export function insertionPoint( state = null, action ) { switch ( action.type ) { - case 'SHOW_INSERTION_POINT': - const { rootClientId, index, __unstableWithInserter } = action; - return { rootClientId, index, __unstableWithInserter }; + case 'SHOW_INSERTION_POINT': { + const { rootClientId, index, __unstableWithInserter, operation } = + action; + const nextState = { + rootClientId, + index, + __unstableWithInserter, + operation, + }; + + // Bail out updates if the states are the same. + return isEqual( state, nextState ) ? state : nextState; + } case 'HIDE_INSERTION_POINT': return null; diff --git a/packages/block-editor/src/utils/math.js b/packages/block-editor/src/utils/math.js index f5140f446c6a12..128972e8a400e1 100644 --- a/packages/block-editor/src/utils/math.js +++ b/packages/block-editor/src/utils/math.js @@ -89,3 +89,20 @@ export function getDistanceToNearestEdge( return [ candidateDistance, candidateEdge ]; } + +/** + * Is the point contained by the rectangle. + * + * @param {WPPoint} point The point. + * @param {DOMRect} rect The rectangle. + * + * @return {boolean} True if the point is contained by the rectangle, false otherwise. + */ +export function isPointContainedByRect( point, rect ) { + return ( + rect.left <= point.x && + rect.right >= point.x && + rect.top <= point.y && + rect.bottom >= point.y + ); +} diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index 18c3f89875d797..54862a509e9b3f 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -11,7 +11,6 @@ ### New Feature - Made it possible to import individual blocks ([#42258](https://github.com/WordPress/gutenberg/pull/42258)). Check [README](./README.md#loading-individual-blocks) for more information. -- Paragraph block: You can now drop files/blocks/HTML on an empty Paragraph block to transform it into relevant blocks ([#42722](https://github.com/WordPress/gutenberg/pull/42722)). ## 7.13.0 (2022-08-24) diff --git a/packages/block-library/src/paragraph/drop-zone.js b/packages/block-library/src/paragraph/drop-zone.js deleted file mode 100644 index e51fb84acf8062..00000000000000 --- a/packages/block-library/src/paragraph/drop-zone.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; -import { - __experimentalUseOnBlockDrop as useOnBlockDrop, - store as blockEditorStore, -} from '@wordpress/block-editor'; -import { - __experimentalUseDropZone as useDropZone, - useReducedMotion, -} from '@wordpress/compose'; -import { - Popover, - __unstableMotion as motion, - __unstableAnimatePresence as AnimatePresence, -} from '@wordpress/components'; - -const animateVariants = { - hide: { opacity: 0, scaleY: 0.75 }, - show: { opacity: 1, scaleY: 1 }, - exit: { opacity: 0, scaleY: 0.9 }, -}; - -export default function DropZone( { paragraphElement, clientId } ) { - const { rootClientId, blockIndex } = useSelect( - ( select ) => { - const selectors = select( blockEditorStore ); - return { - rootClientId: selectors.getBlockRootClientId( clientId ), - blockIndex: selectors.getBlockIndex( clientId ), - }; - }, - [ clientId ] - ); - const onBlockDrop = useOnBlockDrop( rootClientId, blockIndex, { - action: 'replace', - } ); - const [ isDragging, setIsDragging ] = useState( false ); - const [ isVisible, setIsVisible ] = useState( false ); - const popoverRef = useDropZone( { - onDragStart: () => { - setIsDragging( true ); - }, - onDragEnd: () => { - setIsDragging( false ); - }, - } ); - const dropZoneRef = useDropZone( { - onDrop: onBlockDrop, - onDragEnter: () => { - setIsVisible( true ); - }, - onDragLeave: () => { - setIsVisible( false ); - }, - } ); - const reducedMotion = useReducedMotion(); - - return ( - - { isDragging ? ( -
- - { isVisible ? ( - - ) : null } - -
- ) : null } -
- ); -} diff --git a/packages/block-library/src/paragraph/edit.js b/packages/block-library/src/paragraph/edit.js index c049b0b72bd249..5340eb5e4ea543 100644 --- a/packages/block-library/src/paragraph/edit.js +++ b/packages/block-library/src/paragraph/edit.js @@ -6,7 +6,6 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useState } from '@wordpress/element'; import { __, _x, isRTL } from '@wordpress/i18n'; import { ToolbarButton, @@ -21,7 +20,6 @@ import { useBlockProps, useSetting, } from '@wordpress/block-editor'; -import { useMergeRefs } from '@wordpress/compose'; import { createBlock } from '@wordpress/blocks'; import { formatLtr } from '@wordpress/icons'; @@ -29,7 +27,6 @@ import { formatLtr } from '@wordpress/icons'; * Internal dependencies */ import { useOnEnter } from './use-enter'; -import DropZone from './drop-zone'; const name = 'core/paragraph'; @@ -62,12 +59,8 @@ function ParagraphBlock( { } ) { const { align, content, direction, dropCap, placeholder } = attributes; const isDropCapFeatureEnabled = useSetting( 'typography.dropCap' ); - const [ paragraphElement, setParagraphElement ] = useState( null ); const blockProps = useBlockProps( { - ref: useMergeRefs( [ - useOnEnter( { clientId, content } ), - setParagraphElement, - ] ), + ref: useOnEnter( { clientId, content } ), className: classnames( { 'has-drop-cap': hasDropCapDisabled( align ) ? false : dropCap, [ `has-text-align-${ align }` ]: align, @@ -130,12 +123,6 @@ function ParagraphBlock( { ) } - { ! content && ( - - ) } { + // @ts-ignore (Reason: wp isn't typed). + const blocks = window.wp.blocks.parse( _html ); + + // @ts-ignore (Reason: wp isn't typed). + window.wp.data.dispatch( 'core/block-editor' ).resetBlocks( blocks ); + }, html ); +} + +export { setContent }; diff --git a/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts b/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts index f8e237e7e37f1d..da61143196ae1c 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts @@ -9,6 +9,7 @@ import { getType } from 'mime'; * Internal dependencies */ import type { PageUtils } from './index'; +import type { Locator } from '@playwright/test'; type FileObject = { name: string; @@ -99,14 +100,19 @@ async function dragFiles( /** * Drag the files over an element (fires `dragenter` and `dragover` events). * - * @param selector A selector to search for an element. - * @param options The optional options. - * @param options.position A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of the element. + * @param selectorOrLocator A selector or a locator to search for an element. + * @param options The optional options. + * @param options.position A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of the element. */ - dragOver: async ( selector: string, options: Options = {} ) => { - const boundingBox = await this.page - .locator( selector ) - .boundingBox(); + dragOver: async ( + selectorOrLocator: string | Locator, + options: Options = {} + ) => { + const locator = + typeof selectorOrLocator === 'string' + ? this.page.locator( selectorOrLocator ) + : selectorOrLocator; + const boundingBox = await locator.boundingBox(); if ( ! boundingBox ) { throw new Error( diff --git a/packages/e2e-test-utils-playwright/src/test.ts b/packages/e2e-test-utils-playwright/src/test.ts index 22ccf83ff41fcc..ac68ae7630e314 100644 --- a/packages/e2e-test-utils-playwright/src/test.ts +++ b/packages/e2e-test-utils-playwright/src/test.ts @@ -137,6 +137,11 @@ const test = base.extend< await Promise.all( [ requestUtils.activateTheme( 'twentytwentyone' ), + // Disable this test plugin as it's conflicting with some of the tests. + // We already have reduced motion enabled and Playwright will wait for most of the animations anyway. + requestUtils.deactivatePlugin( + 'gutenberg-test-plugin-disables-the-css-animations' + ), requestUtils.deleteAllPosts(), requestUtils.deleteAllBlocks(), requestUtils.resetPreferences(), diff --git a/test/e2e/specs/editor/blocks/paragraph.spec.js b/test/e2e/specs/editor/blocks/paragraph.spec.js index 6a043153320d83..169473c3029c81 100644 --- a/test/e2e/specs/editor/blocks/paragraph.spec.js +++ b/test/e2e/specs/editor/blocks/paragraph.spec.js @@ -8,6 +8,12 @@ const path = require( 'path' ); */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +test.use( { + draggingUtils: async ( { page }, use ) => { + await use( new DraggingUtils( { page } ) ); + }, +} ); + test.describe( 'Paragraph', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); @@ -57,6 +63,7 @@ test.describe( 'Paragraph', () => { editor, page, pageUtils, + draggingUtils, } ) => { await editor.insertBlock( { name: 'core/paragraph' } ); @@ -73,9 +80,8 @@ test.describe( 'Paragraph', () => { await dragOver( '[data-type="core/paragraph"]' ); - await expect( - page.locator( 'data-testid=empty-paragraph-drop-zone' ) - ).toBeVisible(); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect( draggingUtils.insertionIndicator ).not.toBeVisible(); await drop(); @@ -92,6 +98,7 @@ test.describe( 'Paragraph', () => { test( 'should allow dropping blocks on en empty paragraph block', async ( { editor, page, + draggingUtils, } ) => { await editor.insertBlock( { name: 'core/heading', @@ -111,15 +118,10 @@ test.describe( 'Paragraph', () => { '[data-type="core/paragraph"][data-empty="true"]' ); const boundingBox = await emptyParagraph.boundingBox(); - // Call the move function twice to make sure the `dragOver` event is sent. - // @see https://github.com/microsoft/playwright/issues/17153 - for ( let i = 0; i < 2; i += 1 ) { - await page.mouse.move( boundingBox.x, boundingBox.y ); - } + await draggingUtils.dragOver( boundingBox.x, boundingBox.y ); - await expect( - page.locator( 'data-testid=empty-paragraph-drop-zone' ) - ).toBeVisible(); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect( draggingUtils.insertionIndicator ).not.toBeVisible(); await page.mouse.up(); @@ -132,54 +134,20 @@ test.describe( 'Paragraph', () => { test( 'should allow dropping HTML on en empty paragraph block', async ( { editor, page, + draggingUtils, } ) => { await editor.insertBlock( { name: 'core/paragraph' } ); - // Insert a dummy draggable element on the page to simulate dragging - // HTML from other places. - await page.evaluate( () => { - const draggable = document.createElement( 'div' ); - draggable.draggable = true; - draggable.style.width = '10px'; - draggable.style.height = '10px'; - // Position it at the top left corner for convenience. - draggable.style.position = 'fixed'; - draggable.style.top = 0; - draggable.style.left = 0; - draggable.style.zIndex = 999999; - - draggable.addEventListener( - 'dragstart', - ( event ) => { - // Set the data transfer to some HTML on dragstart. - event.dataTransfer.setData( - 'text/html', - '

My Heading

' - ); - }, - { once: true } - ); - - document.body.appendChild( draggable ); - } ); - - // This is where the dummy draggable element is at. - await page.mouse.move( 0, 0 ); - await page.mouse.down(); + await draggingUtils.simulateDraggingHTML( '

My Heading

' ); const emptyParagraph = page.locator( '[data-type="core/paragraph"][data-empty="true"]' ); const boundingBox = await emptyParagraph.boundingBox(); - // Call the move function twice to make sure the `dragOver` event is sent. - // @see https://github.com/microsoft/playwright/issues/17153 - for ( let i = 0; i < 2; i += 1 ) { - await page.mouse.move( boundingBox.x, boundingBox.y ); - } + await draggingUtils.dragOver( boundingBox.x, boundingBox.y ); - await expect( - page.locator( 'data-testid=empty-paragraph-drop-zone' ) - ).toBeVisible(); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect( draggingUtils.insertionIndicator ).not.toBeVisible(); await page.mouse.up(); @@ -188,5 +156,354 @@ test.describe( 'Paragraph', () => {

My Heading

` ); } ); + + test.describe( 'Dragging positions', () => { + test( 'Only the first block is an empty paragraph block', async ( { + editor, + page, + draggingUtils, + } ) => { + await editor.setContent( ` + +

+ + + +

Heading

+ + ` ); + + const emptyParagraph = page.locator( + '[data-type="core/paragraph"]' + ); + const heading = page.locator( 'text=Heading' ); + + await draggingUtils.simulateDraggingHTML( + '

Draggable

' + ); + + const firstBlockBox = await emptyParagraph.boundingBox(); + const headingBox = await heading.boundingBox(); + + { + // Dragging on the top half of an empty paragraph block. + await draggingUtils.dragOver( + firstBlockBox.x, + firstBlockBox.y + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging on the bottom half of an empty paragraph block. + await draggingUtils.dragOver( + firstBlockBox.x, + firstBlockBox.y + firstBlockBox.height - 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging below the empty paragraph block but not yet on the second block. + await draggingUtils.dragOver( + firstBlockBox.x, + firstBlockBox.y + firstBlockBox.height + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging on the top half of the heading block. + await draggingUtils.dragOver( + headingBox.x, + headingBox.y + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging on the bottom half of the heading block. + await draggingUtils.dragOver( + headingBox.x, + headingBox.y + headingBox.height - 1 + ); + await expect( draggingUtils.dropZone ).not.toBeVisible(); + await expect( + draggingUtils.insertionIndicator + ).toBeVisible(); + await expect + .poll( () => + draggingUtils.insertionIndicator + .boundingBox() + .then( ( { y, height } ) => y + height ) + ) + .toBeGreaterThan( headingBox.y + headingBox.height ); + } + } ); + + test( 'Only the second block is an empty paragraph block', async ( { + editor, + page, + draggingUtils, + } ) => { + await editor.setContent( ` + +

Heading

+ + + +

+ + ` ); + + const emptyParagraph = page.locator( + '[data-type="core/paragraph"]' + ); + const heading = page.locator( 'text=Heading' ); + + await draggingUtils.simulateDraggingHTML( + '

Draggable

' + ); + + const secondBlockBox = await emptyParagraph.boundingBox(); + const headingBox = await heading.boundingBox(); + + { + // Dragging on the top half of the heading block. + await draggingUtils.dragOver( + headingBox.x, + headingBox.y + 1 + ); + await expect( draggingUtils.dropZone ).not.toBeVisible(); + await expect( + draggingUtils.insertionIndicator + ).toBeVisible(); + await expect + .poll( () => + draggingUtils.insertionIndicator + .boundingBox() + .then( ( { y } ) => y ) + ) + .toBeLessThan( headingBox.y ); + } + + { + // Dragging on the bottom half of the heading block. + await draggingUtils.dragOver( + headingBox.x, + headingBox.y + headingBox.height - 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + + { + // Dragging below the heading block but not yet on the empty paragraph block. + await draggingUtils.dragOver( + headingBox.x, + headingBox.y + headingBox.height + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + + { + // Dragging on the top half of the empty paragraph block. + await draggingUtils.dragOver( + secondBlockBox.x, + secondBlockBox.y + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + + { + // Dragging on the bottom half of the empty paragraph block. + await draggingUtils.dragOver( + secondBlockBox.x, + secondBlockBox.y + secondBlockBox.height - 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + } ); + + test( 'Both blocks are empty paragraph blocks', async ( { + editor, + page, + draggingUtils, + } ) => { + await editor.setContent( ` + +

+ + + +

+ + ` ); + + const firstEmptyParagraph = page + .locator( '[data-type="core/paragraph"]' ) + .first(); + const secondEmptyParagraph = page + .locator( '[data-type="core/paragraph"]' ) + .nth( 1 ); + + await draggingUtils.simulateDraggingHTML( + '

Draggable

' + ); + + const firstBlockBox = await firstEmptyParagraph.boundingBox(); + const secondBlockBox = await secondEmptyParagraph.boundingBox(); + + { + // Dragging on the top half of the first block. + await draggingUtils.dragOver( + firstBlockBox.x, + firstBlockBox.y + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging on the bottom half of the first block. + await draggingUtils.dragOver( + firstBlockBox.x, + firstBlockBox.y + firstBlockBox.height - 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging slightly below the first block but not yet on the second block. + await draggingUtils.dragOver( + firstBlockBox.x, + firstBlockBox.y + firstBlockBox.height + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( firstBlockBox ); + } + + { + // Dragging slightly above the second block but not yet on the first block. + await draggingUtils.dragOver( + secondBlockBox.x, + secondBlockBox.y - 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + + { + // Dragging on the top half of the second block. + await draggingUtils.dragOver( + secondBlockBox.x, + secondBlockBox.y + 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + + { + // Dragging on the bottom half of the second block. + await draggingUtils.dragOver( + secondBlockBox.x, + secondBlockBox.y + secondBlockBox.height - 1 + ); + await expect( draggingUtils.dropZone ).toBeVisible(); + await expect + .poll( () => draggingUtils.dropZone.boundingBox() ) + .toEqual( secondBlockBox ); + } + } ); + } ); } ); } ); + +class DraggingUtils { + constructor( { page } ) { + this.page = page; + + this.dropZone = page.locator( 'data-testid=block-popover-drop-zone' ); + this.insertionIndicator = page.locator( + 'data-testid=block-list-insertion-point-indicator' + ); + } + + async dragOver( x, y ) { + // Call the move function twice to make sure the `dragOver` event is sent. + // @see https://github.com/microsoft/playwright/issues/17153 + for ( let i = 0; i < 2; i += 1 ) { + await this.page.mouse.move( x, y ); + } + } + + async simulateDraggingHTML( html ) { + // Insert a dummy draggable element on the page to simulate dragging + // HTML from other places. The dummy element will get removed once the drag starts. + await this.page.evaluate( ( _html ) => { + const draggable = document.createElement( 'div' ); + draggable.draggable = true; + draggable.style.width = '10px'; + draggable.style.height = '10px'; + // Position it at the top left corner for convenience. + draggable.style.position = 'fixed'; + draggable.style.top = 0; + draggable.style.left = 0; + draggable.style.zIndex = 999999; + + draggable.addEventListener( + 'dragstart', + ( event ) => { + // Set the data transfer to some HTML on dragstart. + event.dataTransfer.setData( 'text/html', _html ); + + // Some browsers will cancel the drag if the source is immediately removed. + setTimeout( () => { + draggable.remove(); + }, 0 ); + }, + { once: true } + ); + + document.body.appendChild( draggable ); + }, html ); + + // This is where the dummy draggable element is at. + await this.page.mouse.move( 0, 0 ); + await this.page.mouse.down(); + } +}