DigiPress

Highly Flexible WordPress Theme

[WP]ブロックエディターのリッチテキストツールバーに独自のドロップダウンメニューを追加する方法

カスタマイズ・技術情報

WordPress のブロックエディター(Gutenberg) のリッチテキストの標準ツールバーには、「太字」、「イタリック」などのごく限られた装飾ボタンしか用意されていません。

このツールバーに独自のボタンを追加するには、registerFormatType というメソッドを利用すれば可能になりますが、そのままではツールバーの右端の「よりリッチなテキスト制御」ツールバーのドロップダウンに集約されてしまい、アクセスしづらいです。

ここに追加されてしまう

また、多数のボタン(装飾)を追加する場合は、「文字色」、「文字サイズ」などでグループ分けして独立したツールバーボタンにそれぞれドロップダウンメニューに追加するほうがスッキリします。

というわけで、今回は簡単に独自のリッチテキストツールバーボタンの追加と、さらにその中に任意の選択中の文字列に対して複数の class の挿入にも対応したドロップダウンメニューの登録方法を紹介します。


開発環境を用意する

WordPress がローカル環境にない場合は、まずは「LOCAL」などのアプリケーションを利用してローカル環境に WordPress をセットアップしておきます。

Local
https://localwp.com/
Thumbnail

WordPress のブロックエディター開発には、本来は React、Webpack など様々なモジュールやライブラリ環境を事前に整える必要がありますが、WordPress が公式で提供している以下の「Create Block」を利用すれば、数行コマンドを実行するだけで、オリジナルブロックを追加するための開発環境を、難しくて複雑なセットアップ作業をすべて省いて瞬時に構築できます。

Node.js v.10.0.0 以降と npm v.6.9.0 以降だけは事前にインストールしておきます。

コマンドからローカルの WordPress のプラグインフォルダに移動してから以下のコマンドを実行すると、すぐにブロックエディターの開発環境が WordPress プラグインとしてセットアップされます。

コマンド
npx @wordpress/create-block my-custom-block
cd my-custom-block
npm start

上記では、”my-custom-block” というスラッグでプラグイン(開発環境)を作成しています。
/wp-content/plugins「my-custom-block」というフォルダが作成され、このフォルダに移動しておきます。

モニタリング状態(npm start)を解除するには、Command (Windowsは Ctrl ) + C を押します。

次に、プラグインフォルダ内に以下のフォルダとファイルを手動で作成しておきます。

専用のコンポーネントを作成

開発環境が整ったら、リッチテキストにオリジナルツールバーとドロップダウンメニューを追加するためのコーディング作業をしていきます。

オリジナルのフォーマットタイプ生成メソッドを用意

WordPress ネイティブの registerFormatType メソッドでは、スペースで区切られた複数の class の受け渡しには対応できない(フィルターでひっかかってしまう)ため、元の registerFormatType から class 名に半角スペースがあってもフィルタリングしないよう修正したメソッドを用意します。

myRegisterFormatType 関数
import { select, dispatch } from '@wordpress/data';

export function myRegisterFormatType( name, settings ) {
	settings = {
		name,
		...settings,
	};

	if ( typeof settings.name !== 'string' ) {
		window.console.error( 'Format names must be strings.' );
		return;
	}

	if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( settings.name ) ) {
		window.console.error(
			'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
		);
		return;
	}

	if ( select( 'core/rich-text' ).getFormatType( settings.name ) ) {
		window.console.error(
			'Format "' + settings.name + '" is already registered.'
		);
		return;
	}

	if ( typeof settings.tagName !== 'string' || settings.tagName === '' ) {
		window.console.error( 'Format tag names must be a string.' );
		return;
	}

	if (
		( typeof settings.className !== 'string' ||
			settings.className === '' ) &&
		settings.className !== null
	) {
		window.console.error(
			'Format class names must be a string, or null to handle bare elements.'
		);
		return;
	}


	// 半角スペースで区切った複数の class を許可するよう変更

	// if ( ! /^[_a-zA-Z]+[a-zA-Z0-9-]*$/.test( settings.className ) ) {
	if ( ! /^([_a-zA-Z]+[a-zA-Z0-9-]*)(\s+[_a-zA-Z]+[a-zA-Z0-9-]*)*$/.test( settings.className ) ) {
		window.console.error(
			'A class name must begin with a letter, followed by any number of hyphens, letters, or numbers.'
		);
		return;
	}

	if ( settings.className === null ) {
		const formatTypeForBareElement = select(
			'core/rich-text'
		).getFormatTypeForBareElement( settings.tagName );

		if ( formatTypeForBareElement ) {
			window.console.error(
				`Format "${ formatTypeForBareElement.name }" is already registered to handle bare tag name "${ settings.tagName }".`
			);
			return;
		}
	} else {
		const formatTypeForClassName = select(
			'core/rich-text'
		).getFormatTypeForClassName( settings.className );

		if ( formatTypeForClassName ) {
			window.console.error(
				`Format "${ formatTypeForClassName.name }" is already registered to handle class name "${ settings.className }".`
			);
			return;
		}
	}

	if ( ! ( 'title' in settings ) || settings.title === '' ) {
		window.console.error(
			'The format "' + settings.name + '" must have a title.'
		);
		return;
	}

	if ( 'keywords' in settings && settings.keywords.length > 3 ) {
		window.console.error(
			'The format "' +
				settings.name +
				'" can have a maximum of 3 keywords.'
		);
		return;
	}

	if ( typeof settings.title !== 'string' ) {
		window.console.error( 'Format titles must be strings.' );
		return;
	}

	dispatch( 'core/rich-text' ).addFormatTypes( settings );

	return settings;
}

オリジナルのドロップダウンメニュー用メソッドを用意

ネイティブの DropdownMenu メソッドでは、カーソルがあるテキストがドロップダウンメニュー内にある装飾(class)に該当するとき、いわばドロップダウンメニュー(子)の親であるツールバーボタンにその情報を伝達する手段がありません。

さらに、各ドロップダウンメニューに割り当てられている実際の装飾(CSS)はメニュータイトルに反映されるわけではないため、実際に選択したテキストにどんなスタイル(CSS)が反映されるのかがわかりません

これらの課題を解決するために、ネイティブのソースを元にドロップダウンメニュー用のメソッドも自作してみます。

MyDropdownMenu 関数
/**
 * External dependencies
 */
import classnames from 'classnames';
import { flatMap, isEmpty, isFunction } from 'lodash';

/**
 * WordPress dependencies
 */
import { DOWN } from '@wordpress/keycodes';
import deprecated from '@wordpress/deprecated';
import { Button, Dropdown, NavigableMenu } from '@wordpress/components';


function mergeProps( defaultProps = {}, props = {} ) {
	const mergedProps = {
		...defaultProps,
		...props,
	};

	if ( props.className && defaultProps.className ) {
		mergedProps.className = classnames(
			props.className,
			defaultProps.className
		);
	}

	return mergedProps;
}

/**
 * オリジナルのドロップダウンメニュー
 */
function MyDropdownMenu( {
	children,
	className,
	controls,
	icon = 'menu',
	label,
	popoverProps,
	toggleProps,
	menuProps,
	disableOpenOnArrowDown = false,
	// The following props exist for backward compatibility.
	menuLabel,
	position,
	noIcons,
} ) {

	if ( menuLabel ) {
		deprecated( '`menuLabel` prop in `DropdownComponent`', {
			alternative: '`menuProps` object and its `aria-label` property',
			plugin: 'Gutenberg',
		} );
	}

	if ( position ) {
		deprecated( '`position` prop in `DropdownComponent`', {
			alternative: '`popoverProps` object and its `position` property',
			plugin: 'Gutenberg',
		} );
	}

	if ( isEmpty( controls ) && ! isFunction( children ) ) {
		return null;
	}

	// Normalize controls to nested array of objects (sets of controls)
	let controlSets;
	if ( ! isEmpty( controls ) ) {
		controlSets = controls;
		if ( ! Array.isArray( controlSets[ 0 ] ) ) {
			controlSets = [ controlSets ];
		}
	}
	const mergedPopoverProps = mergeProps(
		{
			className: 'components-dropdown-menu__popover',
			position,
		},
		popoverProps
	);

	// フォーカスされた文字列に該当する装飾が適用されているか
	let isNoActiveAll = true
	controlSets.map( ( controlSet, indexOfSet ) => (

		isNoActiveAll = _.every( controlSet, function( control ) {
		  return control.isActive == false
		} )
	) )

	return (
		<Dropdown
			className={ classnames( 'components-dropdown-menu', className ) }
			popoverProps={ mergedPopoverProps }
			renderToggle={ ( { isOpen, onToggle } ) => {
				const openOnArrowDown = ( event ) => {
					if ( disableOpenOnArrowDown ) {
						return;
					}

					if ( ! isOpen && event.keyCode === DOWN ) {
						event.preventDefault();
						event.stopPropagation();
						onToggle();
					}
				};
				const mergedToggleProps = mergeProps(
					{
						className: classnames(
							'components-dropdown-menu__toggle',
							{
								'is-opened': isOpen,
							}
						),
					},
					toggleProps
				);

				return (
					<Button
						{ ...mergedToggleProps }

						// 子(ドロップダウンメニュー)がフォーカスされた文字列に該当する装飾をもつ場合は、is-pressed を追加
						className={
							classnames([
									'components-icon-button components-toolbar__control'
								],
								{
									'is-pressed' : ! isNoActiveAll,
								}
							)
						}

						icon={ icon }
						onClick={ ( event ) => {
							onToggle( event );
							if ( mergedToggleProps.onClick ) {
								mergedToggleProps.onClick( event );
							}
						} }
						onKeyDown={ ( event ) => {
							openOnArrowDown( event );
							if ( mergedToggleProps.onKeyDown ) {
								mergedToggleProps.onKeyDown( event );
							}
						} }
						aria-haspopup="true"
						aria-expanded={ isOpen }
						label={ label }
						showTooltip
					>
						{ mergedToggleProps.children }
					</Button>
				);
			} }
			renderContent={ ( props ) => {
				const mergedMenuProps = mergeProps(
					{
						'aria-label': menuLabel || label,
						className: classnames(
							'components-dropdown-menu__menu',
							{ 'no-icons': noIcons }
						),
					},
					menuProps
				);

				return (
					<NavigableMenu { ...mergedMenuProps } role="menu">
						{ isFunction( children ) ? children( props ) : null }
						{ flatMap( controlSets, ( controlSet, indexOfSet ) =>
							controlSet.map( ( control, indexOfControl ) => (
								<Button
									key={ [
										indexOfSet,
										indexOfControl,
									].join() }
									onClick={ ( event ) => {
										event.stopPropagation();
										props.onClose();
										if ( control.onClick ) {
											control.onClick();
										}
									} }
									className={ classnames(
										'components-dropdown-menu__menu-item',
										{
											'has-separator':
												indexOfSet > 0 &&
												indexOfControl === 0,
											'is-active': control.isActive,
										}
									) }
									icon={ control.icon }
									aria-checked={
										control.role === 'menuitemcheckbox' ||
										control.role === 'menuitemradio'
											? control.isActive
											: undefined
									}
									role={
										control.role === 'menuitemcheckbox' ||
										control.role === 'menuitemradio'
											? control.role
											: 'menuitem'
									}
									disabled={ control.isDisabled }
								>
									{ /* control.title */ }
									<span className={ `dropdown-item-title ${ control.formatClass }` }>
										{ control.title }
									</span>
								</Button>
							) )
						) }
					</NavigableMenu>
				);
			} }
		/>
	);
}

export default MyDropdownMenu;

修正箇所は、isNoActiveAll というフラグを用意し、包括するドロップダウンメニュー内にアクティブな状態( isActive = true )のアイテムが1つでもある場合は、isNoActiveAllfalse にする処理を追加しています。

// フォーカスされた文字列に該当する装飾が適用されているか
let isNoActiveAll = true
controlSets.map( ( controlSet, indexOfSet ) => (
	isNoActiveAll = _.every( controlSet, function( control ) {
		return control.isActive == false
	} )
) )

isNoActiveAllfalse の場合は、ツールバーボタン(.components-button)に is-pressed セレクタを挿入するようにします。
そうすると WordPress の CSS によりボタンが反転します。

<Button
	{ ...mergedToggleProps }

	// 子(ドロップダウンメニュー)がフォーカスされた文字列に該当する装飾をもつ場合は、is-pressed を追加
	className={
		classnames([
				'components-icon-button components-toolbar__control'
			],
			{
				'is-pressed' : ! isNoActiveAll,
			}
		)
	}

	icon={ icon }
	onClick={ ( event ) => {
		onToggle( event );
		if ( mergedToggleProps.onClick ) {
			mergedToggleProps.onClick( event );
		}
	} }
	onKeyDown={ ( event ) => {
		openOnArrowDown( event );
		if ( mergedToggleProps.onKeyDown ) {
			mergedToggleProps.onKeyDown( event );
		}
	} }
	aria-haspopup="true"
	aria-expanded={ isOpen }
	label={ label }
	showTooltip
>
	{ mergedToggleProps.children }
</Button>

最後の修正箇所は、ドロップダウンメニューの各アイテムのタイトルには、対象となる装飾スタイル(CSS)を反映した状態にするため、<Button …>{ control.title }</Button> で括られているタイトル部分を以下のようにして、装飾用のセレクタを持つ span 要素でラップしておきます。

<span className={ `dropdown-item-title ${ control.formatClass }` }>
	{ control.title }
</span>

外部からの呼び出し先となるコンポーネントを用意

独自のツールバーボタンとドロップダウンメニューの実体はここまでで完成したので、あとはこれらを利用して独自のツールバーとドロップダウンメニューを生成する橋渡しとなるコンポーネント(MyCustomToolbarDropdownMenu)を用意します。

MyCustomToolbarDropdownMenu 関数
/**
 * オリジナルのリッチテキストツールバーボタン
 */
export function MyRichTextToolbarButton( { name, shortcutType, shortcutCharacter, ...props } ) {
	let shortcut;
	let fillName = 'MyRichText.ToolbarControls';

	if ( name ) {
		fillName += `.${ name }`;
	}

	if ( shortcutType && shortcutCharacter ) {
		shortcut = displayShortcut[ shortcutType ]( shortcutCharacter );
	}

	return (
		<Fill name={ fillName }>
			<ToolbarButton
				{ ...props }
				shortcut={ shortcut }
			/>
		</Fill>
	);
}


/**
 * オリジナルのリッチテキストツールバードロップダウンメニュー
 */
export const MyCustomToolbarDropdownMenu = ( props ) => {

	const {
		formatTypeName,
		slotName,
		toolbarClass,
		toolbarIcon,
		toolbarLabel,
		dropdownPosition,
		childButtons,
	} = props

	if ( !formatTypeName ) return;
	if ( !childButtons ) return;


	// 専用のドロップダウンボタンとメニューを登録
	myRegisterFormatType(
		formatTypeName,
		{
			title: 'buttons',
			tagName: 'dropdown',
			className: toolbarClass ? `my-toolbar-controls-${toolbarClass}` : null,

			edit( { isActive, value, onChange } ) {

				return (
					<BlockFormatControls>
						<div className="editor-format-toolbar block-editor-format-toolbar my-components-toolbar">
							<Toolbar>
								<Slot name={ `MyRichText.ToolbarControls.${ slotName }` }>
									{ (fills) => fills.length !== 0 &&

										// オリジナルのドロップダウンメニューとして出力
										<MyDropdownMenu
											icon={ toolbarIcon || 'admin-customizer' }
											label={ toolbarLabel }
											hasArrowIndicator={ true }
											popoverProps={ {
												position: dropdownPosition,
											} }
											controls={ fills.map( ( [ { props } ] ) => props ) }
										/>
									}
								</Slot>
							</Toolbar>
						</div>
					</BlockFormatControls>
	            );
	        }
	    }
    );


	// オリジナルのリッチテキストツールバーボタン(子)への追加
	childButtons.map( (idx) => {
		let type = 'my-toolbar/richtext-' + idx.tag;

		// 複数のclassのときはスペースをハイフン(-)にして連結
		if ( idx.class !== null ){
			type += '-' + idx.class.replace( /\s/, '-');
		}

		myRegisterFormatType( type, {
			title: idx.title,
			tagName: idx.tag,
			className: idx.class,

			edit( { isActive, value, onChange } ) {
				return (
					<Fragment>
						{
							// RichTextToolbarButton から MyRichTextToolbarButton へ登録先変更(専用のドロップダウンボタンができる)
						}
						<MyRichTextToolbarButton
							name={ slotName }	// Slot name
							icon={ idx.icon ? idx.icon : null }
							title={ idx.title }
							formatClass={ idx.class }
							isActive={ isActive }
							onClick={ () => onChange( toggleFormat( value, { type: type } ) ) }
						/>
					</Fragment>
				);
			},
		} );
	} );
}

エディター用の CSS を用意(任意)

この独自のツールバーボタンとドロップメニュー用にデザインを調整したい場合は、「components」フォルダ内に作成しておいた “editor.scss” に記述しておきます。
拡張子の通り、SCSS としてコーディングできるので、とても楽です。

editor.scssサンプル
.my-components-dropdown-menu__menu{
	.components-button{
		&.is-active{
			background-color:rgba(#000,.06);
		}
	}
}

まとめ

ここまで作成した処理を「components」フォルダの “my-custom-toolbar-dropdown-menu.js” にまとめたものがこちら。

my-custom-toolbar-dropdown-menu.js
// エディター用のCSS
import './editor.scss';

/**
 * External dependencies
 */
import classnames from 'classnames';
import { flatMap, isEmpty, isFunction } from 'lodash';

/**
 * WordPress dependencies
 */
import { Fragment } from '@wordpress/element';
import { select, dispatch } from '@wordpress/data';
import { DOWN, displayShortcut } from '@wordpress/keycodes';
import { Fill, Slot, Button, Toolbar, ToolbarButton, Dropdown, NavigableMenu } from '@wordpress/components';
import { deprecated } from '@wordpress/deprecated';
import { BlockFormatControls } from '@wordpress/block-editor';
import { toggleFormat } from '@wordpress/rich-text';



/**
 * @typedef {Object} WPFormat
 *
 * @property {string}   name        A string identifying the format. Must be
 *                                  unique across all registered formats.
 * @property {string}   tagName     The HTML tag this format will wrap the
 *                                  selection with.
 * @property {string}   [className] A class to match the format.
 * @property {string}   title       Name of the format.
 * @property {Function} edit        Should return a component for the user to
 *                                  interact with the new registered format.
 */

/**
 * Registers a new format provided a unique name and an object defining its
 * behavior.
 *
 * @param {string}   name                 Format name.
 * @param {WPFormat} settings             Format settings.
 *
 * @return {WPFormat|undefined} The format, if it has been successfully registered;
 *                              otherwise `undefined`.
 *
 * @see https://github.com/WordPress/gutenberg/blob/master/packages/rich-text/src/register-format-type.js
 */
export function myRegisterFormatType( name, settings ) {
	settings = {
		name,
		...settings,
	};

	if ( typeof settings.name !== 'string' ) {
		window.console.error( 'Format names must be strings.' );
		return;
	}

	if ( ! /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test( settings.name ) ) {
		window.console.error(
			'Format names must contain a namespace prefix, include only lowercase alphanumeric characters or dashes, and start with a letter. Example: my-plugin/my-custom-format'
		);
		return;
	}

	if ( select( 'core/rich-text' ).getFormatType( settings.name ) ) {
		window.console.error(
			'Format "' + settings.name + '" is already registered.'
		);
		return;
	}

	if ( typeof settings.tagName !== 'string' || settings.tagName === '' ) {
		window.console.error( 'Format tag names must be a string.' );
		return;
	}

	if (
		( typeof settings.className !== 'string' ||
			settings.className === '' ) &&
		settings.className !== null
	) {
		window.console.error(
			'Format class names must be a string, or null to handle bare elements.'
		);
		return;
	}


	/**
	 * 半角スペースで区切った複数の class を許可するよう変更
	 */
	// if ( ! /^[_a-zA-Z]+[a-zA-Z0-9-]*$/.test( settings.className ) ) {
	if ( ! /^([_a-zA-Z]+[a-zA-Z0-9-]*)(\s+[_a-zA-Z]+[a-zA-Z0-9-]*)*$/.test( settings.className ) ) {
		window.console.error(
			'A class name must begin with a letter, followed by any number of hyphens, letters, or numbers.'
		);
		return;
	}

	if ( settings.className === null ) {
		const formatTypeForBareElement = select(
			'core/rich-text'
		).getFormatTypeForBareElement( settings.tagName );

		if ( formatTypeForBareElement ) {
			window.console.error(
				`Format "${ formatTypeForBareElement.name }" is already registered to handle bare tag name "${ settings.tagName }".`
			);
			return;
		}
	} else {
		const formatTypeForClassName = select(
			'core/rich-text'
		).getFormatTypeForClassName( settings.className );

		if ( formatTypeForClassName ) {
			window.console.error(
				`Format "${ formatTypeForClassName.name }" is already registered to handle class name "${ settings.className }".`
			);
			return;
		}
	}

	if ( ! ( 'title' in settings ) || settings.title === '' ) {
		window.console.error(
			'The format "' + settings.name + '" must have a title.'
		);
		return;
	}

	if ( 'keywords' in settings && settings.keywords.length > 3 ) {
		window.console.error(
			'The format "' +
				settings.name +
				'" can have a maximum of 3 keywords.'
		);
		return;
	}

	if ( typeof settings.title !== 'string' ) {
		window.console.error( 'Format titles must be strings.' );
		return;
	}

	dispatch( 'core/rich-text' ).addFormatTypes( settings );

	return settings;
}



function mergeProps( defaultProps = {}, props = {} ) {
	const mergedProps = {
		...defaultProps,
		...props,
	};

	if ( props.className && defaultProps.className ) {
		mergedProps.className = classnames(
			props.className,
			defaultProps.className
		);
	}

	return mergedProps;
}

/**
 * オリジナルのドロップダウンメニュー
 *
 * @see https://github.com/WordPress/gutenberg/blob/d6c5c5b063fcef5c6d82408af3f8a28c39c88c6f/packages/components/src/dropdown-menu/index.js
 */
function MyDropdownMenu( {
	children,
	className,
	controls,
	icon = 'menu',
	label,
	popoverProps,
	toggleProps,
	menuProps,
	disableOpenOnArrowDown = false,
	// The following props exist for backward compatibility.
	menuLabel,
	position,
	noIcons,
} ) {

	if ( menuLabel ) {
		deprecated( '`menuLabel` prop in `DropdownComponent`', {
			alternative: '`menuProps` object and its `aria-label` property',
			plugin: 'Gutenberg',
		} );
	}

	if ( position ) {
		deprecated( '`position` prop in `DropdownComponent`', {
			alternative: '`popoverProps` object and its `position` property',
			plugin: 'Gutenberg',
		} );
	}

	if ( isEmpty( controls ) && ! isFunction( children ) ) {
		return null;
	}

	// Normalize controls to nested array of objects (sets of controls)
	let controlSets;
	if ( ! isEmpty( controls ) ) {
		controlSets = controls;
		if ( ! Array.isArray( controlSets[ 0 ] ) ) {
			controlSets = [ controlSets ];
		}
	}
	const mergedPopoverProps = mergeProps(
		{
			className: 'components-dropdown-menu__popover',
			position,
		},
		popoverProps
	);

	// フォーカスされた文字列に該当する装飾が適用されているか
	let isNoActiveAll = true
	controlSets.map( ( controlSet, indexOfSet ) => (

		isNoActiveAll = _.every( controlSet, function( control ) {
		  return control.isActive == false
		} )
	) )

	return (
		<Dropdown
			className={ classnames( 'components-dropdown-menu', className ) }
			popoverProps={ mergedPopoverProps }
			renderToggle={ ( { isOpen, onToggle } ) => {
				const openOnArrowDown = ( event ) => {
					if ( disableOpenOnArrowDown ) {
						return;
					}

					if ( ! isOpen && event.keyCode === DOWN ) {
						event.preventDefault();
						event.stopPropagation();
						onToggle();
					}
				};
				const mergedToggleProps = mergeProps(
					{
						className: classnames(
							'components-dropdown-menu__toggle',
							{
								'is-opened': isOpen,
							}
						),
					},
					toggleProps
				);

				return (
					<Button
						{ ...mergedToggleProps }

						// 子(ドロップダウンメニュー)がフォーカスされた文字列に該当する装飾をもつ場合は、is-pressed を追加
						className={
							classnames([
									'components-icon-button components-toolbar__control'
								],
								{
									'is-pressed' : ! isNoActiveAll,
								}
							)
						}

						icon={ icon }
						onClick={ ( event ) => {
							onToggle( event );
							if ( mergedToggleProps.onClick ) {
								mergedToggleProps.onClick( event );
							}
						} }
						onKeyDown={ ( event ) => {
							openOnArrowDown( event );
							if ( mergedToggleProps.onKeyDown ) {
								mergedToggleProps.onKeyDown( event );
							}
						} }
						aria-haspopup="true"
						aria-expanded={ isOpen }
						label={ label }
						showTooltip
					>
						{ mergedToggleProps.children }
					</Button>
				);
			} }
			renderContent={ ( props ) => {
				const mergedMenuProps = mergeProps(
					{
						'aria-label': menuLabel || label,
						className: classnames(
							'components-dropdown-menu__menu',
							{ 'no-icons': noIcons }
						),
					},
					menuProps
				);

				return (
					<NavigableMenu { ...mergedMenuProps } role="menu">
						{ isFunction( children ) ? children( props ) : null }
						{ flatMap( controlSets, ( controlSet, indexOfSet ) =>
							controlSet.map( ( control, indexOfControl ) => (
								<Button
									key={ [
										indexOfSet,
										indexOfControl,
									].join() }
									onClick={ ( event ) => {
										event.stopPropagation();
										props.onClose();
										if ( control.onClick ) {
											control.onClick();
										}
									} }
									className={ classnames(
										'components-dropdown-menu__menu-item',
										{
											'has-separator':
												indexOfSet > 0 &&
												indexOfControl === 0,
											'is-active': control.isActive,
										}
									) }
									icon={ control.icon }
									aria-checked={
										control.role === 'menuitemcheckbox' ||
										control.role === 'menuitemradio'
											? control.isActive
											: undefined
									}
									role={
										control.role === 'menuitemcheckbox' ||
										control.role === 'menuitemradio'
											? control.role
											: 'menuitem'
									}
									disabled={ control.isDisabled }
								>
									{ /* control.title */ }
									<span className={ `dropdown-item-title ${ control.formatClass }` }>
										{ control.title }
									</span>
								</Button>
							) )
						) }
					</NavigableMenu>
				);
			} }
		/>
	);
}

export default MyDropdownMenu;


/**
 * オリジナルのリッチテキストツールバーボタン
 *
 * @see https://github.com/WordPress/gutenberg/blob/master/packages/block-editor/src/components/rich-text/toolbar-button.js
 */
export function MyRichTextToolbarButton( { name, shortcutType, shortcutCharacter, ...props } ) {
	let shortcut;
	let fillName = 'MyRichText.ToolbarControls';

	if ( name ) {
		fillName += `.${ name }`;
	}

	if ( shortcutType && shortcutCharacter ) {
		shortcut = displayShortcut[ shortcutType ]( shortcutCharacter );
	}

	return (
		<Fill name={ fillName }>
			<ToolbarButton
				{ ...props }
				shortcut={ shortcut }
			/>
		</Fill>
	);
}


/**
 * オリジナルのリッチテキストツールバードロップダウンメニュー
 */
export const MyCustomToolbarDropdownMenu = ( props ) => {

	const {
		formatTypeName,
		slotName,
		toolbarClass,
		toolbarIcon,
		toolbarLabel,
		dropdownPosition,
		childButtons,
	} = props

	if ( !formatTypeName ) return;
	if ( !childButtons ) return;


	// 専用のドロップダウンボタンとメニューを登録
	myRegisterFormatType(
		formatTypeName,
		{
			title: 'buttons',
			tagName: 'dropdown',
			className: toolbarClass ? `my-toolbar-controls-${toolbarClass}` : null,

			edit( { isActive, value, onChange } ) {

				return (
					<BlockFormatControls>
						<div className="editor-format-toolbar block-editor-format-toolbar my-components-toolbar">
							<Toolbar>
								<Slot name={ `MyRichText.ToolbarControls.${ slotName }` }>
									{ (fills) => fills.length !== 0 &&

										// オリジナルのドロップダウンメニューとして出力
										<MyDropdownMenu
											icon={ toolbarIcon || 'admin-customizer' }
											label={ toolbarLabel }
											hasArrowIndicator={ true }
											popoverProps={ {
												position: dropdownPosition,
											} }
											controls={ fills.map( ( [ { props } ] ) => props ) }
										/>
									}
								</Slot>
							</Toolbar>
						</div>
					</BlockFormatControls>
	            );
	        }
	    }
    );


	// オリジナルのリッチテキストツールバーボタン(子)への追加
	childButtons.map( (idx) => {
		let type = 'my-toolbar/richtext-' + idx.tag;

		// 複数のclassのときはスペースをハイフン(-)にして連結
		if ( idx.class !== null ){
			type += '-' + idx.class.replace( /\s/, '-');
		}

		myRegisterFormatType( type, {
			title: idx.title,
			tagName: idx.tag,
			className: idx.class,

			edit( { isActive, value, onChange } ) {
				return (
					<Fragment>
						{
							// RichTextToolbarButton から MyRichTextToolbarButton へ登録先変更(専用のドロップダウンボタンができる)
						}
						<MyRichTextToolbarButton
							name={ slotName }	// Slot name
							icon={ idx.icon ? idx.icon : null }
							title={ idx.title }
							formatClass={ idx.class }
							isActive={ isActive }
							onClick={ () => onChange( toggleFormat( value, { type: type } ) ) }
						/>
					</Fragment>
				);
			},
		} );
	} );
}

独自のドロップダウンメニューを登録

あとは、作成した “MyCustomToolbarDropdownMenu” コンポーネントを呼び出して、追加したいツールバーとドロップダウンメニューに必要な情報を渡せば、リッチテキストツールバーに追加したいだけドロップダウンメニューを登録できます。

装飾用のCSSを作成

ドロップダウンメニューのアイテムごとに割り当てるテキスト装飾用の CSS を用意しておきます。
CSS(scss)は、最初に作成しておいた「dropdown-menu」→「text-color」フォルダにある “style.scss” に記述します。

今回のサンプルでは、適当に赤、青、黄のフォントカラー3色と、そのハイライト分の合計6パターンのスタイルを用意しておきます。

scss の例style.scss
.is-text-blue{
	color:#0693e3;
	&.is-stripe{
		background-color:#0693e3;
	}
}

.is-text-yellow{
	color:#cbb11b;
	&.is-stripe{
		background-color:#cbb11b;
	}
}

.is-text-red{
	color:#e21438;
	&.is-stripe{
		background-color:#e21438;
	}
}

[class*="is-text-"]{
	&.is-stripe{
		padding:2px 4px;
		color:#fff;
		background-image:repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.18) 3px, rgba(255,255,255,0.18) 6px);
	}
}

ドロップダウンメニューアイテムの定義

配列にドロップダウンメニューの各アイテムご登録必要な情報をまとめておきます。

ドロップダウンメニューアイテム(配列)の書式

ドロップダウンメニューのアイテム(配列)は、以下の書式で定義します。

{
	// ラッパー要素(HTMLタグ)
	tag: 'span',
	// 挿入する class(複数可)
	class: 'is-text-blue is-stripe',
	// メニュータイトル
	title: __( 'Blue Text' ),
	// アイコン(ダッシュアイコン、または svg でも可)
	icon: 'admin-appearance',
}

ドロップダウンメニューの左端に表示されるアイコン(icon)は、WordPress のダッシュアイコンであれば、そのアイコン名(dashicons を省いたもの)の文字列か、オリジナルのアイコンで表示したい場合はコンポーネント化した svg を直接渡してもOKです。

今回のサンプルの 赤、青、黄の文字色を装飾するドロップダウンメニューを作る場合は以下のように配列にまとめます。

配列定義例
// 登録するドロップダウンメニューアイテム
const tags = [];

// 複数classを持つ方を先に記述
tags.push( {
	tag: 'span',
	class: 'is-text-blue is-stripe',
	title: sprintf( '%s (%s)', __('Blue', 'create-block'), __('Striped', 'create-block') ),
	icon: 'admin-appearance',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-yellow is-stripe',
	title: sprintf( '%s (%s)', __('Yellow', 'create-block'), __('Striped', 'create-block') ),
	icon: 'admin-appearance',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-red is-stripe',
	title: sprintf( '%s (%s)', __('Red', 'create-block'), __('Striped', 'create-block') ),
	icon: 'admin-appearance',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-blue',
	title: __('Blue', 'create-block'),
	icon: 'admin-customizer',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-yellow',
	title: __('Yellow', 'create-block'),
	icon: 'admin-customizer',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-red',
	title: __('Red', 'create-block'),
	icon: 'admin-customizer',
} );

追加したい class の数だけ、配列(tags)に追加しておきます。

複数のセレクタ(class)がある場合は、先に定義して配列に追加しておきます。

ツールバーに独自のドロップダウンメニューを登録

最後にドロップダウンメニューの内容をまとめた配列と共に、独自のツールバーを作成するよう “MyCustomToolbarDropdownMenu” コンポーネントを利用して登録します。

MyCustomToolbarDropdownMenu の書式
MyCustomToolbarDropdownMenu( {
	// フォーマットタイプ名
	formatTypeName : 'my-custom-block/custom-format-text-color',
	// スロット名
	slotName : 'textColor',
	// ツールバーボタン用のclass
	toolbarClass: 'text-color',
	// ツールバーボタンのアイコン
	toolbarIcon : 'edit',
	// ツールバーボタンのポップアップラベル
	toolbarLabel : __( 'Text Color' ),
	// ドロップダウンメニュー位置 : bottom, top / left, center, right
	dropdownPosition : 'bottom center',
	// 各ドロップダウンメニューの内容(配列)
	childButtons : tags,
} )

まとめ

ここまでの内容を「doropdown-menu」→「text-color」フォルダの “index.js” にまとめたものがこちら。

/src/dropdown-menu/text-color/index.js
// 公開用のCSS
import './style.scss';

import {
	__,
	sprintf,
} from '@wordpress/i18n'

import { MyCustomToolbarDropdownMenu } from '../../../components/my-custom-toolbar-dropdown-menu'


// 登録するドロップダウンメニューアイテム
const tags = [];

// 複数classを持つ方を先に記述
tags.push( {
	tag: 'span',
	class: 'is-text-blue is-stripe',
	title: sprintf( '%s (%s)', __('Blue', 'create-block'), __('Striped', 'create-block') ),
	icon: 'admin-appearance',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-yellow is-stripe',
	title: sprintf( '%s (%s)', __('Yellow', 'create-block'), __('Striped', 'create-block') ),
	icon: 'admin-appearance',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-red is-stripe',
	title: sprintf( '%s (%s)', __('Red', 'create-block'), __('Striped', 'create-block') ),
	icon: 'admin-appearance',
} );

tags.push( {
	tag: 'span',
	class: 'is-text-blue',
	title: __('Blue', 'create-block'),
	icon: 'admin-customizer',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-yellow',
	title: __('Yellow', 'create-block'),
	icon: 'admin-customizer',
} );
tags.push( {
	tag: 'span',
	class: 'is-text-red',
	title: __('Red', 'create-block'),
	icon: 'admin-customizer',
} );

/**
 * オリジナルツールバーボタンとドロップダウンメニューの組み込み
 */
MyCustomToolbarDropdownMenu( {
	formatTypeName : 'my-custom-block/custom-format-text-color',
	slotName : 'textColor',
	toolbarClass: 'text-color',
	toolbarIcon : 'edit',
	toolbarLabel : __( 'Text Color' ),
	dropdownPosition : 'bottom center',
	childButtons : tags,
});

最後に、「my-custom-block」→「src」フォルダの “index.js” にこの文字色用のツールバーの内容を import しておきます。

/src/index.js追記内容
import './dropdown-menu/text-color';

この要領で、「dropdown-menu」フォルダ内に追加したいドロップダウンメニューごとにフォルダとファイルを分けて、メインの index.js に import していくだけで、独自のツールバーボタンを追加してドロップダウンメニューを展開させることができます。

プロジェクトのビルド

最後に、コマンドに戻ってビルドを実行します。

npm run build

正常にビルドが完了すると、プラグインフォルダ直下の「build」フォルダ内にすべてのスクリプトと CSS が集約されたものが出力されます。

あとは、この「My Custom Block」プラグインを有効化してブロックエディターを開くと、リッチテキストツールバーに独自のツールバーボタンとドロップダウンメニューが追加されています。

Share / Subscribe
Facebook Likes
Tweets
Hatena Bookmarks
Pinterest
Pocket
Feedly
Send to LINE