Editor UI Extensions

Building an AI-Powered Block Toolbar Action

⏱ 18 min

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.

What you'll learn in this lesson
The editor.BlockEdit filter
Wrapping any block's edit() output with a higher-order component, without touching its source.
BlockControls, ToolbarGroup, and ToolbarButton
Adding a real toolbar button that appears when a matching block is selected.
Scoping the filter to the right blocks
Checking props.name and the shape of the block's content attribute before rendering anything.
Reusing an existing REST endpoint from new UI
The same /my-plugin/v1/rewrite route from Lesson 2, called with a different instruction.
Prerequisites

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.

This reuses Lesson 2's endpoint on purpose

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.

A missing isSelected check, or a missing content-type check, breaks other blocks

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

← Adding an AI Sidebar Panel Adding AI Blocks to the Inserter via Block Variations →