ブロックエディタとUndo/Redoの複雑な関係・・・

  • この内容は好奇心的なもので実践向きではありません!

Gutenbergのデータモジュールにコアなストアがあります。
コアストア(core)にはREST APIを扱いやすくするEntitiesな機能(何という名前かわからない)があります。
REST APIの読み込みや書き込み、それだけではなくUndo/Redoの機能もあります。

例えば投稿記事に「段落ブロック」を追加して、Hello Worldと入力したとします。
タイトルはEntities Undo/Redo testにしたとします。
そして保存します。

Entitiesを使えば簡単にREST APIから引っ張ってこれます。

投稿記事のIDが「1153」の場合はブラウザのコンソールから以下のコードを実行します。

wp.data.select('core').getEntityRecord('postType', 'post', 1153)

ではタイトルをコードから編集したらどうでしょう!?

wp.data.dispatch('core').editEntityRecord('postType', 'post', 1153, { title: 'Kurage Beam!' })

これを実行するとタイトルが変更されます。

さて、タイトルがKurage Beam!に変更されました。

では、この変更をコードから「元に戻す」にはどうすればいいでしょう!?
簡単です。

wp.data.dispatch('core').undo()

これで元のEntities Undo/Redo testに戻ります。
今度は「やり直す」場合はどうでしょう!?

wp.data.dispatch('core').redo()

これだけです。

実はeditEntityRecord()は内部でUndo/Redoのための変更内容を管理してます。
実際には@wordpress/undo-managerというパッケージで管理してます。

現在のスナップショットはgetEditedEntityRecord()で取得出来ます。
今回はその中のtitleプロパティだけに注目します。

タイトルがKurage Beam!の状態で連続して以下のコードを実行してみましょう。

wp.data.select('core').getEditedEntityRecord('postType', 'post', 1153).title
'Kurage Beam!'

wp.data.dispatch('core').undo()

wp.data.select('core').getEditedEntityRecord('postType', 'post', 1153).title
'Entities Undo/Redo test'

このようにコアストアではデータのUndo/Redoを管理出来ることが分かりました。

でもちょっと待ってください!?
そういえば、ブロックエディタで入力した内容もUndo/Redo出来ますよね!?

ブロックを作成するばあいコンポーネントはattributesおよびsetAttributesを受け取ります。
ブロックで編集するということは、このattributesを編集するということです。
ブロックを作成する時、どのようなコードを書くでしょうか?

export default ({attributes, setAttributes, clientId}) =>
{

    return <h2>Hello World</h2>;
}

attributesを使ってデータを取得し、setAttributesによってデータを変更します。

何が不思議なのかというとこれらブロックの入力データはコアストア(core)ではなく、
ブロックエディタストア(core/block-editor)で管理されている点です。

ブロック一覧を取得してみましょう!

wp.data.select('core/block-editor').getBlocks()

段落ブロックを一つ追加しただけなのでブロックは一つしか取得出来ていません。
段落の内容はcontentプロパティにあてられていて、そこからHello Worldの文字列が確認できます。

ではsetAttributesとは何なんでしょうか!?
このプロパティはブロックエディタコンポーネントの高階コンポーネント(createHigherOrderComponent())でプロパティを追加しています。
厳密にはBlockEditBlockwithDispatch()してます(ずいぶん前に紹介したのでここでは省きます)。

厳密にはsetAttributes()updateBlockAttributes()を呼び出しています。
どういうことか、コードで見てみましょう。

wp.data.dispatch('core/block-editor').updateBlockAttributes("2aa59917-fe2f-4dbe-b114-246623f9bea4", { content: 'Hey Kurage!' })

段落ブロックの内容がHey Kurage!に変更になっていることが確認できます。

ただし、段落の元のcontentはリッチエディタなのにただの文字列突っ込んでる点はご了承ください。

第一引数はgetBlocks()から取得出来たclientIdです。
第二引数で変更するプロパティをリテラルオブジェクトで渡します。

では本題に入ります。

何故core(厳密にはeditEntityRecord())の機能なのにcore/block-editorストアの変更を追跡できているのか!?

結論からいいます。

core/block-editorのストアの変更を購読(subscribe)しており、その変更を随時coreeditEntityRecord()に渡しているみたいです。
逆にUndo/Redoの処理も随時監視していてcore/block-editorに反映させているようです。

ただし、めっちゃ複雑です。
@wordpress/core-data, @wordpress/editor, @wordpress/block-editorにまたがる壮大なコードになってます。

それを思いっきり簡素化してみました。
実際には誤作動おきます。

はい、こちら。


// packages/editor/src/components/editor/index.js
const Editor = () =>
{
    const post = useSelect(s => s('core').getEntityRecord('postType', 'post', 1150);

    <EditorProvider post={post} ...>
        ...
    </EditorProvider>
}

// packages/editor/src/components/provider/index.js
const EditorProvider = () =>
{

    const [ blocks, onInput, onChange ] = useEntityBlockEditor('postType', 'post', 1150);

    return <BlockEditorProvider value={blocks} onChange={onChange} onInput={onInput} .../>
}

// packages/core-data/src/entity-provider.js
const useMyEntityBlockEditor = (kind, name, id) =>
{
    const { editEntityRecord } = useDispatch('core');

    const edited = useSelect(
        // @ts-ignore
        s => s('core').getEditedEntityRecord(kind, name, id),
        [kind, name, id]
    );

    const value = useMemo(
        () => edited.kurageState,
        [kind, name, id, edited.kurageState]
    );

    const onChange = useCallback((state: number) => {
        const edits = { kurageState: state };
        editEntityRecord(kind, name, id, edits);
    }, [kind, name, id])

    return [value, onChange];
}

// packages/block-editor/src/components/provider/index.js
const BlockEditorProvider = (props) =>
{
    // packages/block-editor/src/components/provider/use-block-sync.js
    useBlockSync(props);

    return ...
}

const useBlockSync = ({ clientId, onChange, onInput }) =>
{
    const registory = useRegistry();

    registry.subscribe(() =>
    {

        const blocks = getBlocks( clientId )

        onChange(blocks, selection)

        or

        onInput(blocks, selection)

    }, blockEditorStore)
}

まずはuseBlockSync({ clientId, onChange, onInput })です。
この関数はcore/block-editorを監視し、変更をonChangeコールバックに渡したりします。

一方const [ blocks, onInput, onChange ] = useEntityBlockEditor('postType', 'post', 1150);
onChangeコールバックを返しますが、このコールバックの引数で渡された内容をeditEntityRecord()で編集します。

この流れは「core/block-editorcore」の流れです。

その逆「corecore/block-editor」はvalueを監視することで行います。

ただし、このままでは無限ループにはならずとも思い通りのコードにはなりません。
実際にはとても複雑なしょりがされていて、ずべてのコードを追い切れているわけではありません。
もう値を上げました。

以下は一応動いた程度の実装をしたものです(多分バグとか含んでる)。

既に一番最初のUndoでは値が更新されないなどのバグがあります。
理由は分かってますが変更はしません。

import { useEntityBlockEditor } from "@wordpress/core-data";
import { combineReducers, createReduxStore, register, useDispatch, useRegistry, useSelect } from "@wordpress/data";
import { createContext, useCallback, useEffect, useMemo, useRef } from "@wordpress/element";
import React from "react";
import { Button } from "@wordpress/components";

// ------------------------- My Store

const stateManager = (state= 1, action) =>
{
    switch(action.type)
    {
        case 'SET_STATE_MANAGER':
            return action.value;
    }

    return state;
}

export const setStateManager = (value: number) =>
{
    return ({
        type: 'SET_STATE_MANAGER',
        value
    })
}

export const getStateManager = (state) =>
{
    return state.stateManager;
}

const reducer = combineReducers({ stateManager });

export const store = createReduxStore(
    'kurage/undo-tora',
    {
        reducer,
        selectors: { getStateManager },
        actions: { setStateManager }
    }
);

register(store);

// ------------------------- @wordpress/core-data

const useMyEntityBlockEditor = (kind, name, id) =>
{
    const { editEntityRecord } = useDispatch('core');

    const edited = useSelect(
        // @ts-ignore
        s => s('core').getEditedEntityRecord(kind, name, id),
        [kind, name, id]
    );

    const value = useMemo(
        () => edited.kurageState,
        [kind, name, id, edited.kurageState]
    );

    const onChange = useCallback((state: number) => {
        const edits = { kurageState: state };
        editEntityRecord(kind, name, id, edits);
    }, [kind, name, id])

    return [value, onChange];
}

// ------------------------- @wordpress/editor

const MyEditor = ({postType, postId, children}) =>
{
    // @ts-ignore
    const post = useSelect(s => s('core').getEntityRecord('postType', postType, postId), []);

    return <MyEditorProvider post={post}>{children}</MyEditorProvider>
}

const MyEditorProvider = (props) =>
{
    const { post, children, Component = MyBlockEditorProvider } = props;

    const [ value, onChange ] = useMyEntityBlockEditor('postType', post.type, post.id);

    return <Component value={value} onChange={onChange}>{children}</Component>
}

// ------------------------- @wordpress/block-editor

const MyBlockEditorProvider = (props) =>
{
    useMyBlockSync(props);

    return <>{ props.children }</>
}

const useMyBlockSync = ({ value, onChange }) =>
{
    const registry = useRegistry();

    // @ts-ignore
    const { getStateManager } = registry.select(store);

    // @ts-ignore
    const { setStateManager} = registry.dispatch(store);

    const inValue = useRef(null);
    const outValue = useRef(null);

    useEffect(() => {

        if(value === undefined)
        {
            return;
        }

        if(outValue.current !== value)
        {
            outValue.current = null;

            // @ts-ignore
            registry.batch(() => {
                inValue.current = value;
                setStateManager(value);
            });
        }

    }, [value]);

    useEffect(() => {
        // @ts-ignore
        const unsub = registry.subscribe(() => {

            if(inValue.current !== null)
            {
                inValue.current = null;
                return;
            }

            const sm = getStateManager();
            outValue.current = sm;
            onChange(sm);

        }, store);

        return () => unsub();
    }, [registry])
}

// ------------------------- Test Code

export const UndoTest = () =>
{
    const state = useSelect(s => s(store).getStateManager(), []);
    const { setStateManager } = useDispatch(store);

    const btnClick = () =>
    {
        setStateManager(state + 1)
    }

    return (
        <MyEditor postType="post" postId={1150}>
            <h2>state: {state}</h2>
            <Button variant="primary" onClick={btnClick}>カウント</Button>
        </MyEditor>
    );

}

独自ストアをUndo/Redoに参加させてみました。

決してコピペしていいコードではありません。

何が起こるか・・・?

BlockEditor certificate css DataGrid Docker Gutenberg Hyper-V iframe MUI openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク