diff --git a/packages/block-editor/src/components/block-title/test/index.js b/packages/block-editor/src/components/block-title/test/index.js index 8a4d3c2f52fd7..fc6af61bae9e7 100644 --- a/packages/block-editor/src/components/block-title/test/index.js +++ b/packages/block-editor/src/components/block-title/test/index.js @@ -31,7 +31,9 @@ const blockLabelMap = { }; jest.mock( '@wordpress/blocks', () => { + const actualImplementation = jest.requireActual( '@wordpress/blocks' ); return { + ...actualImplementation, isReusableBlock( { title } ) { return title === 'Reusable Block'; }, diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 5a5ce7a801594..9779ae1300fb5 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -109,16 +109,16 @@ function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { * * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. */ -export const getEnabledClientIdsTree = createRegistrySelector( ( select ) => - createSelector( getEnabledClientIdsTreeUnmemoized, ( state ) => [ +export const getEnabledClientIdsTree = createSelector( + getEnabledClientIdsTreeUnmemoized, + ( state ) => [ state.blocks.order, + state.derivedBlockEditingModes, + state.derivedNavModeBlockEditingModes, state.blockEditingModes, state.settings.templateLock, state.blockListSettings, - select( STORE_NAME ).__unstableGetEditorMode( state ), - state.zoomLevel, - getSectionRootClientId( state ), - ] ) + ] ); /** diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 2f0fa70d616fd..1e09ec98f005a 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -9,12 +9,20 @@ import fastDeepEqual from 'fast-deep-equal/es6'; import { pipe } from '@wordpress/compose'; import { combineReducers, select } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; -import { store as blocksStore } from '@wordpress/blocks'; +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; @@ -2131,6 +2139,632 @@ const combinedReducers = combineReducers( { zoomLevel, } ); +/** + * Retrieves a block's tree structure, handling both controlled and uncontrolled inner blocks. + * + * @param {Object} state The current state object. + * @param {string} clientId The client ID of the block to retrieve. + * + * @return {Object|undefined} The block tree object, or undefined if not found. For controlled blocks, + * returns a merged tree with controlled inner blocks. + */ +function getBlockTreeBlock( state, clientId ) { + if ( clientId === '' ) { + const rootBlock = state.blocks.tree.get( clientId ); + + if ( ! rootBlock ) { + return; + } + + // Patch the root block to have a clientId property. + // TODO - consider updating the blocks reducer so that the root block has this property. + return { + clientId: '', + ...rootBlock, + }; + } + + if ( ! state.blocks.controlledInnerBlocks[ clientId ] ) { + return state.blocks.tree.get( clientId ); + } + + const controlledTree = state.blocks.tree.get( `controlled||${ clientId }` ); + const regularTree = state.blocks.tree.get( clientId ); + + return { + ...regularTree, + innerBlocks: controlledTree?.innerBlocks, + }; +} + +/** + * Recursively traverses through a block tree of a given block and executes a callback for each block. + * + * @param {Object} state The store state. + * @param {string} clientId The clientId of the block to start traversing from. + * @param {Function} callback Function to execute for each block encountered during traversal. + * The callback receives the current block as its argument. + */ +function traverseBlockTree( state, clientId, callback ) { + const parentTree = getBlockTreeBlock( state, clientId ); + if ( ! parentTree ) { + return; + } + + callback( parentTree ); + + if ( ! parentTree?.innerBlocks?.length ) { + return; + } + + for ( const block of parentTree?.innerBlocks ) { + traverseBlockTree( state, block.clientId, callback ); + } +} + +/** + * Checks if a block has a parent in a list of client IDs, and if so returns the client ID of the parent. + * + * @param {Object} state The current state object. + * @param {string} clientId The client ID of the block to search the parents of. + * @param {Array} clientIds The client IDs of the blocks to check. + * + * @return {string|undefined} The client ID of the parent block if found, undefined otherwise. + */ +function findParentInClientIdsList( state, clientId, clientIds ) { + let parent = state.blocks.parents.get( clientId ); + while ( parent ) { + if ( clientIds.includes( parent ) ) { + return parent; + } + parent = state.blocks.parents.get( parent ); + } +} + +/** + * Checks if a block has any bindings in its metadata attributes. + * + * @param {Object} block The block object to check for bindings. + * @return {boolean} True if the block has bindings, false otherwise. + */ +function hasBindings( block ) { + return ( + block?.attributes?.metadata?.bindings && + Object.keys( block?.attributes?.metadata?.bindings ).length + ); +} + +/** + * Computes and returns derived block editing modes for a given block tree. + * + * This function calculates the editing modes for each block in the tree, taking into account + * various factors such as zoom level, navigation mode, sections, and synced patterns. + * + * @param {Object} state The current state object. + * @param {boolean} isNavMode Whether the navigation mode is active. + * @param {string} treeClientId The client ID of the root block for the tree. Defaults to an empty string. + * @return {Map} A Map containing the derived block editing modes, keyed by block client ID. + */ +function getDerivedBlockEditingModesForTree( + state, + isNavMode = false, + treeClientId = '' +) { + const isZoomedOut = + state?.zoomLevel < 100 || state?.zoomLevel === 'auto-scaled'; + const derivedBlockEditingModes = new Map(); + + // When there are sections, the majority of blocks are disabled, + // so the default block editing mode is set to disabled. + const sectionRootClientId = state.settings?.[ sectionRootClientIdKey ]; + const sectionClientIds = state.blocks.order.get( sectionRootClientId ); + const syncedPatternClientIds = Object.keys( + state.blocks.controlledInnerBlocks + ).filter( + ( clientId ) => + state.blocks.byClientId?.get( clientId )?.name === 'core/block' + ); + + traverseBlockTree( state, treeClientId, ( block ) => { + const { clientId, name: blockName } = block; + if ( isZoomedOut || isNavMode ) { + // If the root block is the section root set its editing mode to contentOnly. + if ( clientId === sectionRootClientId ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // There are no sections, so everything else is disabled. + if ( ! sectionClientIds?.length ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( sectionClientIds.includes( clientId ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // If zoomed out, all blocks that aren't sections or the section root are + // disabled. + // If the tree root is not in a section, set its editing mode to disabled. + if ( + isZoomedOut || + ! findParentInClientIdsList( state, clientId, sectionClientIds ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + // Handle synced pattern content so the inner blocks of a synced pattern are + // properly disabled. + if ( syncedPatternClientIds.length ) { + const parentPatternClientId = findParentInClientIdsList( + state, + clientId, + syncedPatternClientIds + ); + + if ( parentPatternClientId ) { + // This is a pattern nested in another pattern, it should be disabled. + if ( + findParentInClientIdsList( + state, + parentPatternClientId, + syncedPatternClientIds + ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( hasBindings( block ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // Synced pattern content without a binding isn't editable + // from the instance, the user has to edit the pattern source, + // so return 'disabled'. + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + } + + if ( blockName && isContentBlock( blockName ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( syncedPatternClientIds.length ) { + // Synced pattern blocks (core/block). + if ( syncedPatternClientIds.includes( clientId ) ) { + // This is a pattern nested in another pattern, it should be disabled. + if ( + findParentInClientIdsList( + state, + clientId, + syncedPatternClientIds + ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // Inner blocks of synced patterns. + const parentPatternClientId = findParentInClientIdsList( + state, + clientId, + syncedPatternClientIds + ); + if ( parentPatternClientId ) { + // This is a pattern nested in another pattern, it should be disabled. + if ( + findParentInClientIdsList( + state, + parentPatternClientId, + syncedPatternClientIds + ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( hasBindings( block ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // Synced pattern content without a binding isn't editable + // from the instance, the user has to edit the pattern source, + // so return 'disabled'. + derivedBlockEditingModes.set( clientId, 'disabled' ); + } + } + } ); + + return derivedBlockEditingModes; +} + +/** + * Updates the derived block editing modes based on added and removed blocks. + * + * This function handles the updating of block editing modes when blocks are added, + * removed, or moved within the editor. + * + * It only returns a value when modifications are made to the block editing modes. + * + * @param {Object} options The options for updating derived block editing modes. + * @param {Object} options.prevState The previous state object. + * @param {Object} options.nextState The next state object. + * @param {Array} [options.addedBlocks] An array of blocks that were added. + * @param {Array} [options.removedClientIds] An array of client IDs of blocks that were removed. + * @param {boolean} [options.isNavMode] Whether the navigation mode is active. + * @return {Map|undefined} The updated derived block editing modes, or undefined if no changes were made. + */ +function getDerivedBlockEditingModesUpdates( { + prevState, + nextState, + addedBlocks, + removedClientIds, + isNavMode = false, +} ) { + const prevDerivedBlockEditingModes = isNavMode + ? prevState.derivedNavModeBlockEditingModes + : prevState.derivedBlockEditingModes; + let nextDerivedBlockEditingModes; + + // Perform removals before additions to handle cases like the `MOVE_BLOCKS_TO_POSITION` action. + // That action removes a set of clientIds, but adds the same blocks back in a different location. + // If removals were performed after additions, those moved clientIds would be removed incorrectly. + removedClientIds?.forEach( ( clientId ) => { + // The actions only receive parent block IDs for removal. + // Recurse through the block tree to ensure all blocks are removed. + // Specifically use the previous state, before the blocks were removed. + traverseBlockTree( prevState, clientId, ( block ) => { + if ( prevDerivedBlockEditingModes.has( block.clientId ) ) { + if ( ! nextDerivedBlockEditingModes ) { + nextDerivedBlockEditingModes = new Map( + prevDerivedBlockEditingModes + ); + } + nextDerivedBlockEditingModes.delete( block.clientId ); + } + } ); + } ); + + addedBlocks?.forEach( ( addedBlock ) => { + traverseBlockTree( nextState, addedBlock.clientId, ( block ) => { + const updates = getDerivedBlockEditingModesForTree( + nextState, + isNavMode, + block.clientId + ); + + if ( updates.size ) { + if ( ! nextDerivedBlockEditingModes ) { + nextDerivedBlockEditingModes = new Map( [ + ...( prevDerivedBlockEditingModes?.size + ? prevDerivedBlockEditingModes + : [] ), + ...updates, + ] ); + } else { + nextDerivedBlockEditingModes = new Map( [ + ...( nextDerivedBlockEditingModes?.size + ? nextDerivedBlockEditingModes + : [] ), + ...updates, + ] ); + } + } + } ); + } ); + + return nextDerivedBlockEditingModes; +} + +/** + * Higher-order reducer that adds derived block editing modes to the state. + * + * This function wraps a reducer and enhances it to handle actions that affect + * block editing modes. It updates the derivedBlockEditingModes in the state + * based on various actions such as adding, removing, or moving blocks, or changing + * the editor mode. + * + * @param {Function} reducer The original reducer function to be wrapped. + * @return {Function} A new reducer function that includes derived block editing modes handling. + */ +export function withDerivedBlockEditingModes( reducer ) { + return ( state, action ) => { + const nextState = reducer( state, action ); + + // An exception is needed here to still recompute the block editing modes when + // the editor mode changes since the editor mode isn't stored within the + // block editor state and changing it won't trigger an altered new state. + if ( action.type !== 'SET_EDITOR_MODE' && nextState === state ) { + return state; + } + + switch ( action.type ) { + case 'REMOVE_BLOCKS': { + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + removedClientIds: action.clientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + removedClientIds: action.clientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'RECEIVE_BLOCKS': + case 'INSERT_BLOCKS': { + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'SET_HAS_CONTROLLED_INNER_BLOCKS': { + const updatedBlock = nextState.blocks.tree.get( + action.clientId + ); + // The block might have been removed. + if ( ! updatedBlock ) { + break; + } + + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: [ updatedBlock ], + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: [ updatedBlock ], + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'REPLACE_BLOCKS': { + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds: action.clientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds: action.clientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'REPLACE_INNER_BLOCKS': { + // Get the clientIds of the blocks that are being replaced + // from the old state, before they were removed. + const removedClientIds = state.blocks.order.get( + action.rootClientId + ); + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'MOVE_BLOCKS_TO_POSITION': { + const addedBlocks = action.clientIds.map( ( clientId ) => { + return nextState.blocks.byClientId.get( clientId ); + } ); + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks, + removedClientIds: action.clientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks, + removedClientIds: action.clientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'UPDATE_SETTINGS': { + // Recompute the entire tree if the section root changes. + if ( + state?.settings?.[ sectionRootClientIdKey ] !== + nextState?.settings?.[ sectionRootClientIdKey ] + ) { + return { + ...nextState, + derivedBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + false /* Nav mode off */ + ), + derivedNavModeBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + true /* Nav mode on */ + ), + }; + } + break; + } + case 'RESET_BLOCKS': + case 'SET_EDITOR_MODE': + case 'RESET_ZOOM_LEVEL': + case 'SET_ZOOM_LEVEL': { + // Recompute the entire tree if the editor mode or zoom level changes, + // or if all the blocks are reset. + return { + ...nextState, + derivedBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + false /* Nav mode off */ + ), + derivedNavModeBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + true /* Nav mode on */ + ), + }; + } + } + + // If there's no change, the derivedBlockEditingModes from the previous + // state need to be preserved. + nextState.derivedBlockEditingModes = + state?.derivedBlockEditingModes ?? new Map(); + nextState.derivedNavModeBlockEditingModes = + state?.derivedNavModeBlockEditingModes ?? new Map(); + + return nextState; + }; +} + function withAutomaticChangeReset( reducer ) { return ( state, action ) => { const nextState = reducer( state, action ); @@ -2184,4 +2818,7 @@ function withAutomaticChangeReset( reducer ) { }; } -export default withAutomaticChangeReset( combinedReducers ); +export default pipe( + withDerivedBlockEditingModes, + withAutomaticChangeReset +)( combinedReducers ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index ac1d178f43de7..75c43770f7e17 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -3000,14 +3000,6 @@ export function __unstableIsWithinBlockOverlay( state, clientId ) { return false; } -function isWithinBlock( state, clientId, parentClientId ) { - let parent = state.blocks.parents.get( clientId ); - while ( !! parent && parent !== parentClientId ) { - parent = state.blocks.parents.get( parent ); - } - return parent === parentClientId; -} - /** * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode */ @@ -3049,68 +3041,28 @@ export const getBlockEditingMode = createRegistrySelector( clientId = ''; } - // In zoom-out mode, override the behavior set by - // __unstableSetBlockEditingMode to only allow editing the top-level - // sections. - if ( isZoomOut( state ) ) { - const sectionRootClientId = getSectionRootClientId( state ); - - if ( clientId === '' /* ROOT_CONTAINER_CLIENT_ID */ ) { - return sectionRootClientId ? 'disabled' : 'contentOnly'; - } - if ( clientId === sectionRootClientId ) { - return 'contentOnly'; - } - const sectionsClientIds = getBlockOrder( - state, - sectionRootClientId - ); - - // Sections are always contentOnly. - if ( sectionsClientIds?.includes( clientId ) ) { - return 'contentOnly'; - } - - return 'disabled'; + const isNavMode = + select( preferencesStore )?.get( 'core', 'editorTool' ) === + 'navigation'; + + // If the editor is currently not in navigation mode, check if the clientId + // has an editing mode set in the regular derived map. + // There may be an editing mode set here for synced patterns or in zoomed out + // mode. + if ( + ! isNavMode && + state.derivedBlockEditingModes?.has( clientId ) + ) { + return state.derivedBlockEditingModes.get( clientId ); } - const editorMode = __unstableGetEditorMode( state ); - if ( editorMode === 'navigation' ) { - const sectionRootClientId = getSectionRootClientId( state ); - - // The root section is "default mode" - if ( clientId === sectionRootClientId ) { - return 'default'; - } - - // Sections should always be contentOnly in navigation mode. - const sectionsClientIds = getBlockOrder( - state, - sectionRootClientId - ); - if ( sectionsClientIds.includes( clientId ) ) { - return 'contentOnly'; - } - - // Blocks outside sections should be disabled. - const isWithinSectionRoot = isWithinBlock( - state, - clientId, - sectionRootClientId - ); - if ( ! isWithinSectionRoot ) { - return 'disabled'; - } - - // The rest of the blocks depend on whether they are content blocks or not. - // This "flattens" the sections tree. - const name = getBlockName( state, clientId ); - const { hasContentRoleAttribute } = unlock( - select( blocksStore ) - ); - const isContent = hasContentRoleAttribute( name ); - - return isContent ? 'contentOnly' : 'disabled'; + // If the editor *is* in navigation mode, the block editing mode states + // are stored in the derivedNavModeBlockEditingModes map. + if ( + isNavMode && + state.derivedNavModeBlockEditingModes?.has( clientId ) + ) { + return state.derivedNavModeBlockEditingModes.get( clientId ); } // In normal mode, consider that an explicitely set editing mode takes over. @@ -3120,7 +3072,7 @@ export const getBlockEditingMode = createRegistrySelector( } // In normal mode, top level is default mode. - if ( ! clientId ) { + if ( clientId === '' ) { return 'default'; } diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index fb1d736e175af..268d463f227d4 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -129,6 +129,7 @@ describe( 'private selectors', () => { getBlockEditingMode.registry = { select: jest.fn( () => ( { hasContentRoleAttribute, + get, } ) ), }; __unstableGetEditorMode.registry = { diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index c99d639ba8a09..b539afde9e025 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -10,7 +10,10 @@ import { registerBlockType, unregisterBlockType, createBlock, + privateApis, } from '@wordpress/blocks'; +import { combineReducers, select } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies @@ -38,8 +41,30 @@ import { blockEditingModes, openedBlockSettingsMenu, expandedBlock, + zoomLevel, + withDerivedBlockEditingModes, } from '../reducer'; +import { unlock } from '../../lock-unlock'; +import { sectionRootClientIdKey } from '.././private-keys'; + +const { isContentBlock } = unlock( privateApis ); + +jest.mock( '@wordpress/data/src/select', () => { + const actualSelect = jest.requireActual( '@wordpress/data/src/select' ); + + return { + select: jest.fn( ( ...args ) => actualSelect.select( ...args ) ), + }; +} ); + +jest.mock( '@wordpress/blocks/src/api/utils', () => { + return { + ...jest.requireActual( '@wordpress/blocks/src/api/utils' ), + isContentBlock: jest.fn(), + }; +} ); + const noop = () => {}; describe( 'state', () => { @@ -3544,4 +3569,830 @@ describe( 'state', () => { expect( state ).toBe( null ); } ); } ); + + describe( 'withDerivedBlockEditingModes', () => { + const testReducer = withDerivedBlockEditingModes( + combineReducers( { + blocks, + settings, + zoomLevel, + } ) + ); + + function dispatchActions( actions, reducer, initialState = {} ) { + return actions.reduce( ( _state, action ) => { + return reducer( _state, action ); + }, initialState ); + } + + beforeEach( () => { + isContentBlock.mockImplementation( + ( blockName ) => blockName === 'core/paragraph' + ); + } ); + + afterAll( () => { + isContentBlock.mockRestore(); + } ); + + describe( 'edit mode', () => { + let initialState; + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + ], + testReducer + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns no block editing modes when zoomed out / navigation mode are not active and there are no synced patterns', () => { + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map() + ); + } ); + } ); + + describe( 'synced patterns', () => { + let initialState; + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + + // Simulates how the editor typically inserts controlled blocks, + // - first the pattern is inserted with no inner blocks. + // - next the pattern is marked as a controlled block. + // - finally, once the inner blocks of the pattern are received, they're inserted. + // This process is repeated for the two patterns in this test. + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + { + type: 'INSERT_BLOCKS', + rootClientId: '', + blocks: [ + { + name: 'core/block', + clientId: 'root-pattern', + attributes: {}, + innerBlocks: [], + }, + ], + }, + { + type: 'SET_HAS_CONTROLLED_INNER_BLOCKS', + clientId: 'root-pattern', + hasControlledInnerBlocks: true, + }, + { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'root-pattern', + blocks: [ + { + name: 'core/block', + clientId: 'nested-pattern', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/paragraph', + clientId: 'pattern-paragraph', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'pattern-group', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: + 'pattern-paragraph-with-overrides', + attributes: { + metadata: { + bindings: { + __default: + 'core/pattern-overrides', + }, + }, + }, + innerBlocks: [], + }, + ], + }, + ], + }, + { + type: 'SET_HAS_CONTROLLED_INNER_BLOCKS', + clientId: 'nested-pattern', + hasControlledInnerBlocks: true, + }, + { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'nested-pattern', + blocks: [ + { + name: 'core/paragraph', + clientId: 'nested-paragraph', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'nested-group', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: + 'nested-paragraph-with-overrides', + attributes: { + metadata: { + bindings: { + __default: + 'core/pattern-overrides', + }, + }, + }, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + testReducer, + initialState + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns the expected block editing modes for synced patterns', () => { + // Only the parent pattern and its own children that have bindings + // are in contentOnly mode. All other blocks are disabled. + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + 'root-pattern': 'contentOnly', + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'contentOnly', + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + } ); + + it( 'removes block editing modes when synced patterns are removed', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ 'root-pattern' ], + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( new Map() ); + } ); + + it( 'returns the expected block editing modes for synced patterns when switching to navigation mode', () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + const { + derivedBlockEditingModes, + derivedNavModeBlockEditingModes, + } = dispatchActions( + [ + { + type: 'SET_EDITOR_MODE', + mode: 'navigation', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + 'root-pattern': 'contentOnly', // Pattern and section. + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'contentOnly', // Pattern child with bindings. + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'disabled', + 'paragraph-2': 'contentOnly', // Content block in section. + 'root-pattern': 'contentOnly', // Pattern and section. + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'contentOnly', // Pattern child with bindings. + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + } ); + + it( 'returns the expected block editing modes for synced patterns when switching to zoomed out mode', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_ZOOM_LEVEL', + zoom: 'auto-scaled', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + 'root-pattern': 'contentOnly', // Pattern and section. + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'disabled', + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + } ); + } ); + + describe( 'navigation mode', () => { + let initialState; + + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + ], + testReducer + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns the expected block editing modes', () => { + expect( initialState.derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'disabled', // Non-content block in section. + 'paragraph-2': 'contentOnly', // Content block in section. + } ) + ) + ); + } ); + + it( 'removes block editing modes when blocks are removed', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ 'group-2' ], + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', + 'group-1': 'contentOnly', + 'paragraph-1': 'contentOnly', + } ) + ) + ); + } ); + + it( 'updates block editing modes when new blocks are inserted', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'INSERT_BLOCKS', + rootClientId: '', + blocks: [ + { + name: 'core/group', + clientId: 'group-3', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-3', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-4', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'disabled', // Non-content block in section. + 'paragraph-2': 'contentOnly', // Content block in section. + 'group-3': 'contentOnly', // New section block. + 'paragraph-3': 'contentOnly', // New content block in section. + 'group-4': 'disabled', // Non-content block in section. + } ) + ) + ); + } ); + + it( 'updates block editing modes when blocks are moved to a new position', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'MOVE_BLOCKS_TO_POSITION', + clientIds: [ 'group-2' ], + fromRootClientId: 'group-1', + toRootClientId: '', + }, + ], + testReducer, + initialState + ); + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'contentOnly', // New section block. + 'paragraph-2': 'contentOnly', // Still a content block in a section. + } ) + ) + ); + } ); + + it( 'handles changes to the section root', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: 'group-1', + }, + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'disabled', + 'group-1': 'contentOnly', + 'paragraph-1': 'contentOnly', + 'group-2': 'contentOnly', + 'paragraph-2': 'contentOnly', + } ) + ) + ); + } ); + } ); + + describe( 'zoom out mode', () => { + let initialState; + + beforeAll( () => { + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'SET_ZOOM_LEVEL', + zoom: 'auto-scaled', + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + ], + testReducer + ); + } ); + + it( 'returns the expected block editing modes', () => { + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + + it( 'overrides navigation mode', () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_EDITOR_MODE', + mode: 'navigation', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + } ) + ) + ); + + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + } ); + + it( 'removes block editing modes when blocks are removed', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ 'group-2' ], + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', + 'group-1': 'contentOnly', + 'paragraph-1': 'disabled', + } ) + ) + ); + } ); + + it( 'updates block editing modes when new blocks are inserted', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'INSERT_BLOCKS', + rootClientId: '', + blocks: [ + { + name: 'core/group', + clientId: 'group-3', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-3', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-4', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + 'group-3': 'contentOnly', // New section block. + 'paragraph-3': 'disabled', + 'group-4': 'disabled', + } ) + ) + ); + } ); + + it( 'updates block editing modes when blocks are moved to a new position', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'MOVE_BLOCKS_TO_POSITION', + clientIds: [ 'group-2' ], + fromRootClientId: 'group-1', + toRootClientId: '', + }, + ], + testReducer, + initialState + ); + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'contentOnly', // New section block. + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + + it( 'handles changes to the section root', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: 'group-1', + }, + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'disabled', + 'group-1': 'contentOnly', // New section root. + 'paragraph-1': 'contentOnly', // Section block. + 'group-2': 'contentOnly', // Section block. + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 7c0361449c5fc..7692bd6bf2cbb 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -9,14 +9,12 @@ import { import { RawHTML } from '@wordpress/element'; import { symbol } from '@wordpress/icons'; import { select, dispatch } from '@wordpress/data'; -import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ import * as selectors from '../selectors'; import { store } from '../'; -import { sectionRootClientIdKey } from '../private-keys'; import { lock } from '../../lock-unlock'; const { @@ -4469,29 +4467,19 @@ describe( 'getBlockEditingMode', () => { blockEditingModes: new Map( [] ), }; - const navigationModeStateWithRootSection = { - ...baseState, - settings: { - [ sectionRootClientIdKey ]: 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', // The group is the "main" container - }, - }; - const hasContentRoleAttribute = jest.fn( () => false ); + const get = jest.fn( () => 'edit' ); - const fauxPrivateAPIs = {}; + const mockedSelectors = { get }; - lock( fauxPrivateAPIs, { + lock( mockedSelectors, { hasContentRoleAttribute, } ); getBlockEditingMode.registry = { - select: jest.fn( () => fauxPrivateAPIs ), + select: jest.fn( () => mockedSelectors ), }; - afterEach( () => { - dispatch( preferencesStore ).set( 'core', 'editorTool', undefined ); - } ); - it( 'should return default by default', () => { expect( getBlockEditingMode( @@ -4614,98 +4602,4 @@ describe( 'getBlockEditingMode', () => { getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) ).toBe( 'contentOnly' ); } ); - - describe( 'navigation mode', () => { - const writeModeExperiment = window.__experimentalEditorWriteMode; - beforeAll( () => { - window.__experimentalEditorWriteMode = true; - } ); - afterAll( () => { - window.__experimentalEditorWriteMode = writeModeExperiment; - } ); - it( 'in navigation mode, the root section container is default', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' - ) - ).toBe( 'default' ); - } ); - - it( 'in navigation mode, anything outside the section container is disabled', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - '6cf70164-9097-4460-bcbf-200560546988' - ) - ).toBe( 'disabled' ); - } ); - - it( 'in navigation mode, sections are contentOnly', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'b26fc763-417d-4f01-b81c-2ec61e14a972' - ) - ).toBe( 'contentOnly' ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f' - ) - ).toBe( 'contentOnly' ); - } ); - - it( 'in navigation mode, blocks with content attributes within sections are contentOnly', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - hasContentRoleAttribute.mockReturnValueOnce( true ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' - ) - ).toBe( 'contentOnly' ); - - hasContentRoleAttribute.mockReturnValueOnce( true ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c' - ) - ).toBe( 'contentOnly' ); - } ); - - it( 'in navigation mode, blocks without content attributes within sections are disabled', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - '9b9c5c3f-2e46-4f02-9e14-9fed515b958s' - ) - ).toBe( 'disabled' ); - } ); - } ); } ); diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 104b07157cba7..3d4d07e52b386 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -7,7 +7,7 @@ import clsx from 'clsx'; * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { useRef, useMemo, useEffect } from '@wordpress/element'; +import { useRef, useMemo } from '@wordpress/element'; import { useEntityRecord, store as coreStore, @@ -37,12 +37,10 @@ import { getBlockBindingsSource } from '@wordpress/blocks'; /** * Internal dependencies */ -import { name as patternBlockName } from './index'; import { unlock } from '../lock-unlock'; const { useLayoutClasses } = unlock( blockEditorPrivateApis ); -const { isOverridableBlock, hasOverridableBlocks } = - unlock( patternsPrivateApis ); +const { hasOverridableBlocks } = unlock( patternsPrivateApis ); const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; @@ -75,22 +73,6 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; -function setBlockEditMode( setEditMode, blocks, mode ) { - blocks.forEach( ( block ) => { - const editMode = - mode || - ( isOverridableBlock( block ) ? 'contentOnly' : 'disabled' ); - setEditMode( block.clientId, editMode ); - - setBlockEditMode( - setEditMode, - block.innerBlocks, - // Disable editing for nested patterns. - block.name === patternBlockName ? 'disabled' : mode - ); - } ); -} - function RecursionWarning() { const blockProps = useBlockProps(); return ( @@ -171,7 +153,6 @@ function ReusableBlockEdit( { name, attributes: { ref, content }, __unstableParentLayout: parentLayout, - clientId: patternClientId, setAttributes, } ) { const { record, hasResolved } = useEntityRecord( @@ -184,49 +165,24 @@ function ReusableBlockEdit( { } ); const isMissing = hasResolved && ! record; - const { setBlockEditingMode, __unstableMarkLastChangeAsPersistent } = + const { __unstableMarkLastChangeAsPersistent } = useDispatch( blockEditorStore ); - const { - innerBlocks, - onNavigateToEntityRecord, - editingMode, - hasPatternOverridesSource, - } = useSelect( + const { onNavigateToEntityRecord, hasPatternOverridesSource } = useSelect( ( select ) => { - const { getBlocks, getSettings, getBlockEditingMode } = - select( blockEditorStore ); + const { getSettings } = select( blockEditorStore ); // For editing link to the site editor if the theme and user permissions support it. return { - innerBlocks: getBlocks( patternClientId ), onNavigateToEntityRecord: getSettings().onNavigateToEntityRecord, - editingMode: getBlockEditingMode( patternClientId ), hasPatternOverridesSource: !! getBlockBindingsSource( 'core/pattern-overrides' ), }; }, - [ patternClientId ] + [] ); - // Sync the editing mode of the pattern block with the inner blocks. - useEffect( () => { - setBlockEditMode( - setBlockEditingMode, - innerBlocks, - // Disable editing if the pattern itself is disabled. - editingMode === 'disabled' || ! hasPatternOverridesSource - ? 'disabled' - : undefined - ); - }, [ - editingMode, - innerBlocks, - setBlockEditingMode, - hasPatternOverridesSource, - ] ); - const canOverrideBlocks = useMemo( () => hasPatternOverridesSource && hasOverridableBlocks( blocks ), [ hasPatternOverridesSource, blocks ] @@ -244,7 +200,6 @@ function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { - templateLock: 'all', layout, value: blocks, onInput: NOOP, diff --git a/packages/block-library/src/missing/test/edit.native.js b/packages/block-library/src/missing/test/edit.native.js index 47d0da572b7c8..eba1169ae643b 100644 --- a/packages/block-library/src/missing/test/edit.native.js +++ b/packages/block-library/src/missing/test/edit.native.js @@ -10,7 +10,6 @@ import { Text } from 'react-native'; import { BottomSheet, Icon } from '@wordpress/components'; import { help, plugins } from '@wordpress/icons'; import { storeConfig } from '@wordpress/block-editor'; -jest.mock( '@wordpress/blocks' ); jest.mock( '@wordpress/block-editor/src/store/selectors' ); /** diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 3e5a88a2b92c1..f4805e1c60b38 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -503,6 +503,10 @@ _Returns_ - `Array|string`: A list of blocks or a string, depending on `handlerMode`. +### privateApis + +Undocumented declaration. + ### rawHandler Converts an HTML string to known blocks. diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 3ace68be87393..a03a58d8f9b21 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -1,3 +1,9 @@ +/** + * Internal dependencies + */ +import { lock } from '../lock-unlock'; +import { isContentBlock } from './utils'; + // The blocktype is the most important concept within the block API. It defines // all aspects of the block configuration and its interfaces, including `edit` // and `save`. The transforms specification allows converting one blocktype to @@ -169,3 +175,6 @@ export { __EXPERIMENTAL_ELEMENTS, __EXPERIMENTAL_PATHS_WITH_OVERRIDE, } from './constants'; + +export const privateApis = {}; +lock( privateApis, { isContentBlock } ); diff --git a/packages/blocks/src/api/test/utils.js b/packages/blocks/src/api/test/utils.js index ad76e89aafe5f..548bbb27da388 100644 --- a/packages/blocks/src/api/test/utils.js +++ b/packages/blocks/src/api/test/utils.js @@ -14,6 +14,7 @@ import { getBlockLabel, __experimentalSanitizeBlockAttributes, getBlockAttributesNamesByRole, + isContentBlock, } from '../utils'; const noop = () => {}; @@ -382,3 +383,40 @@ describe( 'getBlockAttributesNamesByRole', () => { ).toEqual( [] ); } ); } ); + +describe( 'isContentBlock', () => { + it( 'returns true if the block has a content role attribute', () => { + registerBlockType( 'core/test-content-block', { + attributes: { + content: { + type: 'string', + role: 'content', + }, + align: { + type: 'string', + }, + }, + save: noop, + category: 'text', + title: 'test content block', + } ); + expect( isContentBlock( 'core/test-content-block' ) ).toBe( true ); + } ); + + it( 'returns false if the block does not have a content role attribute', () => { + registerBlockType( 'core/test-non-content-block', { + attributes: { + content: { + type: 'string', + }, + align: { + type: 'string', + }, + }, + save: noop, + category: 'text', + title: 'test non-content block', + } ); + expect( isContentBlock( 'core/test-non-content-block' ) ).toBe( false ); + } ); +} ); diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index 20f0f6a85ed09..1a21503649655 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -370,6 +370,18 @@ export const __experimentalGetBlockAttributesNamesByRole = ( ...args ) => { return getBlockAttributesNamesByRole( ...args ); }; +export function isContentBlock( name ) { + const attributes = getBlockType( name )?.attributes; + + return !! Object.keys( attributes )?.some( ( attributeKey ) => { + const attribute = attributes[ attributeKey ]; + return ( + attribute?.role === 'content' || + attribute?.__experimentalRole === 'content' + ); + } ); +} + /** * Return a new object with the specified keys omitted. * diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js index 6f4a592930052..20eff4096cb1c 100644 --- a/test/e2e/specs/editor/various/pattern-overrides.spec.js +++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js @@ -226,6 +226,321 @@ test.describe( 'Pattern Overrides', () => { } ); } ); + test.describe( 'block editing modes', () => { + test.beforeEach( async ( { page } ) => { + await page.addInitScript( () => { + window.__experimentalEditorWriteMode = true; + } ); + } ); + + test( 'blocks with bindings in a synced pattern are editable, and all other blocks are disabled', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + const content = ` + +
Pattern Overrides
+ + +Post Meta Binding
+ + +No Overrides or Binding
+ + `; + + const { id } = await requestUtils.createBlock( { + title: 'Pattern', + content, + status: 'publish', + } ); + + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + canvas: 'edit', + } ); + + await editor.setContent( '' ); + + await editor.insertBlock( { + name: 'core/block', + attributes: { ref: id }, + } ); + + const patternBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Pattern', + } ); + const paragraphs = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + const blockWithOverrides = paragraphs.filter( { + hasText: 'Pattern Overrides', + } ); + const blockWithBindings = paragraphs.filter( { + hasText: 'Post Meta Binding', + } ); + const blockWithoutOverridesOrBindings = paragraphs.filter( { + hasText: 'No Overrides or Binding', + } ); + + await test.step( 'Zoomed in / Design mode', async () => { + await editor.switchEditorTool( 'Design' ); + // In zoomed in and design mode the pattern block and child blocks + // with bindings are editable. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithOverrides ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed in / Write mode - pattern as a section', async () => { + await editor.switchEditorTool( 'Write' ); + // The pattern block is still editable as a section. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + // Child blocks of the pattern with bindings are editable. + await expect( blockWithOverrides ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Write mode - pattern as a section', async () => { + await page.getByLabel( 'Zoom Out' ).click(); + // In zoomed out only the pattern block is editable, as in this scenario it's a section. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Design mode - pattern as a section', async () => { + await editor.switchEditorTool( 'Design' ); + // In zoomed out only the pattern block is editable, as in this scenario it's a section. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + // Zoom out and group the pattern. + await page.getByLabel( 'Zoom Out' ).click(); + await editor.selectBlocks( patternBlock ); + await editor.clickBlockOptionsMenuItem( 'Group' ); + + await test.step( 'Zoomed in / Write mode - pattern nested in a section', async () => { + await editor.switchEditorTool( 'Write' ); + // The pattern block is not inert as it has editable content, but it shouldn't be selectable. + // TODO: find a way to test that the block is not selectable. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + // Child blocks of the pattern are editable as normal. + await expect( blockWithOverrides ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Write mode - pattern nested in a section', async () => { + await page.getByLabel( 'Zoom Out' ).click(); + // None of the pattern is editable in zoomed out when nested in a section. + await expect( patternBlock ).toHaveAttribute( 'inert', 'true' ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Design mode - pattern nested in a section', async () => { + await editor.switchEditorTool( 'Design' ); + // None of the pattern is editable in zoomed out when nested in a section. + await expect( patternBlock ).toHaveAttribute( 'inert', 'true' ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + } ); + + test( 'disables editing of nested patterns', async ( { + page, + admin, + requestUtils, + editor, + } ) => { + const paragraphName = 'Editable paragraph'; + const headingName = 'Editable heading'; + const innerPattern = await requestUtils.createBlock( { + title: 'Inner Pattern', + content: ` +Inner paragraph
+ `, + status: 'publish', + } ); + const outerPattern = await requestUtils.createBlock( { + title: 'Outer Pattern', + content: ` +Inner paragraph
-`, - status: 'publish', - } ); - const outerPattern = await requestUtils.createBlock( { - title: 'Outer Pattern', - content: ` -