ブロックのラベルの幅を合わせたい<WordPressのブロック制作>

インナーブロックを含むブロックの作り方

ブロック制作でマスターしておきたい技術の一つにインナーブロックがあります。ブロックをパーツのように子ブロックとし、親となるブロックに集約するのです。次の画像のようなものです。

この画像にあるラベルとインプットボックスは一つのブロックです。つまり、この画像は3つのブロックを集めたブロックなのです。3つのブロックの一つ一つをインナーブロックと呼びます。 この仕組みを作るのは簡単です。 edit.jsに次のようなコードを書けば、

import {useBlockProps,useInnerBlocksProps} from '@wordpress/block-editor';
・・・
return (
<div {...useBlockProps()}>
    <div {...useInnerBlocksProps()}></div>
</div>
)

という感じでブロックを挿入していくことができます。 しかし、挿入したブロックを親が統率したい場面がでてきます。上記の画像のようにラベル幅が揃っていないとガタガタです。次の画像のようにしたいというのが、今回の記事の本題です。

そのための課題は次の3つです。 1. インナーブロックの情報収集 2. 親ブロックにおける要素幅の取得 3. 親ブロックからインナーブロックへの情報伝達

インナーブロックの情報収集

これも覚えてしまえばそれほど難しいわけではありません。useSelect というフックを使えばインナーブロックの情報は簡単に取得できます。具体的には次のコードのとおりです。

import { useSelect } from '@wordpress/data';

export default function Edit({ attributes, setAttributes, clientId }) {
    const innerBlocks = useSelect((select) => select('core/block-editor').getBlocks(clientId), [clientId]);
    ・・・

}

これでinnerBlocksにはインナーブロックの情報をオブジェクトにしたものが配列として格納されます。 あとは、innerBlocksの変化に応じて収集した情報を更新する仕組みを作ります。

useEffect(() => {
    //情報収集のコード
}, [innerBlocks]);

このuseEffectはinnerBlocksが変化すれば発火します。インナーブロックの数が変わればもちろん編集画面で属性値を変更しただけでも発火してくれます。今回インナーブロックとして挿入したブロックはラベルの文字列やフォントをブロックの属性値として持たせてあるので、それが変化したとき、すなわち、ラベルの文字列の長さが変化するような事象が起きれば発火してくれます。

親ブロックにおける要素幅の取得

失敗事例(その1)

この課題が今回は一番難しかったと思います。useSelect でインナーブロックの情報を収集できたのだから、その中からDOM要素として取り出してoffsetWidthで長さを計ればいいんじゃないのと思います。 たしかに、innerBlocksattributesの中にはoriginalContentというオブジェクトがあり、ブロックのHTMLが丸ごと文字列として格納されています。ですから次のようなコードで長さが図れるんではないかと思いました。

const parser = new DOMParser();
const doc = parser.parseFromString(block.originalContent, 'text/html');
const elements = doc.getElementsByTagName('label');
const width = elements[0].offsetWidth

しかし、これではダメです。offsetWidthレンダリングされた結果を返すのであり、単に文字列の情報をパースしても結果は返りません。上記のコードでwidthは0にしかなりませんでした。

失敗事例(その2)

それなら、インナーブロック側でレンダリングされた結果を属性値として記録し、それを親ブロックで収集してインナーブロックにフィードバックしてはしてはどうかと考えました。 しかし、これはどうも順序が違うようでうまくいきません。save.jsで記録されたブロック情報が途中で書き換えられたということでエラーで止まってしまします。

したがって、要素幅は親ブロックで独自に計算するということは必須条件だとわかりました。

ようやく成功

要するに親ブロックが収集したインナーブロックの情報をもとに、独自にラベル要素の幅を計算しないといけないということなのです。これはかなり困難かなと思いましたが、かつて、こんなコードで要素幅を計算したことがありました。

const measureTextWidth = (text, fontSize, fontFamily) => {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    context.font = `${fontSize} ${fontFamily}`;
    const metrics = context.measureText(text);
    return metrics.width;
}

canvasオブジェクトを用意して、そこにレンダリングして幅を計るというものです。このコードは結構役に立ちます。テキストの内容とフォントサイズ、フォントファミリーで要素幅が取得できるのです。他の場面でも使えると思うので、よかったら是非ご利用ください。 ともあれ、これでインナーブロックのレンダリングに頼らずラベルの要素幅を取得することができました。 これでuseEffectの中味も完成します。 これまでの結果をまとめると、以下のようなコードになります。

import { useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';

//要素幅を計測する関数
const measureTextWidth = (text, fontSize, fontFamily) => {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    context.font = `${fontSize} ${fontFamily}`;
    const metrics = context.measureText(text);
    return metrics.width;
}

export default function Edit({ attributes, setAttributes, clientId }) {


    const innerBlocks = useSelect((select) => select('core/block-editor').getBlocks(clientId), [clientId]);

    useEffect(() => {
        const maxNum = innerBlocks.reduce((max, block) => {
            return Math.max(max, measureTextWidth(block.attributes.labelContent, block.attributes.font_style_label.fontSize, block.attributes.font_style_label.fontFamily));
        }, Number.MIN_SAFE_INTEGER);
        setAttributes({ label_width: `${Math.round(maxNum)}px` })
    }, [innerBlocks]);
    
    return(
        <div {...useBlockProps()}>
        </div>
    );
}

useEffect内ではinnerBlocks配列からblockを取り出し、そこからblock.attributes.labelContent(ラベルの文字列)、block.attributes.font_style_label.fontSize(ラベルのフォントサイズ)、block.attributes.font_style_label.fontFamily(ラベルのフォントファミリー)の情報を抽出してmeasureTextWidth関数で幅を計測しています。そして計測結果の最大値をmaxNumに格納して、親ブロックの属性値であるlabel_widthに格納するという仕組みです。

親ブロックからインナーブロックへの情報伝達

ここまでで親ブロックがラベルの幅を計測して決定することができました。しかし、これだけではインナーブロック(子ブロック)には伝わりません。 これを実現するため、Gutenbergはとても便利な仕組みを用意してくれています。 それがprovidesContextusesContextです。 コードで見るのが一番わかりやすいでしょう。 親ブロックのblock.jsonです。

"$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 2,
    ・・・
"attributes": {
    "label_width": {
        "type": "string",
        "default": "100px"
    }
},
"providesContext": {
    "itmar/label_width": "label_width"
},
    ・・・
}

インナーブロックのblock.jsonです。

"$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 2,
    ・・・
"usesContext": [
    "itmar/label_width"
],
    ・・・
}

このようにするとインナーブロックのedit.jsで

const label_width = props.context['itmar/label_width'] || 'auto';
useEffect(() => {
setAttributes({ labelWidth: label_width });
    }, [label_width]);

とすると、親ブロックでprovidesContextとして指定されたlabel_widthの属性値がlabel_width に入ります。上記の例では指定されていなければautoが入ります。そして、setAttributesでインナーブロックの属性値と記録しておきます。 あとは、この labelWidthをCSSのwidthプロパティにセットすれば、親ブロックでlabel_widthが変化すれば、インナーブロックで再レンダリングが起きて更新されるというわけです。

最後に注意事項

ブロック制作においては一般的な注意事項ですが、edit.jsで使える仕組みがsave.jsで使えるとは限りません。というよりも使えないことの方が多いでしょう。 今回利用したprovidesContextusesContextもその一つです。インナーブロックのsave.jsで

const label_width = props.context['itmar/label_width'] || 'auto';

として参照しようと思ってもエラーが返ります。save.jsで参照できるのはattributesだけと考えておきましょう。そのため、edit.jsで

useEffect(() => {
    setAttributes({ labelWidth: label_width });
}, [label_width]);

としたのです。これでsave.jsでも親ブロックから伝達されたlabel_widthが参照できて、フロントエンドにもレンダリングできるようになります。