Skip to content

Commit

Permalink
Enable multi select block attribute controls (#22470)
Browse files Browse the repository at this point in the history
* Enable multi select block attribute controls

* Add e2e test

* Add action test
  • Loading branch information
ellatrix authored Jun 9, 2020
1 parent 47f4796 commit f9d22b3
Show file tree
Hide file tree
Showing 14 changed files with 190 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1395,12 +1395,12 @@ _Returns_

<a name="updateBlockAttributes" href="#updateBlockAttributes">#</a> **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<string>)`: Block client IDs.
- _attributes_ `Object`: Block attributes to be merged.

_Returns_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 2 additions & 3 deletions packages/block-editor/src/components/block-controls/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' );

Expand All @@ -26,8 +26,7 @@ function BlockControlsSlot( props ) {
}

function BlockControlsFill( { controls, children } ) {
const { isSelected } = useBlockEditContext();
if ( ! isSelected ) {
if ( ! useDisplayBlockControls() ) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ const BlockInspector = ( {
showNoBlockSelectedMessage = true,
} ) => {
if ( count > 1 ) {
return <MultiSelectionInspector />;
return (
<div className="block-editor-block-inspector">
<MultiSelectionInspector />
<InspectorControls.Slot bubblesVirtually />
</div>
);
}

const isSelectedBlockUnregistered =
Expand Down
19 changes: 16 additions & 3 deletions packages/block-editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ const applyWithSelect = withSelect(
getTemplateLock,
__unstableGetBlockWithoutInnerBlocks,
isNavigationMode,
getMultiSelectedBlockClientIds,
} = select( 'core/block-editor' );
const block = __unstableGetBlockWithoutInnerBlocks( clientId );
const isSelected = isBlockSelected( clientId );
Expand All @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 9 additions & 12 deletions packages/block-editor/src/components/block-toolbar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function BlockToolbar( { hideDragHandle } ) {
blockType,
hasFixedToolbar,
isValid,
mode,
isVisual,
moverDirection,
} = useSelect( ( select ) => {
const { getBlockType } = select( 'core/blocks' );
Expand Down Expand Up @@ -57,14 +57,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,
};
}, [] );
Expand Down Expand Up @@ -94,7 +92,7 @@ export default function BlockToolbar( { hideDragHandle } ) {
return null;
}

const shouldShowVisualToolbar = isValid && mode === 'visual';
const shouldShowVisualToolbar = isValid && isVisual;
const isMultiToolbar = blockClientIds.length > 1;

const classes = classnames(
Expand Down Expand Up @@ -141,8 +139,7 @@ export default function BlockToolbar( { hideDragHandle } ) {
</BlockDraggable>
) }
</div>

{ shouldShowVisualToolbar && ! isMultiToolbar && (
{ shouldShowVisualToolbar && (
<>
<BlockControls.Slot
bubblesVirtually
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import { createSlotFill } from '@wordpress/components';
/**
* Internal dependencies
*/
import { useBlockEditContext } from '../block-edit/context';
import useDisplayBlockControls from '../use-display-block-controls';

const { Fill, Slot } = createSlotFill( 'InspectorControls' );

function InspectorControls( { children } ) {
const { isSelected } = useBlockEditContext();
return isSelected ? <Fill>{ children }</Fill> : null;
return useDisplayBlockControls() ? <Fill>{ children }</Fill> : null;
}

InspectorControls.Slot = Slot;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 6 additions & 6 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
76 changes: 47 additions & 29 deletions packages/block-editor/src/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
);
}
Expand Down Expand Up @@ -293,14 +293,21 @@ const withBlockCache = ( reducer ) => ( state = {}, action ) => {
break;
}
case 'UPDATE_BLOCK':
case 'UPDATE_BLOCK_ATTRIBUTES':
newState.cache = {
...newState.cache,
...fillKeysWithEmptyObject(
getBlocksWithParentsClientIds( [ action.clientId ] )
),
};
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 )
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 13 additions & 2 deletions packages/block-editor/src/store/test/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
} );
} );
Expand Down
Loading

0 comments on commit f9d22b3

Please sign in to comment.