TextControlに日本語入力ができなくなった!?

どんな状態になったか

いつものようにブロック開発をしていました。何気なく以前作ったブロックに文字入れをしようと思ってTextControlに日本語を入れようと思うと次の画像のような現象が起きました。

固定ページを編集 “コンタクトフォームブロック用の固定ページ” ‹ ブロックテストのテーマ — Wo.png

この画像でおわかりでしょうか? この画像はブロックエディタのサイドバーです。 母音はローマ字変換されているけど子音は母音の入力を待たず入力が確定しているという状態です。これまで見たことがない怪現象です。 IMEの設定がおかしくなったかと思い確認しましたが特に問題はありません。そもそも他のTextControlではこんな現象は起きていません。 よくみると漢字変換もできない状態です。つまり、一文字入力するごとにEnterキーを押したような状態です。 TextControlに何か変なPropsでも渡したかなと思って確認しても特に何もありません。 こんな時はChatGPTかと思って現象を説明しましたしたがReactの非同期処理の問題というような回答でイマイチ的を得ていませんでした。

TextControlのレンダリングコード

TextControlをレンダリングしているコードは次のようになっています。

<TextControl
    label="コピーテキスト"
    labelPosition="top"
    value={optionStyle.copy_content}
    onChange={(newValue) => {
        setLocalOptionStyle(prev => ({ ...prev, copy_content: newValue }));
    }}
/>

上記のコードのonChange={(newValue) => {setLocalOptionStyle(prev => ({ ...prev, copy_content: newValue }));でこのブロックコンポーネントが持つoptionStyleオブジェクト内のcopy_content オブジェクトの値を書き換えています。 そして、

const [localOptionStyle, setLocalOptionStyle] = useState(optionStyle);

// localOptionStyle の変更があるたびに setAttributes を呼び出す
useEffect(() => {
    setAttributes({ optionStyle: localOptionStyle });
}, [localOptionStyle]);

としてあって、setLocalOptionStyleでlocalOptionStyleに変更があれば、optionStyleというブロックの属性が書き換わるという仕組みです。 特別特殊なことは何もしてないし、何か問題が起こるなどとは全く思っていませんでした。 ところが、このコードには重大な欠陥があるのです。おわかりでしょうか?

TextControlのonChangeは一文字入力ごとに発火する

ヒントはこの表題の中にあります。 答えに至るポイントは

value={optionStyle.copy_content}

にありました。 どうしても

onChange={(newValue) => {
    setLocalOptionStyle(prev => ({ ...prev, copy_content: newValue }));
}}

の方に目が行きがちでChatGPTもonChangeでなくonBlurで処理すれば、入力の確定を遅らせることができるなんていう説明をしていました。 少し説明を加えるとonChangeというイベントは文字を入力する度に起きるのですが、onBlurはインプットボックスからフォーカスが外れないと発生しません。つまり、文字の入力の度にオブジェクトの値を更新してるのが悪いというわけです。 しかし、それはどんなTextControlでも同じでしょう。なぜ特定のTextControlでだけこの現象が起きるのでしょうか。

そこの問題ではないのです。onChangeonBlurに変えても結果は変わりません。

そもそも、TextControlに表示されている文字列はvalueに渡された値です。 次のようなコードがより一般的だと思います。

<TextControl
    value={copyInputValue}
    onChange={(newValue) => {
        setCopyInputValue(newValue);
    }}
/>

このコードと問題のコードの違いがおわかりでしょうか。 そう、一般的なコードはvaluenewValueが何も加工されずに渡っています。ところが、問題のコードはvalue{ optionStyle.copy_content }というnewValueを加工したものを表示させています。 そうするとTextControlは未変換の状態を維持することができず、確定した文字列を表示してしまうのです。 「TextControlのonChangeは一文字入力ごとに発火する」ということを意識していれば、そのたびにsetLocalOptionStyle(prev => ({ ...prev, copy_content: newValue }));が働いて一文字一文字確定させていくんだというイメージが湧いたのではないでしょうか。 たとえば次のようなコードならわかりやすいでしょう。

<TextControl
    value={copyInputValue}
    onChange={(newValue) => {
        setCopyInputValue(`${newValue}.`);
    }}
/>

こんなコードを書くことはないでしょうが、テンプレートリテラルnewValueに’.'をつけるような加工をしています。これがTextControl内にレンダリングされたら、その後は日本語変換はできなくなるだろうなということは想像がつくと思います。

それでどうやって問題を解消するか

この問題を解消するコードを示します。

//TextControlの表示用変数
const [copyInputValue, setCopyInputValue] = useState(optionStyle.copy_content);

<TextControl
    label="コピーテキスト"
    labelPosition="top"
    value={copyInputValue}
    onChange={(newValue) => {
        setCopyInputValue(newValue);
        setLocalOptionStyle(prev => ({ ...prev, copy_content: newValue }));
    }}
/>

このコードではTextControlの表示用として状態変数を一つ用意して、それをvalueに渡しています。そうすることでTextControlの表示として加工していないnewValueを渡すことができます。setLocalOptionStyleはこれまでどおり行えばよいのです。ブロックにレンダリングされるのはそれによって書き換えられたoptionStyle.copy_contentです。それ自身は正解で、何もonBlurのイベント発生を待つ必要はありません。それを待っているとブロックへのレンダリングが遅れ、TextControlの入力内容がリアルタイムにブロックの表示に反映されなくなります。

まとめ

TextControlは非常に一般的で基本的なUIだと思っていましたが、他の入力コントロールと違い入力途中の状態を表示するという特殊性があります。そのため入力コントロールの表示とブロックのレンダリングが必ずしも一致しないということを意識しないといけないのかということに気付きました。もちろん、ほとんど場合は一致するので気が付きにくいです。日本語入力でなく、英数字を入力していればこんな現象は起こらず欠陥には気づきません。 そういう意味でなかなか、良い勉強になったと思いました。 参考にしていただけると光栄です。

インナーブロックの属性を動的に変更するのは簡単じゃなかった!

Gutenbergのブロック開発をしたことがある方はご経験があると思いますが、ちょっと実用的なブロックを開発しようと思うとインナーブロックを利用することは必須の技術になると思います。そのインナーブロックを制御するのにかなり深いドツボにハマったという経験をご披露したいと思います。

作成しようとしたブロックの概要

先日次のようなブロックを作成しました。

①、②はインナーブロックで①はAというインナーブロックを、②はB,Cというインナーブロックを持っています。Aは内部にHTMLのinput要素を持っていてユーザーが入力するようになっています。 Bは静的なラベルです。 CはHTMLのtable要素で、Gutenbergのcore/tableでレンダリングしています。 このブロックはユーザーがAに入力した内容をCのテーブルに反映させ、入力内容を確認できるようにするというもので、お問合せフォームなどでよく利用されるものです。上の画像は説明のため①、②を縦に並べましたが、実用段階においては①と②は同時に表示されるのではなく、①のボタンを押すと②が表示され、②のボタンを押すと①に戻るというものです。 一見単純な操作に見えるのですが、Aの入力にあわせてCを再レンダリングするというのが、実はかなり難しいのです。

まず、インナーブロックの設置方法から

まず、インナーブロックの設置方法を説明します。 これは比較的簡単でuseInnerBlocksPropsというフックを使います。具体的には

import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';

const TEMPLATE = [
    ['core/paragraph', {}],
    ['core/table', {}],
];//①

export default function Edit() {
    const blockProps = useBlockProps();
    const innerBlocksProps = useInnerBlocksProps(
        { blockProps },
        {
            template: TEMPLATE,
            templateLock: false
        }
    );//②

    return (
        <div {...innerBlocksProps} />//③
    );
}

これは@wordpress/create-blockで作ったブロックのひな型の中のedit.jsファイルです。

①でインナーブロックのテンプレートを作ります。そして②でuseInnerBlocksPropsフックを実行して、③でレンダリングという仕組みです。 すると、次のようなブロックがブロックエディタ上に現れます。

固定ページを編集 “テストデータを入れたブロックです” ‹ ブロックテストのテーマ — WordPr.png

テンプレートによる初期化

先ほどのコードはTEMPLATEにブロックの名称しか入れなかったので、ブロックエディタ上ブロックは初期化されない状態でレンダリングされましたが、TEMPLATEの配列内の要素に初期化するための属性値をオブジェクトとして与えてやれば、初期化することができます。 コアブロックを初期化するためには、初期化したいブロックに応じたオブジェクトを用意する必要があります。単純なものから複雑なものまで様々です。 こちらの公式ページに初期化できる属性名が掲載されているので、まずはこのページで調べるのですが、属性名がわかってもそれはオブジェクトのキーの部分がわかるだけで、オブジェクトの値をどのようにするのかがわからないという問題につきあたります。この話をしだすと大きく本題からそれるのでしませんが、今回はcore/paragraphcore/tableの初期化方法だけ説明します。 core/paragraphは単純でcontent: '初期化しました'というオブジェクトを用意すれば初期化できます。 core/tableは複雑です。headbodyfootという属性を初期化すればtable要素がレンダリングされるのですが、属性値は単純な文字列ではありません。cellsというキーを持つオブジェクトである必要があります。そこで次のような関数を作りました。

// セル要素を生成する関数
const cellObjects = (inputInnerBlocks) => {
    return inputInnerBlocks.map((input_elm) => ({
        cells: [
            {
                content: input_elm.attributes.labelContent,
                tag: 'th'
            },
            {
                content: input_elm.attributes.inputValue,
                tag: 'td'
            }
        ]
    }));
}

これを使って次のようにオブジェクトを用意します。

const tableHead = [];
const tableBody = cellObjects(inputFigureBlocks);
const tablefoot = [];
const tableAttributes = { head: tableHead, body: tableBody, foot: tablefoot };

今回はtableHeadtablefootは空配列にしましたが、ここにも関数で値を入れればHTMLのthead要素とtfoot要素がレンダリングされます。

ここまでできたら、TEMPLATEにあてはめます。

const TEMPLATE = [
    ['core/paragraph', { content: '初期化しました' }],
    ['core/table', {...tableAttributes}],
];

これでこんなふうに初期化されます。

固定ページを編集 “テストデータを入れたブロックです” ‹ ブロックテストのテーマ — WordPr.png

ここまでの全コードは次のようになります。


import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';

// セル要素を生成する関数
const cellObjects = (inputInnerBlocks) => {
    return inputInnerBlocks.map((input_elm) => ({
        cells: [
            {
                content: input_elm.attributes.labelContent,
                tag: 'th'
            },
            {
                content: input_elm.attributes.inputValue,
                tag: 'td'
            }
        ]
    }));
}

export default function Edit() {
    //とりあえずinputBlocksを静的にハードコードします
    const inputBlocks = [
        { attributes: { labelContent: 'ラベル1', inputValue: 'インプット1' } },
        { attributes: { labelContent: 'ラベル2', inputValue: 'インプット2' } },
    ]
    //テーブルボディを初期化
    const tableBody = cellObjects(inputBlocks);
    const tableAttributes = { body: tableBody };

    const TEMPLATE = [
        ['core/paragraph', { content: '初期化しました' }],
        ['core/table', { ...tableAttributes }],

    ];
    const blockProps = useBlockProps();
    const innerBlocksProps = useInnerBlocksProps({ blockProps }, {
        template: TEMPLATE,
        templateLock: false
    });

    return (
        <div {...innerBlocksProps} />
    );
}

inputBlocksを外部のブロックから取得する

上記のコードは、とりあえずinputBlocksを静的にハードコードしましたが、最初の課題に立ち返ると、inputBlocksは外部のブロックのインナーブロックから情報を取得して配列として生成する必要があります。 そこで、次のようにブロックエディタで別のブロックを用意しました。

固定ページを編集 “テストデータを入れたブロックです” ‹ ブロックテストのテーマ — WordPr.png

core/groupの中にitmar/design-text-ctrlという自作のカスタムブロックをインナーブロックとして2つ入れました。 ここで一つ重要な注意点があります。本題からそれてしまいますが、ここでもかなりハマったので書き留めておきます。

const TEMPLATE1 = [
    ['itmar/design-text-ctrl', {}],
    ['itmar/design-text-ctrl', {}],

];
const TEMPLATE2 = [
    ['core/paragraph', { content: '初期化しました' }],
    ['core/table', { ...tableAttributes }],

];

const innerBlocksProps1 = useInnerBlocksProps({ blockProps }, {
    template: TEMPLATE1,
    templateLock: false
});
const innerBlocksProps2 = useInnerBlocksProps({ blockProps }, {
    template: TEMPLATE2,
    templateLock: false
});


    return (
        <>
            <div {...innerBlocksProps1} />
            <div {...innerBlocksProps2} />
        </>
    );

こんなふうに書きたくなりませんか? このコードでエラーはでません。インナーブロックも2つレンダリングされます。しかし、2種類のテンプレートがレンダリングされることはないのです。こんなふうになります。

固定ページを編集 “固定ページにブロックを入れます” ‹ ブロックテストのテーマ — WordPre.png

最初にセットした<div {...innerBlocksProps1} />のテンプレートの内容しかレンダリングされません。これを回避する方法はなさそうです。 したがって、同じブロックの中に複数のインナーブロックコンポーネントを入れることはできないと覚えておくことが重要です。 ですから、インナーブロックのエリアを複数作りたいのであれば、まったく別のブロックにインナーブロックをいれるという必要があります。

では、本題に戻ります。 今回は自分以外のブロックから情報を引き出す必要あります。そこで必要なのがuseSelectフックです。このフックはブロックエディタ内のすべてのブロックの状態を監視してくれます。 具体的なコードを示します。

const inputBlocks = useSelect((select) => {
    const {getBlocks} = select('core/block-editor');
    //全ブロック
    const allInnerBlocks = getBlocks();
    //親ブロックを取得
    const parentBlock = allBlocks.find(block => block.name === 'core/group');
    //その中のインナーブロック
    const targetBlocks = parentBlock ? parentBlock.innerBlocks : [];
    return targetBlocks; 
}, []);

初めてuseSelectを使う方にはわかりにくいと思うので、もう一度この画像で説明します。

A.jpg

上記のコードでやりたいのは、2つの「A」の状態を監視することです。つまり、まず「A」を抽出しないといけないのです。 「親ブロック」はブロックエディタ全体、つまり'core/block-editor'です。その中のブロック全部であるallInnerBlocksは①と②です。今回は「A」があるブロックは①であり、その名前はcore/groupであるということがわかっているので、const parentBlock = allBlocks.find(block => block.name === 'core/group');で①をparentBlockとすることができました。あとはその中のインナーブロック全部ということで、const targetBlocks = parentBlock ? parentBlock.innerBlocks : [];でtargetBlocksが2つの「A」ということになります。 これで「A」の状態が監視できるようになりました。

ここでまた重要な注意点があります。useSelectは「A」の状態を監視してinputBlocksを書き換えてくれます。しかし、この処理は非同期処理なのです。 つまり、いつ書き換わるかわからないということです。書き換わった時にインナーブロックのテンプレートが確実に書き換わるようにするためにどうしたらいいかという課題が出てきます。これを解決するのがuseEffectです。 こんなふうにしてテンプレートを書き換えます。

const [TEMPLATE, setTemplate] = useState([]);

useEffect(() => {
    //テーブルボディを初期化
    const tableBody = cellObjects(inputBlocks);
    const tableAttributes = { body: tableBody };
    setTemplate(
        [
            ['core/paragraph', { content: '初期化しました' }],
            ['core/table', { ...tableAttributes }],
        ]
    )
}, [inputBlocks]);

このuseEffectinputBlocksを依存配列に持っているのでuseSelectinputBlocksを書き換えると発火してくれます。それでテンプレートを書き換えるのですが、useEffect内でconst宣言した定数は外にスコープが効かないので、useInnerBlocksPropsから見えません。 そこで、TEMPLATEを状態変数にしてuseStateで書き換えるのです。こうすればuseInnerBlocksPropsTEMPLATEの状態変化を検知して、インナーブロックを再レンダリングしてくれるはずです。

テンプレートは動的に書き換わらない!?

ここまでかなり苦労してインナーブロックの再レンダリングの仕組みを作ってきました。 しかし、本当の苦難はこれからでした。 結論から言ってしまいますが、TEMPLATEが書き換わってもインナーブロックの属性は書き換わりません。初期化したときはTEMPLATEの内容にしたがってレンダリングしてくれますが、その後TEMPLATEの内容が書き換わってもその変化には対応してくれません。これはブロックというのはユーザーが手動で書き換えることを前提としたコンポーネントだからでしょう。自動的に書き換えることは、ある意味で御法度なのかもしれません。 一度初期化したブロックは動的に書き換えることはできないということです。 ではどうしたらいいのか? 一旦削除するという方法があるのです。一旦削除すれば、次のレンダリングは初期化から始まるので、新しいテンプレートでレンダリングされます。 では、どうやって削除するのか。

削除の方法はuseDispatchというフックでremoveBlocksという関数を取得し、その関数に引数としてブロックのclientIDを渡せば削除されます。 ただし、単純に削除してテンプレートを書き換えればいいというものではありません。removeBlocksも非同期の関数なのです。つまり、removeBlocksを実行してすぐにテンプレートを書き換えても、ブロックはまだ削除されていないので効果はありません。もう一つuseEffectを用意してremoveBlocksが完了した後に処理が始まるようにしなければなりません。

整理すると次のようになります。 1. inputBlocks(画像のAのブロック)の変化 2. 画像のB,Cブロックの削除 3. 削除されたことの確認 4. テンプレートの再生成 5. インナーブロックの再レンダリング

具体的には次のようなコードになります。

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

export default function Edit() {

    //removeBlocks関数の取得
    const { removeBlocks } = useDispatch('core/block-editor');
    //インナーブロックの監視
    const innerBlockIds = useSelect((select) =>
        select('core/block-editor').getBlocks(clientId).map((block) => block.clientId)
    );
    //inputBlocksに変化があればブロックを一旦削除
    useEffect(() => {
        removeBlocks(innerBlockIds);

    }, [inputBlocks]);

    //ブロックの削除を確認して再度レンダリング
    useEffect(() => {
        if (innerBlockIds.length === 0) {
            //テーブルボディを初期化
            const tableBody = cellObjects(inputBlocks);
            const tableAttributes = { body: tableBody };
            setTemplate(
                [
                    ['core/paragraph', { content: '初期化しました' }],
                    ['core/table', { ...tableAttributes }],
                ]
            )
        }
    }, [innerBlockIds.length]);
    
    ・・・

これで、何とかなるんですが、これだけの工程を踏まないといけないというのは、ちょっと大変すぎますよね。

replaceInnerBlocksという関数が用意されていた

この存在を最近知りました。この関数を使えば一旦レンダリングされたインナーブロックの差し替えができるのです。removeBlocksで削除して、その完了を待って、再レンダリングというのに比べたらはるかに効率的です。 その実行部分のコードを示します。

useEffect(() => {
    //テーブルボディを初期化
    const tableBody = cellObjects(inputBlocks);
    const tableAttributes = { body: tableBody };
    const newInnerBlocks = [
        createBlock('core/paragraph', {}),
        createBlock('core/table', { ...tableAttributes }),
    ];
    replaceInnerBlocks(clientId, newInnerBlocks, false);
}, [inputBlocks]);

useEffectはこの一本だけです。 replaceInnerBlocks関数はテンプレートではなく、createBlock関数でブロックをつくって、既存のインナーブロックと差し替えるというものです。したがって、useInnerBlocksPropsで再レンダリングということも行いません。ですから、useInnerBlocksPropsは、次のように最初に初期化していないインナーブロックの枠だけ作るという役割を果たしてくれればいいのです。

const TEMPLATE = [];

const innerBlocksProps = useInnerBlocksProps({ blockProps }, {
    template: TEMPLATE,
    templateLock: false
});

TEMPLATEは変化しませんから、状態変数にする必要はなく、そのためのuseStateも必要なくなりました。

最後に

長々説明しましたが、最終的な全コードを示します。

import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { createBlock } from '@wordpress/blocks';

// セル要素を生成する関数
const cellObjects = (inputInnerBlocks) => {
    return inputInnerBlocks.map((input_elm) => ({
        cells: [
            {
                content: input_elm.attributes.labelContent,
                tag: 'th'
            },
            {
                content: input_elm.attributes.inputValue,
                tag: 'td'
            }
        ]
    }));
}

export default function Edit({ clientId }) {

    //replaceInnerBlocks関数の取得
    const { replaceInnerBlocks } = useDispatch('core/block-editor');

    //ブロックエディタ全体('core/block-editor')から'itmar/design-text-ctrl'を抽出
    const inputBlocks = useSelect((select) => {
        const { getBlocks } = select('core/block-editor');
        // 全ブロックを取得
        const allBlocks = getBlocks();
        //親ブロックを取得
        const parentBlock = allBlocks.find(block => block.name === 'core/group');
        //その中のインナーブロック
        const targetBlocks = parentBlock ? parentBlock.innerBlocks : [];
        return targetBlocks;
    }, []); //

    //インナーブロックの置き換え
    useEffect(() => {
        //テーブルボディを初期化
        const tableBody = cellObjects(inputBlocks);
        const tableAttributes = { body: tableBody };
        const newInnerBlocks = [
            createBlock('core/paragraph', {}),
            createBlock('core/table', { ...tableAttributes }),
        ];
        replaceInnerBlocks(clientId, newInnerBlocks, false);
    }, [inputBlocks]);


    const blockProps = useBlockProps();

    const TEMPLATE = [];

    const innerBlocksProps = useInnerBlocksProps({ blockProps }, {
        template: TEMPLATE,
        templateLock: false
    });

    return (
        <>
            <div {...innerBlocksProps} />
        </>
    );
}

ちょっと実用的なブロックを作成しようとするとインナーブロックの活用は不可欠です。今回はフロントエンドでのレンダリングについては触れませんでしたが、

<InnerBlocks.Content />

というコンポーネントレンダリングできるのは、コーディングの作業量を大幅に削減してくれます。 ですから、インナーブロックは積極的に使っていくべきだと思っています。

今回の制作作業で是非覚えておきたいことを箇条書きにします - インナーブロックはテンプレートで初期化することができる。 - インナーブロックはテンプレートで複数のブロックを入れることはできるが、テンプレートを分割して複数のエリアに配置することはできない。 - インナーブロックはテンプレートを差し替えても一度レンダリングした属性は動的に変更されない。 - 一度レンダリングしたインナーブロックの属性を変更するにはcreateBlock関数でブロックを作成し、それを配列にしてreplaceInnerBlocks関数で差し替える。

こんな感じでまとめることができると思います。

いかがだったでしょうか。 このブログが、これからブロック制作をしようと思っている方の参考になれば幸いです。

文字はなくてもfont-sizeプロパティの設定が役立つことがありますよ

文字がない要素にfont-sizeプロパティを設定するって意味がわからん。 そう考えがちですが、そんなことはないのです。ふとした思い付きがあったのでご披露させていただきます。

課題は次のようなUIをGutenbergのブロックで制作するにあたってです。

フォームでユーザーに入力作業をしてもらっているとき、その進捗がどうなっているのかを表示するプログレスバーです。Bの数字とCのバーはAの文字要素に付加された擬似要素です。Bがbefore要素でCがafter要素です。B要素内には文字があるのでcssでfont-sizeを設定しました。コード全体としては次のようになります。

&::before {
  content: counter(step);
  counter-increment: step;
  width: 1.5em;
  height: 1.5em;
  line-height: 1.3em;
  text-align: center;
  display: block;
  font-size: ${font_style_num.fontSize};
  font-family: ${font_style_num.fontFamily};
  font-weight: ${font_style_num.fontWeight};
  font-style: ${fontStyle_num};
  color: ${textColor_num};
  background: white;
  border: ${textColor_num} solid 1px;
  border-radius: 50%;
  margin: 0 auto;
}

${font_style_num.fontSize}のようにテンプレートリテラルで動的にプロパティ値を設定できるのはCSS in JSの一つである「styled-components」を使っているからです(styled-componentsについては別のブログで解説します。)。 このようにするとB要素の大きさは動的に変化します。 ここで問題が生じます。B要素の大きさが変化すると、C要素の縦位置を動かす必要が出てきます(C要素はB要素の中心の高さにないと不細工です。)。 C要素は次のようなコードで設定されています。

&::after {
  content: '';
  width: 100%;
  height: 2px;
  position: absolute;
  left: -50%;
  top: 0.75em;
  background-image: linear-gradient(to right, ${textColor_num} 50%, ${bgColor_num} 50%);
  background-position: 0 0;
  background-size: 200% auto;
  transition: all 1s;
  z-index: -1;
  /*put it behind the numbers*/
}

進捗の変化に応じてアニメーションさせるためにtransitionなどの設定がありますが、それは本題からそれるので無視してください。問題はtop: 0.75em;のところです。emは親要素のフォントサイズを1としたときの相対値で設定する単位です。したがって、親要素であるA要素のフォントサイズが20pxだとすれば、0.75emは15pxになるということですね。 C要素の縦位置はこれで設定されています。しかし、A要素のフォントサイズはC要素の縦位置とは関係ないんです。関係あるのはB要素の大きさです。この変化をC要素に反映させるのはちょっと複雑です。

どうしたものかと悩んでいたのですが、ふと目が行ったのはA要素のCSSにある

width: 1.5em;
height: 1.5em;
line-height: 1.3em;

の部分です。これはB要素を〇で囲むためにフォントに外枠を設定している部分ですが、この数値はB要素のフォントサイズの変化に応じて変化します。なぜかというと、この単位emを決定する親要素はA要素のフォントサイズではなく、B要素のフォントサイズだからです。これはB要素のCSS設定にfont-size: ${font_style_num.fontSize};があることの効果です。 だったら、C要素にも同じように設定してやればいいんじゃないのか。要素に文字がなくてもfont-sizeプロパティは設定できますよね。 ということで、こんなコードを書きました。

&::after {
  content: '';
  width: 100%;
  height: 2px;
  position: absolute;
  left: -50%;
  font-size: ${font_style_num.fontSize};//これが入った
  top: 0.75em;
  background-image: linear-gradient(to right, ${textColor_num} 50%, ${bgColor_num} 50%);
  background-position: 0 0;
  background-size: 200% auto;
  transition: all 1s;
  z-index: -1;
  /*put it behind the numbers*/
}

ばっちりB要素の真ん中にバーが設定されるようになりました。 A要素からフォントサイズを引っ張ってきて、その単位がpxか emか remかで条件分岐させて計算するしかないかなと思ってうんざりしていたのですが、こうすれば何の苦労もなく設定できました。

これってCSS in JSを使う以外の場面でも応用が効くと思いますよ。参考になればうれしいです。

オブジェクトの数の数えるにはkeysメソッドを使うって知ってた?

そんなの常識でしょ。と言われちゃうかもしれません。でも、私はハマりました。

オブジェクトって配列に入っているのと、オブジェクトを含んだオブジェクトと2パターンがありますよね。

colors=[
    {
      color: '#72aee6',
      name: 'Blue 20'
    },
    {
      color: '#3582c4',
      name: 'Blue 40'
    },
    {
      color: '#e65054',
      name: 'Red 40'
    },
    {
      color: '#8a2424',
      name: 'Red 70'
    },
    {
      color: '#f2d675',
      name: 'Yellow 10'
    },
    {
      color: '#bd8600',
      name: 'Yellow 40'
    }
]

これはcolorsという名前の配列の中に、オブジェクトが入っています。いくつオブジェクトを含んでいますか?

そうこれはcolors.lengthで6が返ってきます。

これとオブジェクト内のオブジェクトの数を返す方法を同じと考えてはいけません。

border = { 
    topLeft: "0px", 
    topRight: "0px", 
    bottomRight: "0px", 
    bottomLeft: "0px", 
    value: "0px" 
}

このborderオブジェクトにはいくつのオブジェクトが含まれていますか。

ついborder.lengthとやってしまいそうですが、これってundefinedが返ります。

正しくは

Object.keys(radius_heading).length

としないといけないのです。

念のため説明を加えると、Object.keys(radius_heading) というのは、オブジェクトが持っている全てのプロパティ名を配列にするコマンドです。これでプロパティ名が配列になったので、その.length プロパティを使って長さを取得します。それがオブジェクトの数というわけです。

オブジェクト自体はlengthプロパティは持っていません。案外知らなかったということはありませんか?

ラジオボタンのチェックを値で設定するときは配列で設定する!!

ラジオボタンの設定をjqueryスクリプトで行うときにハマるという話です。

問題の前提

ラジオボタンは通常複数の同一name属性をもつ要素が集まってできています。

<input type="radio" name="post_radio" value="val1" checked>要素1 <input type="radio" name="post_radio" value="val2">要素2 <input type="radio" name="post_radio" value="val3">要素3

こんな感じです。 ここで、要素3にチェックを入れるという設定をjqueryでやりたいという要求があるとします まず、

  let elms=$('input:radio[name="post_radio"]')

とやると変数elmsは配列となり、3つの要素が入ります。 そして、要素3にチェックを入れたいわけです。

eqメソッドによる設定

一つの方法としては 3番目の要素をeqメソッドで特定して、propメソッドでチェックを入れる方法。次のような感じです。

elms.eq(2).prop('checked',true);

しかし、この方法は「要素3」が3番目にあることがわかっていなければ成立しません。HTMLが書き換わり、要素の数が増えたり、順番が変わるとjqueryも書き換える必要があります。

値(value属性)で設定する

そこで、HTMLが変わってもjquery側に影響を及ぼさない方法としてもう一つの方法があります。 それはvalue属性で要素を特定するという方法です。もちろんこの方法でもvalue属性が書き換わればjqueryも書き換える必要が生じますが、要素の数が変わったり、順番が変わることの頻度に比べると少ないと思われます。また、スクリプトの可読性も高いと思います(何番目という指定より、〇〇というvalueをもつ要素というほうが分かりやすいでしょ。)。 そこでこんな書き方をします。

  $('input:radio[name="post_radio"]').val(['val3'])

これで要素3にチェックが入るのですが、ここで罠が潜んでいるのです。 よく見ればわかるのですが、valメソッドの引数が配列になっています。これがこの記事の結論です。

2つの失敗例

  $('input:radio[name=post_radio]').val('val3')

これだとチェックは入りません。中途半端に覚えているとこのように文字列を入れてしまうと思いませんか? このコードはラジオボタンvalue属性を変更しようとするものです。これを実行すると、チェックの状態は変わりませんが、全てのラジオボタンvalue属性は'val3'に変わってしまします。

  $('input:radio[name=post_radio]').val(['val3']).prop('checked', true);

これもダメです。これは最後のラジオボタンが選択状態になります。 今回の例はたまたま最後のラジオボタンにチェックを入れようとしているので結果オーライですが2番目に入れようと思って'val2'を指定しても要素3が選択状態になります。これは最初に value="val2" のラジオボタンが選択されるのですが、その後で全てのラジオボタンがチェックされようとします。しかし、ラジオボタンのグループに対して、一度に複数の要素をチェックすることはできません。結果的に最後のラジオボタンだけがチェックされた状態になります。

これ相当ハマりすよ。 ご注意ください。

ブロックのラベルの幅を合わせたい<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が参照できて、フロントエンドにもレンダリングできるようになります。

dangerouslySetInnerHTMLっていかにも危険?!

dangerouslySetInnerHTMLが必要なった場面

Reactコンポーネントでこんなコードを書きました。

const mediaContent = getMediaContent();
return(
    <div className="post_text">
        { mediaContent }
    </div>
)

getMediaContent()は画像等を表示するためのHTMLを生成するためのオリジナルの関数です。mediaContentにはHTMLの文字列が格納されます。 これで画像が表示されるかというと、さにあらず。文字列がブラウジングされずに表示されました。 なんでなのと思って、ChatGPTに聞きました。

JavaScript (JSX) では、HTML 文字列を直接レンダリングすると、文字列として表示されてしまいます。文字列を HTML としてレンダリングするには、dangerouslySetInnerHTML プロパティを使用する必要があります。」 コードは次のようになります。

const mediaContent = getMediaContent();
return(
     <div
        className="post_text"
        dangerouslySetInnerHTML={{ __html: mediaContent }}
    />
)

これで、きちんとブラウジングされました。しかし、なんだかいかがわしい名前の属性と思ってChatGPTの回答を読んでいると、

dangerouslySetInnerHTML は名前の通り、セキュリティリスクがあるため注意して使用してください。mainText に不正なスクリプトが含まれている場合、クロスサイトスクリプティング (XSS) 攻撃の対象となる可能性があります。

まあ、getMediaContent()の出元がはっきりしていればいいようです。

不要なタグが付いてしまうという問題

実は話はこれで終わりません。ここからが備忘録として記録しておくべきところ。 このdangerouslySetInnerHTMLは属性なので、何らかのタグとともにでないと使えません。つまりmediaContentで出来上がった文字列が必要なタグを全部備えていると、不要なタグが一つつくことになるのです。 この不要なタグは実は問題です。mediaContentで出来上がるDOM要素が場面によって変化してしまうのです。これではCSSだって当たらなくなる可能性もあるのです。これは一大事です。

ちなみにWordpressが用意しているwp.element.Fragmentをインポートして

<Fragment 
    dangerouslySetInnerHTML={{ __html: mediaContent }} 
/>

としてもダメです。全く表示されなくなりました。

html-react-parserライブラリの活用

でも、回避する方法はありました。 html-react-parser というライブラリがあるのです。 まず、次のコマンドでインストールします。

npm install html-react-parser

それでReactコンポーネントには次のように書くとOKです。とってもすっきりしてるじゃないですか。

import parse from 'html-react-parser';

// ...

return (
  <>
    {parse(mediaItem)}
  </>
);

ということでhtml-react-parserを使うのがベストチョイスと思いました。だたし、この方法でも

信頼できない HTML 文字列が含まれる場合のセキュリティリスクに注意してください。必要に応じて、サニタイズ処理を実施してください。 -- ChatGPTからの忠告

JSXで要素を直接作成する

でも、条件によってはもっと簡単な方法がありました。 それはJSXで要素を直接作成するという方法です。HTMLを生成している関数が複雑だとそういうわけにはいかないのですが、たとえば、flgという状態変数がtrueなら文字列にspanタグをつけて表示したいというような場合です。 まずは、こんなコードを試してみます。

const dispLabel = flg ? `${labelContent}<span>(${required.display})<span>` : labelContent;
return(
    <label>
        {dispLabel}
    </label>
)

テンプレートリテラルで文字列を生成してレンダリングしようとしています。これだと

WordPr.png

となります。そこで

const dispLabel = flg ? `${labelContent}<span>(${required.display})<span>` : labelContent;
return(
    <label
        dangerouslySetInnerHTML={{ __html: dispLabel }}
    />
)

とすればうまくいくのですが、そもそもdispLabel を文字列で生成するのがよくないわけです。 そこで、

const dispLabel = required.flg ? <>{labelContent}<span>({required.display})</span></> : labelContent;
return(
    <label>
        {dispLabel}
    </label>
)

これでうまくいくのです。 少し説明を加えます。 JSX(JavaScript XML)は、Reactの一部として提供される拡張構文で、JavaScriptの中にHTMLタグを書けるようになるという特徴があります。

const element = <h1>Hello, world!</h1>;

と書くと、、elementはReactが理解できる特別なオブジェクトになります。これはJavaScriptのオブジェクト表現となり、Reactはこれを利用してDOMを更新します。これを「Reactエレメント」と呼びます。 つまり、文字列ではなく「Reactエレメント」をつくればいいということです。 これでdangerouslySetInnerHTMLを使わずに済む範囲が大きくなるのではないでしょうか。