Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion docs/reference-guides/core-blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ Add custom HTML code and preview it as you edit.

- **Name:** [core/html](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/core-block-html/)
- **Category:** [widgets](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-widgets/)
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~
- **Supports:** interactivity (clientNavigation), listView, ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~
- **Attributes:** content

## Icon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ const ModifiedWarning = ( { originalBlock, ...props } ) => {
const convertToHTML = () => {
replaceBlock(
props.clientId,
createBlock( 'core/html', {
content: originalUndelimitedContent,
} )
createBlock( 'core/html', {}, [], [ originalUndelimitedContent ] )
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ export default function BlockInvalidWarning( { clientId } ) {
return replaceBlock( block.clientId, classicBlock );
},
toHTML() {
const htmlBlock = createBlock( 'core/html', {
content: block.originalContent,
} );
const htmlBlock = createBlock(
'core/html',
{},
[],
[ block.originalContent ]
);
return replaceBlock( block.clientId, htmlBlock );
},
toBlocks() {
Expand Down
108 changes: 108 additions & 0 deletions packages/block-editor/src/components/inner-content/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* WordPress dependencies
*/
import {
createPortal,
useLayoutEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { safeHTML } from '@wordpress/dom';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import BlockListBlock from '../block-list/block';
import { LayoutProvider } from '../block-list/layout';

const SLOT_TAG_NAME = 'wp-inner-block-slot';

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.

heh, clever :)


// The static markup surrounding the inner blocks is arbitrary HTML the block
// editor doesn't manage, so it provides no layout context: no alignments are
// available to the inner blocks.
const LAYOUT = { type: 'default', alignments: [] };

/**
* Renders the static HTML fragments of a block with the `innerContent`
* support, mounting each inner block at the position of its `null`
* placeholder within the static markup.
*
* Unlike `InnerBlocks`, the rendered blocks are not a contiguous list: they
* live at arbitrary positions inside markup the block editor doesn't manage.
* The static fragments are sanitized with `safeHTML` before being injected
* into the canvas — the same treatment block save content receives elsewhere
* in the editor — and the inner blocks are portalled into placeholder
* elements. Inner blocks are locked: they can be edited in place but not
* moved, removed, or added to.
*
* @param {Object} props Component props.
* @param {string} props.clientId Client ID of the block whose inner content
* should be rendered.
*/
export default function InnerContent( { clientId } ) {
const { innerContent, order } = useSelect(
( select ) => {
const { getBlock, getBlockOrder } = select( blockEditorStore );
return {
innerContent: getBlock( clientId )?.innerContent,
order: getBlockOrder( clientId ),
};
},
[ clientId ]
);
const html = useMemo( () => {
let slotIndex = 0;
return ( innerContent ?? [] )
.map( ( item ) =>
item === null
? `<${ SLOT_TAG_NAME } data-slot-index="${ slotIndex++ }" style="display: contents"></${ SLOT_TAG_NAME }>`
: item
)
.join( '' );
}, [ innerContent ] );

const containerRef = useRef();
const [ slots, setSlots ] = useState( [] );

useLayoutEffect( () => {
const container = containerRef.current;

// Sanitize before injecting into the canvas: `safeHTML` removes
// `<script>` elements and inline event handlers, matching how block
// save content is rendered elsewhere in the editor.
container.innerHTML = safeHTML( html );

setSlots(
Array.from( container.querySelectorAll( SLOT_TAG_NAME ) ).sort(
( a, b ) =>
Number( a.dataset.slotIndex ) -
Number( b.dataset.slotIndex )
)
);
}, [ html ] );

return (
<LayoutProvider value={ LAYOUT }>
<div
ref={ containerRef }
className="block-editor-inner-content"
style={ { display: 'contents' } }
/>
{ order.map( ( childClientId, index ) =>
slots[ index ]
? createPortal(
<BlockListBlock
rootClientId={ clientId }
clientId={ childClientId }
/>,
slots[ index ],
childClientId
)
: null
) }
</LayoutProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';

/**
* WordPress dependencies
*/
import {
createBlock,
registerBlockType,
unregisterBlockType,
} from '@wordpress/blocks';

/**
* Internal dependencies
*/
import InnerContent from '../';
import { BlockEditorProvider } from '../../provider';

const BLOCK_NAME = 'core/html';

// These tests inspect the raw injected DOM to verify the static markup is
// sanitized before being added to the editor canvas, so direct node access is
// intentional.
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/render-result-naming-convention */

function renderWithInnerContent( innerContent ) {
const block = createBlock( BLOCK_NAME, {}, [], innerContent );
const { container } = render(
<BlockEditorProvider value={ [ block ] }>
<InnerContent clientId={ block.clientId } />
</BlockEditorProvider>
);
return container.querySelector( '.block-editor-inner-content' );
}

describe( 'InnerContent', () => {
beforeAll( () => {
registerBlockType( BLOCK_NAME, {
apiVersion: 3,
title: 'Custom HTML',
category: 'text',
save: () => null,
} );
} );

afterAll( () => {
unregisterBlockType( BLOCK_NAME );
} );

it( 'renders the static fragments into the canvas', () => {
const root = renderWithInnerContent( [
'<div class="banner"><h1>Static heading</h1></div>',
] );

expect( root.querySelector( 'h1' ) ).toHaveTextContent(
'Static heading'
);
} );

it( 'strips script elements from the static fragments', () => {
const root = renderWithInnerContent( [
'<div><script>window.__innerContentRan = true;</script>Safe</div>',
] );

expect( root.querySelector( 'script' ) ).toBeNull();
expect( root ).toHaveTextContent( 'Safe' );
} );

it( 'strips inline event handlers from the static fragments', () => {
const root = renderWithInnerContent( [
'<button onclick="alert(1)">Click</button>',
] );

expect( root.querySelector( '[onclick]' ) ).toBeNull();
expect( root.querySelector( 'button' ) ).toHaveTextContent( 'Click' );
} );
} );

/* eslint-enable testing-library/no-node-access, testing-library/no-container, testing-library/render-result-naming-convention */
2 changes: 2 additions & 0 deletions packages/block-editor/src/private-apis.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { LinkPicker } from './components/link-picker';
import useRemoteUrlData from './components/link-control/use-rich-url-data';
import { PrivateBlockContext } from './components/block-list/private-block-context';
import useListViewPanelState from './components/use-list-view-panel-state';
import InnerContent from './components/inner-content';
import {
isHashLink,
isRelativePath,
Expand Down Expand Up @@ -147,4 +148,5 @@ lock( privateApis, {
useListViewPanelState,
isHashLink,
isRelativePath,
InnerContent,
} );
40 changes: 40 additions & 0 deletions packages/block-editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,12 @@ const canInsertBlockTypeUnmemoized = (
return false;
}

// No insertion within static inner content: the inner blocks are fixed
// at their placeholder positions within the static markup.
if ( isInnerContentRoot( state, rootClientId ) ) {
return false;
}

const blockEditingMode = getBlockEditingMode( state, rootClientId ?? '' );

// Compute section context early so the disabled check below can use it.
Expand Down Expand Up @@ -1899,6 +1905,24 @@ export function canInsertBlocks( state, clientIds, rootClientId = null ) {
);
}

/**
* Returns whether the given root block keeps its markup as static inner
* content (the Custom HTML block). Its inner blocks are fixed at their
* positions within the static markup: they can be edited in place, but not
* moved or removed, and no blocks can be inserted alongside them. This only
* applies to the direct children; deeper descendants are unaffected.
*
* @param {Object} state Editor state.
* @param {?string} rootClientId Root block client ID.
*
* @return {boolean} Whether the root block uses static inner content.
*/
function isInnerContentRoot( state, rootClientId ) {
return (
!! rootClientId && getBlockName( state, rootClientId ) === 'core/html'
);
}

/**
* Determines if the given block is allowed to be deleted.
*
Expand All @@ -1913,6 +1937,14 @@ export function canRemoveBlock( state, clientId ) {
return false;
}

// Blocks within static inner content are fixed in place; a `lock`
// attribute can't override the structural constraint.
if (
isInnerContentRoot( state, getBlockRootClientId( state, clientId ) )
) {
return false;
}

const attributes = getBlockAttributes( state, clientId );
if ( attributes === null ) {
return true;
Expand Down Expand Up @@ -2014,6 +2046,14 @@ export function canMoveBlock( state, clientId ) {
return false;
}

// Blocks within static inner content are fixed in place; a `lock`
// attribute can't override the structural constraint.
if (
isInnerContentRoot( state, getBlockRootClientId( state, clientId ) )
) {
return false;
}

const attributes = getBlockAttributes( state, clientId );
if ( attributes === null ) {
return true;
Expand Down
83 changes: 83 additions & 0 deletions packages/block-editor/src/store/test/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -4802,6 +4802,89 @@ describe( 'selectors', () => {
} );
} );

describe( 'static inner content (Custom HTML block)', () => {
beforeEach( () => {
registerBlockType( 'core/html', {
apiVersion: 3,
save: () => null,
category: 'text',
title: 'Custom HTML',
icon: 'test',
} );
} );

afterEach( () => {
unregisterBlockType( 'core/html' );
} );

// The Custom HTML block parent with a child container, which has a
// child of its own. The direct child is fixed in place; the grandchild
// is regular, fully editable content.
const buildState = () => ( {
blocks: {
byClientId: new Map(
Object.entries( {
parent: { name: 'core/html' },
child: { name: 'core/test-block-b' },
grandchild: { name: 'core/test-block-b' },
} )
),
attributes: new Map(
Object.entries( {
parent: {},
child: {},
grandchild: {},
} )
),
parents: new Map(
Object.entries( {
parent: '',
child: 'parent',
grandchild: 'child',
} )
),
order: new Map( [
[ '', [ 'parent' ] ],
[ 'parent', [ 'child' ] ],
[ 'child', [ 'grandchild' ] ],
] ),
blockEditingModes: new Map(),
},
blockListSettings: new Map( [
[ 'parent', {} ],
[ 'child', {} ],
] ),
settings: {},
derivedBlockEditingModes: new Map(),
} );

it( 'prevents removing a direct child', () => {
expect( canRemoveBlock( buildState(), 'child' ) ).toBe( false );
} );

it( 'prevents moving a direct child', () => {
expect( canMoveBlock( buildState(), 'child' ) ).toBe( false );
} );

it( 'prevents insertion into the inner content root', () => {
expect(
canInsertBlockType(
buildState(),
'core/test-block-b',
'parent'
)
).toBe( false );
} );

it( 'does not cascade to deeper descendants', () => {
expect( canRemoveBlock( buildState(), 'grandchild' ) ).toBe( true );
expect( canMoveBlock( buildState(), 'grandchild' ) ).toBe( true );
expect(
canInsertBlockType( buildState(), 'core/test-block-b', 'child' )
).toBe( true );
} );
} );

describe( 'canMoveBlock', () => {
it( 'allows moving within a non-section contentOnly container', () => {
// When the parent has contentOnly editing mode but is NOT
Expand Down
Loading
Loading