diff --git a/packages/block-editor/src/components/selection-scroll-into-view/index.js b/packages/block-editor/src/components/selection-scroll-into-view/index.js
index 5e9ff07f1ca6d8..bbfb6f7370119b 100644
--- a/packages/block-editor/src/components/selection-scroll-into-view/index.js
+++ b/packages/block-editor/src/components/selection-scroll-into-view/index.js
@@ -17,10 +17,21 @@ import { getBlockDOMNode } from '../../utils/dom';
import { store as blockEditorStore } from '../../store';
export function useScrollSelectionIntoView( ref ) {
- const selectionEnd = useSelect(
- ( select ) => select( blockEditorStore ).getBlockSelectionEnd(),
- []
- );
+ // Although selectionRootClientId isn't used directly in calculating
+ // whether scrolling should occur, it is used as a dependency of the
+ // effect to take into account situations where a block might be moved
+ // to a different parent. In this situation, the selectionEnd clientId
+ // remains the same, so the rootClientId is used to trigger the effect.
+ const { selectionRootClientId, selectionEnd } = useSelect( ( select ) => {
+ const selectors = select( blockEditorStore );
+ const selectionEndClientId = selectors.getBlockSelectionEnd();
+ return {
+ selectionEnd: selectionEndClientId,
+ selectionRootClientId: selectors.getBlockRootClientId(
+ selectionEndClientId
+ ),
+ };
+ }, [] );
useEffect( () => {
if ( ! selectionEnd ) {
@@ -45,7 +56,7 @@ export function useScrollSelectionIntoView( ref ) {
scrollIntoView( extentNode, scrollContainer, {
onlyScrollIfNeeded: true,
} );
- }, [ selectionEnd ] );
+ }, [ selectionRootClientId, selectionEnd ] );
}
/**
diff --git a/packages/e2e-tests/specs/widgets/adding-widgets.test.js b/packages/e2e-tests/specs/widgets/adding-widgets.test.js
index 3bfa04945c2a3a..7f935c12a09b47 100644
--- a/packages/e2e-tests/specs/widgets/adding-widgets.test.js
+++ b/packages/e2e-tests/specs/widgets/adding-widgets.test.js
@@ -2,11 +2,13 @@
* WordPress dependencies
*/
import {
- visitAdminPage,
- deactivatePlugin,
activatePlugin,
activateTheme,
+ clickBlockToolbarButton,
+ deactivatePlugin,
pressKeyWithModifier,
+ showBlockToolbar,
+ visitAdminPage,
} from '@wordpress/e2e-test-utils';
/**
@@ -521,6 +523,54 @@ describe( 'Widgets screen', () => {
}
` );
} );
+
+ it( 'allows widgets to be moved between widget areas using the dropdown in the block toolbar', async () => {
+ const widgetAreas = await page.$$(
+ '[aria-label="Block: Widget Area"][role="group"]'
+ );
+ const [ firstWidgetArea ] = widgetAreas;
+
+ // Insert a paragraph it should be in the first widget area.
+ const inserterParagraphBlock = await getBlockInGlobalInserter(
+ 'Paragraph'
+ );
+ await inserterParagraphBlock.hover();
+ await inserterParagraphBlock.click();
+ const addedParagraphBlockInFirstWidgetArea = await firstWidgetArea.$(
+ '[data-block][data-type="core/paragraph"][aria-label^="Empty block"]'
+ );
+ await addedParagraphBlockInFirstWidgetArea.focus();
+ await page.keyboard.type( 'First Paragraph' );
+
+ // Check that the block exists in the first widget area.
+ await page.waitForXPath(
+ '//*[@aria-label="Block: Widget Area"][@role="group"][1]//p[@data-type="core/paragraph"][.="First Paragraph"]'
+ );
+
+ // Move the block to the second widget area.
+ await showBlockToolbar();
+ await clickBlockToolbarButton( 'Move to widget area' );
+ const widgetAreaButton = await page.waitForXPath(
+ '//button[@role="menuitemradio"][contains(.,"Footer #2")]'
+ );
+ await widgetAreaButton.click();
+
+ // Check that the block exists in the second widget area.
+ await page.waitForXPath(
+ '//*[@aria-label="Block: Widget Area"][@role="group"][2]//p[@data-type="core/paragraph"][.="First Paragraph"]'
+ );
+
+ // Assert that the serialized widget areas shows the block as in the second widget area.
+ await saveWidgets();
+ const serializedWidgetAreas2 = await getSerializedWidgetAreas();
+ expect( serializedWidgetAreas2 ).toMatchInlineSnapshot( `
+ Object {
+ "sidebar-2": "
",
+ }
+ ` );
+ } );
} );
async function saveWidgets() {
diff --git a/packages/edit-widgets/src/components/move-to-widget-area/index.js b/packages/edit-widgets/src/components/move-to-widget-area/index.js
new file mode 100644
index 00000000000000..b2d44624b4d16c
--- /dev/null
+++ b/packages/edit-widgets/src/components/move-to-widget-area/index.js
@@ -0,0 +1,51 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ DropdownMenu,
+ MenuGroup,
+ MenuItemsChoice,
+ ToolbarGroup,
+ ToolbarItem,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { moveTo } from '@wordpress/icons';
+
+export default function MoveToWidgetArea( {
+ currentWidgetArea,
+ widgetAreas,
+ onSelect,
+} ) {
+ return (
+
+
+ { ( toggleProps ) => (
+
+ { ( { onClose } ) => (
+
+ ( {
+ value: widgetArea.id,
+ label: widgetArea.name,
+ info: widgetArea.description,
+ } )
+ ) }
+ value={ currentWidgetArea?.id }
+ onSelect={ ( value ) => {
+ onSelect( value );
+ onClose();
+ } }
+ />
+
+ ) }
+
+ ) }
+
+
+ );
+}
diff --git a/packages/edit-widgets/src/filters/index.js b/packages/edit-widgets/src/filters/index.js
new file mode 100644
index 00000000000000..3cd2ae58dc5bf3
--- /dev/null
+++ b/packages/edit-widgets/src/filters/index.js
@@ -0,0 +1,5 @@
+/**
+ * Internal dependencies
+ */
+import './move-to-widget-area';
+import './replace-media-upload';
diff --git a/packages/edit-widgets/src/filters/move-to-widget-area.js b/packages/edit-widgets/src/filters/move-to-widget-area.js
new file mode 100644
index 00000000000000..5a825c16e0b93f
--- /dev/null
+++ b/packages/edit-widgets/src/filters/move-to-widget-area.js
@@ -0,0 +1,63 @@
+/**
+ * WordPress dependencies
+ */
+
+import { BlockControls } from '@wordpress/block-editor';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { addFilter } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import MoveToWidgetArea from '../components/move-to-widget-area';
+import { store as editWidgetsStore } from '../store';
+
+const withMoveToWidgetAreaToolbarItem = createHigherOrderComponent(
+ ( BlockEdit ) => ( props ) => {
+ const { __internalWidgetId } = props.attributes;
+ const { widgetAreas, currentWidgetArea } = useSelect(
+ ( select ) => {
+ const selectors = select( editWidgetsStore );
+ return {
+ widgetAreas: selectors.getWidgetAreas(),
+ currentWidgetArea: __internalWidgetId
+ ? selectors.getWidgetAreaForWidgetId(
+ __internalWidgetId
+ )
+ : undefined,
+ };
+ },
+ [ __internalWidgetId ]
+ );
+
+ const { moveBlockToWidgetArea } = useDispatch( editWidgetsStore );
+
+ return (
+ <>
+
+ { props.name !== 'core/widget-area' && (
+
+ {
+ moveBlockToWidgetArea(
+ props.clientId,
+ widgetAreaId
+ );
+ } }
+ />
+
+ ) }
+ >
+ );
+ },
+ 'withMoveToWidgetAreaToolbarItem'
+);
+
+addFilter(
+ 'editor.BlockEdit',
+ 'core/edit-widgets/block-edit',
+ withMoveToWidgetAreaToolbarItem
+);
diff --git a/packages/edit-widgets/src/hooks/components/index.js b/packages/edit-widgets/src/filters/replace-media-upload.js
similarity index 100%
rename from packages/edit-widgets/src/hooks/components/index.js
rename to packages/edit-widgets/src/filters/replace-media-upload.js
diff --git a/packages/edit-widgets/src/hooks/index.js b/packages/edit-widgets/src/hooks/index.js
deleted file mode 100644
index c6cbc1d173e861..00000000000000
--- a/packages/edit-widgets/src/hooks/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Internal dependencies
- */
-import './components';
diff --git a/packages/edit-widgets/src/index.js b/packages/edit-widgets/src/index.js
index 521c997ec791b3..78ea17b68c8a3b 100644
--- a/packages/edit-widgets/src/index.js
+++ b/packages/edit-widgets/src/index.js
@@ -17,7 +17,7 @@ import { __experimentalFetchLinkSuggestions as fetchLinkSuggestions } from '@wor
* Internal dependencies
*/
import './store';
-import './hooks';
+import './filters';
import * as widgetArea from './blocks/widget-area';
import Layout from './components/layout';
import registerLegacyWidgetVariations from './register-legacy-widget-variations';
diff --git a/packages/edit-widgets/src/store/actions.js b/packages/edit-widgets/src/store/actions.js
index c8934021c18a5a..9c5b86924e2908 100644
--- a/packages/edit-widgets/src/store/actions.js
+++ b/packages/edit-widgets/src/store/actions.js
@@ -321,3 +321,60 @@ export function* closeGeneralSidebar() {
editWidgetsStoreName
);
}
+
+/**
+ * Action that handles moving a block between widget areas
+ *
+ * @param {string} clientId The clientId of the block to move.
+ * @param {string} widgetAreaId The id of the widget area to move the block to.
+ */
+export function* moveBlockToWidgetArea( clientId, widgetAreaId ) {
+ const sourceRootClientId = yield select(
+ 'core/block-editor',
+ 'getBlockRootClientId',
+ [ clientId ]
+ );
+
+ // Search the top level blocks (widget areas) for the one with the matching
+ // id attribute. Makes the assumption that all top-level blocks are widget
+ // areas.
+ const widgetAreas = yield select( 'core/block-editor', 'getBlocks' );
+ const destinationWidgetAreaBlock = widgetAreas.find(
+ ( { attributes } ) => attributes.id === widgetAreaId
+ );
+ const destinationRootClientId = destinationWidgetAreaBlock.clientId;
+
+ // Get the index for moving to the end of the the destination widget area.
+ const destinationInnerBlocksClientIds = yield select(
+ 'core/block-editor',
+ 'getBlockOrder',
+ destinationRootClientId
+ );
+ const destinationIndex = destinationInnerBlocksClientIds.length;
+
+ // Reveal the widget area, if it's not open.
+ const isDestinationWidgetAreaOpen = yield select(
+ editWidgetsStoreName,
+ 'getIsWidgetAreaOpen',
+ destinationRootClientId
+ );
+
+ if ( ! isDestinationWidgetAreaOpen ) {
+ yield dispatch(
+ editWidgetsStoreName,
+ 'setIsWidgetAreaOpen',
+ destinationRootClientId,
+ true
+ );
+ }
+
+ // Move the block.
+ yield dispatch(
+ 'core/block-editor',
+ 'moveBlocksToPosition',
+ [ clientId ],
+ sourceRootClientId,
+ destinationRootClientId,
+ destinationIndex
+ );
+}
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index 22e0b3aa7a7073..3fa3853b61568f 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -122,6 +122,7 @@ export { default as more } from './library/more';
export { default as moreHorizontal } from './library/more-horizontal';
export { default as moreHorizontalMobile } from './library/more-horizontal-mobile';
export { default as moreVertical } from './library/more-vertical';
+export { default as moveTo } from './library/move-to';
export { default as navigation } from './library/navigation';
export { default as overlayText } from './library/overlay-text';
export { default as pageBreak } from './library/page-break';
diff --git a/packages/icons/src/library/move-to.js b/packages/icons/src/library/move-to.js
new file mode 100644
index 00000000000000..b4caef9ce499e5
--- /dev/null
+++ b/packages/icons/src/library/move-to.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { Path, SVG } from '@wordpress/primitives';
+
+const moveTo = (
+
+);
+
+export default moveTo;