diff --git a/package-lock.json b/package-lock.json
index 2cee4b81..904b2684 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,6 +34,7 @@
"@wordpress/i18n": "^6.21.1",
"@wordpress/keycodes": "^4.34.0",
"@wordpress/notices": "^5.48.1",
+ "@wordpress/private-apis": "^1.49.0",
"@wordpress/scripts": "^32.4.1",
"clsx": "2.1.1",
"husky": "^9.1.7",
diff --git a/package.json b/package.json
index 7ff0c1d9..9acbef22 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"@wordpress/i18n": "^6.21.1",
"@wordpress/keycodes": "^4.34.0",
"@wordpress/notices": "^5.48.1",
+ "@wordpress/private-apis": "^1.49.0",
"@wordpress/scripts": "^32.4.1",
"clsx": "2.1.1",
"husky": "^9.1.7",
diff --git a/src/block-editor/edit-legacy.tsx b/src/block-editor/edit-legacy.tsx
new file mode 100644
index 00000000..61f9dde0
--- /dev/null
+++ b/src/block-editor/edit-legacy.tsx
@@ -0,0 +1,204 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useMemo, useState, useRef } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import {
+ BlockControls,
+ transformStyles,
+ useBlockProps,
+ store as blockEditorStore,
+} from '@wordpress/block-editor';
+import {
+ ResizableBox,
+ ToolbarButton,
+ Disabled,
+ SandBox,
+ ToolbarGroup,
+ Modal,
+ Notice,
+} from '@wordpress/components';
+import { Stack } from '@wordpress/ui';
+import { fullscreen } from '@wordpress/icons';
+import type { BlockEditProps } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import MonacoEditor, { type MonacoError } from '../components/monaco-editor';
+
+type HTMLEditProps = BlockEditProps< {
+ content: string;
+ height: number;
+} > & {
+ // Injected by the block editor but not included in BlockEditProps.
+ toggleSelection: ( isSelectionEnabled: boolean ) => void;
+};
+
+const MIN_HEIGHT = 100;
+const MAX_HEIGHT = 1000;
+
+const DEFAULT_STYLES = `
+ html,body,:root {
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: visible !important;
+ min-height: auto !important;
+ }
+`;
+
+export default function HTMLEdit( {
+ attributes,
+ isSelected,
+ setAttributes,
+ toggleSelection,
+}: HTMLEditProps ) {
+ const { content, height } = attributes;
+ const { editorSettings, editorOptions } = window.chbeObj;
+
+ const [ isPreview, setIsPreview ] = useState< boolean >();
+ const [ isModalEditorOpen, setIsModalEditorOpen ] = useState< boolean >();
+
+ const [ errorMessage, setErrorMessage ] = useState< string >();
+
+ const ref = useRef< HTMLDivElement >( null );
+
+ const settingStyles = useSelect(
+ ( select ) => select( blockEditorStore ).getSettings().styles,
+ []
+ );
+
+ const styles = useMemo(
+ () => [
+ DEFAULT_STYLES,
+ ...transformStyles(
+ ( settingStyles ?? [] ).filter( ( style: { css?: string } ) => style.css )
+ ),
+ ],
+ [ settingStyles ]
+ );
+
+ const onResizeStart = () => {
+ toggleSelection( false );
+ };
+
+ const onResizeStop = (
+ _event: unknown,
+ _direction: unknown,
+ _elt: unknown,
+ delta: { height: number }
+ ) => {
+ const newHeight = Math.min(
+ Math.max( parseInt( `${ height + delta.height }`, 10 ), MIN_HEIGHT ),
+ MAX_HEIGHT
+ );
+ setAttributes( { height: newHeight } );
+ };
+
+ const onChange = ( value: string ) => {
+ setAttributes( { content: value } );
+ };
+
+ const onError = ( error: MonacoError ) => {
+ if ( ( error.type === 'timeout' || error.type === 'scripterror' ) && error.msg ) {
+ setErrorMessage( error.msg );
+ }
+ };
+
+ function switchToPreview() {
+ setIsPreview( true );
+ }
+
+ function switchToHTML() {
+ setIsPreview( false );
+ }
+
+ return (
+
+
+ { ! isPreview && (
+
+ setIsModalEditorOpen( true ) }
+ />
+
+ ) }
+
+
+ { __( 'HTML', 'custom-html-block-extension' ) }
+
+
+ { __( 'Preview', 'custom-html-block-extension' ) }
+
+
+
+
+ { ( isDisabled ) =>
+ isPreview || isDisabled ? (
+ <>
+
+ { ! isSelected && }
+ >
+ ) : (
+
+ { errorMessage && { errorMessage } }
+
+
+
+
+ )
+ }
+
+ { isModalEditorOpen && (
+
setIsModalEditorOpen( false ) }
+ >
+
+
+ ) }
+
+ );
+}
diff --git a/src/block-editor/edit.tsx b/src/block-editor/edit.tsx
index 61f9dde0..05110404 100644
--- a/src/block-editor/edit.tsx
+++ b/src/block-editor/edit.tsx
@@ -2,26 +2,28 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { useMemo, useState, useRef } from '@wordpress/element';
-import { useSelect } from '@wordpress/data';
+import { useEffect, useState, useRef } from '@wordpress/element';
+import { useSelect, useDispatch, useRegistry } from '@wordpress/data';
+import * as blockEditor from '@wordpress/block-editor';
import {
BlockControls,
- transformStyles,
+ BlockIcon,
useBlockProps,
store as blockEditorStore,
} from '@wordpress/block-editor';
+import { parse, serialize, getBlockContent } from '@wordpress/blocks';
import {
ResizableBox,
ToolbarButton,
- Disabled,
- SandBox,
ToolbarGroup,
Modal,
Notice,
+ Placeholder,
} from '@wordpress/components';
import { Stack } from '@wordpress/ui';
-import { fullscreen } from '@wordpress/icons';
-import type { BlockEditProps } from '@wordpress/blocks';
+import { code, fullscreen } from '@wordpress/icons';
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+import type { Block, BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -29,6 +31,13 @@ import type { BlockEditProps } from '@wordpress/blocks';
import './style.scss';
import MonacoEditor, { type MonacoError } from '../components/monaco-editor';
+const { InnerContent } = __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
+ '@wordpress/block-editor'
+).unlock( blockEditor.privateApis ) as {
+ InnerContent: React.ComponentType< { clientId: string } >;
+};
+
type HTMLEditProps = BlockEditProps< {
content: string;
height: number;
@@ -37,25 +46,21 @@ type HTMLEditProps = BlockEditProps< {
toggleSelection: ( isSelectionEnabled: boolean ) => void;
};
+type BlockWithInnerContent = Block & {
+ innerContent?: Array< string | null >;
+};
+
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 1000;
-const DEFAULT_STYLES = `
- html,body,:root {
- margin: 0 !important;
- padding: 0 !important;
- overflow: visible !important;
- min-height: auto !important;
- }
-`;
-
export default function HTMLEdit( {
+ clientId,
attributes,
isSelected,
setAttributes,
toggleSelection,
}: HTMLEditProps ) {
- const { content, height } = attributes;
+ const { height } = attributes;
const { editorSettings, editorOptions } = window.chbeObj;
const [ isPreview, setIsPreview ] = useState< boolean >();
@@ -65,21 +70,23 @@ export default function HTMLEdit( {
const ref = useRef< HTMLDivElement >( null );
- const settingStyles = useSelect(
- ( select ) => select( blockEditorStore ).getSettings().styles,
- []
- );
+ const registry = useRegistry();
+ const { updateBlock, replaceInnerBlocks } = useDispatch( blockEditorStore );
- const styles = useMemo(
- () => [
- DEFAULT_STYLES,
- ...transformStyles(
- ( settingStyles ?? [] ).filter( ( style: { css?: string } ) => style.css )
- ),
- ],
- [ settingStyles ]
+ const { content, isPreviewMode } = useSelect(
+ ( select ) => {
+ const { getBlock, getSettings } = select( blockEditorStore );
+ const block = getBlock( clientId );
+ return {
+ content: block ? getBlockContent( block ) : '',
+ isPreviewMode: ( getSettings() as { isPreviewMode?: boolean } ).isPreviewMode,
+ };
+ },
+ [ clientId ]
);
+ const hasContent = !! content.trim();
+
const onResizeStart = () => {
toggleSelection( false );
};
@@ -97,10 +104,55 @@ export default function HTMLEdit( {
setAttributes( { height: newHeight } );
};
- const onChange = ( value: string ) => {
- setAttributes( { content: value } );
+ // 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 onChange = ( nextContent: string ) => {
+ if ( nextContent === content ) {
+ return;
+ }
+
+ const [ parsedBlock ] = parse(
+ `\n${ nextContent }\n`
+ ) as BlockWithInnerContent[];
+ 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: Block, index: number ) =>
+ serialize( block ) === serialize( nextInnerBlocks[ index ] )
+ );
+
+ registry.batch( () => {
+ updateBlock( clientId, {
+ innerContent: parsedBlock?.innerContent ?? ( nextContent ? [ nextContent ] : [] ),
+ } as Partial< Block > );
+ 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;
+ }
+
+ updateBlock( clientId, {
+ attributes: { content: undefined },
+ innerContent: [ attributes.content ],
+ } as Partial< Block > );
+ }, [ clientId, updateBlock, attributes.content ] );
+
const onError = ( error: MonacoError ) => {
if ( ( error.type === 'timeout' || error.type === 'scripterror' ) && error.msg ) {
setErrorMessage( error.msg );
@@ -136,49 +188,54 @@ export default function HTMLEdit( {
-
- { ( isDisabled ) =>
- isPreview || isDisabled ? (
- <>
-
- { ! isSelected && }
- >
- ) : (
-
- { errorMessage && { errorMessage } }
-
-
-
-
- )
- }
-
+ { isPreview || isPreviewMode ? (
+ <>
+ { ! hasContent && (
+ }
+ label={ __( 'Custom HTML', 'custom-html-block-extension' ) }
+ instructions={ __(
+ 'Add custom HTML code and preview how it looks.',
+ 'custom-html-block-extension'
+ ) }
+ />
+ ) }
+ { hasContent && }
+ >
+ ) : (
+
+ { errorMessage && { errorMessage } }
+
+
+
+
+ ) }
{ isModalEditorOpen && (
{
if ( 'core/html' !== settings.name ) {
return settings;
}
+ // Since WordPress 7.1 the Custom HTML block stores its markup in
+ // `innerContent` instead of the `content` attribute, so its `content`
+ // attribute definition drops `source: 'raw'` in favor of `role: 'local'`.
+ // Use that to pick the matching Edit implementation.
+ // TODO: Remove `editLegacy` once the minimum supported version is 7.1.
+ const hasInnerContent = settings.attributes?.content?.role === 'local';
+
const newSettings: BlockConfiguration = {
...settings,
icon,
@@ -25,7 +33,7 @@ const customHtmlBlockExtension = ( settings: BlockConfiguration ): BlockConfigur
default: 300,
},
},
- edit: edit as BlockConfiguration[ 'edit' ],
+ edit: ( hasInnerContent ? edit : editLegacy ) as BlockConfiguration[ 'edit' ],
};
return newSettings;
};
diff --git a/src/global.d.ts b/src/global.d.ts
index 9460c8e2..c89de721 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -51,4 +51,9 @@ declare module 'react' {
}
}
+// `privateApis` is exported at runtime but missing from the package types.
+declare module '@wordpress/block-editor' {
+ export const privateApis: unknown;
+}
+
export {};