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">` + : 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: - '' + + innerContent: [ + '' + __( 'Welcome to the wonderful world of blocks…' ) + '', - }, + ], }, edit, save, diff --git a/packages/block-library/src/html/modal.js b/packages/block-library/src/html/modal.js index 42f95a19fb5a9e..16deeb9502f114 100644 --- a/packages/block-library/src/html/modal.js +++ b/packages/block-library/src/html/modal.js @@ -26,11 +26,7 @@ import { parseContent, serializeContent } from './utils'; const { Tabs } = unlock( componentsPrivateApis ); -export default function HTMLEditModal( { - onRequestClose, - content, - setAttributes, -} ) { +export default function HTMLEditModal( { onRequestClose, content, onUpdate } ) { // Parse content into separate sections and use as initial state const { html, css, js } = parseContent( content ); const [ editedHtml, setEditedHtml ] = useState( html ); @@ -56,13 +52,13 @@ export default function HTMLEditModal( { const handleUpdate = () => { // For users without unfiltered_html capability, strip CSS and JS content // to prevent kses from leaving broken content - setAttributes( { - content: serializeContent( { + onUpdate( + serializeContent( { html: editedHtml, css: canUserUseUnfilteredHTML ? editedCss : '', js: canUserUseUnfilteredHTML ? editedJs : '', - } ), - } ); + } ) + ); }; const handleUpdateAndClose = () => { handleUpdate(); diff --git a/packages/block-library/src/html/save.js b/packages/block-library/src/html/save.js index 08d72be511bb29..c84e002336a239 100644 --- a/packages/block-library/src/html/save.js +++ b/packages/block-library/src/html/save.js @@ -1,8 +1,5 @@ -/** - * WordPress dependencies - */ -import { RawHTML } from '@wordpress/element'; - -export default function save( { attributes } ) { - return { attributes.content }; +// The block's markup is serialized from its `innerContent` (static HTML +// fragments interleaved with inner blocks), not from a save implementation. +export default function save() { + return null; } diff --git a/packages/block-library/src/html/test/index.js b/packages/block-library/src/html/test/index.js new file mode 100644 index 00000000000000..900dcf6d31c49e --- /dev/null +++ b/packages/block-library/src/html/test/index.js @@ -0,0 +1,143 @@ +/** + * WordPress dependencies + */ +import { + createBlock, + registerBlockType, + serialize, + switchToBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; +import { create, toHTMLString } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import * as html from '../'; + +describe( 'core/html', () => { + beforeAll( () => { + registerBlockType( + { name: html.name, ...html.metadata }, + html.settings + ); + } ); + + afterAll( () => { + unregisterBlockType( html.name ); + } ); + + describe( 'deprecated content attribute', () => { + it( 'preserves content passed to createBlock', () => { + const block = createBlock( 'core/html', { + content: 'Hello', + } ); + + // The attribute is kept so it can be migrated rather than dropped. + expect( block.attributes.content ).toBe( + 'Hello' + ); + } ); + + it( 'keeps the content attribute out of the block delimiter', () => { + const block = createBlock( 'core/html', { + content: 'Hello', + } ); + + // `role: 'local'` prevents the attribute from being written into + // the comment delimiter as JSON. + expect( serialize( block ) ).not.toContain( '{"content"' ); + } ); + } ); + + describe( 'inner content', () => { + it( 'serializes from inner content interleaved with inner blocks', () => { + registerBlockType( 'core/paragraph', { + apiVersion: 3, + category: 'text', + title: 'Paragraph', + attributes: { + content: { type: 'string', source: 'html' }, + }, + save: ( { attributes } ) => attributes.content || null, + } ); + + const block = createBlock( + 'core/html', + {}, + [ createBlock( 'core/paragraph', { content: 'Editable' } ) ], + [ '
', null, '
' ] + ); + + expect( serialize( block ) ).toBe( + '\n' + + '
\n' + + 'Editable\n' + + '
\n' + + '' + ); + + unregisterBlockType( 'core/paragraph' ); + } ); + } ); + + describe( 'transform from core/code', () => { + beforeEach( () => { + registerBlockType( 'core/paragraph', { + apiVersion: 3, + category: 'text', + title: 'Paragraph', + attributes: { + content: { type: 'string', source: 'html' }, + }, + save: ( { attributes } ) => attributes.content || null, + } ); + // A minimal stand-in: the transform only reads `attributes.content`. + registerBlockType( 'core/code', { + apiVersion: 3, + category: 'text', + title: 'Code', + attributes: { + content: { type: 'string', source: 'html' }, + }, + save: ( { attributes } ) => attributes.content || null, + } ); + } ); + + afterEach( () => { + unregisterBlockType( 'core/code' ); + unregisterBlockType( 'core/paragraph' ); + } ); + + // Mirrors how the code block stores HTML-block source: as escaped, + // plain-text rich text. + const asCodeContent = ( source ) => + toHTMLString( { value: create( { text: source } ) } ); + + it( 'restores block delimiters as editable inner blocks', () => { + const codeBlock = createBlock( 'core/code', { + content: asCodeContent( + '
\nEditable\n
' + ), + } ); + + const [ htmlBlock ] = switchToBlockType( codeBlock, 'core/html' ); + + expect( htmlBlock.name ).toBe( 'core/html' ); + expect( htmlBlock.innerBlocks ).toHaveLength( 1 ); + expect( htmlBlock.innerBlocks[ 0 ].name ).toBe( 'core/paragraph' ); + expect( htmlBlock.innerContent ).toContain( null ); + } ); + + it( 'keeps plain code (no delimiters) as a single static fragment', () => { + const codeBlock = createBlock( 'core/code', { + content: asCodeContent( '
Just static HTML
' ), + } ); + + const [ htmlBlock ] = switchToBlockType( codeBlock, 'core/html' ); + + expect( htmlBlock.innerBlocks ).toHaveLength( 0 ); + expect( htmlBlock.innerContent ).not.toContain( null ); + } ); + } ); +} ); diff --git a/packages/block-library/src/html/transforms.js b/packages/block-library/src/html/transforms.js index af1a16288fe9fc..52f60fdb03fe2c 100644 --- a/packages/block-library/src/html/transforms.js +++ b/packages/block-library/src/html/transforms.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { createBlock } from '@wordpress/blocks'; +import { createBlock, parse } from '@wordpress/blocks'; import { create } from '@wordpress/rich-text'; const transforms = { @@ -10,11 +10,16 @@ const transforms = { type: 'block', blocks: [ 'core/code' ], transform: ( { content: html } ) => { - return createBlock( 'core/html', { - // The code block may output HTML formatting, so convert it - // to plain text. - content: create( { html } ).text, - } ); + // The code block may output HTML formatting, so convert it + // to plain text, then parse it so any block delimiters become + // editable inner blocks at their positions within the static + // HTML, rather than inert comment text. + const text = create( { html } ).text; + const [ block ] = parse( + `\n${ text }\n` + ); + + return block ?? createBlock( 'core/html', {}, [], [ text ] ); }, }, ], diff --git a/packages/block-library/src/missing/edit.js b/packages/block-library/src/missing/edit.js index 00c5e349ee838b..6c080e133f96ba 100644 --- a/packages/block-library/src/missing/edit.js +++ b/packages/block-library/src/missing/edit.js @@ -39,9 +39,7 @@ export default function MissingEdit( { attributes, clientId } ) { function convertToHTML() { replaceBlock( clientId, - createBlock( 'core/html', { - content: originalUndelimitedContent, - } ) + createBlock( 'core/html', {}, [], [ originalUndelimitedContent ] ) ); } diff --git a/packages/blocks/README.md b/packages/blocks/README.md index d027785ebaef5f..6c2d8fc24a9fc1 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -45,6 +45,7 @@ _Parameters_ - _name_ `string`: Block name. - _attributes_ `Record< string, unknown >`: Block attributes. - _innerBlocks_ `Block[]`: Nested blocks. +- _innerContent_ `Array< string | null >`: Static HTML fragments interleaved with inner blocks, where `null` entries mark inner block positions. Only applies to the Custom HTML block. _Returns_ diff --git a/packages/blocks/src/api/factory.ts b/packages/blocks/src/api/factory.ts index 3d79d693c33a38..1117678524bb01 100644 --- a/packages/blocks/src/api/factory.ts +++ b/packages/blocks/src/api/factory.ts @@ -7,6 +7,7 @@ import { v4 as uuid } from 'uuid'; * WordPress dependencies */ import { createHooks, applyFilters } from '@wordpress/hooks'; +import warning from '@wordpress/warning'; /** * Internal dependencies @@ -38,16 +39,20 @@ const getBlockTypeWithTransformMetadata = ( /** * Returns a block object given its type and attributes. * - * @param name Block name. - * @param attributes Block attributes. - * @param innerBlocks Nested blocks. + * @param name Block name. + * @param attributes Block attributes. + * @param innerBlocks Nested blocks. + * @param innerContent Static HTML fragments interleaved with inner blocks, + * where `null` entries mark inner block positions. Only + * applies to the Custom HTML block. * * @return Block object. */ export function createBlock( name: string, attributes: Record< string, unknown > = {}, - innerBlocks: Block[] = [] + innerBlocks: Block[] = [], + innerContent?: Array< string | null > ): Block { if ( ! isBlockRegistered( name ) ) { return createBlock( 'core/missing', { @@ -66,13 +71,27 @@ export function createBlock( // Blocks are stored with a unique ID, the assigned type name, the block // attributes, and their inner blocks. - return { + const block: Block = { clientId, name, isValid: true, attributes: sanitizedAttributes, innerBlocks, }; + + if ( innerContent ) { + // Static inner content is currently a Custom HTML block mechanism + // only; it isn't exposed as a block support. + if ( name === 'core/html' ) { + block.innerContent = innerContent; + } else { + warning( + `The innerContent argument passed to createBlock for the "${ name }" block was ignored. Only the Custom HTML block stores static inner content.` + ); + } + } + + return block; } /** @@ -684,6 +703,7 @@ type BlockExample = { attributes?: Record< string, unknown >; innerBlocks?: BlockExample[ 'innerBlocks' ]; } >; + innerContent?: Array< string | null >; }; export const getBlockFromExample = ( @@ -695,5 +715,6 @@ export const getBlockFromExample = ( example.attributes, ( example.innerBlocks ?? [] ).map( ( innerBlock ) => getBlockFromExample( innerBlock.name, innerBlock ) - ) + ), + example.innerContent ); diff --git a/packages/blocks/src/api/parser/index.ts b/packages/blocks/src/api/parser/index.ts index efcbe4bfe4d1bf..600b0cdbb87336 100644 --- a/packages/blocks/src/api/parser/index.ts +++ b/packages/blocks/src/api/parser/index.ts @@ -215,6 +215,19 @@ export function parseRawBlock( ); parsedBlock.originalContent = normalizedBlock.innerHTML; + // The Custom HTML block keeps the interleaved static HTML fragments as + // the canonical source of its own markup, so the `save`-based validation + // and deprecation flows don't apply: serializing the parsed content + // reproduces the input by construction. + if ( blockType.name === 'core/html' ) { + parsedBlock.innerContent = normalizedBlock.innerContent ?? [ + normalizedBlock.innerHTML, + ]; + parsedBlock.isValid = true; + parsedBlock.validationIssues = []; + return parsedBlock; + } + const validatedBlock = applyBlockValidation( parsedBlock, blockType ); const { validationIssues } = validatedBlock; diff --git a/packages/blocks/src/api/parser/test/index.js b/packages/blocks/src/api/parser/test/index.js index ffba8811b44ce9..f78942afa67605 100644 --- a/packages/blocks/src/api/parser/test/index.js +++ b/packages/blocks/src/api/parser/test/index.js @@ -487,4 +487,76 @@ describe( 'block parser', () => { expect( parsed[ 0 ].attributes.content ).toBe( content ); } ); } ); + + describe( 'Custom HTML block static inner content', () => { + const htmlBlockSettings = { + apiVersion: 3, + category: 'text', + title: 'Custom HTML', + save: () => null, + }; + + const innerBlockSettings = { + apiVersion: 3, + category: 'text', + title: 'inner block', + attributes: { + content: { + type: 'string', + source: 'html', + }, + }, + save: ( { attributes } ) => attributes.content || null, + }; + + it( 'should retain innerContent and mark the block valid', () => { + registerBlockType( 'core/html', htmlBlockSettings ); + registerBlockType( 'core/inner', innerBlockSettings ); + + const [ block ] = parse( + '\n' + + '
\nBananas\n
\n' + + '' + ); + + expect( block.name ).toBe( 'core/html' ); + expect( block.isValid ).toBe( true ); + expect( block.innerContent ).toEqual( [ + '\n
', + null, + '
\n', + ] ); + expect( block.innerBlocks ).toHaveLength( 1 ); + expect( block.innerBlocks[ 0 ].name ).toBe( 'core/inner' ); + expect( block.innerBlocks[ 0 ].attributes.content ).toBe( + 'Bananas' + ); + } ); + + it( 'should serialize parsed static HTML interleaved with inner blocks back to identical markup', () => { + registerBlockType( 'core/html', htmlBlockSettings ); + registerBlockType( 'core/inner', innerBlockSettings ); + + const content = + '\n' + + '\n' + + ''; + + expect( serialize( parse( content ) ) ).toBe( content ); + } ); + + it( 'should serialize parsed static HTML without inner blocks back to identical markup', () => { + registerBlockType( 'core/html', htmlBlockSettings ); + + const content = + '\n' + + '

Some HTML code

\n' + + '
This is a div
\n' + + ''; + + expect( serialize( parse( content ) ) ).toBe( content ); + } ); + } ); } ); diff --git a/packages/blocks/src/api/raw-handling/html-to-blocks.ts b/packages/blocks/src/api/raw-handling/html-to-blocks.ts index 41e3ea3b24390d..5e2af674a6d8ce 100644 --- a/packages/blocks/src/api/raw-handling/html-to-blocks.ts +++ b/packages/blocks/src/api/raw-handling/html-to-blocks.ts @@ -39,7 +39,9 @@ export function htmlToBlocks( return createBlock( // Should not be hardcoded. 'core/html', - getBlockAttributes( 'core/html', node.outerHTML ) + {}, + [], + [ node.outerHTML ] ); } diff --git a/packages/blocks/src/api/serializer.tsx b/packages/blocks/src/api/serializer.tsx index 224c31efc2c145..1b6549af0c2dfd 100644 --- a/packages/blocks/src/api/serializer.tsx +++ b/packages/blocks/src/api/serializer.tsx @@ -330,6 +330,42 @@ export function serializeAttributes( ); } +/** + * Given the static HTML fragments of the Custom HTML block and its inner + * blocks, returns the block's inner HTML markup by interleaving the fragments + * with the serialized inner blocks. + * + * @param innerContent Static HTML fragments, `null` marking inner block positions. + * @param innerBlocks Inner blocks. + * + * @return HTML. + */ +function serializeInnerContent( + innerContent: Array< string | null >, + innerBlocks: Block[] +): string { + let childIndex = 0; + const parts = innerContent.map( ( item ) => { + if ( item !== null ) { + return item; + } + const innerBlock = innerBlocks[ childIndex++ ]; + return innerBlock + ? serializeBlock( innerBlock, { isInnerBlocks: true } ) + : ''; + } ); + + // Defensively append inner blocks without a matching placeholder so that + // content is never lost when the two arrays fall out of sync. + for ( ; childIndex < innerBlocks.length; childIndex++ ) { + parts.push( + serializeBlock( innerBlocks[ childIndex ], { isInnerBlocks: true } ) + ); + } + + return parts.join( '' ).trim(); +} + /** * Given a block object, returns the Block's Inner HTML markup. * @@ -338,6 +374,12 @@ export function serializeAttributes( * @return HTML. */ export function getBlockInnerHTML( block: Block ): string { + // The Custom HTML block serializes from its static HTML fragments rather + // than from a `save` implementation. + if ( block.innerContent && block.name === 'core/html' ) { + return serializeInnerContent( block.innerContent, block.innerBlocks ); + } + // If block was parsed as invalid or encounters an error while generating // save content, use original content instead to avoid content loss. If a // block contains nested content, exempt it from this condition because we diff --git a/packages/blocks/src/api/test/factory.js b/packages/blocks/src/api/test/factory.js index f3a09de9efb518..3fa8f3792039c8 100644 --- a/packages/blocks/src/api/test/factory.js +++ b/packages/blocks/src/api/test/factory.js @@ -26,6 +26,7 @@ import { unregisterBlockType, setGroupingBlockName, } from '../registration'; +import { logged as warningLoggedSet } from '../../../../warning/src/utils'; const noop = () => {}; @@ -47,6 +48,11 @@ describe( 'block factory', () => { require( '../../store' ); } ); + beforeEach( () => { + // Reset warning logging so deduped warnings fire within each test. + warningLoggedSet.clear(); + } ); + afterEach( () => { getBlockTypes().forEach( ( block ) => { unregisterBlockType( block.name ); @@ -181,6 +187,28 @@ describe( 'block factory', () => { expect( block.attributes ).toEqual( {} ); } ); + + it( 'should attach innerContent for the Custom HTML block', () => { + registerBlockType( 'core/html', defaultBlockSettings ); + + const block = createBlock( 'core/html', {}, [], [ '
' ] ); + + expect( block.innerContent ).toEqual( [ '
' ] ); + } ); + + it( 'should ignore innerContent and warn for other blocks', () => { + registerBlockType( 'core/test-block', defaultBlockSettings ); + + const block = createBlock( + 'core/test-block', + {}, + [], + [ '
' ] + ); + + expect( block.innerContent ).toBeUndefined(); + expect( console ).toHaveWarned(); + } ); } ); describe( 'createBlocksFromInnerBlocksTemplate', () => { diff --git a/packages/blocks/src/api/test/serializer.js b/packages/blocks/src/api/test/serializer.js index 0585a54cc8e673..c0cc2a1c341e40 100644 --- a/packages/blocks/src/api/test/serializer.js +++ b/packages/blocks/src/api/test/serializer.js @@ -391,6 +391,74 @@ describe( 'block serializer', () => { expect( serializeBlock( block ) ).toBe( '' ); } ); + it( 'serializes the Custom HTML block from its static fragments', () => { + registerBlockType( 'core/html', { + apiVersion: 3, + category: 'text', + title: 'Custom HTML', + save: () => null, + } ); + registerBlockType( 'core/fruit', { + apiVersion: 3, + category: 'text', + title: 'fruit block', + attributes: { + fruit: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.fruit, + } ); + + const block = createBlock( + 'core/html', + {}, + [ createBlock( 'core/fruit', { fruit: 'Bananas' } ) ], + [ '
', null, '
' ] + ); + + expect( serializeBlock( block ) ).toBe( + '\n' + + '
\n' + + 'Bananas\n' + + '
\n' + + '' + ); + } ); + it( 'appends inner blocks missing an innerContent placeholder', () => { + registerBlockType( 'core/html', { + apiVersion: 3, + category: 'text', + title: 'Custom HTML', + save: () => null, + } ); + registerBlockType( 'core/fruit', { + apiVersion: 3, + category: 'text', + title: 'fruit block', + attributes: { + fruit: { + type: 'string', + }, + }, + save: ( { attributes } ) => attributes.fruit, + } ); + + const block = createBlock( + 'core/html', + {}, + [ createBlock( 'core/fruit', { fruit: 'Bananas' } ) ], + [ '
' ] + ); + + expect( serializeBlock( block ) ).toBe( + '\n' + + '
\n' + + 'Bananas\n' + + '\n' + + '' + ); + } ); } ); describe( 'serialize()', () => { diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 83894f5027a0a1..7d008d03b9a4af 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -487,6 +487,14 @@ export interface Block< * Inner blocks. */ innerBlocks: Block[]; + /** + * Static HTML fragments interleaved with inner blocks, for the Custom HTML + * block. `null` entries mark the positions of the inner blocks within the + * static markup. When present, this is the canonical source of the block's + * own markup and is used for serialization instead of the `save` + * implementation. + */ + innerContent?: Array< string | null >; /** * Original content of the block before validation fixes. */ diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 40b6f616dd963d..1c79e09ac6a7a7 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -46,6 +46,7 @@ export interface Block { attributes: BlockAttributes; clientId?: string; innerBlocks: Block[]; + innerContent?: Array< string | null >; isValid?: boolean; name: string; originalContent?: string; diff --git a/packages/core-data/src/utils/test/crdt-blocks.ts b/packages/core-data/src/utils/test/crdt-blocks.ts index 306a541b5c5d6e..794bab96651f57 100644 --- a/packages/core-data/src/utils/test/crdt-blocks.ts +++ b/packages/core-data/src/utils/test/crdt-blocks.ts @@ -165,6 +165,64 @@ describe( 'crdt-blocks', () => { expect( content.toString() ).toBe( 'Hello World' ); } ); + it( 'syncs innerContent of static inner-content blocks into the Y.Doc', () => { + const incomingBlocks: Block[] = [ + { + name: 'core/html', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Editable' }, + innerBlocks: [], + clientId: 'inner-1', + }, + ], + innerContent: [ '' ], + clientId: 'html-1', + }, + ]; + + mergeCrdtBlocks( yblocks, incomingBlocks, null ); + + const block = yblocks.get( 0 ); + expect( block.get( 'innerContent' ) ).toEqual( [ + '', + ] ); + } ); + + it( 'updates innerContent when the static markup changes', () => { + const initialBlocks: Block[] = [ + { + name: 'core/html', + attributes: {}, + innerBlocks: [], + innerContent: [ '

one

' ], + clientId: 'html-1', + }, + ]; + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const updatedBlocks: Block[] = [ + { + name: 'core/html', + attributes: {}, + innerBlocks: [], + innerContent: [ '

two

' ], + clientId: 'html-1', + }, + ]; + + mergeCrdtBlocks( yblocks, updatedBlocks, null ); + + expect( yblocks.get( 0 ).get( 'innerContent' ) ).toEqual( [ + '

two

', + ] ); + } ); + it( 'updates existing blocks when content changes', () => { const initialBlocks: Block[] = [ { diff --git a/packages/widgets/src/blocks/legacy-widget/transforms.js b/packages/widgets/src/blocks/legacy-widget/transforms.js index 3e4aab3cc0b5b1..f3062f2e5a9692 100644 --- a/packages/widgets/src/blocks/legacy-widget/transforms.js +++ b/packages/widgets/src/blocks/legacy-widget/transforms.js @@ -15,9 +15,8 @@ const legacyWidgetTransforms = [ { block: 'core/html', widget: 'custom_html', - transform: ( { content } ) => ( { - content, - } ), + transformBlock: ( { content } ) => + createBlock( 'core/html', {}, [], [ content ] ), }, { block: 'core/archives', @@ -160,7 +159,7 @@ const legacyWidgetTransforms = [ }; }, }, -].map( ( { block, widget, transform } ) => { +].map( ( { block, widget, transform, transformBlock } ) => { return { type: 'block', blocks: [ block ], @@ -168,10 +167,12 @@ const legacyWidgetTransforms = [ return idBase === widget && !! instance?.raw; }, transform: ( { instance } ) => { - const transformedBlock = createBlock( - block, - transform ? transform( instance.raw ) : undefined - ); + const transformedBlock = transformBlock + ? transformBlock( instance.raw ) + : createBlock( + block, + transform ? transform( instance.raw ) : undefined + ); if ( ! instance.raw?.title ) { return transformedBlock; } diff --git a/test/e2e/specs/editor/blocks/code.spec.js b/test/e2e/specs/editor/blocks/code.spec.js index 13aaf3be0010f0..e3f8e18060db6c 100644 --- a/test/e2e/specs/editor/blocks/code.spec.js +++ b/test/e2e/specs/editor/blocks/code.spec.js @@ -90,12 +90,14 @@ test.describe( 'Code', () => { test.describe( 'FROM HTML', () => { test( 'should preserve the content', async ( { editor } ) => { - await editor.insertBlock( { - name: 'core/html', - attributes: { - content: 'initial content', - }, - } ); + // The HTML block's markup lives in its inner content, not in + // an attribute, so it can't be seeded via `insertBlock`. + await editor.setContent( + `\ninitial content\n` + ); + await editor.canvas + .locator( '[data-type="core/html"]' ) + .click(); await editor.transformBlockTo( 'core/code' ); const codeBlock = ( await editor.getBlocks() )[ 0 ]; expect( codeBlock.name ).toBe( 'core/code' ); @@ -110,7 +112,6 @@ test.describe( 'Code', () => { await editor.insertBlock( { name: 'core/html', attributes: { - content: 'initial content', metadata: { name: 'Custom name', }, diff --git a/test/e2e/specs/editor/blocks/html.spec.js b/test/e2e/specs/editor/blocks/html.spec.js index 7e812aaf90db8b..e2a8c4815449ca 100644 --- a/test/e2e/specs/editor/blocks/html.spec.js +++ b/test/e2e/specs/editor/blocks/html.spec.js @@ -63,7 +63,54 @@ test.describe( 'HTML block', () => { await editor.publishPost(); await page.reload(); await expect( - editor.canvas.locator( '[data-type="core/html"] iframe' ) - ).toBeVisible(); + editor.canvas.locator( '[data-type="core/html"]' ) + ).toContainText( '1 < 2' ); + } ); + + test( 'supports editable inner blocks within static HTML', async ( { + editor, + page, + } ) => { + await editor.setContent( + ` + +` + ); + + // The inner paragraph renders at its position within the static + // markup and is editable in place. + const paragraph = editor.canvas.locator( + 'role=document[name="Block: Paragraph"i]' + ); + await expect( paragraph ).toBeVisible(); + await paragraph.click(); + await expect( paragraph ).toBeFocused(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( ' updated' ); + + expect( await editor.getEditedPostContent() ).toBe( + ` + +` + ); + + // The inner block is locked: the options menu offers no removal and + // the toolbar offers no movers. + await editor.clickBlockToolbarButton( 'Options' ); + const optionsMenu = page.getByRole( 'menu', { name: 'Options' } ); + await expect( optionsMenu ).toBeVisible(); + await expect( + optionsMenu.getByRole( 'menuitem', { name: 'Delete' } ) + ).toBeHidden(); + await page.keyboard.press( 'Escape' ); + await expect( + page.locator( + 'role=toolbar[name="Block tools"i] >> role=button[name="Move up"i]' + ) + ).toBeHidden(); } ); } ); diff --git a/test/e2e/specs/editor/collaboration/collaboration-block-gauntlet.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-block-gauntlet.spec.ts index 0a19dbb2ab9efb..c1d545cc4849ed 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-block-gauntlet.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-block-gauntlet.spec.ts @@ -624,9 +624,17 @@ test.describe( 'Collaboration - Block Gauntlet', () => { name: 'core/embed', attributes: { caption: 'Embed A' }, } ); - await editor.insertBlock( { - name: 'core/html', - attributes: { content: '

Hello HTML

' }, + // The HTML block stores its markup as inner content, which + // `insertBlock` (attributes/innerBlocks only) can't express, so insert + // it directly via the data API. + await editor.page.evaluate( () => { + const block = window.wp.blocks.createBlock( + 'core/html', + {}, + [], + [ '

Hello HTML

' ] + ); + window.wp.data.dispatch( 'core/block-editor' ).insertBlock( block ); } ); await editor.insertBlock( { name: 'core/shortcode', @@ -757,7 +765,7 @@ test.describe( 'Collaboration - Block Gauntlet', () => { } ); } ); - // HTML: edit via data API + // HTML: edit the static markup (stored as inner content) via data API. await page2.evaluate( () => { const blocks = window.wp.data .select( 'core/block-editor' ) @@ -770,8 +778,8 @@ test.describe( 'Collaboration - Block Gauntlet', () => { } window.wp.data .dispatch( 'core/block-editor' ) - .updateBlockAttributes( html.clientId, { - content: '
Edited HTML
', + .updateBlock( html.clientId, { + innerContent: [ '
Edited HTML
' ], } ); } ); @@ -832,8 +840,10 @@ test.describe( 'Collaboration - Block Gauntlet', () => { attributes: { caption: 'Embed edited by B' }, }, { + // The HTML block's markup lives in inner content, which the + // default `getBlocks()` doesn't surface; it's asserted + // separately below. name: 'core/html', - attributes: { content: '
Edited HTML
' }, }, { name: 'core/shortcode', @@ -849,6 +859,22 @@ test.describe( 'Collaboration - Block Gauntlet', () => { }, ] ); + // The HTML block's edited markup is stored as inner content (not an + // attribute), so assert it synced to both users via the full blocks. + const getHtmlInnerContent = async ( ed: typeof editor ) => { + const blocks = ( await ed.getBlocks( { full: true } ) ) as Array< { + name: string; + innerContent?: Array< string | null >; + } >; + return blocks.find( ( b ) => b.name === 'core/html' )?.innerContent; + }; + await expect + .poll( () => getHtmlInnerContent( editor ), { timeout: 10_000 } ) + .toEqual( [ '
Edited HTML
' ] ); + await expect + .poll( () => getHtmlInnerContent( editor2 ), { timeout: 10_000 } ) + .toEqual( [ '
Edited HTML
' ] ); + // Verify both users have identical final block state. const blocksA = await editor.getBlocks(); const blocksB = await editor2.getBlocks(); diff --git a/test/integration/fixtures/blocks/core__html.json b/test/integration/fixtures/blocks/core__html.json index faa0024b94df43..5c74fa44781f7b 100644 --- a/test/integration/fixtures/blocks/core__html.json +++ b/test/integration/fixtures/blocks/core__html.json @@ -2,9 +2,10 @@ { "name": "core/html", "isValid": true, - "attributes": { - "content": "

Some HTML code

\n
This is a div
" - }, - "innerBlocks": [] + "attributes": {}, + "innerBlocks": [], + "innerContent": [ + "\n

Some HTML code

\n
This is a div
\n" + ] } ] diff --git a/test/integration/fixtures/blocks/core__html__inner-blocks.html b/test/integration/fixtures/blocks/core__html__inner-blocks.html new file mode 100644 index 00000000000000..1454e410968b8e --- /dev/null +++ b/test/integration/fixtures/blocks/core__html__inner-blocks.html @@ -0,0 +1,5 @@ + + + diff --git a/test/integration/fixtures/blocks/core__html__inner-blocks.json b/test/integration/fixtures/blocks/core__html__inner-blocks.json new file mode 100644 index 00000000000000..356029bad39a8a --- /dev/null +++ b/test/integration/fixtures/blocks/core__html__inner-blocks.json @@ -0,0 +1,23 @@ +[ + { + "name": "core/html", + "isValid": true, + "attributes": {}, + "innerBlocks": [ + { + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": "Editable paragraph", + "dropCap": false + }, + "innerBlocks": [] + } + ], + "innerContent": [ + "\n

Static heading

", + null, + "
Static footer
\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__html__inner-blocks.parsed.json b/test/integration/fixtures/blocks/core__html__inner-blocks.parsed.json new file mode 100644 index 00000000000000..fe5ad0b342a27e --- /dev/null +++ b/test/integration/fixtures/blocks/core__html__inner-blocks.parsed.json @@ -0,0 +1,21 @@ +[ + { + "blockName": "core/html", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/paragraph", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n

Editable paragraph

\n", + "innerContent": [ "\n

Editable paragraph

\n" ] + } + ], + "innerHTML": "\n

Static heading

Static footer
\n", + "innerContent": [ + "\n

Static heading

", + null, + "
Static footer
\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__html__inner-blocks.serialized.html b/test/integration/fixtures/blocks/core__html__inner-blocks.serialized.html new file mode 100644 index 00000000000000..1454e410968b8e --- /dev/null +++ b/test/integration/fixtures/blocks/core__html__inner-blocks.serialized.html @@ -0,0 +1,5 @@ + + + diff --git a/test/integration/full-content/full-content.test.js b/test/integration/full-content/full-content.test.js index 3df3bd3833a1c5..9691753ea966ee 100644 --- a/test/integration/full-content/full-content.test.js +++ b/test/integration/full-content/full-content.test.js @@ -49,6 +49,7 @@ const normalizeParsedBlocks = ( blocks ) => isValid: block.isValid, attributes: JSON.parse( JSON.stringify( block.attributes ) ), innerBlocks: normalizeParsedBlocks( block.innerBlocks ), + ...( block.innerContent ? { innerContent: block.innerContent } : {} ), } ) ); describe( 'full post content fixture', () => {