ブロックエディターでユーザーに紐づいた情報を保存したいときは @wordpress/preferences が便利

Snow Monkey Editor にはブロックの設定値をプリセットとして保存する機能があります。この機能で僕が実現したかった要件は下記の2つ。

  • 「ユーザーごと」にプリセットを持てるようにしたい。
  • プリセットは「ブロックの種類ごと」に持てるようにしたい。また、複数個を追加・削除できるようにしたい。

これらは @wordpress/preferences というライブラリを使ってかなりシンプルに実装できたのですが、実は僕はこのライブラリは勝手に「設定パネル」用だと思いこんでいたので、当初はこのライブラリを使わずに自力でユーザーメタにデータを保存・取得するコードを書きました。まぁそれがちょっと色々不都合があったので、その辺も踏まえて @wordpress/preferences は凄いという記事を書きたいと思います。

設定をどこに保存するか

「ユーザーごとにプリセットを持てるようにしたい」を実現するには、どこに設定を保存すれば良いでしょうか。

例えばブロックの attributes に保存するようにすると「そのブロック」にはプリセットが保存され次から自由にプリセットを使えるようになりますが、プリセットが保存されるのはあくまで「そのブロック」なので、同じ種類の他のブロックではそのプリセットを使うことはできません。さらに「ユーザーごと」にもなっていません。

options に保存すれば「ブロックの種類ごと」に設定を保存できますが、「ユーザーごと」にはなっていません。

ということで、素直に「ユーザーメタ」に設定を保存するようにするのが最適だと考えました。PHP であれば update_user_meta() とか get_user_meta() とか使えば簡単なのですが、ブロックエディターで設定値を保存・取得できるようにするには JavaScript でコードを書かなければなりません…(苦手)。

ユーザーメタを操作する API

ユーザーメタの取得

ブロックエディター上では WP REST API でユーザーメタを操作することができます。WP REST API と通信するのは @wordpress/api-fetch を使うのが簡単です。

とりあえず下記のようなコードを書いてみると「各ブロックごと」に、現在ログインしているユーザーのユーザーメタを取得する処理が実行されます。

import { useEffect, useState } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import apiFetch from '@wordpress/api-fetch';

const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
    return ( props ) => {
        const [ userMeta, setUserMeta ] = useState( undefined );

        useEffect( async () => {
            await apiFetch( { path: '/wp/v2/users/me?context=edit' } ).then( ( response ) => {
                setUserMeta( response?.meta ); // 取得したユーザーメタを state に保存
            } );
        } );

        return <BlockEdit { ...props } />;
    }
}, 'withInspectorControl' );

addFilter(
    'editor.BlockEdit',
    'namespace/with-inspector-controls',
    withInspectorControls
);

「各ブロックごと」に現在ログインしているユーザーのユーザーメタを取得する処理」なので、ブロックが再描画されるたびに通信が発生します。本来設定値はユーザーメタに保存されていれば1回の取得で良いはずですなので、これは相当無駄です…。書き方によっては実現できるのでしょうがちょっと僕にはわかりませんでした。

これから保存の処理についても書きますが、もうこの時点で結構ヤバい感じがしますね…。

ユーザーメタへの保存

とりあえず取得はできたので、次は保存処理を書いてみます。「Save」ボタンをクリックすると、ユーザーメタの meta.snowmonkey.blockPresets.test に現在のブロックの設定(attributes)が保存されるようにします。

import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody } from '@wordpress/components';
import { useEffect, useState } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';

const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
    return ( props ) => {
        const { name } = props;
        const [ userMeta, setUserMeta ] = useState( undefined );

        useEffect( async () => {
            await apiFetch( { path: '/wp/v2/users/me?context=edit' } ).then( ( response ) => {
                setUserMeta( response?.meta );
            } );
        } );

        return (
            <>
                <InspectorControls>
                    <PanelBody title={ __( 'Presets' ) }>
                        { !! userMeta?.snowmonkey?.blockPresets?.[name]?.test && (
                            <button onClick={ /* 省略 */ }>Test</button>
                            <button onClick={ /* 省略 */ }>Remove</button>
                        ) }

                        <button
                            onClick={ async () => {
                                const newPreset = { ...attributes };

                                await apiFetch( {
                                    path: '/wp/v2/users/me?context=edit',
                                    method: 'POST',
                                    data: {
                                        meta: {
                                            ...userMeta,
                                            snowmonkey: {
                                                ...userMeta?.snowmonkey,
                                                blockPresets: {
                                                    ...userMeta?.snowmonkey?.blockPresets,
                                                     [ name ]: {
                                                        test: newPreset,
                                                    }
                                                }
                                            }
                                        }
                                    },
                                } ).then( ( response ) => {
                                    setUserMeta( response?.meta );
                                } ).catch( () => {} );
                            } );
                        >{ __( 'Save' ) }</button>
                    </PanelBody>
                </InspectorControls>

                <BlockEdit { ...props } />
            </>
        );
    }
}, 'withInspectorControl' );

addFilter(
    'editor.BlockEdit',
    'namespace/with-inspector-controls',
    withInspectorControls
);

保存されません!!なぜ!

ここで結構ハマりました。要はブロックエディターでメタを操作するときは register_meta() しとかないとダメなのです。

あと、これ実行してもらうとよくわかるのですが、Save をクリックして WP REST API からのレスポンスが返ってきてから画面上に設定値が描画されることになるので遅いんですよね。なので別の state とか Local Strage とかに保存させておいて、裏でユーザーメタに保存するのが良いのだろうと思います。

@wordpress/preferences を使うとユーザーメタに保存されているのに描画も高速なので、恐らくこの辺のことをうまくやっているのだろうなと思います。

保存したプリセットの削除

「Save」ボタンをクリックすると WP REST API で設定を保存して、保存が完了すると画面に「Test」と「Remove」というボタンが表示されます。

もうだいぶしんどくなってきたので説明は飛ばして問題の概要だけ書きますが、例えば「段落」ブロックで何か設定をして「Save」をクリックしてその設定をプリセットとして保存させる。そして新しい「段落」を挿入すると「Test」というプリセットが表示されていてクリックすると反映される。「Remove」をクリックするとそのプリセットが削除される、という動きを実現したいわけです。

なので「Remove」がクリックされたら WP REST API で通信してユーザーメタを更新してレスポンスが返ってきたら state を更新して…でもそのままだと重いから……とかあってもう色々面倒くさい…となったところで @wordpress/preferences を試してみることにしました。

@wordpress/preferences 版

こんな感じで書けます。

import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import { createHigherOrderComponent } from '@wordpress/compose';
import { useSelect, dispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import { store as preferencesStore } from '@wordpress/preferences';
import { __ } from '@wordpress/i18n';

const PREFERENCE_SCOPE = 'snow-monkey-editor/preferences';

const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
    // デフォルト値を定義
    dispatch( preferencesStore ).setDefaults( PREFERENCE_SCOPE, {
        blockPresets: {},
    } );

    return ( props ) => {
        const { name, attributes, setAttributes } = props;

        const [ newPresetName, setNewPresetName ] = useState( undefined );
        const [ removPresetCount, setRemovPresetCount ] = useState( 0 );

        // ユーザーメタから設定値を取得
        const blockPresets = useSelect( ( select ) => {
            return select( preferencesStore ).get(
                PREFERENCE_SCOPE,
                'blockPresets'
            );
        }, [] );

        const presets = blockPresets?.[ name ];

        return (
            <>
                <InspectorControls>
                    <PanelBody title={ __( 'Presets', 'snow-monkey-editor' ) }>
                        { !! presets && !! Object.values( presets ) && (
                            <div className="sme-editor-block-presets">
                                { Object.values( presets ).map(
                                    ( preset, index ) => {
                                        const presetName = Object.keys( presets )[ index ];

                                        return (
                                            <div
                                                className="sme-editor-block-presets__preset"
                                                key={ index }
                                            >
                                                <button onClick={ /* 省略 */ }>{ presetName }</button>
                                                <Button label={ __( 'Remove this preset' ) } onClick={ /* 省略 */ }>{ __( 'Remove' ) }</button>
                                            </div>
                                        );
                                    }
                                ) }
                            </div>
                        ) }

                        <div className="sme-editor-block-presets-inserter">
                            <h3 className="sme-editor-block-presets-inserter__title">{ __( 'Save current settings as a preset' ) }</h3>

                            <div className="sme-editor-block-presets-inserter__control">
                                <TextControl
                                    value={ newPresetName || '' }
                                    placeholder={ __( 'Input the preset name.' ) }
                                    onChange={ ( newValue ) => setNewPresetName( newValue ) }
                                />

                                <Button
                                    variant="primary"
                                    disabled={ ! newPresetName }
                                    onClick={ async () => {
                                        const newBlockPresets = {
                                            ...blockPresets,
                                            [ name ]: {
                                                ...presets,
                                                [ newPresetName ]: {
                                                    ...newPreset,
                                                },
                                            },
                                        };

                                        // ユーザーメタに設定値を保存
                                        // 本当は useDispatch を使ったほうが良いのかな?と思ったけど useDispatch だと null が返ってきて set できなかったので直接 dispatch を使用
                                        // 結構前に書いたコードだから今はまた違うかも(未確認)
                                        dispatch( preferencesStore ).set(
                                            PREFERENCE_SCOPE,
                                            'blockPresets',
                                            newBlockPresets
                                        );
                                        setNewPresetName( undefined );
                                    } }
                                >
                                    { __( 'Save' ) }
                                </Button>
                            </div>
                        </div>
                    </PanelBody>
                </InspectorControls>

                <BlockEdit { ...props } />
            </>
        );
    };
}, 'withInspectorControl' );

addFilter(
    'editor.BlockEdit',
    'snow-monkey-editor/block-presets/with-inspector-controls',
    withInspectorControls
);

state を使わなくても「Save」したときは再描画されたのですが「Remove」したときには再描画されなかったので一応どちらのときも state を更新するようにしてみました。特に他のブロック間で同期をとろうとか、API の通信がどうとか意識していないのに完璧に要件を満たしているので @wordpress/preferences マジすげー!と思いました。

この記事を書いた人

アバター画像

キタジマ タカシ

長崎県長崎市在住。地元のWeb制作会社でWebデザイナー/エンジニアとして従事した後、2015年にフリーランス [ モンキーレンチ ] として独立。WordPress のテーマやプラグイン、ライブラリ、CSS フレームワーク等、多数のプロダクトをオープンソースで開発・公開しています。

Snow Monkey オンラインコミュニティ

Snow Monkey をより良いテーマにするために、今後の機能開発等について情報共有したりディスカッションをしたりする場所です。より多くのユーザーの交流があったほうがより良いプロダクトに育っていくと思いますので、ぜひご参加ください!