Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/block-editor/src/components/block-list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ function Items( {
getTemplateLock,
getBlockEditingMode,
isSectionBlock,
isContainerInsertableToInWriteMode,
isContainerInsertableToInContentOnlyMode,
getBlockName,
isZoomOut: _isZoomOut,
canInsertBlockType,
Expand Down Expand Up @@ -223,7 +223,7 @@ function Items( {
isZoomOut: _isZoomOut(),
shouldRenderAppender:
( ! isSectionBlock( rootClientId ) ||
isContainerInsertableToInWriteMode(
isContainerInsertableToInContentOnlyMode(
getBlockName( selectedBlockClientId ),
rootClientId
) ) &&
Expand Down
4 changes: 2 additions & 2 deletions packages/block-editor/src/store/private-selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const isBlockSubtreeDisabled = ( state, clientId ) => {
* @param {string} rootClientId The client ID of the root container block.
* @return {boolean} Whether the container allows insertion.
*/
export function isContainerInsertableToInWriteMode(
export function isContainerInsertableToInContentOnlyMode(
state,
blockName,
rootClientId
Expand All @@ -105,7 +105,7 @@ export function isContainerInsertableToInWriteMode(
const isContainerContentBlock = isContentBlock( rootBlockName );
const isRootBlockMain = getSectionRootClientId( state ) === rootClientId;

// In write mode, containers shouldn't be inserted into unless:
// In contentOnly mode, containers shouldn't be inserted into unless:
// 1. they are a section root;
// 2. they are a content block and the block to be inserted is also content.
return (
Expand Down
80 changes: 59 additions & 21 deletions packages/block-editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
isSectionBlock,
getParentSectionBlock,
isZoomOut,
isContainerInsertableToInWriteMode,
isContainerInsertableToInContentOnlyMode,
} from './private-selectors';

const { isContentBlock } = unlock( blocksPrivateApis );
Expand Down Expand Up @@ -1682,15 +1682,7 @@ const canInsertBlockTypeUnmemoized = (
blockType = getBlockType( blockName );
}

const isLocked = !! getTemplateLock( state, rootClientId );
if ( isLocked ) {
return false;
}
const isContentRoleBlock = isContentBlock( blockName );
const isParentSectionBlock = !! isSectionBlock( state, rootClientId );
// It shouldn't be possible to insert inside a section block unless in
// some cases when the block is a content block.
if ( isParentSectionBlock && ! isContentRoleBlock ) {
if ( getTemplateLock( state, rootClientId ) ) {
return false;
}

Expand All @@ -1707,10 +1699,29 @@ const canInsertBlockTypeUnmemoized = (
return false;
}

// In write mode, check if this container allows insertion.
// It shouldn't be possible to insert inside a section block unless in
// some cases when the block is a content block.
const isContentRoleBlock = isContentBlock( blockName );
const isParentSectionBlock = !! isSectionBlock( state, rootClientId );
const isBlockWithinSection = !! getParentSectionBlock(
state,
rootClientId
);
if (
( isParentSectionBlock || isBlockWithinSection ) &&
! isContentRoleBlock
) {
return false;
}

// In content only mode, check if this container allows insertion.
if (
blockEditingMode === 'contentOnly' &&
! isContainerInsertableToInWriteMode( state, blockName, rootClientId )
( isParentSectionBlock || blockEditingMode === 'contentOnly' ) &&

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the parent is a 'section' it usually has a blockEditingMode of default (so that you can move, insert, remove the section), but I think that results in skipping the isContainerInsertableToInContentOnlyMode logic, so I've added an extra check for isParentSectionBlock here..

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe change isContainerInsertableToInContentOnlyMode to remove the logic that allows inserting if a block is a section root? That made sense in write mode so blocks could be added to empty templates, but with the patterns approach we probably don't need to do that anymore?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me try it. I guess zoomed out would be the only concern, as I think it still uses section roots. Though I don't think it uses contentOnly at all, so I don't think it should be a problem.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still needed for zoomed out based on my testing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could maybe look at removing it when we get to updating the zoom out logic (i.e. as part of #71807).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should remove it when we simplify zoom out.

! isContainerInsertableToInContentOnlyMode(
state,
blockName,
rootClientId
)
) {
return false;
}
Expand Down Expand Up @@ -1858,6 +1869,8 @@ export function canRemoveBlock( state, clientId ) {
return false;
}

// It shouldn't be possible to move in a section block unless in
// some cases when the block is a content block.
const isBlockWithinSection = !! getParentSectionBlock( state, clientId );
const isContentRoleBlock = isContentBlock(
getBlockName( state, clientId )
Expand All @@ -1866,21 +1879,21 @@ export function canRemoveBlock( state, clientId ) {
return false;
}

const blockEditingMode = getBlockEditingMode( state, rootClientId );

// Check if the parent container allows insertion/removal in write mode
const isParentSectionBlock = !! isSectionBlock( state, rootClientId );
const rootBlockEditingMode = getBlockEditingMode( state, rootClientId );
// Check if the parent container allows insertion/removal in contentOnly mode
if (
blockEditingMode === 'contentOnly' &&
! isContainerInsertableToInWriteMode(
( isParentSectionBlock || rootBlockEditingMode === 'contentOnly' ) &&
! isContainerInsertableToInContentOnlyMode(
state,
getBlockName( state, rootClientId ),
getBlockName( state, clientId ),

@talldan talldan Oct 17, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line looks to be a typo, because it results in isContainerInsertableToInContentOnlyMode checking whether the root block is a content block twice.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking if user-created unsynced patterns are supposed to be included? I can still delete child blocks.

I enabled the content only experiment, created an unsynced pattern, then inserted it into a post via the inserter.

The first pattern is mine, the second is a theme pattern inserted from the patterns inserter.

Kapture.2025-10-21.at.11.56.38.mp4

rootClientId
)
) {
return false;
}

return blockEditingMode !== 'disabled';
return rootBlockEditingMode !== 'disabled';
}

/**
Expand Down Expand Up @@ -1913,9 +1926,34 @@ export function canMoveBlock( state, clientId ) {
}

const rootClientId = getBlockRootClientId( state, clientId );
if ( getTemplateLock( state, rootClientId ) === 'all' ) {
const templateLock = getTemplateLock( state, rootClientId );
if ( templateLock === 'all' || templateLock === 'contentOnly' ) {
return false;
}

const isBlockWithinSection = !! getParentSectionBlock( state, clientId );
const isContentRoleBlock = isContentBlock(
getBlockName( state, clientId )
);
if ( isBlockWithinSection && ! isContentRoleBlock ) {
return false;
}

// If the parent is a section or is `contentOnly`, then check is the inner block
// should be allowed to move.
const isParentSectionBlock = !! isSectionBlock( state, rootClientId );
const rootBlockEditingMode = getBlockEditingMode( state, rootClientId );
if (
( isParentSectionBlock || rootBlockEditingMode === 'contentOnly' ) &&
! isContainerInsertableToInContentOnlyMode(
state,
getBlockName( state, clientId ),
rootClientId
)
) {
return false;
}

return getBlockEditingMode( state, rootClientId ) !== 'disabled';
}

Expand Down
2 changes: 1 addition & 1 deletion packages/block-editor/src/store/test/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -3081,7 +3081,7 @@ describe( 'selectors', () => {
byClientId: new Map(
Object.entries( {
block1: { name: 'core/test-block-ancestor' },
block2: { name: 'core/block' },
block2: { name: 'core/group' },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core/block is a synced pattern, which is a section, so it wouldn't allow insertion, the selector now prevents it. The block in this test needed to be changed to a different container block, one that's not a section.

block3: { name: 'core/test-block-parent' },
} )
),
Expand Down
3 changes: 2 additions & 1 deletion packages/block-editor/src/store/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { parse as grammarParse } from '@wordpress/block-serialization-default-pa
import { selectBlockPatternsKey } from './private-keys';
import { unlock } from '../lock-unlock';
import { STORE_NAME } from './constants';
import { getSectionRootClientId } from './private-selectors';
import { getSectionRootClientId, isSectionBlock } from './private-selectors';
import { getBlockEditingMode } from './selectors';
import { INSERTER_PATTERN_TYPES } from '../components/inserter/block-patterns-tab/utils';

Expand Down Expand Up @@ -140,5 +140,6 @@ export const getInsertBlockTypeDependants = () => ( state, rootClientId ) => {
state.settings.templateLock,
getBlockEditingMode( state, rootClientId ),
getSectionRootClientId( state ),
isSectionBlock( state, rootClientId ),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From looking at isSectionBlock it seems relatively simple in that it doesn't do too many lookups / calculations. But since it's being added to the list of dependencies just thought I'd double-check, do you think this has any potential performance concerns? My hunch is "no" 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's simple enough not to cause an issue. getParentSection is one I was concerned about though, it is used in the selector, but I haven't added it in the deps, so that might cause issues, though I didn't spot any.

];
};
3 changes: 2 additions & 1 deletion packages/block-library/src/home-link/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"textdomain": "default",
"attributes": {
"label": {
"type": "string"
"type": "string",
"role": "content"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to mention that there's a problem in trunk that this partially solves.

In the navigation editor (in the site editor), the navigation block there is set to a contentOnly block editing mode, and it's also a content block, which means it can now only have other content blocks inserted within it.

In trunk it means you can't insert a 'Home Link' block at all because it's not a content block. This was caught by a test, so I'm fixing it here.

While I've fixed Home Link, there might be other blocks that are affected (third party blocks too). It might be a back compat break (not to mention an issue in 6.9). We might need to consider again how that editor or the contentOnly insertion thing works. Maybe some kind of exception is needed for the navigation block, or maybe we can use a different technique to make sure the Navigation block can't be removed.

cc @getdave @jeryj @scruffian to make you aware of this. I'll also make an issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
},
"usesContext": [
Expand Down
138 changes: 136 additions & 2 deletions test/e2e/specs/editor/various/content-only-lock.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ test.describe( 'Content-only lock', () => {
<div class="wp-block-group"><!-- wp:paragraph -->
<p>Locked block a</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>Locked block b</p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->

<!-- wp:heading -->
<h2 class="wp-block-heading"><strong>outside block</strong></h2>
<!-- /wp:heading -->` );
Expand Down Expand Up @@ -128,4 +128,138 @@ test.describe( 'Content-only lock', () => {
page.locator( '.color-block-support-panel' )
).not.toBeAttached();
} );

test( 'blocks within content-only locked container cannot be duplicated, inserted before/after, or moved', async ( {
editor,
page,
pageUtils,
} ) => {
// Add content only locked block with paragraph and list
await pageUtils.pressKeys( 'secondary+M' );

await page.getByPlaceholder( 'Start writing with text or HTML' )
.fill( `<!-- wp:group {"templateLock":"contentOnly","layout":{"type":"constrained"}} -->
<div class="wp-block-group"><!-- wp:paragraph -->
<p>First paragraph</p>
<!-- /wp:paragraph -->

<!-- wp:list -->
<ul class="wp-block-list"><!-- wp:list-item -->
<li>List item one</li>
<!-- /wp:list-item -->

<!-- wp:list-item -->
<li>List item two</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></div>
<!-- /wp:group -->` );

await pageUtils.pressKeys( 'secondary+M' );

const groupBlock = editor.canvas.getByRole( 'document', {
name: 'Block: Group',
} );
const paragraph = editor.canvas
.getByRole( 'document', {
name: 'Block: Paragraph',
includeHidden: true,
} )
.filter( { hasText: 'First paragraph' } );
const firstListItem = editor.canvas
.getByRole( 'document', {
name: 'Block: List item',
includeHidden: true,
} )
.filter( { hasText: 'List item one' } );
const secondListItem = editor.canvas
.getByRole( 'document', {
name: 'Block: List item',
includeHidden: true,
} )
.filter( { hasText: 'List item two' } );

// Select the content-locked group block.
await editor.selectBlocks( groupBlock );
await test.step( 'Blocks cannot be inserted before/after or duplicated', async () => {
// Test paragraph.
await editor.selectBlocks( paragraph );
await editor.showBlockToolbar();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Options' } )
).toBeHidden();

// Test first list item.
await editor.selectBlocks( firstListItem );
await editor.showBlockToolbar();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Options' } )
).toBeHidden();

// Test second list item.
await editor.selectBlocks( secondListItem );
await editor.showBlockToolbar();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Options' } )
).toBeHidden();
} );

await test.step( 'Blocks cannot be moved', async () => {
// Test paragraph.
await editor.selectBlocks( paragraph );
await editor.showBlockToolbar();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Move up' } )
).toBeHidden();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Move down' } )
).toBeHidden();

// Test first list item.
await editor.selectBlocks( firstListItem );
await editor.showBlockToolbar();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Move up' } )
).toBeHidden();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Move down' } )
).toBeHidden();

// Test second list item.
await editor.selectBlocks( secondListItem );
await editor.showBlockToolbar();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Move up' } )
).toBeHidden();

await expect(
page
.getByRole( 'toolbar', { name: 'Block tools' } )
.getByRole( 'button', { name: 'Move down' } )
).toBeHidden();
} );
} );
} );
Loading
Loading