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 {};