@wordpress/block-editor
Version:
1,795 lines (1,634 loc) • 62.4 kB
JavaScript
/* eslint no-console: [ 'error', { allow: [ 'error', 'warn' ] } ] */
/**
* WordPress dependencies
*/
import {
cloneBlock,
__experimentalCloneSanitizedBlock,
createBlock,
doBlocksMatchTemplate,
getBlockType,
getDefaultBlockName,
hasBlockSupport,
switchToBlockType,
synchronizeBlocksWithTemplate,
getBlockSupport,
isUnmodifiedDefaultBlock,
isUnmodifiedBlock,
} from '@wordpress/blocks';
import { speak } from '@wordpress/a11y';
import { __, _n, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { create, insert, remove, toHTMLString } from '@wordpress/rich-text';
import deprecated from '@wordpress/deprecated';
import { store as preferencesStore } from '@wordpress/preferences';
/**
* Internal dependencies
*/
import {
retrieveSelectedAttribute,
findRichTextAttributeKey,
START_OF_SELECTED_AREA,
} from '../utils/selection';
import {
__experimentalUpdateSettings,
privateRemoveBlocks,
} from './private-actions';
/** @typedef {import('../components/use-on-block-drop/types').WPDropOperation} WPDropOperation */
const castArray = ( maybeArray ) =>
Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ];
/**
* Action that resets blocks state to the specified array of blocks, taking precedence
* over any other content reflected as an edit in state.
*
* @param {Array} blocks Array of blocks.
*/
export const resetBlocks =
( blocks ) =>
( { dispatch } ) => {
dispatch( { type: 'RESET_BLOCKS', blocks } );
dispatch( validateBlocksToTemplate( blocks ) );
};
/**
* Block validity is a function of blocks state (at the point of a
* reset) and the template setting. As a compromise to its placement
* across distinct parts of state, it is implemented here as a side
* effect of the block reset action.
*
* @param {Array} blocks Array of blocks.
*/
export const validateBlocksToTemplate =
( blocks ) =>
( { select, dispatch } ) => {
const template = select.getTemplate();
const templateLock = select.getTemplateLock();
// Unlocked templates are considered always valid because they act
// as default values only.
const isBlocksValidToTemplate =
! template ||
templateLock !== 'all' ||
doBlocksMatchTemplate( blocks, template );
// Update if validity has changed.
const isValidTemplate = select.isValidTemplate();
if ( isBlocksValidToTemplate !== isValidTemplate ) {
dispatch.setTemplateValidity( isBlocksValidToTemplate );
return isBlocksValidToTemplate;
}
};
/**
* A block selection object.
*
* @typedef {Object} WPBlockSelection
*
* @property {string} clientId A block client ID.
* @property {string} attributeKey A block attribute key.
* @property {number} offset An attribute value offset, based on the rich
* text value. See `wp.richText.create`.
*/
/**
* A selection object.
*
* @typedef {Object} WPSelection
*
* @property {WPBlockSelection} start The selection start.
* @property {WPBlockSelection} end The selection end.
*/
/* eslint-disable jsdoc/valid-types */
/**
* Returns an action object used in signalling that selection state should be
* reset to the specified selection.
*
* @param {WPBlockSelection} selectionStart The selection start.
* @param {WPBlockSelection} selectionEnd The selection end.
* @param {0|-1|null} initialPosition Initial block position.
*
* @return {Object} Action object.
*/
export function resetSelection(
selectionStart,
selectionEnd,
initialPosition
) {
/* eslint-enable jsdoc/valid-types */
return {
type: 'RESET_SELECTION',
selectionStart,
selectionEnd,
initialPosition,
};
}
/**
* Returns an action object used in signalling that blocks have been received.
* Unlike resetBlocks, these should be appended to the existing known set, not
* replacing.
*
* @deprecated
*
* @param {Object[]} blocks Array of block objects.
*
* @return {Object} Action object.
*/
export function receiveBlocks( blocks ) {
deprecated( 'wp.data.dispatch( "core/block-editor" ).receiveBlocks', {
since: '5.9',
alternative: 'resetBlocks or insertBlocks',
} );
return {
type: 'RECEIVE_BLOCKS',
blocks,
};
}
/**
* Action that updates attributes of multiple blocks with the specified client IDs.
*
* @param {string|string[]} clientIds Block client IDs.
* @param {Object} attributes Block attributes to be merged. Should be keyed by clientIds if
* uniqueByBlock is true.
* @param {boolean} uniqueByBlock true if each block in clientIds array has a unique set of attributes
* @return {Object} Action object.
*/
export function updateBlockAttributes(
clientIds,
attributes,
uniqueByBlock = false
) {
return {
type: 'UPDATE_BLOCK_ATTRIBUTES',
clientIds: castArray( clientIds ),
attributes,
uniqueByBlock,
};
}
/**
* Action that updates the block with the specified client ID.
*
* @param {string} clientId Block client ID.
* @param {Object} updates Block attributes to be merged.
*
* @return {Object} Action object.
*/
export function updateBlock( clientId, updates ) {
return {
type: 'UPDATE_BLOCK',
clientId,
updates,
};
}
/* eslint-disable jsdoc/valid-types */
/**
* Returns an action object used in signalling that the block with the
* specified client ID has been selected, optionally accepting a position
* value reflecting its selection directionality. An initialPosition of -1
* reflects a reverse selection.
*
* @param {string} clientId Block client ID.
* @param {0|-1|null} initialPosition Optional initial position. Pass as -1 to
* reflect reverse selection.
*
* @return {Object} Action object.
*/
export function selectBlock( clientId, initialPosition = 0 ) {
/* eslint-enable jsdoc/valid-types */
return {
type: 'SELECT_BLOCK',
initialPosition,
clientId,
};
}
/**
* Returns an action object used in signalling that the block with the
* specified client ID has been hovered.
*
* @param {string} clientId Block client ID.
*
* @return {Object} Action object.
*/
export function hoverBlock( clientId ) {
return {
type: 'HOVER_BLOCK',
clientId,
};
}
/**
* Yields action objects used in signalling that the block preceding the given
* clientId (or optionally, its first parent from bottom to top)
* should be selected.
*
* @param {string} clientId Block client ID.
* @param {boolean} fallbackToParent If true, select the first parent if there is no previous block.
*/
export const selectPreviousBlock =
( clientId, fallbackToParent = false ) =>
( { select, dispatch } ) => {
const previousBlockClientId =
select.getPreviousBlockClientId( clientId );
if ( previousBlockClientId ) {
dispatch.selectBlock( previousBlockClientId, -1 );
} else if ( fallbackToParent ) {
const firstParentClientId = select.getBlockRootClientId( clientId );
if ( firstParentClientId ) {
dispatch.selectBlock( firstParentClientId, -1 );
}
}
};
/**
* Yields action objects used in signalling that the block following the given
* clientId should be selected.
*
* @param {string} clientId Block client ID.
*/
export const selectNextBlock =
( clientId ) =>
( { select, dispatch } ) => {
const nextBlockClientId = select.getNextBlockClientId( clientId );
if ( nextBlockClientId ) {
dispatch.selectBlock( nextBlockClientId );
}
};
/**
* Action that starts block multi-selection.
*
* @return {Object} Action object.
*/
export function startMultiSelect() {
return {
type: 'START_MULTI_SELECT',
};
}
/**
* Action that stops block multi-selection.
*
* @return {Object} Action object.
*/
export function stopMultiSelect() {
return {
type: 'STOP_MULTI_SELECT',
};
}
/**
* Action that changes block multi-selection.
*
* @param {string} start First block of the multi selection.
* @param {string} end Last block of the multiselection.
* @param {number|null} __experimentalInitialPosition Optional initial position. Pass as null to skip focus within editor canvas.
*/
export const multiSelect =
( start, end, __experimentalInitialPosition = 0 ) =>
( { select, dispatch } ) => {
const startBlockRootClientId = select.getBlockRootClientId( start );
const endBlockRootClientId = select.getBlockRootClientId( end );
// Only allow block multi-selections at the same level.
if ( startBlockRootClientId !== endBlockRootClientId ) {
return;
}
dispatch( {
type: 'MULTI_SELECT',
start,
end,
initialPosition: __experimentalInitialPosition,
} );
const blockCount = select.getSelectedBlockCount();
speak(
sprintf(
/* translators: %s: number of selected blocks */
_n( '%s block selected.', '%s blocks selected.', blockCount ),
blockCount
),
'assertive'
);
};
/**
* Action that clears the block selection.
*
* @return {Object} Action object.
*/
export function clearSelectedBlock() {
return {
type: 'CLEAR_SELECTED_BLOCK',
};
}
/**
* Action that enables or disables block selection.
*
* @param {boolean} [isSelectionEnabled=true] Whether block selection should
* be enabled.
*
* @return {Object} Action object.
*/
export function toggleSelection( isSelectionEnabled = true ) {
return {
type: 'TOGGLE_SELECTION',
isSelectionEnabled,
};
}
/* eslint-disable jsdoc/valid-types */
/**
* Action that replaces given blocks with one or more replacement blocks.
*
* @param {(string|string[])} clientIds Block client ID(s) to replace.
* @param {(Object|Object[])} blocks Replacement block(s).
* @param {number} indexToSelect Index of replacement block to select.
* @param {0|-1|null} initialPosition Index of caret after in the selected block after the operation.
* @param {?Object} meta Optional Meta values to be passed to the action object.
*
* @return {Object} Action object.
*/
export const replaceBlocks =
( clientIds, blocks, indexToSelect, initialPosition = 0, meta ) =>
( { select, dispatch, registry } ) => {
/* eslint-enable jsdoc/valid-types */
clientIds = castArray( clientIds );
blocks = castArray( blocks );
const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] );
// Replace is valid if the new blocks can be inserted in the root block.
for ( let index = 0; index < blocks.length; index++ ) {
const block = blocks[ index ];
const canInsertBlock = select.canInsertBlockType(
block.name,
rootClientId
);
if ( ! canInsertBlock ) {
return;
}
}
// We're batching these two actions because an extra `undo/redo` step can
// be created, based on whether we insert a default block or not.
registry.batch( () => {
dispatch( {
type: 'REPLACE_BLOCKS',
clientIds,
blocks,
time: Date.now(),
indexToSelect,
initialPosition,
meta,
} );
// To avoid a focus loss when removing the last block, assure there is
// always a default block if the last of the blocks have been removed.
dispatch.ensureDefaultBlock();
} );
};
/**
* Action that replaces a single block with one or more replacement blocks.
*
* @param {(string|string[])} clientId Block client ID to replace.
* @param {(Object|Object[])} block Replacement block(s).
*
* @return {Object} Action object.
*/
export function replaceBlock( clientId, block ) {
return replaceBlocks( clientId, block );
}
/**
* Higher-order action creator which, given the action type to dispatch creates
* an action creator for managing block movement.
*
* @param {string} type Action type to dispatch.
*
* @return {Function} Action creator.
*/
const createOnMove =
( type ) =>
( clientIds, rootClientId ) =>
( { select, dispatch } ) => {
// If one of the blocks is locked or the parent is locked, we cannot move any block.
const canMoveBlocks = select.canMoveBlocks( clientIds );
if ( ! canMoveBlocks ) {
return;
}
dispatch( { type, clientIds: castArray( clientIds ), rootClientId } );
};
export const moveBlocksDown = createOnMove( 'MOVE_BLOCKS_DOWN' );
export const moveBlocksUp = createOnMove( 'MOVE_BLOCKS_UP' );
/**
* Action that moves given blocks to a new position.
*
* @param {?string} clientIds The client IDs of the blocks.
* @param {?string} fromRootClientId Root client ID source.
* @param {?string} toRootClientId Root client ID destination.
* @param {number} index The index to move the blocks to.
*/
export const moveBlocksToPosition =
( clientIds, fromRootClientId = '', toRootClientId = '', index ) =>
( { select, dispatch } ) => {
const canMoveBlocks = select.canMoveBlocks( clientIds );
// If one of the blocks is locked or the parent is locked, we cannot move any block.
if ( ! canMoveBlocks ) {
return;
}
// If moving inside the same root block the move is always possible.
if ( fromRootClientId !== toRootClientId ) {
const canRemoveBlocks = select.canRemoveBlocks( clientIds );
// If we're moving to another block, it means we're deleting blocks from
// the original block, so we need to check if removing is possible.
if ( ! canRemoveBlocks ) {
return;
}
const canInsertBlocks = select.canInsertBlocks(
clientIds,
toRootClientId
);
// If moving to other parent block, the move is possible if we can insert a block of the same type inside the new parent block.
if ( ! canInsertBlocks ) {
return;
}
}
dispatch( {
type: 'MOVE_BLOCKS_TO_POSITION',
fromRootClientId,
toRootClientId,
clientIds,
index,
} );
};
/**
* Action that moves given block to a new position.
*
* @param {?string} clientId The client ID of the block.
* @param {?string} fromRootClientId Root client ID source.
* @param {?string} toRootClientId Root client ID destination.
* @param {number} index The index to move the block to.
*/
export function moveBlockToPosition(
clientId,
fromRootClientId = '',
toRootClientId = '',
index
) {
return moveBlocksToPosition(
[ clientId ],
fromRootClientId,
toRootClientId,
index
);
}
/**
* Action that inserts a single block, optionally at a specific index respective a root block list.
*
* Only allowed blocks are inserted. The action may fail silently for blocks that are not allowed or if
* a templateLock is active on the block list.
*
* @param {Object} block Block object to insert.
* @param {?number} index Index at which block should be inserted.
* @param {?string} rootClientId Optional root client ID of block list on which to insert.
* @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true.
* @param {?Object} meta Optional Meta values to be passed to the action object.
*
* @return {Object} Action object.
*/
export function insertBlock(
block,
index,
rootClientId,
updateSelection,
meta
) {
return insertBlocks(
[ block ],
index,
rootClientId,
updateSelection,
0,
meta
);
}
/* eslint-disable jsdoc/valid-types */
/**
* Action that inserts an array of blocks, optionally at a specific index respective a root block list.
*
* Only allowed blocks are inserted. The action may fail silently for blocks that are not allowed or if
* a templateLock is active on the block list.
*
* @param {Object[]} blocks Block objects to insert.
* @param {?number} index Index at which block should be inserted.
* @param {?string} rootClientId Optional root client ID of block list on which to insert.
* @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true.
* @param {0|-1|null} initialPosition Initial focus position. Setting it to null prevent focusing the inserted block.
* @param {?Object} meta Optional Meta values to be passed to the action object.
*
* @return {Object} Action object.
*/
export const insertBlocks =
(
blocks,
index,
rootClientId,
updateSelection = true,
initialPosition = 0,
meta
) =>
( { select, dispatch } ) => {
/* eslint-enable jsdoc/valid-types */
if ( initialPosition !== null && typeof initialPosition === 'object' ) {
meta = initialPosition;
initialPosition = 0;
deprecated(
"meta argument in wp.data.dispatch('core/block-editor')",
{
since: '5.8',
hint: 'The meta argument is now the 6th argument of the function',
}
);
}
blocks = castArray( blocks );
const allowedBlocks = [];
for ( const block of blocks ) {
const isValid = select.canInsertBlockType(
block.name,
rootClientId
);
if ( isValid ) {
allowedBlocks.push( block );
}
}
if ( allowedBlocks.length ) {
dispatch( {
type: 'INSERT_BLOCKS',
blocks: allowedBlocks,
index,
rootClientId,
time: Date.now(),
updateSelection,
initialPosition: updateSelection ? initialPosition : null,
meta,
} );
}
};
/**
* Action that shows the insertion point.
*
* @param {?string} rootClientId Optional root client ID of block list on
* which to insert.
* @param {?number} index Index at which block should be inserted.
* @param {?Object} __unstableOptions Additional options.
* @property {boolean} __unstableWithInserter Whether or not to show an inserter button.
* @property {WPDropOperation} operation The operation to perform when applied,
* either 'insert' or 'replace' for now.
*
* @return {Object} Action object.
*/
export function showInsertionPoint(
rootClientId,
index,
__unstableOptions = {}
) {
const { __unstableWithInserter, operation, nearestSide } =
__unstableOptions;
return {
type: 'SHOW_INSERTION_POINT',
rootClientId,
index,
__unstableWithInserter,
operation,
nearestSide,
};
}
/**
* Action that hides the insertion point.
*/
export const hideInsertionPoint =
() =>
( { select, dispatch } ) => {
if ( ! select.isBlockInsertionPointVisible() ) {
return;
}
dispatch( {
type: 'HIDE_INSERTION_POINT',
} );
};
/**
* Action that resets the template validity.
*
* @param {boolean} isValid template validity flag.
*
* @return {Object} Action object.
*/
export function setTemplateValidity( isValid ) {
return {
type: 'SET_TEMPLATE_VALIDITY',
isValid,
};
}
/**
* Action that synchronizes the template with the list of blocks.
*
* @return {Object} Action object.
*/
export const synchronizeTemplate =
() =>
( { select, dispatch } ) => {
dispatch( { type: 'SYNCHRONIZE_TEMPLATE' } );
const blocks = select.getBlocks();
const template = select.getTemplate();
const updatedBlockList = synchronizeBlocksWithTemplate(
blocks,
template
);
dispatch.resetBlocks( updatedBlockList );
};
/**
* Delete the current selection.
*
* @param {boolean} isForward
*/
export const __unstableDeleteSelection =
( isForward ) =>
( { registry, select, dispatch } ) => {
const selectionAnchor = select.getSelectionStart();
const selectionFocus = select.getSelectionEnd();
if ( selectionAnchor.clientId === selectionFocus.clientId ) {
return;
}
// It's not mergeable if there's no rich text selection.
if (
! selectionAnchor.attributeKey ||
! selectionFocus.attributeKey ||
typeof selectionAnchor.offset === 'undefined' ||
typeof selectionFocus.offset === 'undefined'
) {
return false;
}
const anchorRootClientId = select.getBlockRootClientId(
selectionAnchor.clientId
);
const focusRootClientId = select.getBlockRootClientId(
selectionFocus.clientId
);
// It's not mergeable if the selection doesn't start and end in the same
// block list. Maybe in the future it should be allowed.
if ( anchorRootClientId !== focusRootClientId ) {
return;
}
const blockOrder = select.getBlockOrder( anchorRootClientId );
const anchorIndex = blockOrder.indexOf( selectionAnchor.clientId );
const focusIndex = blockOrder.indexOf( selectionFocus.clientId );
// Reassign selection start and end based on order.
let selectionStart, selectionEnd;
if ( anchorIndex > focusIndex ) {
selectionStart = selectionFocus;
selectionEnd = selectionAnchor;
} else {
selectionStart = selectionAnchor;
selectionEnd = selectionFocus;
}
const targetSelection = isForward ? selectionEnd : selectionStart;
const targetBlock = select.getBlock( targetSelection.clientId );
const targetBlockType = getBlockType( targetBlock.name );
if ( ! targetBlockType.merge ) {
return;
}
const selectionA = selectionStart;
const selectionB = selectionEnd;
const blockA = select.getBlock( selectionA.clientId );
const blockB = select.getBlock( selectionB.clientId );
const htmlA = blockA.attributes[ selectionA.attributeKey ];
const htmlB = blockB.attributes[ selectionB.attributeKey ];
let valueA = create( { html: htmlA } );
let valueB = create( { html: htmlB } );
valueA = remove( valueA, selectionA.offset, valueA.text.length );
valueB = insert( valueB, START_OF_SELECTED_AREA, 0, selectionB.offset );
// Clone the blocks so we don't manipulate the original.
const cloneA = cloneBlock( blockA, {
[ selectionA.attributeKey ]: toHTMLString( { value: valueA } ),
} );
const cloneB = cloneBlock( blockB, {
[ selectionB.attributeKey ]: toHTMLString( { value: valueB } ),
} );
const followingBlock = isForward ? cloneA : cloneB;
// We can only merge blocks with similar types
// thus, we transform the block to merge first
const blocksWithTheSameType =
blockA.name === blockB.name
? [ followingBlock ]
: switchToBlockType( followingBlock, targetBlockType.name );
// If the block types can not match, do nothing
if ( ! blocksWithTheSameType || ! blocksWithTheSameType.length ) {
return;
}
let updatedAttributes;
if ( isForward ) {
const blockToMerge = blocksWithTheSameType.pop();
updatedAttributes = targetBlockType.merge(
blockToMerge.attributes,
cloneB.attributes
);
} else {
const blockToMerge = blocksWithTheSameType.shift();
updatedAttributes = targetBlockType.merge(
cloneA.attributes,
blockToMerge.attributes
);
}
const newAttributeKey = retrieveSelectedAttribute( updatedAttributes );
const convertedHtml = updatedAttributes[ newAttributeKey ];
const convertedValue = create( { html: convertedHtml } );
const newOffset = convertedValue.text.indexOf( START_OF_SELECTED_AREA );
const newValue = remove( convertedValue, newOffset, newOffset + 1 );
const newHtml = toHTMLString( { value: newValue } );
updatedAttributes[ newAttributeKey ] = newHtml;
const selectedBlockClientIds = select.getSelectedBlockClientIds();
const replacement = [
...( isForward ? blocksWithTheSameType : [] ),
{
// Preserve the original client ID.
...targetBlock,
attributes: {
...targetBlock.attributes,
...updatedAttributes,
},
},
...( isForward ? [] : blocksWithTheSameType ),
];
registry.batch( () => {
dispatch.selectionChange(
targetBlock.clientId,
newAttributeKey,
newOffset,
newOffset
);
dispatch.replaceBlocks(
selectedBlockClientIds,
replacement,
0, // If we don't pass the `indexToSelect` it will default to the last block.
select.getSelectedBlocksInitialCaretPosition()
);
} );
};
/**
* Split the current selection.
* @param {?Array} blocks
*/
export const __unstableSplitSelection =
( blocks = [] ) =>
( { registry, select, dispatch } ) => {
const selectionAnchor = select.getSelectionStart();
const selectionFocus = select.getSelectionEnd();
const anchorRootClientId = select.getBlockRootClientId(
selectionAnchor.clientId
);
const focusRootClientId = select.getBlockRootClientId(
selectionFocus.clientId
);
// It's not splittable if the selection doesn't start and end in the same
// block list. Maybe in the future it should be allowed.
if ( anchorRootClientId !== focusRootClientId ) {
return;
}
const blockOrder = select.getBlockOrder( anchorRootClientId );
const anchorIndex = blockOrder.indexOf( selectionAnchor.clientId );
const focusIndex = blockOrder.indexOf( selectionFocus.clientId );
// Reassign selection start and end based on order.
let selectionStart, selectionEnd;
if ( anchorIndex > focusIndex ) {
selectionStart = selectionFocus;
selectionEnd = selectionAnchor;
} else {
selectionStart = selectionAnchor;
selectionEnd = selectionFocus;
}
const selectionA = selectionStart;
const selectionB = selectionEnd;
const blockA = select.getBlock( selectionA.clientId );
const blockB = select.getBlock( selectionB.clientId );
const blockAType = getBlockType( blockA.name );
const blockBType = getBlockType( blockB.name );
const attributeKeyA =
typeof selectionA.attributeKey === 'string'
? selectionA.attributeKey
: findRichTextAttributeKey( blockAType );
const attributeKeyB =
typeof selectionB.attributeKey === 'string'
? selectionB.attributeKey
: findRichTextAttributeKey( blockBType );
const blockAttributes = select.getBlockAttributes(
selectionA.clientId
);
const bindings = blockAttributes?.metadata?.bindings;
// If the attribute is bound, don't split the selection and insert a new block instead.
if ( bindings?.[ attributeKeyA ] ) {
// Show warning if user tries to insert a block into another block with bindings.
if ( blocks.length ) {
const { createWarningNotice } =
registry.dispatch( noticesStore );
createWarningNotice(
__(
"Blocks can't be inserted into other blocks with bindings"
),
{
type: 'snackbar',
}
);
return;
}
dispatch.insertAfterBlock( selectionA.clientId );
return;
}
// Can't split if the selection is not set.
if (
! attributeKeyA ||
! attributeKeyB ||
typeof selectionAnchor.offset === 'undefined' ||
typeof selectionFocus.offset === 'undefined'
) {
return;
}
// We can do some short-circuiting if the selection is collapsed.
if (
selectionA.clientId === selectionB.clientId &&
attributeKeyA === attributeKeyB &&
selectionA.offset === selectionB.offset
) {
// If an unmodified default block is selected, replace it. We don't
// want to be converting into a default block.
if ( blocks.length ) {
if ( isUnmodifiedDefaultBlock( blockA ) ) {
dispatch.replaceBlocks(
[ selectionA.clientId ],
blocks,
blocks.length - 1,
-1
);
return;
}
}
// If selection is at the start or end, we can simply insert an
// empty block, provided this block has no inner blocks.
else if ( ! select.getBlockOrder( selectionA.clientId ).length ) {
function createEmpty() {
const defaultBlockName = getDefaultBlockName();
return select.canInsertBlockType(
defaultBlockName,
anchorRootClientId
)
? createBlock( defaultBlockName )
: createBlock(
select.getBlockName( selectionA.clientId )
);
}
const length = blockAttributes[ attributeKeyA ].length;
if ( selectionA.offset === 0 && length ) {
dispatch.insertBlocks(
[ createEmpty() ],
select.getBlockIndex( selectionA.clientId ),
anchorRootClientId,
false
);
return;
}
if ( selectionA.offset === length ) {
dispatch.insertBlocks(
[ createEmpty() ],
select.getBlockIndex( selectionA.clientId ) + 1,
anchorRootClientId
);
return;
}
}
}
const htmlA = blockA.attributes[ attributeKeyA ];
const htmlB = blockB.attributes[ attributeKeyB ];
let valueA = create( { html: htmlA } );
let valueB = create( { html: htmlB } );
valueA = remove( valueA, selectionA.offset, valueA.text.length );
valueB = remove( valueB, 0, selectionB.offset );
let head = {
// Preserve the original client ID.
...blockA,
// If both start and end are the same, should only copy innerBlocks
// once.
innerBlocks:
blockA.clientId === blockB.clientId ? [] : blockA.innerBlocks,
attributes: {
...blockA.attributes,
[ attributeKeyA ]: toHTMLString( { value: valueA } ),
},
};
let tail = {
...blockB,
// Only preserve the original client ID if the end is different.
clientId:
blockA.clientId === blockB.clientId
? createBlock( blockB.name ).clientId
: blockB.clientId,
attributes: {
...blockB.attributes,
[ attributeKeyB ]: toHTMLString( { value: valueB } ),
},
};
// When splitting a block, attempt to convert the tail block to the
// default block type. For example, when splitting a heading block, the
// tail block will be converted to a paragraph block. Note that for
// blocks such as a list item and button, this will be skipped because
// the default block type cannot be inserted.
const defaultBlockName = getDefaultBlockName();
if (
// A block is only split when the selection is within the same
// block.
blockA.clientId === blockB.clientId &&
defaultBlockName &&
tail.name !== defaultBlockName &&
select.canInsertBlockType( defaultBlockName, anchorRootClientId )
) {
const switched = switchToBlockType( tail, defaultBlockName );
if ( switched?.length === 1 ) {
tail = switched[ 0 ];
}
}
if ( ! blocks.length ) {
dispatch.replaceBlocks( select.getSelectedBlockClientIds(), [
head,
tail,
] );
return;
}
let selection;
const output = [];
const clonedBlocks = [ ...blocks ];
const firstBlock = clonedBlocks.shift();
const headType = getBlockType( head.name );
const firstBlocks =
headType.merge && firstBlock.name === headType.name
? [ firstBlock ]
: switchToBlockType( firstBlock, headType.name );
if ( firstBlocks?.length ) {
const first = firstBlocks.shift();
head = {
...head,
attributes: {
...head.attributes,
...headType.merge( head.attributes, first.attributes ),
},
};
output.push( head );
selection = {
clientId: head.clientId,
attributeKey: attributeKeyA,
offset: create( { html: head.attributes[ attributeKeyA ] } )
.text.length,
};
clonedBlocks.unshift( ...firstBlocks );
} else {
if ( ! isUnmodifiedBlock( head ) ) {
output.push( head );
}
output.push( firstBlock );
}
const lastBlock = clonedBlocks.pop();
const tailType = getBlockType( tail.name );
if ( clonedBlocks.length ) {
output.push( ...clonedBlocks );
}
if ( lastBlock ) {
const lastBlocks =
tailType.merge && tailType.name === lastBlock.name
? [ lastBlock ]
: switchToBlockType( lastBlock, tailType.name );
if ( lastBlocks?.length ) {
const last = lastBlocks.pop();
output.push( {
...tail,
attributes: {
...tail.attributes,
...tailType.merge( last.attributes, tail.attributes ),
},
} );
output.push( ...lastBlocks );
selection = {
clientId: tail.clientId,
attributeKey: attributeKeyB,
offset: create( {
html: last.attributes[ attributeKeyB ],
} ).text.length,
};
} else {
output.push( lastBlock );
if ( ! isUnmodifiedBlock( tail ) ) {
output.push( tail );
}
}
} else if ( ! isUnmodifiedBlock( tail ) ) {
output.push( tail );
}
registry.batch( () => {
dispatch.replaceBlocks(
select.getSelectedBlockClientIds(),
output,
output.length - 1,
0
);
if ( selection ) {
dispatch.selectionChange(
selection.clientId,
selection.attributeKey,
selection.offset,
selection.offset
);
}
} );
};
/**
* Expand the selection to cover the entire blocks, removing partial selection.
*/
export const __unstableExpandSelection =
() =>
( { select, dispatch } ) => {
const selectionAnchor = select.getSelectionStart();
const selectionFocus = select.getSelectionEnd();
dispatch.selectionChange( {
start: { clientId: selectionAnchor.clientId },
end: { clientId: selectionFocus.clientId },
} );
};
/**
* Action that merges two blocks.
*
* @param {string} firstBlockClientId Client ID of the first block to merge.
* @param {string} secondBlockClientId Client ID of the second block to merge.
*/
export const mergeBlocks =
( firstBlockClientId, secondBlockClientId ) =>
( { registry, select, dispatch } ) => {
const clientIdA = firstBlockClientId;
const clientIdB = secondBlockClientId;
const blockA = select.getBlock( clientIdA );
const blockAType = getBlockType( blockA.name );
if ( ! blockAType ) {
return;
}
const blockB = select.getBlock( clientIdB );
if (
! blockAType.merge &&
getBlockSupport( blockA.name, '__experimentalOnMerge' )
) {
// If there's no merge function defined, attempt merging inner
// blocks.
const blocksWithTheSameType = switchToBlockType(
blockB,
blockAType.name
);
// Only focus the previous block if it's not mergeable.
if ( blocksWithTheSameType?.length !== 1 ) {
dispatch.selectBlock( blockA.clientId );
return;
}
const [ blockWithSameType ] = blocksWithTheSameType;
if ( blockWithSameType.innerBlocks.length < 1 ) {
dispatch.selectBlock( blockA.clientId );
return;
}
registry.batch( () => {
dispatch.insertBlocks(
blockWithSameType.innerBlocks,
undefined,
clientIdA
);
dispatch.removeBlock( clientIdB );
dispatch.selectBlock(
blockWithSameType.innerBlocks[ 0 ].clientId
);
// Attempt to merge the next block if it's the same type and
// same attributes. This is useful when merging a paragraph into
// a list, and the next block is also a list. If we don't merge,
// it looks like one list, but it's actually two lists. The same
// applies to other blocks such as a group with the same
// attributes.
const nextBlockClientId =
select.getNextBlockClientId( clientIdA );
if (
nextBlockClientId &&
select.getBlockName( clientIdA ) ===
select.getBlockName( nextBlockClientId )
) {
const rootAttributes =
select.getBlockAttributes( clientIdA );
const previousRootAttributes =
select.getBlockAttributes( nextBlockClientId );
if (
Object.keys( rootAttributes ).every(
( key ) =>
rootAttributes[ key ] ===
previousRootAttributes[ key ]
)
) {
dispatch.moveBlocksToPosition(
select.getBlockOrder( nextBlockClientId ),
nextBlockClientId,
clientIdA
);
dispatch.removeBlock( nextBlockClientId, false );
}
}
} );
return;
}
if ( isUnmodifiedDefaultBlock( blockA ) ) {
dispatch.removeBlock(
clientIdA,
select.isBlockSelected( clientIdA )
);
return;
}
if ( isUnmodifiedDefaultBlock( blockB ) ) {
dispatch.removeBlock(
clientIdB,
select.isBlockSelected( clientIdB )
);
return;
}
if ( ! blockAType.merge ) {
dispatch.selectBlock( blockA.clientId );
return;
}
const blockBType = getBlockType( blockB.name );
const { clientId, attributeKey, offset } = select.getSelectionStart();
const selectedBlockType =
clientId === clientIdA ? blockAType : blockBType;
const attributeDefinition =
selectedBlockType.attributes[ attributeKey ];
const canRestoreTextSelection =
( clientId === clientIdA || clientId === clientIdB ) &&
attributeKey !== undefined &&
offset !== undefined &&
// We cannot restore text selection if the RichText identifier
// is not a defined block attribute key. This can be the case if the
// fallback instance ID is used to store selection (and no RichText
// identifier is set), or when the identifier is wrong.
!! attributeDefinition;
if ( ! attributeDefinition ) {
if ( typeof attributeKey === 'number' ) {
window.console.error(
`RichText needs an identifier prop that is the block attribute key of the attribute it controls. Its type is expected to be a string, but was ${ typeof attributeKey }`
);
} else {
window.console.error(
'The RichText identifier prop does not match any attributes defined by the block.'
);
}
}
// Clone the blocks so we don't insert the character in a "live" block.
const cloneA = cloneBlock( blockA );
const cloneB = cloneBlock( blockB );
if ( canRestoreTextSelection ) {
const selectedBlock = clientId === clientIdA ? cloneA : cloneB;
const html = selectedBlock.attributes[ attributeKey ];
const value = insert(
create( { html } ),
START_OF_SELECTED_AREA,
offset,
offset
);
selectedBlock.attributes[ attributeKey ] = toHTMLString( {
value,
} );
}
// We can only merge blocks with similar types
// thus, we transform the block to merge first.
const blocksWithTheSameType =
blockA.name === blockB.name
? [ cloneB ]
: switchToBlockType( cloneB, blockA.name );
// If the block types can not match, do nothing.
if ( ! blocksWithTheSameType || ! blocksWithTheSameType.length ) {
return;
}
// Calling the merge to update the attributes and remove the block to be merged.
const updatedAttributes = blockAType.merge(
cloneA.attributes,
blocksWithTheSameType[ 0 ].attributes
);
if ( canRestoreTextSelection ) {
const newAttributeKey =
retrieveSelectedAttribute( updatedAttributes );
const convertedHtml = updatedAttributes[ newAttributeKey ];
const convertedValue = create( { html: convertedHtml } );
const newOffset = convertedValue.text.indexOf(
START_OF_SELECTED_AREA
);
const newValue = remove( convertedValue, newOffset, newOffset + 1 );
const newHtml = toHTMLString( { value: newValue } );
updatedAttributes[ newAttributeKey ] = newHtml;
dispatch.selectionChange(
blockA.clientId,
newAttributeKey,
newOffset,
newOffset
);
}
dispatch.replaceBlocks(
[ blockA.clientId, blockB.clientId ],
[
{
...blockA,
attributes: {
...blockA.attributes,
...updatedAttributes,
},
},
...blocksWithTheSameType.slice( 1 ),
],
0 // If we don't pass the `indexToSelect` it will default to the last block.
);
};
/**
* Yields action objects used in signalling that the blocks corresponding to
* the set of specified client IDs are to be removed.
*
* @param {string|string[]} clientIds Client IDs of blocks to remove.
* @param {boolean} selectPrevious True if the previous block
* or the immediate parent
* (if no previous block exists)
* should be selected
* when a block is removed.
*/
export const removeBlocks = ( clientIds, selectPrevious = true ) =>
privateRemoveBlocks( clientIds, selectPrevious );
/**
* Returns an action object used in signalling that the block with the
* specified client ID is to be removed.
*
* @param {string} clientId Client ID of block to remove.
* @param {boolean} selectPrevious True if the previous block should be
* selected when a block is removed.
*
* @return {Object} Action object.
*/
export function removeBlock( clientId, selectPrevious ) {
return removeBlocks( [ clientId ], selectPrevious );
}
/* eslint-disable jsdoc/valid-types */
/**
* Returns an action object used in signalling that the inner blocks with the
* specified client ID should be replaced.
*
* @param {string} rootClientId Client ID of the block whose InnerBlocks will re replaced.
* @param {Object[]} blocks Block objects to insert as new InnerBlocks
* @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to false.
* @param {0|-1|null} initialPosition Initial block position.
* @return {Object} Action object.
*/
export function replaceInnerBlocks(
rootClientId,
blocks,
updateSelection = false,
initialPosition = 0
) {
/* eslint-enable jsdoc/valid-types */
return {
type: 'REPLACE_INNER_BLOCKS',
rootClientId,
blocks,
updateSelection,
initialPosition: updateSelection ? initialPosition : null,
time: Date.now(),
};
}
/**
* Returns an action object used to toggle the block editing mode between
* visual and HTML modes.
*
* @param {string} clientId Block client ID.
*
* @return {Object} Action object.
*/
export function toggleBlockMode( clientId ) {
return {
type: 'TOGGLE_BLOCK_MODE',
clientId,
};
}
/**
* Returns an action object used in signalling that the user has begun to type.
*
* @return {Object} Action object.
*/
export function startTyping() {
return {
type: 'START_TYPING',
};
}
/**
* Returns an action object used in signalling that the user has stopped typing.
*
* @return {Object} Action object.
*/
export function stopTyping() {
return {
type: 'STOP_TYPING',
};
}
/**
* Returns an action object used in signalling that the user has begun to drag blocks.
*
* @param {string[]} clientIds An array of client ids being dragged
*
* @return {Object} Action object.
*/
export function startDraggingBlocks( clientIds = [] ) {
return {
type: 'START_DRAGGING_BLOCKS',
clientIds,
};
}
/**
* Returns an action object used in signalling that the user has stopped dragging blocks.
*
* @return {Object} Action object.
*/
export function stopDraggingBlocks() {
return {
type: 'STOP_DRAGGING_BLOCKS',
};
}
/**
* Returns an action object used in signalling that the caret has entered formatted text.
*
* @deprecated
*
* @return {Object} Action object.
*/
export function enterFormattedText() {
deprecated( 'wp.data.dispatch( "core/block-editor" ).enterFormattedText', {
since: '6.1',
version: '6.3',
} );
return {
type: 'DO_NOTHING',
};
}
/**
* Returns an action object used in signalling that the user caret has exited formatted text.
*
* @deprecated
*
* @return {Object} Action object.
*/
export function exitFormattedText() {
deprecated( 'wp.data.dispatch( "core/block-editor" ).exitFormattedText', {
since: '6.1',
version: '6.3',
} );
return {
type: 'DO_NOTHING',
};
}
/**
* Action that changes the position of the user caret.
*
* @param {string|WPSelection} clientId The selected block client ID.
* @param {string} attributeKey The selected block attribute key.
* @param {number} startOffset The start offset.
* @param {number} endOffset The end offset.
*
* @return {Object} Action object.
*/
export function selectionChange(
clientId,
attributeKey,
startOffset,
endOffset
) {
if ( typeof clientId === 'string' ) {
return {
type: 'SELECTION_CHANGE',
clientId,
attributeKey,
startOffset,
endOffset,
};
}
return { type: 'SELECTION_CHANGE', ...clientId };
}
/**
* Action that adds a new block of the default type to the block list.
*
* @param {?Object} attributes Optional attributes of the block to assign.
* @param {?string} rootClientId Optional root client ID of block list on which
* to append.
* @param {?number} index Optional index where to insert the default block.
*/
export const insertDefaultBlock =
( attributes, rootClientId, index ) =>
( { dispatch } ) => {
// Abort if there is no default block type (if it has been unregistered).
const defaultBlockName = getDefaultBlockName();
if ( ! defaultBlockName ) {
return;
}
const block = createBlock( defaultBlockName, attributes );
return dispatch.insertBlock( block, index, rootClientId );
};
/**
* @typedef {Object< string, Object >} SettingsByClientId
*/
/**
* Action that changes the nested settings of the given block(s).
*
* @param {string | SettingsByClientId} clientId Client ID of the block whose
* nested setting are being
* received, or object of settings
* by client ID.
* @param {Object} settings Object with the new settings
* for the nested block.
*
* @return {Object} Action object
*/
export function updateBlockListSettings( clientId, settings ) {
return {
type: 'UPDATE_BLOCK_LIST_SETTINGS',
clientId,
settings,
};
}
/**
* Action that updates the block editor settings.
*
* @param {Object} settings Updated settings
*
* @return {Object} Action object
*/
export function updateSettings( settings ) {
return __experimentalUpdateSettings( settings, {
stripExperimentalSettings: true,
} );
}
/**
* Action that signals that a temporary reusable block has been saved
* in order to switch its temporary id with the real id.
*
* @param {string} id Reusable block's id.
* @param {string} updatedId Updated block's id.
*
* @return {Object} Action object.
*/
export function __unstableSaveReusableBlock( id, updatedId ) {
return {
type: 'SAVE_REUSABLE_BLOCK_SUCCESS',
id,
updatedId,
};
}
/**
* Action that marks the last block change explicitly as persistent.
*
* @return {Object} Action object.
*/
export function __unstableMarkLastChangeAsPersistent() {
return { type: 'MARK_LAST_CHANGE_AS_PERSISTENT' };
}
/**
* Action that signals that the next block change should be marked explicitly as not persistent.
*
* @return {Object} Action object.
*/
export function __unstableMarkNextChangeAsNotPersistent() {
return { type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT' };
}
/**
* Action that marks the last block change as an automatic change, meaning it was not
* performed by the user, and can be undone using the `Escape` and `Backspace` keys.
* This action must be called after the change was made, and any actions that are a
* consequence of it, so it is recommended to be called at the next idle period to ensure all
* selection changes have been recorded.
*/
export const __unstableMarkAutomaticChange =
() =>
( { dispatch } ) => {
dispatch( { type: 'MARK_AUTOMATIC_CHANGE' } );
const { requestIdleCallback = ( cb ) => setTimeout( cb, 100 ) } =
window;
requestIdleCallback( () => {
dispatch( { type: 'MARK_AUTOMATIC_CHANGE_FINAL' } );
} );
};
/**
* Action that enables or disables the navigation mode.
*
* @param {boolean} isNavigationMode Enable/Disable navigation mode.
*/
export const setNavigationMode =
( isNavigationMode = true ) =>
( { dispatch } ) => {
dispatch.__unstableSetEditorMode(
isNavigationMode ? 'navigation' : 'edit'
);
};
/**
* Action that sets the editor mode
*
* @param {string} mode Editor mode
*/
export const __unstableSetEditorMode =
( mode ) =>
( { registry } ) => {
registry.dispatch( preferencesStore ).set( 'core', 'editorTool', mode );
if ( mode === 'navigation' ) {
speak( __( 'You are currently in Write mode.' ) );
} else if ( mode === 'edit' ) {
speak( __( 'You are currently in Design mode.' ) );
}
};
/**
* Set the block moving client ID.
*
* @deprecated
*
* @return {Object} Action object.
*/
export function setBlockMovingClientId() {
deprecated(
'wp.data.dispatch( "core/block-editor" ).setBlockMovingClientId',
{
since: '6.7',
hint: 'Block moving mode feature has been removed',
}
);
return {
type: 'DO_NOTHING',
};
}
/**
* Action that duplicates a list of blocks.
*
* @param {string[]} clientIds
* @param {boolean} updateSelection
*/
export const duplicateBlocks =
( clientIds, updateSelection = true ) =>
( { select, dispatch } ) => {
if ( ! clientIds || ! clientIds.length ) {
return;
}
// Return early if blocks don't exist.
const blocks = select.getBlocksByClientId( clientIds );
if ( blocks.some( ( block ) => ! block ) ) {
return;
}
// Return early if blocks don't support multiple usage.
const blockNames = blocks.map( ( block ) => block.name );
if (
blockNames.some(
( blockName ) =>
! hasBlockSupport( blockName, 'multiple', true )
)
) {
return;
}
const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] );
const clientIdsArray = castArray( clientIds );
const lastSelectedIndex = select.getBlockIndex(
clientIdsArray[ clientIdsArray.length - 1 ]
);
const clonedBlocks = blocks.map( ( block ) =>
__experimentalCloneSanitizedBlock( block )
);
dispatch.insertBlocks(
clonedBlocks,
lastSelectedIndex + 1,
rootClientId,
updateSelection
);
if ( clonedBlocks.length > 1 && updateSelection ) {
dispatch.multiSelect(
clonedBlocks[ 0 ].clientId,
clonedBlocks[ clonedBlocks.length - 1 ].clientId
);
}
return clonedBlocks.map( ( block ) => block.clientId );
};
/**
* Action that inserts a default block before a given block.
*
* @param {string} clientId
*/
export const insertBeforeBlock =
( clientId ) =>
( { select, dispatch } ) => {
if ( ! clientId ) {
return;
}
const rootClientId = select.getBlockRootClientId( clientId );
const isLocked = select.getTemplateLock( rootClientId );
if ( isLocked ) {
return;
}
const blockIndex = select.getBlockIndex( clientId );
const directInsertBlock = rootClientId
? select.getDirectInsertBlock( rootClientId )
: null;
if ( ! directInsertBlock ) {
return dispatch.insertDefaultBlock( {}, rootClientId, blockIndex );
}
const copiedAttributes = {};
if ( directInsertBlock.attributesToCopy ) {
const attributes = select.getBlockAttributes( clientId );
directInsertBlock.attributesToCopy.forEach( ( key ) => {
if ( attributes[ key ] ) {
copiedAttributes[ key ] = attributes[ key ];
}
} );
}
const block = createBlock( directInsertBlock.name, {
...directInsertBlock.attributes,
...copiedAttributes,
} );
return dispatch.insertBl