The custom block from Lesson 2 has its own built-in AI button. This lesson does something more broadly useful: adding an AI toolbar action to blocks you didn’t write, core’s own Paragraph and Heading blocks, using the block filter system rather than modifying those blocks directly. The toolbar button rewrites or summarizes whatever block is currently selected, using the exact REST endpoint Lesson 2 already built.
Lesson 2’s /my-plugin/v1/rewrite REST endpoint must already be registered, this
lesson calls it again rather than building a new one. Familiarity with
createHigherOrderComponent from @wordpress/compose is helpful but explained here.
Step 1: Why a filter, not a block edit
Core’s Paragraph and Heading blocks aren’t yours to modify directly. The block filter
system exists exactly for this: adding behavior to an existing block’s edit()
function from separate plugin code, without forking or replacing that block. The
specific filter for this, editor.BlockEdit, wraps every registered block’s edit
component in a higher-order component you supply, letting you render extra UI, like a
toolbar button, alongside whatever that block already renders.
Step 2: Wrapping BlockEdit with a toolbar button
// File: my-plugin/src/ai-toolbar-action.js
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import { BlockControls } from '@wordpress/block-editor';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
import { useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
const SUPPORTED_BLOCKS = [ 'core/paragraph', 'core/heading' ];
const withAiToolbarAction = createHigherOrderComponent( ( BlockEdit ) => {
return ( props ) => {
const [ isBusy, setIsBusy ] = useState( false );
const isSupported =
SUPPORTED_BLOCKS.includes( props.name ) && typeof props.attributes.content === 'string';
function transform( instruction ) {
setIsBusy( true );
apiFetch( {
path: '/my-plugin/v1/rewrite',
method: 'POST',
data: { content: props.attributes.content, instruction },
} )
.then( ( response ) => props.setAttributes( { content: response.rewritten } ) )
.finally( () => setIsBusy( false ) );
}
return (
<>
<BlockEdit key="edit" { ...props } />
{ isSupported && props.isSelected && (
<BlockControls>
<ToolbarGroup>
<ToolbarButton
icon="edit"
label={ __( 'Rewrite with AI' ) }
isBusy={ isBusy }
onClick={ () =>
transform(
'Rewrite the following text to be clearer and more concise, keep it roughly the same length.'
)
}
/>
<ToolbarButton
icon="excerpt-view"
label={ __( 'Summarize with AI' ) }
isBusy={ isBusy }
onClick={ () => transform( 'Summarize the following text in one sentence.' ) }
/>
</ToolbarGroup>
</BlockControls>
) }
</>
);
};
}, 'withAiToolbarAction' );
addFilter( 'editor.BlockEdit', 'my-plugin/with-ai-toolbar-action', withAiToolbarAction );
props.name is the block’s registered name, exactly what SUPPORTED_BLOCKS checks
against, and props.attributes / props.setAttributes behave exactly like they do
inside that block’s own edit() function, the higher-order component receives the
same props the wrapped block would.
Step 3: Why the isSelected check matters
BlockControls renders into the toolbar that appears above the currently selected
block. Without checking props.isSelected, every matching paragraph and heading on the
page would attempt to render its own toolbar simultaneously, which isn’t how the
toolbar UI is meant to behave and produces confusing duplicate rendering. Gating on
props.isSelected (a prop every block’s edit() receives) keeps the button showing up
only for the one block currently focused, exactly like every built-in toolbar control.
There’s no new PHP in this lesson. The whole point of building /my-plugin/v1/rewrite
to accept a variable instruction parameter back in Lesson 2 was so it could serve more
than one caller, the custom block’s own button, and now this toolbar action on
completely different, core-owned blocks.
Test it
Enqueue the script above on enqueue_block_editor_assets with wp-hooks, wp-compose,
wp-block-editor, wp-components, wp-element, and wp-api-fetch as dependencies.
Open a post, click into any paragraph or heading with some real text, and confirm two
new icons appear in that block’s toolbar. Click Rewrite with AI and confirm the
block’s text changes in place. Click into a different block type, an Image block, for
instance, and confirm the AI buttons don’t appear there at all.
If you skip the typeof props.attributes.content === 'string' check and a matching
block name happens to define content as something other than a plain string (a rich
text object in some custom blocks, for example), calling setAttributes with a plain
string in the wrong shape can silently corrupt that block. Always confirm both the
block name and the actual shape of the attribute you’re about to overwrite before
wiring a toolbar action to it.
Recap
The editor.BlockEdit filter, applied with createHigherOrderComponent and
addFilter(), lets you wrap any registered block’s edit output, including core’s own
Paragraph and Heading blocks, without modifying that block’s source. Rendering
BlockControls inside the wrapper, gated on props.isSelected and a check on
props.name, adds a real, scoped toolbar action, and this one calls the exact same
/my-plugin/v1/rewrite REST endpoint Lesson 2 already built, just with a different
instruction string depending on which button was clicked.
Resources & further reading
- Block Filters reference: editor.BlockEdit, developer.wordpress.org
- BlockControls reference, developer.wordpress.org
- @wordpress/compose package reference, developer.wordpress.org