diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md
index 58b7bf52e33270..04bbcae93c77cc 100644
--- a/docs/designers-developers/developers/data/data-core-block-editor.md
+++ b/docs/designers-developers/developers/data/data-core-block-editor.md
@@ -1395,12 +1395,12 @@ _Returns_
# **updateBlockAttributes**
-Returns an action object used in signalling that the block attributes with
-the specified client ID has been updated.
+Returns an action object used in signalling that the multiple blocks'
+attributes with the specified client IDs have been updated.
_Parameters_
-- _clientId_ `string`: Block client ID.
+- _clientIds_ `(string|Array)`: Block client IDs.
- _attributes_ `Object`: Block attributes to be merged.
_Returns_
diff --git a/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md
index 6d9268c3d225fe..b1798ccde55ca9 100644
--- a/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md
+++ b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbar-and-sidebar.md
@@ -187,3 +187,6 @@ If you have settings that affects only selected content inside a block (example:
The Block Tab is shown in place of the Document Tab when a block is selected.
Similar to rendering a toolbar, if you include an `InspectorControls` element in the return value of your block type's `edit` function, those controls will be shown in the Settings Sidebar region.
+
+Block controls rendered in both the toolbar and sidebar will also be used when
+multiple blocks of the same type are selected.
diff --git a/packages/block-editor/src/components/block-controls/index.js b/packages/block-editor/src/components/block-controls/index.js
index 28d9d94a1585ae..297d3839df4aa9 100644
--- a/packages/block-editor/src/components/block-controls/index.js
+++ b/packages/block-editor/src/components/block-controls/index.js
@@ -16,7 +16,7 @@ import {
/**
* Internal dependencies
*/
-import { useBlockEditContext } from '../block-edit/context';
+import useDisplayBlockControls from '../use-display-block-controls';
const { Fill, Slot } = createSlotFill( 'BlockControls' );
@@ -26,8 +26,7 @@ function BlockControlsSlot( props ) {
}
function BlockControlsFill( { controls, children } ) {
- const { isSelected } = useBlockEditContext();
- if ( ! isSelected ) {
+ if ( ! useDisplayBlockControls() ) {
return null;
}
diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js
index 24a86350e06ff1..37db430c300c09 100644
--- a/packages/block-editor/src/components/block-inspector/index.js
+++ b/packages/block-editor/src/components/block-inspector/index.js
@@ -32,7 +32,12 @@ const BlockInspector = ( {
showNoBlockSelectedMessage = true,
} ) => {
if ( count > 1 ) {
- return ;
+ return (
+
+
+
+
+ );
}
const isSelectedBlockUnregistered =
diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js
index 6e8cb32daff44e..f91b5e5223a98d 100644
--- a/packages/block-editor/src/components/block-list/block.js
+++ b/packages/block-editor/src/components/block-list/block.js
@@ -229,6 +229,7 @@ const applyWithSelect = withSelect(
getTemplateLock,
__unstableGetBlockWithoutInnerBlocks,
isNavigationMode,
+ getMultiSelectedBlockClientIds,
} = select( 'core/block-editor' );
const block = __unstableGetBlockWithoutInnerBlocks( clientId );
const isSelected = isBlockSelected( clientId );
@@ -247,6 +248,7 @@ const applyWithSelect = withSelect(
// the state. It happens now because the order in withSelect rendering
// is not correct.
const { name, attributes, isValid } = block || {};
+ const isFirstMultiSelected = isFirstMultiSelectedBlock( clientId );
// Do not add new properties here, use `useSelect` instead to avoid
// leaking new props to the public API (editor.BlockListBlock filter).
@@ -255,9 +257,12 @@ const applyWithSelect = withSelect(
isPartOfMultiSelection:
isBlockMultiSelected( clientId ) ||
isAncestorMultiSelected( clientId ),
- isFirstMultiSelected: isFirstMultiSelectedBlock( clientId ),
+ isFirstMultiSelected,
isLastMultiSelected:
getLastMultiSelectedBlockClientId() === clientId,
+ multiSelectedClientIds: isFirstMultiSelected
+ ? getMultiSelectedBlockClientIds()
+ : undefined,
// We only care about this prop when the block is selected
// Thus to avoid unnecessary rerenders we avoid updating the prop if
@@ -301,8 +306,16 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => {
// leaking new props to the public API (editor.BlockListBlock filter).
return {
setAttributes( newAttributes ) {
- const { clientId } = ownProps;
- updateBlockAttributes( clientId, newAttributes );
+ const {
+ clientId,
+ isFirstMultiSelected,
+ multiSelectedClientIds,
+ } = ownProps;
+ const clientIds = isFirstMultiSelected
+ ? multiSelectedClientIds
+ : [ clientId ];
+
+ updateBlockAttributes( clientIds, newAttributes );
},
onInsertBlocks( blocks, index ) {
const { rootClientId } = ownProps;
diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js
index 8572805766e988..49c0d946ad4ca9 100644
--- a/packages/block-editor/src/components/block-toolbar/index.js
+++ b/packages/block-editor/src/components/block-toolbar/index.js
@@ -28,7 +28,7 @@ export default function BlockToolbar( { hideDragHandle } ) {
blockType,
hasFixedToolbar,
isValid,
- mode,
+ isVisual,
moverDirection,
} = useSelect( ( select ) => {
const { getBlockType } = select( 'core/blocks' );
@@ -56,14 +56,12 @@ export default function BlockToolbar( { hideDragHandle } ) {
getBlockType( getBlockName( selectedBlockClientId ) ),
hasFixedToolbar: getSettings().hasFixedToolbar,
rootClientId: blockRootClientId,
- isValid:
- selectedBlockClientIds.length === 1
- ? isBlockValid( selectedBlockClientIds[ 0 ] )
- : null,
- mode:
- selectedBlockClientIds.length === 1
- ? getBlockMode( selectedBlockClientIds[ 0 ] )
- : null,
+ isValid: selectedBlockClientIds.every( ( id ) =>
+ isBlockValid( id )
+ ),
+ isVisual: selectedBlockClientIds.every(
+ ( id ) => getBlockMode( id ) === 'visual'
+ ),
moverDirection: __experimentalMoverDirection,
};
}, [] );
@@ -93,7 +91,7 @@ export default function BlockToolbar( { hideDragHandle } ) {
return null;
}
- const shouldShowVisualToolbar = isValid && mode === 'visual';
+ const shouldShowVisualToolbar = isValid && isVisual;
const isMultiToolbar = blockClientIds.length > 1;
const animatedMoverStyles = {
@@ -144,8 +142,7 @@ export default function BlockToolbar( { hideDragHandle } ) {
) }
-
- { shouldShowVisualToolbar && ! isMultiToolbar && (
+ { shouldShowVisualToolbar && (
<>
{ children } : null;
+ return useDisplayBlockControls() ? { children } : null;
}
InspectorControls.Slot = Slot;
diff --git a/packages/block-editor/src/components/use-display-block-controls/index.js b/packages/block-editor/src/components/use-display-block-controls/index.js
new file mode 100644
index 00000000000000..58bb0e18ffa0ac
--- /dev/null
+++ b/packages/block-editor/src/components/use-display-block-controls/index.js
@@ -0,0 +1,38 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { useBlockEditContext } from '../block-edit/context';
+
+export default function useDisplayBlockControls() {
+ const { isSelected, clientId, name } = useBlockEditContext();
+ const isFirstAndSameTypeMultiSelected = useSelect(
+ ( select ) => {
+ // Don't bother checking, see OR statement below.
+ if ( isSelected ) {
+ return;
+ }
+
+ const {
+ getBlockName,
+ isFirstMultiSelectedBlock,
+ getMultiSelectedBlockClientIds,
+ } = select( 'core/block-editor' );
+
+ if ( ! isFirstMultiSelectedBlock( clientId ) ) {
+ return false;
+ }
+
+ return getMultiSelectedBlockClientIds().every(
+ ( id ) => getBlockName( id ) === name
+ );
+ },
+ [ clientId, name ]
+ );
+
+ return isSelected || isFirstAndSameTypeMultiSelected;
+}
diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js
index ff7874e849c832..89ce0d29b1615d 100644
--- a/packages/block-editor/src/store/actions.js
+++ b/packages/block-editor/src/store/actions.js
@@ -97,18 +97,18 @@ export function receiveBlocks( blocks ) {
}
/**
- * Returns an action object used in signalling that the block attributes with
- * the specified client ID has been updated.
+ * Returns an action object used in signalling that the multiple blocks'
+ * attributes with the specified client IDs have been updated.
*
- * @param {string} clientId Block client ID.
- * @param {Object} attributes Block attributes to be merged.
+ * @param {string|string[]} clientIds Block client IDs.
+ * @param {Object} attributes Block attributes to be merged.
*
* @return {Object} Action object.
*/
-export function updateBlockAttributes( clientId, attributes ) {
+export function updateBlockAttributes( clientIds, attributes ) {
return {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId,
+ clientIds: castArray( clientIds ),
attributes,
};
}
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index 223fe269508841..38e6bbde0d7c92 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -211,7 +211,7 @@ export function isUpdatingSameBlockAttribute( action, lastAction ) {
action.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
lastAction !== undefined &&
lastAction.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
- action.clientId === lastAction.clientId &&
+ isEqual( action.clientIds, lastAction.clientIds ) &&
hasSameKeys( action.attributes, lastAction.attributes )
);
}
@@ -293,7 +293,6 @@ const withBlockCache = ( reducer ) => ( state = {}, action ) => {
break;
}
case 'UPDATE_BLOCK':
- case 'UPDATE_BLOCK_ATTRIBUTES':
newState.cache = {
...newState.cache,
...fillKeysWithEmptyObject(
@@ -301,6 +300,14 @@ const withBlockCache = ( reducer ) => ( state = {}, action ) => {
),
};
break;
+ case 'UPDATE_BLOCK_ATTRIBUTES':
+ newState.cache = {
+ ...newState.cache,
+ ...fillKeysWithEmptyObject(
+ getBlocksWithParentsClientIds( action.clientIds )
+ ),
+ };
+ break;
case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN':
const parentClientIds = fillKeysWithEmptyObject(
getBlocksWithParentsClientIds( action.replacedClientIds )
@@ -810,40 +817,45 @@ export const blocks = flow(
},
};
- case 'UPDATE_BLOCK_ATTRIBUTES':
- // Ignore updates if block isn't known
- if ( ! state[ action.clientId ] ) {
+ case 'UPDATE_BLOCK_ATTRIBUTES': {
+ // Avoid a state change if none of the block IDs are known.
+ if ( action.clientIds.every( ( id ) => ! state[ id ] ) ) {
return state;
}
- // Consider as updates only changed values
- const nextAttributes = reduce(
- action.attributes,
- ( result, value, key ) => {
- if ( value !== result[ key ] ) {
- result = getMutateSafeObject(
- state[ action.clientId ],
- result
- );
- result[ key ] = value;
- }
-
- return result;
- },
- state[ action.clientId ]
+ const next = action.clientIds.reduce(
+ ( accumulator, id ) => ( {
+ ...accumulator,
+ [ id ]: reduce(
+ action.attributes,
+ ( result, value, key ) => {
+ // Consider as updates only changed values.
+ if ( value !== result[ key ] ) {
+ result = getMutateSafeObject(
+ state[ id ],
+ result
+ );
+ result[ key ] = value;
+ }
+
+ return result;
+ },
+ state[ id ]
+ ),
+ } ),
+ {}
);
- // Skip update if nothing has been changed. The reference will
- // match the original block if `reduce` had no changed values.
- if ( nextAttributes === state[ action.clientId ] ) {
+ if (
+ action.clientIds.every(
+ ( id ) => next[ id ] === state[ id ]
+ )
+ ) {
return state;
}
- // Otherwise replace attributes in state
- return {
- ...state,
- [ action.clientId ]: nextAttributes,
- };
+ return { ...state, ...next };
+ }
case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN':
if ( ! action.blocks ) {
@@ -1541,7 +1553,13 @@ export function lastBlockAttributesChange( state, action ) {
return { [ action.clientId ]: action.updates.attributes };
case 'UPDATE_BLOCK_ATTRIBUTES':
- return { [ action.clientId ]: action.attributes };
+ return action.clientIds.reduce(
+ ( accumulator, id ) => ( {
+ ...accumulator,
+ [ id ]: action.attributes,
+ } ),
+ {}
+ );
}
return null;
diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js
index 3cad986cd639be..d0eb2098adc52f 100644
--- a/packages/block-editor/src/store/test/actions.js
+++ b/packages/block-editor/src/store/test/actions.js
@@ -45,13 +45,24 @@ describe( 'actions', () => {
} );
describe( 'updateBlockAttributes', () => {
- it( 'should return the UPDATE_BLOCK_ATTRIBUTES action', () => {
+ it( 'should return the UPDATE_BLOCK_ATTRIBUTES action (string)', () => {
const clientId = 'myclientid';
const attributes = {};
const result = updateBlockAttributes( clientId, attributes );
expect( result ).toEqual( {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId,
+ clientIds: [ clientId ],
+ attributes,
+ } );
+ } );
+
+ it( 'should return the UPDATE_BLOCK_ATTRIBUTES action (array)', () => {
+ const clientIds = [ 'myclientid' ];
+ const attributes = {};
+ const result = updateBlockAttributes( clientIds, attributes );
+ expect( result ).toEqual( {
+ type: 'UPDATE_BLOCK_ATTRIBUTES',
+ clientIds,
attributes,
} );
} );
diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js
index aa1b9fe8bcee97..5f5a43eaa7e91b 100644
--- a/packages/block-editor/src/store/test/reducer.js
+++ b/packages/block-editor/src/store/test/reducer.js
@@ -70,7 +70,7 @@ describe( 'state', () => {
it( 'should return false if last action was not updating block attributes', () => {
const action = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
foo: 10,
},
@@ -88,14 +88,14 @@ describe( 'state', () => {
it( 'should return false if not updating the same block', () => {
const action = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
foo: 10,
},
};
const previousAction = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1',
+ clientIds: [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ],
attributes: {
foo: 20,
},
@@ -109,14 +109,14 @@ describe( 'state', () => {
it( 'should return false if not updating the same block attributes', () => {
const action = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
foo: 10,
},
};
const previousAction = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
bar: 20,
},
@@ -130,7 +130,7 @@ describe( 'state', () => {
it( 'should return false if no previous action', () => {
const action = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
foo: 10,
},
@@ -145,14 +145,14 @@ describe( 'state', () => {
it( 'should return true if updating the same block attributes', () => {
const action = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
foo: 10,
},
};
const previousAction = {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: '9db792c6-a25a-495d-adbd-97d56a4c4189',
+ clientIds: [ '9db792c6-a25a-495d-adbd-97d56a4c4189' ],
attributes: {
foo: 20,
},
@@ -1640,7 +1640,7 @@ describe( 'state', () => {
);
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1666,7 +1666,7 @@ describe( 'state', () => {
);
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1692,7 +1692,7 @@ describe( 'state', () => {
);
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1718,7 +1718,7 @@ describe( 'state', () => {
);
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
moreUpdated: true,
},
@@ -1739,7 +1739,7 @@ describe( 'state', () => {
);
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1765,7 +1765,7 @@ describe( 'state', () => {
);
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1792,7 +1792,7 @@ describe( 'state', () => {
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1826,7 +1826,7 @@ describe( 'state', () => {
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: false,
},
@@ -1850,7 +1850,7 @@ describe( 'state', () => {
);
original = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: false,
},
@@ -1858,7 +1858,7 @@ describe( 'state', () => {
const state = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -1882,14 +1882,14 @@ describe( 'state', () => {
);
original = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: false,
},
} );
original = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'kumquat',
+ clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
@@ -2605,7 +2605,7 @@ describe( 'state', () => {
const state = lastBlockAttributesChange( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
- clientId: 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1',
+ clientIds: [ 'afd1cb17-2c08-4e7a-91be-007ba7ddc3a1' ],
attributes: {
food: 'banana',
},
diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap
index a227c9f3f56cff..02609fe3594712 100644
--- a/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap
+++ b/packages/e2e-tests/specs/editor/various/__snapshots__/multi-block-selection.test.js.snap
@@ -124,6 +124,16 @@ exports[`Multi-block selection should return original focus after failed multi s
"
`;
+exports[`Multi-block selection should set attributes for multiple paragraphs 1`] = `
+"
+1
+
+
+
+2
+"
+`;
+
exports[`Multi-block selection should use selection direction to determine vertical edge 1`] = `
"
1
2.
diff --git a/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js b/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js
index bbd07d83e510a3..e4537418ea693d 100644
--- a/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js
+++ b/packages/e2e-tests/specs/editor/various/multi-block-selection.test.js
@@ -8,6 +8,7 @@ import {
pressKeyTimes,
getEditedPostContent,
clickBlockToolbarButton,
+ clickButton,
} from '@wordpress/e2e-test-utils';
async function getSelectedFlatIndices() {
@@ -540,4 +541,17 @@ describe( 'Multi-block selection', () => {
expect( await getEditedPostContent() ).toMatchSnapshot();
} );
+
+ it( 'should set attributes for multiple paragraphs', async () => {
+ await clickBlockAppender();
+ await page.keyboard.type( '1' );
+ await page.keyboard.press( 'Enter' );
+ await page.keyboard.type( '2' );
+ await pressKeyWithModifier( 'primary', 'a' );
+ await pressKeyWithModifier( 'primary', 'a' );
+ await clickBlockToolbarButton( 'Change text alignment' );
+ await clickButton( 'Align text center' );
+
+ expect( await getEditedPostContent() ).toMatchSnapshot();
+ } );
} );