diff --git a/package-lock.json b/package-lock.json index 86445e18976909..9eb370081bea49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15509,6 +15509,12 @@ "@types/unist": "*" } }, + "@types/mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==", + "dev": true + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -16979,7 +16985,16 @@ "@wordpress/keycodes": "file:packages/keycodes", "@wordpress/url": "file:packages/url", "change-case": "^4.1.2", - "form-data": "^4.0.0" + "form-data": "^4.0.0", + "mime": "^3.0.0" + }, + "dependencies": { + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + } } }, "@wordpress/e2e-tests": { @@ -18666,7 +18681,7 @@ "app-root-dir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", - "integrity": "sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==", + "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=", "dev": true }, "app-root-path": { @@ -26845,7 +26860,7 @@ "babel-plugin-add-react-displayname": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/babel-plugin-add-react-displayname/-/babel-plugin-add-react-displayname-0.0.5.tgz", - "integrity": "sha512-LY3+Y0XVDYcShHHorshrDbt4KFWL4bSeniCtl4SYZbask+Syngk1uMPCeN9+nSiZo6zX5s0RTq/J9Pnaaf/KHw==", + "integrity": "sha1-M51M3be2X9YtHfnbn+BN4TQSK9U=", "dev": true }, "babel-plugin-apply-mdx-type-prop": { @@ -27268,7 +27283,7 @@ "batch-processor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz", - "integrity": "sha512-xoLQD8gmmR32MeuBHgH0Tzd5PuSZx71ZsbhVxOCRbgktZEPe4SQy7s9Z50uPp0F/f7iw2XmkHN2xkgbMfckMDA==", + "integrity": "sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg=", "dev": true }, "bcrypt-pbkdf": { @@ -30558,7 +30573,7 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", "dev": true }, "cssesc": { @@ -36679,7 +36694,7 @@ "has-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz", - "integrity": "sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==", + "integrity": "sha1-mqqe7b/7G6OZCnsAEPtnjuAIEgc=", "dev": true, "requires": { "is-glob": "^3.0.0" @@ -36688,7 +36703,7 @@ "is-glob": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", "dev": true, "requires": { "is-extglob": "^2.1.0" @@ -38502,7 +38517,7 @@ "is-window": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz", - "integrity": "sha512-uj00kdXyZb9t9RcAUAwMZAnkBUwdYGhYlt7djMXhfyhUCzwNba50tIiBKR7q0l7tdoBtFVw/3JmLY6fI3rmZmg==", + "integrity": "sha1-LIlspT25feRdPDMTOmXYyfVjSA0=", "dev": true }, "is-windows": { @@ -41879,7 +41894,7 @@ "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", - "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", + "integrity": "sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=", "dev": true }, "js-tokens": { @@ -43407,7 +43422,7 @@ "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", - "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", "dev": true }, "macos-release": { @@ -46738,7 +46753,7 @@ "num2fraction": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", - "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", "dev": true }, "number-is-nan": { @@ -47817,7 +47832,7 @@ "p-defer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", - "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", "dev": true }, "p-event": { @@ -49156,7 +49171,7 @@ "pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "dev": true }, "prismjs": { @@ -51388,7 +51403,7 @@ "relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "dev": true }, "remark": { diff --git a/package.json b/package.json index 61027290d08af3..2131c0815308c5 100755 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@types/highlight-words-core": "1.2.1", "@types/istanbul-lib-report": "3.0.0", "@types/lodash": "4.14.172", + "@types/mime": "2.0.3", "@types/npm-package-arg": "6.1.1", "@types/prettier": "2.4.4", "@types/qs": "6.9.7", diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 6b7a24887b3423..0ad7de536282e2 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -155,6 +155,7 @@ 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/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index ac6a0ea3d1f670..3bef31546c711e 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 @@ -73,6 +73,20 @@ export function getNearestBlockIndex( elements, position, orientation ) { return candidateIndex; } +/** + * 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' + ); +} + /** * @typedef {Object} WPBlockDropZoneConfig * @property {string} rootClientId The root client id for the block list. @@ -130,7 +144,18 @@ export default function useBlockDropZone( { setTargetBlockIndex( targetIndex === undefined ? 0 : targetIndex ); - if ( targetIndex !== null ) { + 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 ) + ) { + return; + } + showInsertionPoint( targetRootClientId, targetIndex ); } }, [] ), 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 449a9fc5dc2a9c..245f912bcf4da7 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 @@ -1,13 +1,14 @@ /** * WordPress dependencies */ +import { useCallback } from '@wordpress/element'; import { cloneBlock, findTransform, getBlockTransforms, pasteHandler, } from '@wordpress/blocks'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch, useSelect, useRegistry } from '@wordpress/data'; import { getFilesFromDataTransfer } from '@wordpress/dom'; /** @@ -56,8 +57,8 @@ export function parseDropEvent( event ) { * @param {number} targetBlockIndex The index where the block(s) will be inserted. * @param {Function} getBlockIndex A function that gets the index of a block. * @param {Function} getClientIdsOfDescendants A function that gets the client ids of descendant blocks. - * @param {Function} moveBlocksToPosition A function that moves blocks. - * @param {Function} insertBlocks A function that inserts blocks. + * @param {Function} moveBlocks A function that moves blocks. + * @param {Function} insertOrReplaceBlocks A function that inserts or replaces blocks. * @param {Function} clearSelectedBlock A function that clears block selection. * @return {Function} The event handler for a block drop event. */ @@ -66,8 +67,8 @@ export function onBlockDrop( targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition, - insertBlocks, + moveBlocks, + insertOrReplaceBlocks, clearSelectedBlock ) { return ( event ) => { @@ -84,13 +85,7 @@ export function onBlockDrop( const blocksToInsert = blocks.map( ( block ) => cloneBlock( block ) ); - insertBlocks( - blocksToInsert, - targetBlockIndex, - targetRootClientId, - true, - null - ); + insertOrReplaceBlocks( blocksToInsert, true, null ); } // If the user is moving a block. @@ -128,12 +123,7 @@ export function onBlockDrop( ? targetBlockIndex - draggedBlockCount : targetBlockIndex; - moveBlocksToPosition( - sourceClientIds, - sourceRootClientId, - targetRootClientId, - insertIndex - ); + moveBlocks( sourceClientIds, sourceRootClientId, insertIndex ); } }; } @@ -146,7 +136,7 @@ export function onBlockDrop( * @param {boolean} hasUploadPermissions Whether the user has upload permissions. * @param {Function} updateBlockAttributes A function that updates a block's attributes. * @param {Function} canInsertBlockType A function that returns checks whether a block type can be inserted. - * @param {Function} insertBlocks A function that inserts blocks. + * @param {Function} insertOrReplaceBlocks A function that inserts or replaces blocks. * * @return {Function} The event handler for a block-related file drop event. */ @@ -156,7 +146,7 @@ export function onFilesDrop( hasUploadPermissions, updateBlockAttributes, canInsertBlockType, - insertBlocks + insertOrReplaceBlocks ) { return ( files ) => { if ( ! hasUploadPermissions ) { @@ -176,7 +166,7 @@ export function onFilesDrop( files, updateBlockAttributes ); - insertBlocks( blocks, targetBlockIndex, targetRootClientId ); + insertOrReplaceBlocks( blocks ); } }; } @@ -184,22 +174,22 @@ export function onFilesDrop( /** * A function that returns an event handler function for block-related HTML drop events. * - * @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 {Function} insertBlocks A function that inserts blocks. + * @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 {Function} insertOrReplaceBlocks A function that inserts or replaces blocks. * * @return {Function} The event handler for a block-related HTML drop event. */ export function onHTMLDrop( targetRootClientId, targetBlockIndex, - insertBlocks + insertOrReplaceBlocks ) { return ( HTML ) => { const blocks = pasteHandler( { HTML, mode: 'BLOCKS' } ); if ( blocks.length ) { - insertBlocks( blocks, targetBlockIndex, targetRootClientId ); + insertOrReplaceBlocks( blocks ); } }; } @@ -207,32 +197,117 @@ export function onHTMLDrop( /** * A React hook for handling block drop events. * - * @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. + * @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 {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`. */ -export default function useOnBlockDrop( targetRootClientId, targetBlockIndex ) { +export default function useOnBlockDrop( + targetRootClientId, + targetBlockIndex, + options = {} +) { + const { action = 'insert' } = options; const hasUploadPermissions = useSelect( ( select ) => select( blockEditorStore ).getSettings().mediaUpload, [] ); - const { canInsertBlockType, getBlockIndex, getClientIdsOfDescendants } = - useSelect( blockEditorStore ); + const { + canInsertBlockType, + getBlockIndex, + getClientIdsOfDescendants, + getBlockOrder, + getBlocksByClientId, + } = useSelect( blockEditorStore ); const { insertBlocks, moveBlocksToPosition, updateBlockAttributes, clearSelectedBlock, + replaceBlocks, + removeBlocks, } = useDispatch( blockEditorStore ); + const registry = useRegistry(); + + const insertOrReplaceBlocks = useCallback( + ( blocks, updateSelection = true, initialPosition = 0 ) => { + if ( action === 'replace' ) { + const clientIds = getBlockOrder( targetRootClientId ); + const clientId = clientIds[ targetBlockIndex ]; + + replaceBlocks( clientId, blocks, undefined, initialPosition ); + } else { + insertBlocks( + blocks, + targetBlockIndex, + targetRootClientId, + updateSelection, + initialPosition + ); + } + }, + [ + action, + getBlockOrder, + insertBlocks, + replaceBlocks, + targetBlockIndex, + targetRootClientId, + ] + ); + + const moveBlocks = useCallback( + ( sourceClientIds, sourceRootClientId, insertIndex ) => { + if ( action === 'replace' ) { + const sourceBlocks = getBlocksByClientId( sourceClientIds ); + const targetBlockClientIds = + getBlockOrder( targetRootClientId ); + const targetBlockClientId = + targetBlockClientIds[ targetBlockIndex ]; + + registry.batch( () => { + // Remove the source blocks. + removeBlocks( sourceClientIds, false ); + // Replace the target block with the source blocks. + replaceBlocks( + targetBlockClientId, + sourceBlocks, + undefined, + 0 + ); + } ); + } else { + moveBlocksToPosition( + sourceClientIds, + sourceRootClientId, + targetRootClientId, + insertIndex + ); + } + }, + [ + action, + getBlockOrder, + getBlocksByClientId, + insertBlocks, + moveBlocksToPosition, + removeBlocks, + targetBlockIndex, + targetRootClientId, + ] + ); const _onDrop = onBlockDrop( targetRootClientId, targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition, - insertBlocks, + moveBlocks, + insertOrReplaceBlocks, clearSelectedBlock ); const _onFilesDrop = onFilesDrop( @@ -241,12 +316,12 @@ export default function useOnBlockDrop( targetRootClientId, targetBlockIndex ) { hasUploadPermissions, updateBlockAttributes, canInsertBlockType, - insertBlocks + insertOrReplaceBlocks ); const _onHTMLDrop = onHTMLDrop( targetRootClientId, targetBlockIndex, - insertBlocks + insertOrReplaceBlocks ); return ( event ) => { diff --git a/packages/block-editor/src/components/use-on-block-drop/test/index.js b/packages/block-editor/src/components/use-on-block-drop/test/index.js index e2789bb879473e..1b95cc0085a79e 100644 --- a/packages/block-editor/src/components/use-on-block-drop/test/index.js +++ b/packages/block-editor/src/components/use-on-block-drop/test/index.js @@ -98,7 +98,7 @@ describe( 'onBlockDrop', () => { const targetBlockIndex = 0; const getBlockIndex = noop; const getClientIdsOfDescendants = noop; - const moveBlocksToPosition = jest.fn(); + const moveBlocks = jest.fn(); const event = { dataTransfer: { @@ -115,11 +115,11 @@ describe( 'onBlockDrop', () => { targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition + moveBlocks ); eventHandler( event ); - expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + expect( moveBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing if the block is dropped to the same place it was dragged from', () => { @@ -128,7 +128,7 @@ describe( 'onBlockDrop', () => { // Target and source block index is the same. const getBlockIndex = jest.fn( () => targetBlockIndex ); const getClientIdsOfDescendants = noop; - const moveBlocksToPosition = jest.fn(); + const moveBlocks = jest.fn(); const event = { dataTransfer: { @@ -148,11 +148,11 @@ describe( 'onBlockDrop', () => { targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition + moveBlocks ); eventHandler( event ); - expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + expect( moveBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing if the block is dropped as a child of itself', () => { @@ -160,7 +160,7 @@ describe( 'onBlockDrop', () => { const targetBlockIndex = 0; const getBlockIndex = jest.fn( () => 6 ); const getClientIdsOfDescendants = noop; - const moveBlocksToPosition = jest.fn(); + const moveBlocks = jest.fn(); const event = { dataTransfer: { @@ -180,11 +180,11 @@ describe( 'onBlockDrop', () => { targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition + moveBlocks ); eventHandler( event ); - expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + expect( moveBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing if the block is dropped as a descendant of itself', () => { @@ -195,7 +195,7 @@ describe( 'onBlockDrop', () => { const getClientIdsOfDescendants = jest.fn( () => [ targetRootClientId, ] ); - const moveBlocksToPosition = jest.fn(); + const moveBlocks = jest.fn(); const event = { dataTransfer: { @@ -214,11 +214,11 @@ describe( 'onBlockDrop', () => { targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition + moveBlocks ); eventHandler( event ); - expect( moveBlocksToPosition ).not.toHaveBeenCalled(); + expect( moveBlocks ).not.toHaveBeenCalled(); } ); it( 'inserts blocks if the drop is valid', () => { @@ -228,7 +228,7 @@ describe( 'onBlockDrop', () => { const targetBlockIndex = 0; const getBlockIndex = jest.fn( () => 1 ); const getClientIdsOfDescendants = () => []; - const moveBlocksToPosition = jest.fn(); + const moveBlocks = jest.fn(); const event = { dataTransfer: { @@ -247,14 +247,13 @@ describe( 'onBlockDrop', () => { targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition + moveBlocks ); eventHandler( event ); - expect( moveBlocksToPosition ).toHaveBeenCalledWith( + expect( moveBlocks ).toHaveBeenCalledWith( sourceClientIds, sourceRootClientId, - targetRootClientId, targetBlockIndex ); } ); @@ -267,7 +266,7 @@ describe( 'onBlockDrop', () => { const getBlockIndex = jest.fn( () => 1 ); // Dragged block is being dropped as a descendant of itself. const getClientIdsOfDescendants = () => []; - const moveBlocksToPosition = jest.fn(); + const moveBlocks = jest.fn(); const event = { dataTransfer: { @@ -289,14 +288,13 @@ describe( 'onBlockDrop', () => { targetBlockIndex, getBlockIndex, getClientIdsOfDescendants, - moveBlocksToPosition + moveBlocks ); eventHandler( event ); - expect( moveBlocksToPosition ).toHaveBeenCalledWith( + expect( moveBlocks ).toHaveBeenCalledWith( sourceClientIds, sourceRootClientId, - targetRootClientId, insertIndex ); } ); @@ -306,7 +304,7 @@ describe( 'onFilesDrop', () => { it( 'does nothing if hasUploadPermissions is false', () => { const updateBlockAttributes = jest.fn(); const canInsertBlockType = noop; - const insertBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); const targetRootClientId = '1'; const targetBlockIndex = 0; const uploadPermissions = false; @@ -317,12 +315,12 @@ describe( 'onFilesDrop', () => { uploadPermissions, updateBlockAttributes, canInsertBlockType, - insertBlocks + insertOrReplaceBlocks ); onFileDropHandler(); expect( findTransform ).not.toHaveBeenCalled(); - expect( insertBlocks ).not.toHaveBeenCalled(); + expect( insertOrReplaceBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing if the block has no matching file transforms', () => { @@ -330,7 +328,7 @@ describe( 'onFilesDrop', () => { // to have no return value. findTransform.mockImplementation( noop ); const updateBlockAttributes = noop; - const insertBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); const canInsertBlockType = noop; const targetRootClientId = '1'; const targetBlockIndex = 0; @@ -342,12 +340,12 @@ describe( 'onFilesDrop', () => { uploadPermissions, updateBlockAttributes, canInsertBlockType, - insertBlocks + insertOrReplaceBlocks ); onFileDropHandler(); expect( findTransform ).toHaveBeenCalled(); - expect( insertBlocks ).not.toHaveBeenCalled(); + expect( insertOrReplaceBlocks ).not.toHaveBeenCalled(); } ); it( 'inserts blocks if a valid transform can be found', () => { @@ -359,7 +357,7 @@ describe( 'onFilesDrop', () => { findTransform.mockImplementation( () => transformation ); const updateBlockAttributes = noop; const canInsertBlockType = noop; - const insertBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); const targetRootClientId = '1'; const targetBlockIndex = 0; const uploadPermissions = true; @@ -370,7 +368,7 @@ describe( 'onFilesDrop', () => { uploadPermissions, updateBlockAttributes, canInsertBlockType, - insertBlocks + insertOrReplaceBlocks ); const files = 'test'; onFileDropHandler( files ); @@ -380,11 +378,7 @@ describe( 'onFilesDrop', () => { files, updateBlockAttributes ); - expect( insertBlocks ).toHaveBeenCalledWith( - blocks, - targetBlockIndex, - targetRootClientId - ); + expect( insertOrReplaceBlocks ).toHaveBeenCalledWith( blocks ); } ); } ); @@ -393,16 +387,16 @@ describe( 'onHTMLDrop', () => { pasteHandler.mockImplementation( () => [] ); const targetRootClientId = '1'; const targetBlockIndex = 0; - const insertBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); const eventHandler = onHTMLDrop( targetRootClientId, targetBlockIndex, - insertBlocks + insertOrReplaceBlocks ); eventHandler(); - expect( insertBlocks ).not.toHaveBeenCalled(); + expect( insertOrReplaceBlocks ).not.toHaveBeenCalled(); } ); it( 'inserts blocks if the HTML can be converted into blocks', () => { @@ -410,19 +404,15 @@ describe( 'onHTMLDrop', () => { pasteHandler.mockImplementation( () => blocks ); const targetRootClientId = '1'; const targetBlockIndex = 0; - const insertBlocks = jest.fn(); + const insertOrReplaceBlocks = jest.fn(); const eventHandler = onHTMLDrop( targetRootClientId, targetBlockIndex, - insertBlocks + insertOrReplaceBlocks ); eventHandler(); - expect( insertBlocks ).toHaveBeenCalledWith( - blocks, - targetBlockIndex, - targetRootClientId - ); + expect( insertOrReplaceBlocks ).toHaveBeenCalledWith( blocks ); } ); } ); diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index fb8296ad3ee019..d887afb9fcae2b 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -7,6 +7,7 @@ ### 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 new file mode 100644 index 00000000000000..e51fb84acf8062 --- /dev/null +++ b/packages/block-library/src/paragraph/drop-zone.js @@ -0,0 +1,105 @@ +/** + * 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 dd47ae81466229..799d2b9d5c462c 100644 --- a/packages/block-library/src/paragraph/edit.js +++ b/packages/block-library/src/paragraph/edit.js @@ -6,6 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ +import { useState } from '@wordpress/element'; import { __, _x, isRTL } from '@wordpress/i18n'; import { ToolbarButton, @@ -20,6 +21,7 @@ import { useBlockProps, useSetting, } from '@wordpress/block-editor'; +import { useMergeRefs } from '@wordpress/compose'; import { createBlock } from '@wordpress/blocks'; import { formatLtr } from '@wordpress/icons'; @@ -27,6 +29,7 @@ import { formatLtr } from '@wordpress/icons'; * Internal dependencies */ import { useOnEnter } from './use-enter'; +import DropZone from './drop-zone'; const name = 'core/paragraph'; @@ -55,8 +58,12 @@ function ParagraphBlock( { } ) { const { align, content, direction, dropCap, placeholder } = attributes; const isDropCapFeatureEnabled = useSetting( 'typography.dropCap' ); + const [ paragraphElement, setParagraphElement ] = useState( null ); const blockProps = useBlockProps( { - ref: useOnEnter( { clientId, content } ), + ref: useMergeRefs( [ + useOnEnter( { clientId, content } ), + setParagraphElement, + ] ), className: classnames( { 'has-drop-cap': dropCap, [ `has-text-align-${ align }` ]: align, @@ -108,6 +115,12 @@ function ParagraphBlock( { ) } + { ! content && ( + + ) } { - onDropRef.current = null; - onDragStartRef.current = null; - onDragEnterRef.current = null; - onDragLeaveRef.current = null; - onDragEndRef.current = null; - onDragOverRef.current = null; delete element.dataset.isDropZone; element.removeEventListener( 'drop', onDrop ); element.removeEventListener( 'dragenter', onDragEnter ); @@ -232,7 +220,10 @@ export default function useDropZone( { element.removeEventListener( 'dragleave', onDragLeave ); ownerDocument.removeEventListener( 'dragend', maybeDragEnd ); ownerDocument.removeEventListener( 'mousemove', maybeDragEnd ); - ownerDocument.addEventListener( 'dragenter', maybeDragStart ); + ownerDocument.removeEventListener( + 'dragenter', + maybeDragStart + ); }; }, [ isDisabled ] diff --git a/packages/e2e-test-utils-playwright/package.json b/packages/e2e-test-utils-playwright/package.json index 432bf50989e4d6..d168c7d70afbc4 100644 --- a/packages/e2e-test-utils-playwright/package.json +++ b/packages/e2e-test-utils-playwright/package.json @@ -35,7 +35,8 @@ "@wordpress/keycodes": "file:../keycodes", "@wordpress/url": "file:../url", "change-case": "^4.1.2", - "form-data": "^4.0.0" + "form-data": "^4.0.0", + "mime": "^3.0.0" }, "peerDependencies": { "@playwright/test": ">=1" 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 new file mode 100644 index 00000000000000..f8e237e7e37f1d --- /dev/null +++ b/packages/e2e-test-utils-playwright/src/page-utils/drag-files.ts @@ -0,0 +1,159 @@ +/** + * External dependencies + */ +import { readFile } from 'fs/promises'; +import { basename } from 'path'; +import { getType } from 'mime'; + +/** + * Internal dependencies + */ +import type { PageUtils } from './index'; + +type FileObject = { + name: string; + mimeType?: string; + buffer: Buffer; +}; + +type Options = { + position?: { x: number; y: number }; +}; + +/** + * Simulate dragging files from outside the current page. + * + * @param this + * @param files The files to be dragged. + * @return The methods of the drag operation. + */ +async function dragFiles( + this: PageUtils, + files: string | string[] | FileObject | FileObject[] +) { + const filesList = Array.isArray( files ) ? files : [ files ]; + const fileObjects = await Promise.all( + filesList.map( async ( filePathOrObject ) => { + if ( typeof filePathOrObject !== 'string' ) { + return { + name: filePathOrObject.name, + mimeType: + filePathOrObject.mimeType || + getType( filePathOrObject.name ), + base64: filePathOrObject.buffer.toString( 'base64' ), + }; + } + const base64 = await readFile( filePathOrObject, 'base64' ); + const name = basename( filePathOrObject ); + return { + name, + mimeType: getType( filePathOrObject ), + base64, + }; + } ) + ); + + const dataTransfer = await this.page.evaluateHandle( + async ( _fileObjects ) => { + const dt = new DataTransfer(); + const fileInstances = await Promise.all( + _fileObjects.map( async ( fileObject ) => { + const blob = await fetch( + `data:${ fileObject.mimeType };base64,${ fileObject.base64 }` + ).then( ( res ) => res.blob() ); + return new File( [ blob ], fileObject.name, { + type: fileObject.mimeType ?? undefined, + } ); + } ) + ); + + fileInstances.forEach( ( file ) => { + dt.items.add( file ); + } ); + + return dt; + }, + fileObjects + ); + + // CDP doesn't actually support dragging files, this is only a _good enough_ + // dummy data so that it will correctly send the relevant events. + const dragData = { + items: fileObjects.map( ( fileObject ) => ( { + mimeType: fileObject.mimeType ?? 'File', + data: fileObject.base64, + } ) ), + files: fileObjects.map( ( fileObject ) => fileObject.name ), + // Copy = 1, Link = 2, Move = 16. + dragOperationsMask: 1, + }; + + const cdpSession = await this.context.newCDPSession( this.page ); + + const position = { + x: 0, + y: 0, + }; + + return { + /** + * 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. + */ + dragOver: async ( selector: string, options: Options = {} ) => { + const boundingBox = await this.page + .locator( selector ) + .boundingBox(); + + if ( ! boundingBox ) { + throw new Error( + 'Cannot find the element or the element is not visible on the viewport.' + ); + } + + position.x = + boundingBox.x + + ( options.position?.x ?? boundingBox.width / 2 ); + position.y = + boundingBox.y + + ( options.position?.y ?? boundingBox.height / 2 ); + + await cdpSession.send( 'Input.dispatchDragEvent', { + type: 'dragEnter', + ...position, + data: dragData, + } ); + await cdpSession.send( 'Input.dispatchDragEvent', { + type: 'dragOver', + ...position, + data: dragData, + } ); + }, + + /** + * Drop the files at the current position. + */ + drop: async () => { + const topMostElement = await this.page.evaluateHandle( + ( { x, y } ) => { + return document.elementFromPoint( x, y ); + }, + position + ); + const elementHandle = topMostElement.asElement(); + + if ( ! elementHandle ) { + throw new Error( 'Element not found.' ); + } + + await elementHandle.dispatchEvent( 'drop', { dataTransfer } ); + + await cdpSession.detach(); + }, + }; +} + +export { dragFiles }; diff --git a/packages/e2e-test-utils-playwright/src/page-utils/index.ts b/packages/e2e-test-utils-playwright/src/page-utils/index.ts index d147365fe5e0e8..95d64a022e22e2 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/index.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/index.ts @@ -6,6 +6,7 @@ import type { Browser, Page, BrowserContext } from '@playwright/test'; /** * Internal dependencies */ +import { dragFiles } from './drag-files'; import { isCurrentURL } from './is-current-url'; import { setClipboardData, @@ -29,6 +30,7 @@ class PageUtils { this.browser = this.context.browser()!; } + dragFiles = dragFiles.bind( this ); isCurrentURL = isCurrentURL.bind( this ); pressKeyTimes = pressKeyTimes.bind( this ); pressKeyWithModifier = pressKeyWithModifier.bind( this ); diff --git a/test/e2e/specs/editor/blocks/paragraph.spec.js b/test/e2e/specs/editor/blocks/paragraph.spec.js index d4848234986f2b..6a043153320d83 100644 --- a/test/e2e/specs/editor/blocks/paragraph.spec.js +++ b/test/e2e/specs/editor/blocks/paragraph.spec.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + /** * WordPress dependencies */ @@ -28,4 +33,160 @@ test.describe( 'Paragraph', () => { // style. expect( firstBlockTagName ).toBe( 'P' ); } ); + + test.describe( 'Empty paragraph', () => { + test.use( { + // Make the viewport large enough so that a scrollbar isn't displayed. + // Otherwise, the page scrolling can interfere with the test runner's + // ability to drop a block in the right location. + viewport: { + width: 960, + height: 1024, + }, + } ); + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( 'should allow dropping an image on en empty paragraph block', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/paragraph' } ); + + const testImageName = '10x10_e2e_test_image_z9T8jK.png'; + const testImagePath = path.join( + __dirname, + '../../../assets', + testImageName + ); + + const { dragOver, drop } = await pageUtils.dragFiles( + testImagePath + ); + + await dragOver( '[data-type="core/paragraph"]' ); + + await expect( + page.locator( 'data-testid=empty-paragraph-drop-zone' ) + ).toBeVisible(); + + await drop(); + + const imageBlock = page.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + await expect( imageBlock.locator( 'role=img' ) ).toHaveAttribute( + 'src', + new RegExp( testImageName.replace( '.', '\\.' ) ) + ); + } ); + + test( 'should allow dropping blocks on en empty paragraph block', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/heading', + attributes: { content: 'My Heading' }, + } ); + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.focus( 'text=My Heading' ); + await editor.showBlockToolbar(); + + const dragHandle = page.locator( + 'role=toolbar[name="Block tools"i] >> role=button[name="Drag"i][include-hidden]' + ); + await dragHandle.hover(); + await page.mouse.down(); + + 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 expect( + page.locator( 'data-testid=empty-paragraph-drop-zone' ) + ).toBeVisible(); + + await page.mouse.up(); + + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

My Heading

+` ); + } ); + + test( 'should allow dropping HTML on en empty paragraph block', async ( { + editor, + page, + } ) => { + 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(); + + 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 expect( + page.locator( 'data-testid=empty-paragraph-drop-zone' ) + ).toBeVisible(); + + await page.mouse.up(); + + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

My Heading

+` ); + } ); + } ); } );