UNPKG

@wordpress/block-editor

Version:
1,885 lines (1,712 loc) 78.5 kB
/** * External dependencies */ import fastDeepEqual from 'fast-deep-equal/es6'; /** * WordPress dependencies */ import { pipe } from '@wordpress/compose'; import { combineReducers, select } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; import { store as blocksStore, privateApis as blocksPrivateApis, } from '@wordpress/blocks'; /** * Internal dependencies */ import { PREFERENCES_DEFAULTS, SETTINGS_DEFAULTS } from './defaults'; import { insertAt, moveTo } from './array'; import { sectionRootClientIdKey } from './private-keys'; import { unlock } from '../lock-unlock'; const { isContentBlock } = unlock( blocksPrivateApis ); const identity = ( x ) => x; /** * Given an array of blocks, returns an object where each key is a nesting * context, the value of which is an array of block client IDs existing within * that nesting context. * * @param {Array} blocks Blocks to map. * @param {?string} rootClientId Assumed root client ID. * * @return {Object} Block order map object. */ function mapBlockOrder( blocks, rootClientId = '' ) { const result = new Map(); const current = []; result.set( rootClientId, current ); blocks.forEach( ( block ) => { const { clientId, innerBlocks } = block; current.push( clientId ); mapBlockOrder( innerBlocks, clientId ).forEach( ( order, subClientId ) => { result.set( subClientId, order ); } ); } ); return result; } /** * Given an array of blocks, returns an object where each key contains * the clientId of the block and the value is the parent of the block. * * @param {Array} blocks Blocks to map. * @param {?string} rootClientId Assumed root client ID. * * @return {Object} Block order map object. */ function mapBlockParents( blocks, rootClientId = '' ) { const result = []; const stack = [ [ rootClientId, blocks ] ]; while ( stack.length ) { const [ parent, currentBlocks ] = stack.shift(); currentBlocks.forEach( ( { innerBlocks, ...block } ) => { result.push( [ block.clientId, parent ] ); if ( innerBlocks?.length ) { stack.push( [ block.clientId, innerBlocks ] ); } } ); } return result; } /** * Helper method to iterate through all blocks, recursing into inner blocks, * applying a transformation function to each one. * Returns a flattened object with the transformed blocks. * * @param {Array} blocks Blocks to flatten. * @param {Function} transform Transforming function to be applied to each block. * * @return {Array} Flattened object. */ function flattenBlocks( blocks, transform = identity ) { const result = []; const stack = [ ...blocks ]; while ( stack.length ) { const { innerBlocks, ...block } = stack.shift(); stack.push( ...innerBlocks ); result.push( [ block.clientId, transform( block ) ] ); } return result; } function getFlattenedClientIds( blocks ) { const result = {}; const stack = [ ...blocks ]; while ( stack.length ) { const { innerBlocks, ...block } = stack.shift(); stack.push( ...innerBlocks ); result[ block.clientId ] = true; } return result; } /** * Given an array of blocks, returns an object containing all blocks, without * attributes, recursing into inner blocks. Keys correspond to the block client * ID, the value of which is the attributes object. * * @param {Array} blocks Blocks to flatten. * * @return {Array} Flattened block attributes object. */ function getFlattenedBlocksWithoutAttributes( blocks ) { return flattenBlocks( blocks, ( block ) => { const { attributes, ...restBlock } = block; return restBlock; } ); } /** * Given an array of blocks, returns an object containing all block attributes, * recursing into inner blocks. Keys correspond to the block client ID, the * value of which is the attributes object. * * @param {Array} blocks Blocks to flatten. * * @return {Array} Flattened block attributes object. */ function getFlattenedBlockAttributes( blocks ) { return flattenBlocks( blocks, ( block ) => block.attributes ); } /** * Returns true if the two object arguments have the same keys, or false * otherwise. * * @param {Object} a First object. * @param {Object} b Second object. * * @return {boolean} Whether the two objects have the same keys. */ export function hasSameKeys( a, b ) { return fastDeepEqual( Object.keys( a ), Object.keys( b ) ); } /** * Returns true if, given the currently dispatching action and the previously * dispatched action, the two actions are updating the same block attribute, or * false otherwise. * * @param {Object} action Currently dispatching action. * @param {Object} lastAction Previously dispatched action. * * @return {boolean} Whether actions are updating the same block attribute. */ export function isUpdatingSameBlockAttribute( action, lastAction ) { return ( action.type === 'UPDATE_BLOCK_ATTRIBUTES' && lastAction !== undefined && lastAction.type === 'UPDATE_BLOCK_ATTRIBUTES' && fastDeepEqual( action.clientIds, lastAction.clientIds ) && hasSameKeys( action.attributes, lastAction.attributes ) ); } function updateBlockTreeForBlocks( state, blocks ) { const treeToUpdate = state.tree; const stack = [ ...blocks ]; const flattenedBlocks = [ ...blocks ]; while ( stack.length ) { const block = stack.shift(); stack.push( ...block.innerBlocks ); flattenedBlocks.push( ...block.innerBlocks ); } // Create objects before mutating them, that way it's always defined. for ( const block of flattenedBlocks ) { treeToUpdate.set( block.clientId, {} ); } for ( const block of flattenedBlocks ) { treeToUpdate.set( block.clientId, Object.assign( treeToUpdate.get( block.clientId ), { ...state.byClientId.get( block.clientId ), attributes: state.attributes.get( block.clientId ), innerBlocks: block.innerBlocks.map( ( subBlock ) => treeToUpdate.get( subBlock.clientId ) ), } ) ); } } function updateParentInnerBlocksInTree( state, updatedClientIds, updateChildrenOfUpdatedClientIds = false ) { const treeToUpdate = state.tree; const uncontrolledParents = new Set( [] ); const controlledParents = new Set(); for ( const clientId of updatedClientIds ) { let current = updateChildrenOfUpdatedClientIds ? clientId : state.parents.get( clientId ); do { if ( state.controlledInnerBlocks[ current ] ) { // Should stop on controlled blocks. // If we reach a controlled parent, break out of the loop. controlledParents.add( current ); break; } else { // Else continue traversing up through parents. uncontrolledParents.add( current ); current = state.parents.get( current ); } } while ( current !== undefined ); } // To make sure the order of assignments doesn't matter, // we first create empty objects and mutates the inner blocks later. for ( const clientId of uncontrolledParents ) { treeToUpdate.set( clientId, { ...treeToUpdate.get( clientId ) } ); } for ( const clientId of uncontrolledParents ) { treeToUpdate.get( clientId ).innerBlocks = ( state.order.get( clientId ) || [] ).map( ( subClientId ) => treeToUpdate.get( subClientId ) ); } // Controlled parent blocks, need a dedicated key for their inner blocks // to be used when doing getBlocks( controlledBlockClientId ). for ( const clientId of controlledParents ) { treeToUpdate.set( 'controlled||' + clientId, { innerBlocks: ( state.order.get( clientId ) || [] ).map( ( subClientId ) => treeToUpdate.get( subClientId ) ), } ); } } /** * Higher-order reducer intended to compute full block objects key for each block in the post. * This is a denormalization to optimize the performance of the getBlock selectors and avoid * recomputing the block objects and avoid heavy memoization. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ const withBlockTree = ( reducer ) => ( state = {}, action ) => { const newState = reducer( state, action ); if ( newState === state ) { return state; } newState.tree = state.tree ? state.tree : new Map(); switch ( action.type ) { case 'RECEIVE_BLOCKS': case 'INSERT_BLOCKS': { newState.tree = new Map( newState.tree ); updateBlockTreeForBlocks( newState, action.blocks ); updateParentInnerBlocksInTree( newState, action.rootClientId ? [ action.rootClientId ] : [ '' ], true ); break; } case 'UPDATE_BLOCK': newState.tree = new Map( newState.tree ); newState.tree.set( action.clientId, { ...newState.tree.get( action.clientId ), ...newState.byClientId.get( action.clientId ), attributes: newState.attributes.get( action.clientId ), } ); updateParentInnerBlocksInTree( newState, [ action.clientId ], false ); break; case 'SYNC_DERIVED_BLOCK_ATTRIBUTES': case 'UPDATE_BLOCK_ATTRIBUTES': { newState.tree = new Map( newState.tree ); action.clientIds.forEach( ( clientId ) => { newState.tree.set( clientId, { ...newState.tree.get( clientId ), attributes: newState.attributes.get( clientId ), } ); } ); updateParentInnerBlocksInTree( newState, action.clientIds, false ); break; } case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const inserterClientIds = getFlattenedClientIds( action.blocks ); newState.tree = new Map( newState.tree ); action.replacedClientIds.forEach( ( clientId ) => { newState.tree.delete( clientId ); // Controlled inner blocks are only removed // if the block doesn't move to another position // otherwise their content will be lost. if ( ! inserterClientIds[ clientId ] ) { newState.tree.delete( 'controlled||' + clientId ); } } ); updateBlockTreeForBlocks( newState, action.blocks ); updateParentInnerBlocksInTree( newState, action.blocks.map( ( b ) => b.clientId ), false ); // If there are no replaced blocks, it means we're removing blocks so we need to update their parent. const parentsOfRemovedBlocks = []; for ( const clientId of action.clientIds ) { const parentId = state.parents.get( clientId ); if ( parentId !== undefined && ( parentId === '' || newState.byClientId.get( parentId ) ) ) { parentsOfRemovedBlocks.push( parentId ); } } updateParentInnerBlocksInTree( newState, parentsOfRemovedBlocks, true ); break; } case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': const parentsOfRemovedBlocks = []; for ( const clientId of action.clientIds ) { const parentId = state.parents.get( clientId ); if ( parentId !== undefined && ( parentId === '' || newState.byClientId.get( parentId ) ) ) { parentsOfRemovedBlocks.push( parentId ); } } newState.tree = new Map( newState.tree ); action.removedClientIds.forEach( ( clientId ) => { newState.tree.delete( clientId ); newState.tree.delete( 'controlled||' + clientId ); } ); updateParentInnerBlocksInTree( newState, parentsOfRemovedBlocks, true ); break; case 'MOVE_BLOCKS_TO_POSITION': { const updatedBlockUids = []; if ( action.fromRootClientId ) { updatedBlockUids.push( action.fromRootClientId ); } else { updatedBlockUids.push( '' ); } if ( action.toRootClientId ) { updatedBlockUids.push( action.toRootClientId ); } newState.tree = new Map( newState.tree ); updateParentInnerBlocksInTree( newState, updatedBlockUids, true ); break; } case 'MOVE_BLOCKS_UP': case 'MOVE_BLOCKS_DOWN': { const updatedBlockUids = [ action.rootClientId ? action.rootClientId : '', ]; newState.tree = new Map( newState.tree ); updateParentInnerBlocksInTree( newState, updatedBlockUids, true ); break; } case 'SAVE_REUSABLE_BLOCK_SUCCESS': { const updatedBlockUids = []; newState.attributes.forEach( ( attributes, clientId ) => { if ( newState.byClientId.get( clientId ).name === 'core/block' && attributes.ref === action.updatedId ) { updatedBlockUids.push( clientId ); } } ); newState.tree = new Map( newState.tree ); updatedBlockUids.forEach( ( clientId ) => { newState.tree.set( clientId, { ...newState.byClientId.get( clientId ), attributes: newState.attributes.get( clientId ), innerBlocks: newState.tree.get( clientId ).innerBlocks, } ); } ); updateParentInnerBlocksInTree( newState, updatedBlockUids, false ); } } return newState; }; /** * Higher-order reducer intended to augment the blocks reducer, assigning an * `isPersistentChange` property value corresponding to whether a change in * state can be considered as persistent. All changes are considered persistent * except when updating the same block attribute as in the previous action. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ function withPersistentBlockChange( reducer ) { let lastAction; let markNextChangeAsNotPersistent = false; let explicitPersistent; return ( state, action ) => { let nextState = reducer( state, action ); let nextIsPersistentChange; if ( action.type === 'SET_EXPLICIT_PERSISTENT' ) { explicitPersistent = action.isPersistentChange; nextIsPersistentChange = state.isPersistentChange ?? true; } if ( explicitPersistent !== undefined ) { nextIsPersistentChange = explicitPersistent; return nextIsPersistentChange === nextState.isPersistentChange ? nextState : { ...nextState, isPersistentChange: nextIsPersistentChange, }; } const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' || markNextChangeAsNotPersistent; // Defer to previous state value (or default) unless changing or // explicitly marking as persistent. if ( state === nextState && ! isExplicitPersistentChange ) { markNextChangeAsNotPersistent = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; nextIsPersistentChange = state?.isPersistentChange ?? true; if ( state.isPersistentChange === nextIsPersistentChange ) { return state; } return { ...nextState, isPersistentChange: nextIsPersistentChange, }; } nextState = { ...nextState, isPersistentChange: isExplicitPersistentChange ? ! markNextChangeAsNotPersistent : ! isUpdatingSameBlockAttribute( action, lastAction ), }; // In comparing against the previous action, consider only those which // would have qualified as one which would have been ignored or not // have resulted in a changed state. lastAction = action; markNextChangeAsNotPersistent = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; return nextState; }; } /** * Higher-order reducer intended to augment the blocks reducer, assigning an * `isIgnoredChange` property value corresponding to whether a change in state * can be considered as ignored. A change is considered ignored when the result * of an action not incurred by direct user interaction. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ function withIgnoredBlockChange( reducer ) { /** * Set of action types for which a blocks state change should be ignored. * * @type {Set} */ const IGNORED_ACTION_TYPES = new Set( [ 'RECEIVE_BLOCKS' ] ); return ( state, action ) => { const nextState = reducer( state, action ); if ( nextState !== state ) { nextState.isIgnoredChange = IGNORED_ACTION_TYPES.has( action.type ); } return nextState; }; } /** * Higher-order reducer targeting the combined blocks reducer, augmenting * block client IDs in remove action to include cascade of inner blocks. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => { // Gets all children which need to be removed. const getAllChildren = ( clientIds ) => { let result = clientIds; for ( let i = 0; i < result.length; i++ ) { if ( ! state.order.get( result[ i ] ) || ( action.keepControlledInnerBlocks && action.keepControlledInnerBlocks[ result[ i ] ] ) ) { continue; } if ( result === clientIds ) { result = [ ...result ]; } result.push( ...state.order.get( result[ i ] ) ); } return result; }; if ( state ) { switch ( action.type ) { case 'REMOVE_BLOCKS': action = { ...action, type: 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN', removedClientIds: getAllChildren( action.clientIds ), }; break; case 'REPLACE_BLOCKS': action = { ...action, type: 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN', replacedClientIds: getAllChildren( action.clientIds ), }; break; } } return reducer( state, action ); }; /** * Higher-order reducer which targets the combined blocks reducer and handles * the `RESET_BLOCKS` action. When dispatched, this action will replace all * blocks that exist in the post, leaving blocks that exist only in state (e.g. * reusable blocks and blocks controlled by inner blocks controllers) alone. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ const withBlockReset = ( reducer ) => ( state, action ) => { if ( action.type === 'RESET_BLOCKS' ) { const newState = { ...state, byClientId: new Map( getFlattenedBlocksWithoutAttributes( action.blocks ) ), attributes: new Map( getFlattenedBlockAttributes( action.blocks ) ), order: mapBlockOrder( action.blocks ), parents: new Map( mapBlockParents( action.blocks ) ), controlledInnerBlocks: {}, }; newState.tree = new Map( state?.tree ); updateBlockTreeForBlocks( newState, action.blocks ); newState.tree.set( '', { innerBlocks: action.blocks.map( ( subBlock ) => newState.tree.get( subBlock.clientId ) ), } ); return newState; } return reducer( state, action ); }; /** * Higher-order reducer which targets the combined blocks reducer and handles * the `REPLACE_INNER_BLOCKS` action. When dispatched, this action the state * should become equivalent to the execution of a `REMOVE_BLOCKS` action * containing all the child's of the root block followed by the execution of * `INSERT_BLOCKS` with the new blocks. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ const withReplaceInnerBlocks = ( reducer ) => ( state, action ) => { if ( action.type !== 'REPLACE_INNER_BLOCKS' ) { return reducer( state, action ); } // Finds every nested inner block controller. We must check the action blocks // and not just the block parent state because some inner block controllers // should be deleted if specified, whereas others should not be deleted. If // a controlled should not be deleted, then we need to avoid deleting its // inner blocks from the block state because its inner blocks will not be // attached to the block in the action. const nestedControllers = {}; if ( Object.keys( state.controlledInnerBlocks ).length ) { const stack = [ ...action.blocks ]; while ( stack.length ) { const { innerBlocks, ...block } = stack.shift(); stack.push( ...innerBlocks ); if ( !! state.controlledInnerBlocks[ block.clientId ] ) { nestedControllers[ block.clientId ] = true; } } } // The `keepControlledInnerBlocks` prop will keep the inner blocks of the // marked block in the block state so that they can be reattached to the // marked block when we re-insert everything a few lines below. let stateAfterBlocksRemoval = state; if ( state.order.get( action.rootClientId ) ) { stateAfterBlocksRemoval = reducer( stateAfterBlocksRemoval, { type: 'REMOVE_BLOCKS', keepControlledInnerBlocks: nestedControllers, clientIds: state.order.get( action.rootClientId ), } ); } let stateAfterInsert = stateAfterBlocksRemoval; if ( action.blocks.length ) { stateAfterInsert = reducer( stateAfterInsert, { ...action, type: 'INSERT_BLOCKS', index: 0, } ); // We need to re-attach the controlled inner blocks to the blocks tree and // preserve their block order. Otherwise, an inner block controller's blocks // will be deleted entirely from its entity. const stateAfterInsertOrder = new Map( stateAfterInsert.order ); Object.keys( nestedControllers ).forEach( ( key ) => { if ( state.order.get( key ) ) { stateAfterInsertOrder.set( key, state.order.get( key ) ); } } ); stateAfterInsert.order = stateAfterInsertOrder; stateAfterInsert.tree = new Map( stateAfterInsert.tree ); Object.keys( nestedControllers ).forEach( ( _key ) => { const key = `controlled||${ _key }`; if ( state.tree.has( key ) ) { stateAfterInsert.tree.set( key, state.tree.get( key ) ); } } ); } return stateAfterInsert; }; /** * Higher-order reducer which targets the combined blocks reducer and handles * the `SAVE_REUSABLE_BLOCK_SUCCESS` action. This action can't be handled by * regular reducers and needs a higher-order reducer since it needs access to * both `byClientId` and `attributes` simultaneously. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ const withSaveReusableBlock = ( reducer ) => ( state, action ) => { if ( state && action.type === 'SAVE_REUSABLE_BLOCK_SUCCESS' ) { const { id, updatedId } = action; // If a temporary reusable block is saved, we swap the temporary id with the final one. if ( id === updatedId ) { return state; } state = { ...state }; state.attributes = new Map( state.attributes ); state.attributes.forEach( ( attributes, clientId ) => { const { name } = state.byClientId.get( clientId ); if ( name === 'core/block' && attributes.ref === id ) { state.attributes.set( clientId, { ...attributes, ref: updatedId, } ); } } ); } return reducer( state, action ); }; /** * Higher-order reducer which removes blocks from state when switching parent block controlled state. * * @param {Function} reducer Original reducer function. * * @return {Function} Enhanced reducer function. */ const withResetControlledBlocks = ( reducer ) => ( state, action ) => { if ( action.type === 'SET_HAS_CONTROLLED_INNER_BLOCKS' ) { // when switching a block from controlled to uncontrolled or inverse, // we need to remove its content first. const tempState = reducer( state, { type: 'REPLACE_INNER_BLOCKS', rootClientId: action.clientId, blocks: [], } ); return reducer( tempState, action ); } return reducer( state, action ); }; /** * Reducer returning the blocks state. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {Object} Updated state. */ export const blocks = pipe( combineReducers, withSaveReusableBlock, // Needs to be before withBlockCache. withBlockTree, // Needs to be before withInnerBlocksRemoveCascade. withInnerBlocksRemoveCascade, withReplaceInnerBlocks, // Needs to be after withInnerBlocksRemoveCascade. withBlockReset, withPersistentBlockChange, withIgnoredBlockChange, withResetControlledBlocks )( { // The state is using a Map instead of a plain object for performance reasons. // You can run the "./test/performance.js" unit test to check the impact // code changes can have on this reducer. byClientId( state = new Map(), action ) { switch ( action.type ) { case 'RECEIVE_BLOCKS': case 'INSERT_BLOCKS': { const newState = new Map( state ); getFlattenedBlocksWithoutAttributes( action.blocks ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'UPDATE_BLOCK': { // Ignore updates if block isn't known. if ( ! state.has( action.clientId ) ) { return state; } // Do nothing if only attributes change. const { attributes, ...changes } = action.updates; if ( Object.values( changes ).length === 0 ) { return state; } const newState = new Map( state ); newState.set( action.clientId, { ...state.get( action.clientId ), ...changes, } ); return newState; } case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': { if ( ! action.blocks ) { return state; } const newState = new Map( state ); action.replacedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); getFlattenedBlocksWithoutAttributes( action.blocks ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const newState = new Map( state ); action.removedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); return newState; } } return state; }, // The state is using a Map instead of a plain object for performance reasons. // You can run the "./test/performance.js" unit test to check the impact // code changes can have on this reducer. attributes( state = new Map(), action ) { switch ( action.type ) { case 'RECEIVE_BLOCKS': case 'INSERT_BLOCKS': { const newState = new Map( state ); getFlattenedBlockAttributes( action.blocks ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'UPDATE_BLOCK': { // Ignore updates if block isn't known or there are no attribute changes. if ( ! state.get( action.clientId ) || ! action.updates.attributes ) { return state; } const newState = new Map( state ); newState.set( action.clientId, { ...state.get( action.clientId ), ...action.updates.attributes, } ); return newState; } case 'SYNC_DERIVED_BLOCK_ATTRIBUTES': case 'UPDATE_BLOCK_ATTRIBUTES': { // Avoid a state change if none of the block IDs are known. if ( action.clientIds.every( ( id ) => ! state.get( id ) ) ) { return state; } let hasChange = false; const newState = new Map( state ); for ( const clientId of action.clientIds ) { const updatedAttributeEntries = Object.entries( action.uniqueByBlock ? action.attributes[ clientId ] : action.attributes ?? {} ); if ( updatedAttributeEntries.length === 0 ) { continue; } let hasUpdatedAttributes = false; const existingAttributes = state.get( clientId ); const newAttributes = {}; updatedAttributeEntries.forEach( ( [ key, value ] ) => { if ( existingAttributes[ key ] !== value ) { hasUpdatedAttributes = true; newAttributes[ key ] = value; } } ); hasChange = hasChange || hasUpdatedAttributes; if ( hasUpdatedAttributes ) { newState.set( clientId, { ...existingAttributes, ...newAttributes, } ); } } return hasChange ? newState : state; } case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': { if ( ! action.blocks ) { return state; } const newState = new Map( state ); action.replacedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); getFlattenedBlockAttributes( action.blocks ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const newState = new Map( state ); action.removedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); return newState; } } return state; }, // The state is using a Map instead of a plain object for performance reasons. // You can run the "./test/performance.js" unit test to check the impact // code changes can have on this reducer. order( state = new Map(), action ) { switch ( action.type ) { case 'RECEIVE_BLOCKS': { const blockOrder = mapBlockOrder( action.blocks ); const newState = new Map( state ); blockOrder.forEach( ( order, clientId ) => { if ( clientId !== '' ) { newState.set( clientId, order ); } } ); newState.set( '', ( state.get( '' ) ?? [] ).concat( blockOrder[ '' ] ) ); return newState; } case 'INSERT_BLOCKS': { const { rootClientId = '' } = action; const subState = state.get( rootClientId ) || []; const mappedBlocks = mapBlockOrder( action.blocks, rootClientId ); const { index = subState.length } = action; const newState = new Map( state ); mappedBlocks.forEach( ( order, clientId ) => { newState.set( clientId, order ); } ); newState.set( rootClientId, insertAt( subState, mappedBlocks.get( rootClientId ), index ) ); return newState; } case 'MOVE_BLOCKS_TO_POSITION': { const { fromRootClientId = '', toRootClientId = '', clientIds, } = action; const { index = state.get( toRootClientId ).length } = action; // Moving inside the same parent block. if ( fromRootClientId === toRootClientId ) { const subState = state.get( toRootClientId ); const fromIndex = subState.indexOf( clientIds[ 0 ] ); const newState = new Map( state ); newState.set( toRootClientId, moveTo( state.get( toRootClientId ), fromIndex, index, clientIds.length ) ); return newState; } // Moving from a parent block to another. const newState = new Map( state ); newState.set( fromRootClientId, state .get( fromRootClientId ) ?.filter( ( id ) => ! clientIds.includes( id ) ) ?? [] ); newState.set( toRootClientId, insertAt( state.get( toRootClientId ), clientIds, index ) ); return newState; } case 'MOVE_BLOCKS_UP': { const { clientIds, rootClientId = '' } = action; const firstClientId = clientIds[ 0 ]; const subState = state.get( rootClientId ); if ( ! subState.length || firstClientId === subState[ 0 ] ) { return state; } const firstIndex = subState.indexOf( firstClientId ); const newState = new Map( state ); newState.set( rootClientId, moveTo( subState, firstIndex, firstIndex - 1, clientIds.length ) ); return newState; } case 'MOVE_BLOCKS_DOWN': { const { clientIds, rootClientId = '' } = action; const firstClientId = clientIds[ 0 ]; const lastClientId = clientIds[ clientIds.length - 1 ]; const subState = state.get( rootClientId ); if ( ! subState.length || lastClientId === subState[ subState.length - 1 ] ) { return state; } const firstIndex = subState.indexOf( firstClientId ); const newState = new Map( state ); newState.set( rootClientId, moveTo( subState, firstIndex, firstIndex + 1, clientIds.length ) ); return newState; } case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const { clientIds } = action; if ( ! action.blocks ) { return state; } const mappedBlocks = mapBlockOrder( action.blocks ); const newState = new Map( state ); action.replacedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); mappedBlocks.forEach( ( order, clientId ) => { if ( clientId !== '' ) { newState.set( clientId, order ); } } ); newState.forEach( ( order, clientId ) => { const newSubOrder = Object.values( order ).reduce( ( result, subClientId ) => { if ( subClientId === clientIds[ 0 ] ) { return [ ...result, ...mappedBlocks.get( '' ) ]; } if ( clientIds.indexOf( subClientId ) === -1 ) { result.push( subClientId ); } return result; }, [] ); newState.set( clientId, newSubOrder ); } ); return newState; } case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const newState = new Map( state ); // Remove inner block ordering for removed blocks. action.removedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); newState.forEach( ( order, clientId ) => { const newSubOrder = order?.filter( ( id ) => ! action.removedClientIds.includes( id ) ) ?? []; if ( newSubOrder.length !== order.length ) { newState.set( clientId, newSubOrder ); } } ); return newState; } } return state; }, // While technically redundant data as the inverse of `order`, it serves as // an optimization for the selectors which derive the ancestry of a block. parents( state = new Map(), action ) { switch ( action.type ) { case 'RECEIVE_BLOCKS': { const newState = new Map( state ); mapBlockParents( action.blocks ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'INSERT_BLOCKS': { const newState = new Map( state ); mapBlockParents( action.blocks, action.rootClientId || '' ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'MOVE_BLOCKS_TO_POSITION': { const newState = new Map( state ); action.clientIds.forEach( ( id ) => { newState.set( id, action.toRootClientId || '' ); } ); return newState; } case 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const newState = new Map( state ); action.replacedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); mapBlockParents( action.blocks, state.get( action.clientIds[ 0 ] ) ).forEach( ( [ key, value ] ) => { newState.set( key, value ); } ); return newState; } case 'REMOVE_BLOCKS_AUGMENTED_WITH_CHILDREN': { const newState = new Map( state ); action.removedClientIds.forEach( ( clientId ) => { newState.delete( clientId ); } ); return newState; } } return state; }, controlledInnerBlocks( state = {}, { type, clientId, hasControlledInnerBlocks } ) { if ( type === 'SET_HAS_CONTROLLED_INNER_BLOCKS' ) { return { ...state, [ clientId ]: hasControlledInnerBlocks, }; } return state; }, } ); /** * Reducer returning visibility status of block interface. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function isBlockInterfaceHidden( state = false, action ) { switch ( action.type ) { case 'HIDE_BLOCK_INTERFACE': return true; case 'SHOW_BLOCK_INTERFACE': return false; } return state; } /** * Reducer returning typing state. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function isTyping( state = false, action ) { switch ( action.type ) { case 'START_TYPING': return true; case 'STOP_TYPING': return false; } return state; } /** * Reducer returning dragging state. It is possible for a user to be dragging * data from outside of the editor, so this state is separate from `draggedBlocks`. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function isDragging( state = false, action ) { switch ( action.type ) { case 'START_DRAGGING': return true; case 'STOP_DRAGGING': return false; } return state; } /** * Reducer returning dragged block client id. * * @param {string[]} state Current state. * @param {Object} action Dispatched action. * * @return {string[]} Updated state. */ export function draggedBlocks( state = [], action ) { switch ( action.type ) { case 'START_DRAGGING_BLOCKS': return action.clientIds; case 'STOP_DRAGGING_BLOCKS': return []; } return state; } /** * Reducer tracking the visible blocks. * * @param {Record<string,boolean>} state Current state. * @param {Object} action Dispatched action. * * @return {Record<string,boolean>} Block visibility. */ export function blockVisibility( state = {}, action ) { if ( action.type === 'SET_BLOCK_VISIBILITY' ) { return { ...state, ...action.updates, }; } return state; } /** * Internal helper reducer for selectionStart and selectionEnd. Can hold a block * selection, represented by an object with property clientId. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {Object} Updated state. */ function selectionHelper( state = {}, action ) { switch ( action.type ) { case 'CLEAR_SELECTED_BLOCK': { if ( state.clientId ) { return {}; } return state; } case 'SELECT_BLOCK': if ( action.clientId === state.clientId ) { return state; } return { clientId: action.clientId }; case 'REPLACE_INNER_BLOCKS': case 'INSERT_BLOCKS': { if ( ! action.updateSelection || ! action.blocks.length ) { return state; } return { clientId: action.blocks[ 0 ].clientId }; } case 'REMOVE_BLOCKS': if ( ! action.clientIds || ! action.clientIds.length || action.clientIds.indexOf( state.clientId ) === -1 ) { return state; } return {}; case 'REPLACE_BLOCKS': { if ( action.clientIds.indexOf( state.clientId ) === -1 ) { return state; } const blockToSelect = action.blocks[ action.indexToSelect ] || action.blocks[ action.blocks.length - 1 ]; if ( ! blockToSelect ) { return {}; } if ( blockToSelect.clientId === state.clientId ) { return state; } return { clientId: blockToSelect.clientId }; } } return state; } /** * Reducer returning the selection state. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function selection( state = {}, action ) { switch ( action.type ) { case 'SELECTION_CHANGE': if ( action.clientId ) { return { selectionStart: { clientId: action.clientId, attributeKey: action.attributeKey, offset: action.startOffset, }, selectionEnd: { clientId: action.clientId, attributeKey: action.attributeKey, offset: action.endOffset, }, }; } return { selectionStart: action.start || state.selectionStart, selectionEnd: action.end || state.selectionEnd, }; case 'RESET_SELECTION': const { selectionStart, selectionEnd } = action; return { selectionStart, selectionEnd, }; case 'MULTI_SELECT': const { start, end } = action; if ( start === state.selectionStart?.clientId && end === state.selectionEnd?.clientId ) { return state; } return { selectionStart: { clientId: start }, selectionEnd: { clientId: end }, }; case 'RESET_BLOCKS': const startClientId = state?.selectionStart?.clientId; const endClientId = state?.selectionEnd?.clientId; // Do nothing if there's no selected block. if ( ! startClientId && ! endClientId ) { return state; } // If the start of the selection won't exist after reset, remove selection. if ( ! action.blocks.some( ( block ) => block.clientId === startClientId ) ) { return { selectionStart: {}, selectionEnd: {}, }; } // If the end of the selection won't exist after reset, collapse selection. if ( ! action.blocks.some( ( block ) => block.clientId === endClientId ) ) { return { ...state, selectionEnd: state.selectionStart, }; } } const selectionStart = selectionHelper( state.selectionStart, action ); const selectionEnd = selectionHelper( state.selectionEnd, action ); if ( selectionStart === state.selectionStart && selectionEnd === state.selectionEnd ) { return state; } return { selectionStart, selectionEnd, }; } /** * Reducer returning whether the user is multi-selecting. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function isMultiSelecting( state = false, action ) { switch ( action.type ) { case 'START_MULTI_SELECT': return true; case 'STOP_MULTI_SELECT': return false; } return state; } /** * Reducer returning whether selection is enabled. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function isSelectionEnabled( state = true, action ) { switch ( action.type ) { case 'TOGGLE_SELECTION': return action.isSelectionEnabled; } return state; } /** * Reducer returning the data needed to display a prompt when certain blocks * are removed, or `false` if no such prompt is requested. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {Object|false} Data for removal prompt display, if any. */ function removalPromptData( state = false, action ) { switch ( action.type ) { case 'DISPLAY_BLOCK_REMOVAL_PROMPT': const { clientIds, selectPrevious, message } = action; return { clientIds, selectPrevious, message, }; case 'CLEAR_BLOCK_REMOVAL_PROMPT': return false; } return state; } /** * Reducer returning any rules that a block editor may provide in order to * prevent a user from accidentally removing certain blocks. These rules are * then used to display a confirmation prompt to the user. For instance, in the * Site Editor, the Query Loop block is important enough to warrant such * confirmation. * * The data is a record whose keys are block types (e.g. 'core/query') and * whose values are the explanation to be shown to users (e.g. 'Query Loop * displays a list of posts or pages.'). * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {Record<string,string>} Updated state. */ function blockRemovalRules( state = false, action ) { switch ( action.type ) { case 'SET_BLOCK_REMOVAL_RULES': return action.rules; } return state; } /** * Reducer returning the initial block selection. * * Currently this in only used to restore the selection after block deletion and * pasting new content.This reducer should eventually be removed in favour of setting * selection directly. * * @param {boolean} state Current state. * @param {Object} action Dispatched action. * * @return {number|null} Initial position: 0, -1 or null. */ export function initialPosition( state = null, action ) { if ( action.type === 'REPLACE_BLOCKS' && action.initialPosition !== undefined ) { return action.initialPosition; } else if ( [ 'MULTI_SELECT', 'SELECT_BLOCK', 'RESET_SELECTION', 'INSERT_BLOCKS', 'REPLACE_INNER_BLOCKS', ].includes( action.type ) ) { return action.initialPosition; } return state; } export function blocksMode( state = {}, action ) { if ( action.type === 'TOGGLE_BLOCK_MODE' ) { const { clientId } = action; return { ...state, [ clientId ]: state[ clientId ] && state[ clientId ] === 'html' ? 'visual' : 'html', }; } return state; } /** * Reducer returning the block insertion point visibility, either null if there * is not an explicit insertion point assigned, or an object of its `index` and * `rootClientId`. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {Object} Updated state. */ export function insertionCue( state = null, action ) { switch ( action.type ) { case 'SHOW_INSERTION_POINT': { const { rootClientId, index, __unstableWithInserter, operation, nearestSide, } = action; const nextState = { rootClientId, index, __unstableWithInserter, operation, nearestSide, }; // Bail out updates if the states are the same. return fastDeepEqual( state, nextState ) ? state : nextState; } case 'HIDE_INSERTION_POINT': return null; } return state; } /** * Reducer returning whether the post blocks match the defined template or not. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {boolean} Updated state. */ export function template( state = { isValid: true }, action ) { switch ( action.type ) { case 'SET_TEMPLATE_VALIDITY': return { ...state, isValid: action.isValid, }; } return state; } /** * Reducer returning the editor setting. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {Object} Updated state. */ export function settings( state = SETTINGS_DEFAULTS, action ) { switch ( action.type ) { case 'UPDATE_SETTINGS': { const updatedSettings = action.reset ? { ...SETTINGS_DEFAULTS, ...action.settings, } : { ...state, ...action.settings, }; Object.defineProperty( updatedSettings, '__unstableIsPreviewMode', { get() { deprecated( '__unstableIsPreviewMode', { since: '6.8', alternative: 'isPreviewMode', } ); return this.isPreviewMode; }, } ); return updatedSettings; } } return state; } /** * Reducer returning the user preferences. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {string} Updated state. */ export function preferences( state = PREFERENCES_DEFAULTS, action ) { switch ( action.type ) { case 'INSERT_BLOCKS': case 'REPLACE_BLOCKS': { const nextInsertUsage = action.blocks.reduce( ( prevUsage, block ) => { const { attributes, name: blockName } = block; let id = blockName; // If a block variation match is found change the name to be the same with the // one that is used for block variations in the Inserter (`getItemFromVariation`). const match = select( blocksStore ).getActiveBlockVariation( blockName, attributes ); if ( match?.name ) { id += '/' + match.name; } if ( blockName === 'core/block' ) { id += '/' + attributes.ref; } return { ...prevUsage, [ id ]: { time: action.time, count: prevUsage[ id ] ? prevUsage[ id ].count + 1 : 1, }, }; }, state.insertUsage ); return { ...state, insertUsage: nextInsertUsage, }; } } return state; } /** * Reducer returning an object where each key is a block client ID, its value * representing the settings for its nested blocks. * * @param {Object} state Current state. * @param {Object} action Dispatched action. * * @return {Object} Updated state. */ export const blockListSettings = ( state = {}, action ) => { switch ( action.type ) { // Even if the replaced blocks have the same client ID, our logic // should correct the state. case 'REPLACE_BLOCKS': case 'REMOVE_BLOCKS': { return Object.fromEntries( Object.entries( state ).filter( ( [ id ] ) => ! action.clientIds.includes( id ) ) ); } case 'UPDATE_BLOCK_LIST_SETTINGS': { const updates = typeof action.clientId === 'string' ? { [ action.clientId ]: action.settings } : action.clientId; // Remove settings that are the same as the current state. for ( const clientId in updates ) { if ( ! updates[ clientId ] ) { if ( ! state[ clientId ] ) { delete updates[ clientId ]; } } else if ( fastDeepEqual( state[ clientId ], updates[ clientId ] ) ) { delete updates[ clientId ]; } } if ( Object.keys( updates ).length === 0 ) { return state; } const merged = { ...state, ...updates }; for ( const clientId in updates ) { if ( ! updates[ clientId ] ) { delete merged[ clientId ]; } } return merged; } } return state; }; /** * Reducer return an updated state representing the most recent block attribute * update. The state is structured as an object where the keys represent the * client IDs of blocks, the values a subset of attributes from the most recent * block update. The state is always reset to null if the last action is * anything other than an attributes update. * * @param {Object<string,Object>} state Current state. * @param {Object} action Action object. * * @return {[string,Object]} Updated state. */ export function lastBlockAttributesChange( state = null, action ) { switch ( action.type ) { case 'UPDATE_BLOCK': if ( ! action.updates.attributes ) { break; } return { [ action.clientId ]: action.updates.attributes }; case 'UPDATE_BLOCK_ATTRIBUTES': return action.clientIds.reduce( ( accumulator, id ) => ( { ...accumulator, [ id ]: action.uniqueByBlock ? action.attributes[ id ] : action.attributes, } ), {} ); } return state; } /** * Reducer returning current highlighted block. * * @param {boolean} state Current highlighted block. * @param {Object} action Dispatched action. * * @return {string} Updated state. */ export function highlightedBlock( state, action ) { switch ( action.type ) { case 'TOGGLE_BLOCK_HIGHLIGHT': const { clientId, isHighlighted } = action; if ( isHighlighted ) { return clientId; } else if ( state === clientId ) { return null; } return state; case 'SELECT_BLOCK': if ( action.clientId !== state ) { return null; } } return state; } /** * Reducer returning current expanded block in the list view. * * @param {string|null} state Current expanded block. * @param {Object} action Dispatched action. * * @return {string|null} Updated state. */ export function expandedBlock( state = null, action ) { swi