diff --git a/docs/reference-guides/core-blocks/README.md b/docs/reference-guides/core-blocks/README.md
index 056723b042cf80..978d1b604f40ee 100644
--- a/docs/reference-guides/core-blocks/README.md
+++ b/docs/reference-guides/core-blocks/README.md
@@ -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
diff --git a/packages/block-directory/src/plugins/get-install-missing/index.js b/packages/block-directory/src/plugins/get-install-missing/index.js
index 657bc49083bd82..e73de6ae21b714 100644
--- a/packages/block-directory/src/plugins/get-install-missing/index.js
+++ b/packages/block-directory/src/plugins/get-install-missing/index.js
@@ -53,9 +53,7 @@ const ModifiedWarning = ( { originalBlock, ...props } ) => {
const convertToHTML = () => {
replaceBlock(
props.clientId,
- createBlock( 'core/html', {
- content: originalUndelimitedContent,
- } )
+ createBlock( 'core/html', {}, [], [ originalUndelimitedContent ] )
);
};
diff --git a/packages/block-editor/src/components/block-list/block-invalid-warning.js b/packages/block-editor/src/components/block-list/block-invalid-warning.js
index a35ecaf6bae8eb..4514d15ac68296 100644
--- a/packages/block-editor/src/components/block-list/block-invalid-warning.js
+++ b/packages/block-editor/src/components/block-list/block-invalid-warning.js
@@ -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() {
diff --git a/packages/block-editor/src/components/inner-content/index.js b/packages/block-editor/src/components/inner-content/index.js
new file mode 100644
index 00000000000000..bf9ea82541a73b
--- /dev/null
+++ b/packages/block-editor/src/components/inner-content/index.js
@@ -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';
+
+// 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
+ // `Safe',
+ ] );
+
+ expect( root.querySelector( 'script' ) ).toBeNull();
+ expect( root ).toHaveTextContent( 'Safe' );
+ } );
+
+ it( 'strips inline event handlers from the static fragments', () => {
+ const root = renderWithInnerContent( [
+ '',
+ ] );
+
+ 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 */
diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js
index 4dbd42bd34c251..e9b90926ff1f6e 100644
--- a/packages/block-editor/src/private-apis.js
+++ b/packages/block-editor/src/private-apis.js
@@ -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,
@@ -147,4 +148,5 @@ lock( privateApis, {
useListViewPanelState,
isHashLink,
isRelativePath,
+ InnerContent,
} );
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 08750ffa30c60c..8aca66a0c9b7a2 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -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.
@@ -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.
*
@@ -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;
@@ -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;
diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js
index 64c7f554246565..db80feecb8354a 100644
--- a/packages/block-editor/src/store/test/selectors.js
+++ b/packages/block-editor/src/store/test/selectors.js
@@ -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
diff --git a/packages/block-library/src/code/transforms.js b/packages/block-library/src/code/transforms.js
index 35d5def7e4970d..041232210b02e7 100644
--- a/packages/block-library/src/code/transforms.js
+++ b/packages/block-library/src/code/transforms.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { createBlock } from '@wordpress/blocks';
+import { createBlock, getBlockContent } from '@wordpress/blocks';
import { create, toHTMLString } from '@wordpress/rich-text';
/**
@@ -31,14 +31,16 @@ const transforms = {
{
type: 'block',
blocks: [ 'core/html' ],
- transform: ( attributes ) => {
- const { content: text } = attributes;
+ __experimentalConvert( block ) {
+ const { attributes } = block;
return createBlock( 'core/code', {
...attributes,
...getTransformedAttributes( attributes, 'core/code' ),
// The HTML is plain text (with plain line breaks), so
// convert it to rich text.
- content: toHTMLString( { value: create( { text } ) } ),
+ content: toHTMLString( {
+ value: create( { text: getBlockContent( block ) } ),
+ } ),
} );
},
},
diff --git a/packages/block-library/src/freeform/migration-notice.js b/packages/block-library/src/freeform/migration-notice.js
index c463400e7ab89e..77f492c1b1d80e 100644
--- a/packages/block-library/src/freeform/migration-notice.js
+++ b/packages/block-library/src/freeform/migration-notice.js
@@ -34,7 +34,7 @@ export default function MigrationNotice( { content, onReplace } ) {
key="convert-to-html"
variant="secondary"
onClick={ () =>
- onReplace( createBlock( 'core/html', { content } ) )
+ onReplace( createBlock( 'core/html', {}, [], [ content ] ) )
}
>
{ __( 'Convert to HTML' ) }
diff --git a/packages/block-library/src/html/README.md b/packages/block-library/src/html/README.md
index 217885a47feab2..ae753f7467c7fe 100644
--- a/packages/block-library/src/html/README.md
+++ b/packages/block-library/src/html/README.md
@@ -15,7 +15,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref
| Attribute | [Type](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation) | [Default](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#default-value) | Description |
|-----------|------|---------|-------------|
-| `content` | `string` | — | [Source](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#value-source): `raw`. [Role](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#role): `content` |
+| `content` | `string` | — | [Role](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#role): `local` |
## Supports
@@ -26,6 +26,7 @@ _Defined via the [`supports`](https://developer.wordpress.org/block-editor/refer
- [`html`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#html): `false`
- [`interactivity`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#interactivity):
- `clientNavigation`: `true`
+- [`listView`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#listview): `true`
- `customCSS`: `false`
- [`visibility`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/#visibility): `false`
diff --git a/packages/block-library/src/html/block.json b/packages/block-library/src/html/block.json
index 8134003313b49f..9a59152fd57f24 100644
--- a/packages/block-library/src/html/block.json
+++ b/packages/block-library/src/html/block.json
@@ -10,8 +10,7 @@
"attributes": {
"content": {
"type": "string",
- "source": "raw",
- "role": "content"
+ "role": "local"
}
},
"supports": {
@@ -21,6 +20,7 @@
"interactivity": {
"clientNavigation": true
},
+ "listView": true,
"customCSS": false,
"visibility": false
},
diff --git a/packages/block-library/src/html/edit.js b/packages/block-library/src/html/edit.js
index f4d90732d295b9..92936b028a197b 100644
--- a/packages/block-library/src/html/edit.js
+++ b/packages/block-library/src/html/edit.js
@@ -2,13 +2,18 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { useState } from '@wordpress/element';
+import { useEffect, useState } from '@wordpress/element';
import {
BlockControls,
BlockIcon,
InspectorControls,
useBlockProps,
+ store as blockEditorStore,
+ privateApis as blockEditorPrivateApis,
} from '@wordpress/block-editor';
+import { parse, serialize, getBlockContent } from '@wordpress/blocks';
+import { useSelect, useDispatch, useRegistry } from '@wordpress/data';
+import deprecated from '@wordpress/deprecated';
import {
ToolbarButton,
ToolbarGroup,
@@ -21,17 +26,87 @@ import { code } from '@wordpress/icons';
/**
* Internal dependencies
*/
-import Preview from './preview';
+import { unlock } from '../lock-unlock';
import HTMLEditModal from './modal';
-export default function HTMLEdit( { attributes, setAttributes, isSelected } ) {
+const { InnerContent } = unlock( blockEditorPrivateApis );
+
+export default function HTMLEdit( { clientId, attributes } ) {
const [ isModalOpen, setIsModalOpen ] = useState( false );
+ const registry = useRegistry();
+ const { updateBlock, replaceInnerBlocks } = useDispatch( blockEditorStore );
+ const content = useSelect(
+ ( select ) => {
+ const block = select( blockEditorStore ).getBlock( clientId );
+ return block ? getBlockContent( block ) : '';
+ },
+ [ clientId ]
+ );
const blockProps = useBlockProps( {
className: 'block-library-html__edit',
} );
+ // Re-parse the edited content: static HTML becomes the block's
+ // `innerContent` fragments and `` delimited segments become
+ // inner blocks mounted at their positions within the static markup.
+ const onUpdate = ( nextContent ) => {
+ if ( nextContent === content ) {
+ return;
+ }
+
+ const [ parsedBlock ] = parse(
+ `\n${ nextContent }\n`
+ );
+ const nextInnerBlocks = parsedBlock?.innerBlocks ?? [];
+ const prevInnerBlocks = registry
+ .select( blockEditorStore )
+ .getBlocks( clientId );
+ // Keep the existing inner blocks, and thereby their client IDs and
+ // selection, when their markup is unchanged — e.g. when only the
+ // surrounding static HTML was edited.
+ const innerBlocksUnchanged =
+ prevInnerBlocks.length === nextInnerBlocks.length &&
+ prevInnerBlocks.every(
+ ( block, index ) =>
+ serialize( block ) === serialize( nextInnerBlocks[ index ] )
+ );
+
+ registry.batch( () => {
+ updateBlock( clientId, {
+ innerContent:
+ parsedBlock?.innerContent ??
+ ( nextContent ? [ nextContent ] : [] ),
+ } );
+ if ( ! innerBlocksUnchanged ) {
+ replaceInnerBlocks( clientId, nextInnerBlocks, false );
+ }
+ } );
+ };
+
+ // Migrate the deprecated `content` attribute. The block's markup now lives
+ // in its inner content, but a block created via `createBlock( 'core/html',
+ // { content } )` still arrives with a `content` attribute. As soon as it
+ // loads, move that markup into the block's inner content and clear the
+ // attribute.
+ useEffect( () => {
+ if ( ! attributes.content ) {
+ return;
+ }
+
+ deprecated( 'The content attribute on the Custom HTML block', {
+ since: '7.1',
+ alternative: 'inner content',
+ } );
+
+ updateBlock( clientId, {
+ attributes: { content: undefined },
+ innerContent: [ attributes.content ],
+ } );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ attributes.content ] );
+
// Show placeholder when content is empty
- if ( ! attributes.content?.trim() ) {
+ if ( ! content?.trim() ) {
return (
setIsModalOpen( false ) }
- content={ attributes.content }
- setAttributes={ setAttributes }
+ content={ content }
+ onUpdate={ onUpdate }
/>
) }
@@ -70,12 +145,9 @@ export default function HTMLEdit( { attributes, setAttributes, isSelected } ) {
-
+
-
+
{ isModalOpen && (
setIsModalOpen( false ) }
- content={ attributes.content }
- setAttributes={ setAttributes }
+ content={ content }
+ onUpdate={ onUpdate }
/>
) }
diff --git a/packages/block-library/src/html/editor.scss b/packages/block-library/src/html/editor.scss
index 4dc0212b404808..5a4f1f00de0eff 100644
--- a/packages/block-library/src/html/editor.scss
+++ b/packages/block-library/src/html/editor.scss
@@ -12,6 +12,16 @@
}
}
+// The "Edit code" button in the inspector renders inside the tabs panel, so
+// it needs spacing on all sides, including against the tab list above it.
+.block-library-html__edit-code {
+ margin: $grid-unit-20;
+
+ .block-library-html__edit-code-button {
+ justify-content: center;
+ }
+}
+
// Modal styles
.block-library-html__modal {
diff --git a/packages/block-library/src/html/index.js b/packages/block-library/src/html/index.js
index cd25f25126a61d..0ac2a0efa2c301 100644
--- a/packages/block-library/src/html/index.js
+++ b/packages/block-library/src/html/index.js
@@ -20,12 +20,11 @@ export { metadata, name };
export const settings = {
icon,
example: {
- attributes: {
- content:
- '