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
+` );
+ } );
+ } );
} );