DigiPress

Highly Flexible WordPress Theme

メニュー

[Gutenberg]リッチテキストのツールバーに独自のツールバーボタンとドロップダウンを追加する方法

[Gutenberg]リッチテキストのツールバーに独自のツールバーボタンとドロップダウンを追加する方法

以前、WordPress のブロックエディター(Gutenberg)のリッチテキストに独自のカスタムツールバーボタンを追加する方法を紹介しました。

しかし、WordPress 5.6 からこのような方法でカスタムコンポーネントをツールバーコントロールとして組み込むことは非推奨となってしまい、コンソールに以下の警告が表示されるようになりました。

これを解決すべく、明示されている通りおとなしく ToolbarItemToolbarButtonToolbarDropdownMenu の標準コンポーネントを利用して独自のツールバーボタンとドロップダウンの追加と、これによる選択テキストへの class の挿入ができないか試してみました。

単純にドロップダウンメニューに対応するカスタムツールバーボタンを追加するだけなら、以下のように BlockFormatControlsToolbarDropdownMenu を利用して registerFormatType 関数で実装できます。

カスタムツールバーとドロップダウンを追加するコンポーネント

CustomToolbarDropdown
import { BlockFormatControls } from '@wordpress/block-editor'
import { ToolbarDropdownMenu } from '@wordpress/components'
import {
	applyFormat,
	removeFormat,
} from '@wordpress/rich-text'
import classnames from 'classnames'

export const CustomToolbarDropdown = props => {

	const {
		formatTypeName,		// フォーマットタイプ名 (例 : my-plugin/my-custom-format)
		formatTitle,		// フォーマットのタイトル
		formatClass,		// ラップするHTMLタグに挿入する class
		tagName,		// ラップするHTMLタグ(例 : span)
		toolbarButtonClass,	// ツールバーボタンに挿入するclass
		toolbarButtonIcon,	// ツールバーボタンのアイコン
		toolbarButtonLabel,	// ツールバーボタンのラベル
		tooltipLabel,		// ツールチップのテキスト
		isMultiSelect,		// ドロップダウン内のボタンの複数選択を許可するか
		childButtons,		// ドロップダウン内のボタンのデータ(配列)
	} = props

	// class の挿入、削除
	// 空の場合は、フォーマットを削除(リセット)
	const createApplyFormat = ( formatTypeName, className, activeClasses, value, isMultiSelect ) => {

		let arrActiveClasses
		let insertClasses = className

		// 挿入しようとしている class が既存の class と同じ場合はリセット
		if ( ! className || ( insertClasses == activeClasses ) ) {
			return removeFormat(
				value,
				formatTypeName,
			)
		}

		// マルチセレクト型ドロップダウンの場合
		if ( isMultiSelect && typeof activeClasses === 'string' ) {

			// 既存の class を半角スペースで区切って配列化
			arrActiveClasses = activeClasses.split(' ')

			if ( Array.isArray( arrActiveClasses ) ) {

				// 挿入しようとしている class が既存の class の中にある場合は挿入する class と既存の class から削除
				if ( arrActiveClasses.includes( insertClasses ) ) {
					insertClasses = undefined
					activeClasses = arrActiveClasses.filter( val => val != className )
				}
			}
		}

		// 挿入するclass
		const classes = classnames([
			insertClasses,
			isMultiSelect && activeClasses
		])

		// セレクタの挿入
		return applyFormat(
			value,
			{
				type: formatTypeName,
				attributes: {
					class: classes,
				},
			}
		)
	}

	// ドロップダウンメニュー
	const DropdownPopover = props => {

		// registerFormatType からのプロパティ
		const {
			activeAttributes,	// 選択テキストに既に適用されているフォーマットがある場合にその class が入る
			isActive,			// 選択テキストに現在のフォーマットが含まれる場合は true になる
			onChange,			// ドロップダウン内のボタンがクリックされたときの処理
			value,				// 対象のテキスト
		} = props

		const className = classnames(
			[
				'components-icon-button',
				'components-toolbar__control',
				`deb-toolbar-button__${ toolbarButtonClass }`,
			],
			{
				'is-pressed' : isActive,
			}
		)

		let containActiveClass = false

		// カスタムツールバー表示
		return (
			<BlockFormatControls>
				<ToolbarDropdownMenu
					className={ className }
					icon={ toolbarButtonIcon }
					label={ tooltipLabel }
					aria-haspopup="true"
					aria-label={ toolbarButtonLabel }
					label={ tooltipLabel }
					tooltip={ tooltipLabel }
					controls={
						// ドロップダウンボタンの追加
						childButtons.map( current => {

							if ( isMultiSelect ) {

								// 複数選択有効の場合
								if ( typeof activeAttributes.class === 'string' ) {

									// 半角スペースで区切って配列化し、その中にこのボタンで挿入される class と一致した場合は true
									containActiveClass = activeAttributes.class.split(' ').includes( current.class )
								}

							} else {

								// 単一ボタン(1つのclass)のみアクティブにする場合(複数選択無効)
								// 既存の class がこのボタンで挿入される class と一致する場合は true
								containActiveClass = activeAttributes.class == current.class ? true : false
							}

							return {
								title: current.title,	// このドロップダウンボタンのタイトル
								icon: current.icon,		// このドロップダウンボタンのアイコン
								onClick:() => {
									onChange(
										createApplyFormat( formatTypeName, current.class, activeAttributes.class, value, isMultiSelect )
									)
								},
								isActive: isActive && containActiveClass,
							}
						} )
					}
				/>
			</BlockFormatControls>
		)
	}

    // フォーマットを登録
    registerFormatType(
		formatTypeName, {
			title: formatTitle,
			tagName: tagName,
			className: formatClass,
			edit: DropdownPopover,
			attributes: {
				class: 'class',
				style: 'style',
			},
		}
	)
}

このコードでは、ドロップダウン内のボタン(class)の複数選択可否にも対応していて、複数選択が無効の場合は、ドロップダウン内のボタンで挿入される class は常に1種類のみとなり、別のボタンが選択されると既存の class と入れ替わります。
現在アクティブなボタン(class)を選択した場合は、スタイルをリセット(フォーマットをクリア)します。

toggleFormat 関数と同じ動作です。

ただ、この方法だと以下のように各ドロップダウンボタンのテキストを装飾できず、どのようなスタイルが適用されるのかが事前に分かりません。

どんな装飾なのか分からない

やりたいのは、以前紹介した方法のようにドロップダウンボタンの各テキストは実際に適用されるスタイルを反映した状態でカスタムツールバーを実装することです。

そこで、ToolbarDropdownMenu ではなく、ToolbarButtonPopoverButton コンポーネントを利用してツールバーボタンと連動したポップオーバーによる擬似的なドロップダウンを実装してみます。

ポップオーバーを利用した疑似ドロップダウン

ポップオーバーを利用する場合、ツールバーとそれに連動するポップオーバーの出力コードは以下のようにしてみます。

ツールバー出力部分
<BlockFormatControls>
	<Toolbar label={ toolbarButtonLabel }>
		<ToolbarButton
			id={ toolbarButtonId }
			className={ className }
			icon={ toolbarButtonIcon }
			aria-haspopup="true"
			aria-label={ toolbarButtonLabel }
			label={ tooltipLabel }
			tooltip={ tooltipLabel }
			ref={ popoverEl }
			onClick={ () => setIsOpen( ! isOpen ) }
		/>

		{
		// ドロップダウンの開閉フラグが true になったらポップオーバーを表示
		isOpen &&
			// ドロップダウンメニュー(実際はポップオーバー)
			<Popover
				position={ dropdownPosition }
				className={ classnames([
					"components-dropdown__content",
					"components-dropdown-menu__popover",
					])
				}
				focusOnMount="container"
				useRef={ popoverEl }
			>
				<div
					role="menu"
					aria-orientation="vertical"
					aria-label={ toolbarButtonLabel }
					className="components-dropdown-menu__menu"
				>
				{
					// ドロップダウンボタンの追加
					childButtons.map( ( current ) => {
						if ( isMultiSelect ) {
							if ( typeof activeAttributes.class === 'string' ) {
								containActiveClass = activeAttributes.class.split(' ').includes( current.class )
							}
						} else {
							containActiveClass = activeAttributes.class == current.class ? true : false
						}

						return (
							<Button
								label={ current.title }
								onClick={ () => {
									onChange(
										createApplyFormat( formatTypeName, current.class, activeAttributes.class, value, isMultiSelect )
									)
								} }
								className={ classnames(
										'components-dropdown-menu__menu-item',
									{
										// カレントの class が一致したらボタンをアクティブにする
										'is-active' : isActive && containActiveClass
									}
								) }
								icon={ current.icon }
							>
								<span
									className={ classnames( [
										'dropdown-item-title',
										current.class
									] )
								}>
									{ current.title }
								</span>
							</Button>
						)
					} )
				}
				</div>
			</Popover>
		}
	</Toolbar>
</BlockFormatControls>

ドロップダウン内のボタンは、Button コンポーネントにし、実際にどのような装飾なのか分かるよう、ボタンテキストを span タグでラップし、その span タグに対して実際に挿入される class をセットしておきます。

ドロップダウン内のボタンを追加するループ処理( childButtons.map( ( current ) => {} ) )にある以下の部分です。

ドロップダウン内のボタン
<Button
	label={ current.title }
	onClick={ () => {
		onChange(
			createApplyFormat( formatTypeName, current.class, activeAttributes.class, value, isMultiSelect )
		)
	} }
	className={ classnames(
			'components-dropdown-menu__menu-item',
		{
			// カレントの class が一致したらボタンをアクティブにする
			'is-active' : isActive && containActiveClass
		}
	) }
	icon={ current.icon }
>
	<span
		className={ classnames( [
			'dropdown-item-title',
			current.class
		] )
	}>
		{ current.title }
	</span>
</Button>

ポップオーバーの表示を切り替えるための準備

連動するポップオーバー(ドロップダウン)は、対象のツールバーボタンのクリックで表示しますが、逆にポップオーバーを閉じる場合、

  • 既にポップオーバーが開いている状態でツールバーボタンがクリックされたとき
  • ツールバーボタンとポップオーバーでないエリアがクリックされたとき
  • シングルセレクトのドロップダウンボタンがクリックされたとき

などの複合条件によって柔軟にポップオーバーを閉じるようにします。

useStateuseRefuseEffect 関数を利用して、このポップーオーバーを開閉するための状態管理をします。

const [ isOpen, setIsOpen ] = useState( false )
const popoverEl = useRef( null )

// クリックイベントのコールバック
const clickPopoverCloseListener = useCallback( event => {
	if ( isOpen ) {
		// ドロップダウン内のボタンまたはツールバーボタン(親)がクリックされたら閉じる(isOpen を false にする)
		if ( event.target.closest( '.components-dropdown-menu__menu-item' ) || ( ! event.target.closest( `#${ popoverEl.current.id }` ) && ! event.target.closest( '.components-popover' ) ) ) {
			setIsOpen( false )
		}
	}
} )

// ドロップダウンを閉じるためのクリックイベントリスナー
useEffect( () => {
	document.body.addEventListener( 'click', clickPopoverCloseListener )
	return () => document.body.removeEventListener( 'click', clickPopoverCloseListener )
}, [ clickPopoverCloseListener ] )

ToolbarButton コンポーネントには上記のクリックイベントでツールバーボタンを判定するために id をセットし、クリック時に setIsOpen 関数でフラグを切り替えます。

ToolbarButton
<ToolbarButton
	id={ toolbarButtonId }
	className={ className }
	icon={ toolbarButtonIcon }
	aria-haspopup="true"
	aria-label={ toolbarButtonLabel }
	label={ tooltipLabel }
	tooltip={ tooltipLabel }
	ref={ popoverEl }
	onClick={ () => setIsOpen( ! isOpen ) }
/>

ドロップダウン内のボタンがクリックされたときの処理

ドロップダウン内のボタンがクリックされた際は、選択中のテキストに対して対象の class を挿入し、該当するスタイル(CSS)を適用します。
既に同一グループのフォーマットが対象テキストに反映されていた場合は、新しいスタイルと入れ替えます。

また、選択中のテキストに既に挿入されている class と同じ class を持つドロップダウンのボタンがクリックされた際は、逆に現在のスタイル(フォーマット)を削除する必要があります。

さらに、ドロップダウンのボタンがマルチセレクト型だった場合は、同一グループの既存の class を残しつつ、新規の class を追加するか、同じ class が含まれていた場合はその class だけを削除します。

これらのボタンクリックのイベントの処理は createApplyFormat という関数でまとめています。

createApplyFormat
// セレクタの挿入、削除
const createApplyFormat = ( formatTypeName, className, activeClasses, value, isMultiSelect ) => {

	let arrActiveClasses
	let insertClass = className

	// 挿入しようとしている class が既存の class と同じ場合はリセット
	if ( ! className || ( insertClass == activeClasses ) ) {
		return removeFormat(
			value,
			formatTypeName,
		)
	}

	// マルチセレクト型ドロップダウンの場合
	if ( isMultiSelect && typeof activeClasses === 'string' ) {
		// 複数の class を区切って配列化
		arrActiveClasses = activeClasses.split(' ')

		if ( Array.isArray( arrActiveClasses ) ) {
			// 挿入しようとしている class が既存の class の中にある場合は挿入する class と既存の class から削除
			if ( arrActiveClasses.includes( insertClass ) ) {
				insertClass = undefined
				activeClasses = arrActiveClasses.filter( val => val != className )
			}
		}
	}

	// 挿入する class
	const classes = classnames([
		insertClass,
		isMultiSelect && activeClasses
	])

	// セレクタの挿入(フォーマットの適用)
	return applyFormat(
		value,
		{
			type: formatTypeName,
			attributes: {
				class: classes,
			},
		}
	)
}

まとめる

これらをまとめて出来上がったカスタムツールバーコンポーネントが以下です。

CustomToolbarDropdown
import { BlockFormatControls } from '@wordpress/block-editor'

import {
	Toolbar,
	ToolbarButton,
	Popover,
} from '@wordpress/components'

import {
	useState,
	useRef,
	useEffect,
	useCallback,
} from '@wordpress/element'

import {
	applyFormat,
	removeFormat,
} from '@wordpress/rich-text'

import classnames from 'classnames'

export const CustomToolbarDropdown = props => {

	const {
		formatTypeName,		// フォーマットタイプ名 (例 : my-plugin/my-custom-format)
		formatTitle,		// フォーマットのタイトル
		formatClass,		// ラップするHTMLタグに挿入する class
		tagName,			// ラップするHTMLタグ(例 : span)
		toolbarButtonId,	// ツールバーボタンのID
		toolbarButtonClass,	// ツールバーボタンに挿入するclass
		toolbarButtonIcon,	// ツールバーボタンのアイコン
		toolbarButtonLabel,	// ツールバーボタンのラベル
		tooltipLabel,		// ツールチップのテキスト
		isMultiSelect,		// ドロップダウン内のボタンの複数選択を許可するか
		childButtons,		// ドロップダウン内のボタンのデータ(配列)
	} = props


	// class の挿入、削除
	// 空の場合は、フォーマットを削除(リセット)
	const createApplyFormat = ( formatTypeName, className, activeClasses, value, isMultiSelect ) => {

		let arrActiveClasses
		let insertClasses = className

		// 挿入しようとしている class が既存の class と同じ場合はリセット
		if ( ! className || ( insertClasses == activeClasses ) ) {
			return removeFormat(
				value,
				formatTypeName,
			)
		}


		// マルチセレクト型ドロップダウンの場合
		if ( isMultiSelect && typeof activeClasses === 'string' ) {

			// 既存の class を半角スペースで区切って配列化
			arrActiveClasses = activeClasses.split(' ')

			if ( Array.isArray( arrActiveClasses ) ) {

				// 挿入しようとしている class が既存の class の中にある場合は挿入する class と既存の class から削除
				if ( arrActiveClasses.includes( insertClasses ) ) {
					insertClasses = undefined
					activeClasses = arrActiveClasses.filter( val => val != className )
				}
			}
		}

		// 挿入するclass
		const classes = classnames([
			insertClasses,
			isMultiSelect && activeClasses
		])

		// セレクタの挿入
		return applyFormat(
			value,
			{
				type: formatTypeName,
				attributes: {
					class: classes,
				},
			}
		)
	}

	// ドロップダウンメニュー
	const DropdownPopover = props => {

		const {
			activeAttributes,
			isActive,
			onChange,
			value,
		} = props

		const className = classnames(
			[
				'components-icon-button',
				'components-toolbar__control',
				toolbarButtonClass,
			],
			{
				'is-pressed' : isActive,
			}
		)

		const [ isOpen, setIsOpen ] = useState( false )
		const popoverEl = useRef( null )

		// クリックイベントのコールバック
		const clickPopoverCloseListener = useCallback( event => {
			if ( isOpen ) {
				// ドロップダウン内のボタンまたはツールバーボタン(親)がクリックされたら閉じる(isOpen を false にする)
				if ( event.target.closest( '.components-dropdown-menu__menu-item' ) || ( ! event.target.closest( `#${ popoverEl.current.id }` ) && ! event.target.closest( '.components-popover' ) ) ) {
					setIsOpen( false )
				}
			}
		} )

		// ドロップダウンを閉じるためのクリックイベントリスナー
		useEffect( () => {
			document.body.addEventListener( 'click', clickPopoverCloseListener )
			return () => document.body.removeEventListener( 'click', clickPopoverCloseListener )
		}, [ clickPopoverCloseListener ] )

		// 対象テキストに既存のフォーマットが含まれるか判定するフラグ
		let containActiveClass = false

		// ツールバーボタンとドロップダウン(ポップオーバー)
		return (
			<BlockFormatControls>
				<Toolbar label={ toolbarButtonLabel }>
					<ToolbarButton
						id={ toolbarButtonId }
						className={ className }
						icon={ toolbarButtonIcon }
						aria-haspopup="true"
						aria-label={ toolbarButtonLabel }
						label={ tooltipLabel }
						tooltip={ tooltipLabel }
						ref={ popoverEl }
						onClick={ () => setIsOpen( ! isOpen ) }
					/>


					{ isOpen &&
						<Popover
							position={ dropdownPosition }
							className={ classnames([
								"components-dropdown__content",
								"components-dropdown-menu__popover",
								])
							}
							focusOnMount="container"
							useRef={ popoverEl }
						>

							<div
								role="menu"
								aria-orientation="vertical"
								aria-label={ toolbarButtonLabel }
								className="components-dropdown-menu__menu"
							>
							{
								childButtons.map( ( current ) => {

									if ( isMultiSelect ) {
										if ( typeof activeAttributes.class === 'string' ) {
											containActiveClass = activeAttributes.class.split(' ').includes( current.class )
										}
									} else {
										containActiveClass = activeAttributes.class == current.class ? true : false
									}

									return (
										<Button
											label={ current.title }
											onClick={ () => {
												onChange(
													createApplyFormat( formatTypeName, current.class, activeAttributes.class, value, isMultiSelect )
												)
											} }
											className={ classnames(
													'components-dropdown-menu__menu-item',
												{
													'is-active' : isActive && containActiveClass
												}
											) }
											icon={ current.icon }
										>
											<span
												className={ classnames( [
													'dropdown-item-title',
													current.class
												] )
											}>
												{ current.title }
											</span>
										</Button>
									)
								} )
							}
							</div>
						</Popover>
					}
				</Toolbar>
			</BlockFormatControls>
		)
	}

	// フォーマットを登録
    registerFormatType(
		formatTypeName, {
			title: formatTitle,
			tagName: tagName,
			className: formatClass,
			edit: DropdownPopover,
			attributes: {
				class: 'class',
				style: 'style',
			},
		}
	)
}

// デフォルト値
CustomToolbarDropdown.defaultProps = {
	formatTypeName: '',
	formatTitle: '',
	formatClass: '',
	tagName: 'span',
	toolbarButtonId: '',
	toolbarButtonClass: '',
	toolbarButtonIcon: '',
	toolbarButtonLabel: '',
	tooltipLabel: '',
	dropdownPosition: 'center bottom',
	isMultiSelect: false,
	childButtons: [],
}

カスタムツールバーボタンを利用する

作成した CustomToolbarButton コンポーネントを実際に利用して独自のツールバーボタンとドロップダウンを組み込むには、以下のように呼び出します。

import { CustomToolbarDropdown } from './components/custom-toolbar-dropdown'

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

tags.push( {
	tag: 'span',  // 選択テキストを括るHTMLタグ
	class: 'txt-blue',  // 挿入する装飾用の class
	title: 'Blue',  // ボタンラベル
	icon: 'art',  // ボタンアイコン(ダッシュアイコンの場合は、dashicons- を省いたアイコン名、独自アイコンの場合は svg を渡す)
} )

tags.push( {
	tag: 'span',
	class: 'txt-lightblue',
	title: 'Lightblue',
	icon: 'art',
} )

tags.push( {
	tag: 'span',
	class: 'txt-green',
	title: 'Green',
	icon: 'art',
} )
// 以下、追加したいだけ tags.push を記述


// カスタムツールバー
CustomToolbarDropdown( {
	formatTypeName: 'my-plugin/text-color',
	formatTitle: 'Text Color',
	formatClass: 'pre--txt-color',
	tagName: 'span',
	toolbarButtonId: 'my-plugin--text-color',
	toolbarButtonClass: 'text-color',
	toolbarButtonIcon: 'art',
	toolbarButtonLabel: 'Text Color',
	tooltipLabel: 'Text Color',
	dropdownPosition : 'bottom center',
	childButtons : tags,
})

これで、実際に組み込まれるカスタムツールバーボタンは以下のようになります。

実装例
ドロップダウン内のボタンテキストにスタイルが反映される

提供中の WordPress テーマ「DigiPress」専用のブロックエディター用プラグイン「DigiPress Ex – Blocks」独自のカスタムツールバーの構造が WordPress 5.6 より非推奨になっていたため、今回このような方法でプログラムを刷新しました。

従来とカスタムツールバーの見た目は全く変わっていませんが、Slot などややこしい部分がなくなってすっきりしたと思います。

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