Gutenberg(ブロックエディタ)メモ: 任意のHTMLタグを範囲選択で挿入可能なツールバーを付ける

WordPressのGutenberg
WordPressのGutenberg

Gutenbergを触りはじめた理由の1つである「範囲選択でタグを挿入する」機能が作れたのでメモとして残します。

無理矢理感はあるため正規の方法とはほど遠い可能性があり、ご覧になる方は一例という程度の認識でお願いします。

構築環境
WordPress5.0-beta3-43876
JavaScriptJSX/ESNext
Gutenberg4.2.0

注意点

  • Gutenbergがコアにはいっている5.0-beta3-43876を使用して構築
  • 書き方だけではなく、語句や名称にも問題がある可能性があります
  • バッドノウハウな状態だと思いますので、読む方は参考程度に考えてください
  • 私自身の書き方がまだ固まっていないためと参考にしたサンプルの都合上、当ブログ内のサンプルコードにはアロー関数の使用の有無などに表記揺れがあります

実現したいこと

  • カスタムHTMLブロックに任意のタグを挿入可能なツールバーを付ける
  • 入力したテキストを範囲選択してタグで囲む動作を実現する

簡単に言えば、クラシックエディタ(5.0より前の旧エディタ)のテキストモードで、上部に付けたボタンと同種の動作をGutenbergでも実現したいということになります。

入力した文字列の一部をドラッグで範囲選択し、ボタンを押すことで予め設定したタグで囲める機能は旧エディタでよく使っていたので、これがGutenbergでは出来ないのはこまるというのが動機です。

サンプルコード

サンプルコードにはベースとして以前書いた「Gutenbergメモ: カスタムHTMLを使う」のコードを使い、そこに手を入れる形で作成しています。

そのため、一応プレビュー機能付きと簡素なシンプル版の2つを掲載します。部分だけ載せられても見る方は困ると思うので、相当に長くなりますがそのまま記載します。
加えて以下が動作に関する注意や留意点です。

  • カスタムHTMLに使われていたcodeEditorがGutenberg4.2.0からplainTextに変更されていたため、PlainTextで構築
  • textareaが出力されるplainTextを使う場合にのみ動作する仕組み(RictTextなどでは動作しない)
  • 動作は同じで、ボタンとドロップダウンの2つの方法を実験的に併置
  • 挿入可能なのはインライン要素(クラスありのspan)とブロック要素(pulol)
  • 範囲選択しない場合は、先頭(入力テキスト内のどこかが指定されているならその箇所)にタグだけを挿入

当初はspanだけが目的でしたが、pulliのセット等を最初にボタン1つで挿入できる方が便利かとブロック要素の挿入もできるようにしています。

なお、今回の肝とも言える範囲選択でタグを挿入する機能は以下の記事を参考にさせていただきました。

2007年の記事ですが、これを見なければ当記事のサンプルはまだ完成していなかったと思います。

サンプル1:プレビュー付き版

プレビュー機能付きカスタムHTML
プレビュー機能付きカスタムHTML

//初期設定
add_action( 'enqueue_block_editor_assets', function() {
  wp_enqueue_script(
    'myplugin-gutenberge',
    plugins_url( 'block.js', __FILE__ ),
    [ 'wp-blocks', 'wp-element', 'wp-components', 'wp-i18n', 'wp-editor', 'wp-compose' ]
  );
} );

const { registerBlockType } = wp.blocks;
const {
  setDefaultBlockName,
  setUnknownTypeHandlerName,
} = wp.blocks;
const { RawHTML } = wp.element
const { __ } = wp.i18n;
const { Disabled, SandBox, CodeEditor } = wp.components;
const { getPhrasingContentSchema } = wp.blocks;
const { BlockControls } = wp.editor;
const { withState } = wp.compose;

registerBlockType( 'my-plugin/test-toolbar', {
  title: 'test-toolbar',
  icon: 'universal-access-alt',
  category: 'layout',

  supports: {
    customClassName: false,
    className: false,
    html: false,
  },

  attributes: {
    content: {
      type: 'string',
      source: 'html',
      placeholder: {
        type: 'string',
      },
    },
  },

  transforms: {
    from: [
      {
        type: 'raw',
        isMatch: ( node ) => node.nodeName === 'FIGURE' && !! node.querySelector( 'iframe' ),
        schema: {
          figure: {
            require: [ 'iframe' ],
            children: {
              iframe: {
                attributes: [ 'src', 'allowfullscreen', 'height', 'width' ],
              },
              figcaption: {
                children: getPhrasingContentSchema(),
              },
            },
          },
        },
      },
    ],
  },

  edit: withState( {
    isPreview: false,
  } )( ( { attributes, setAttributes, setState, isSelected, toggleSelection, isPreview, clientId, placeholder } ) => {
    const { content } = attributes;
    //clientIdでブロックに自動で割り振られるユニークID(data-blockにセットされている値)を取得可能
    const id = 'my-plugin-textarea-' + clientId;
    
    //ツールバーとドロップダウンメニューで共通に使える項目を設定する配列
    const controlsArrayBlock = [
      {
        title: 'p',
        icon: 'editor-paragraph',
        onClick: () => surroundHTML('p',false),
      },
      {
        title: 'ul',
        icon: 'editor-ul',
        //クリックで関数を実行。引数として「タグ名」「クラス名」を設定して渡す。クラス名が不要な場合はfalseを設定。
        onClick: () => surroundHTML('ul',false)
      },
      {
        title: 'ol',
        icon: 'editor-ol',
        onClick: () => surroundHTML('ol',false)
      },
    ];
    const controlsArrayInline = [
      {
        title: '背景色',
        icon: 'admin-appearance',
        //クリックで関数を実行。引数として「タグ名」「クラス名」を設定して渡す。
        onClick: () => surroundHTML('span','my-plugin-bg'),
      },
      {
        title: '下線',
        icon: 'editor-underline',
        onClick: () => surroundHTML('span','my-plugin-underline')
      },
      {
        title: '強調',
        icon: 'visibility',
        onClick: () => surroundHTML('span','my-plugin-strong')
      },
    ];
    //ツールバーに全項目のボタンを並べるため、2つの配列を結合して1つにしている
    const controlsArrayAll = controlsArrayBlock.concat(controlsArrayInline);

    const onChangeContent = newContent => {
      setAttributes( { content: newContent } );
    };

    //範囲指定して挿入するための記述
    const surroundHTML = (tag, name) => {
      let target = document.getElementById(id);

      let pos = getAreaRange(target);

      let val        = target.value;
      let range      = val.slice(pos.start, pos.end);
      let beforeNode = val.slice(0, pos.start);
      let afterNode  = val.slice(pos.end);
      let insertNode;
      let afterValue;
      let tagStart;
      let tagEnd;

      //クラス名の指定があれば出力に追加
      if(name){
        tagStart = tag + ' class="' + name + '"';
        tagEnd   = tag;
      } else {
        tagStart = tag;
        tagEnd   = tag;
      }

      if (range || pos.start != pos.end) {
        if(isList(tag)){
          insertNode   = '<' + tagStart + '>\n<li>' + range + '</li>\n</' + tagEnd + '>';
        } else {
          insertNode   = '<' + tagStart + '>' + range + '</' + tagEnd + '>';
        }
      } else if (pos.start == pos.end) {
        if(isList(tag)){
          insertNode   = '<' + tagStart + '>\n<li>' + range + '</li>\n</' + tagEnd + '>';
        } else {
          insertNode   = '<' + tagStart + '></' + tagEnd + '>';
        }
      } else {
        return;
      }

      target.value = beforeNode + insertNode + afterNode;
      afterValue = target.value;
      //タグ挿入後のコンテンツをattributesのcontentに入れて上書き
      onChangeContent(afterValue);
    };

    //ulとolはliを持つ必要があるため処理がふえるので、その判定用
    const isList = tag => {
      if(tag == 'ul' || tag == 'ol') { return true; }
      return false;
    }

    //範囲指定の始点と終点を取得して返す
    const getAreaRange = id => {
      let pos   = new Object();
      pos.start = id.selectionStart;
      pos.end   = id.selectionEnd;
      return pos;
    };

    return(
      <Fragment>
        <InspectorControls>
          {/*
           アイコンだけで役割を伝えるのは非常に難しいため、サイドバーを利用して説明文を記載して補助としている。
           必要ないならInspectorControlsは全て消しても問題ない。
          */}
          <PanelBody title="ブロック詳細">
            <p>任意のタグを範囲指定で挿入します。ドロップダウン内のと項目とボタンは同じ動作をするため、どちらでも利用可能です。</p>
            <p>ドロップダウンは<span class="dashicons dashicons-media-default"></span>がブロック要素で、<span class="dashicons dashicons-media-code"></span>がインライン(span)にクラスを付与した形で出力されます。</p>
          </PanelBody>
          <PanelBody title="アイコンの意味とタグ">
            <ul>
              <li>
                <p><span class="dashicons dashicons-editor-paragraph"></span>:pタグを追加(p)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-editor-ul"></span>:ulとliを追加(ul>li)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-editor-uol"></span>:olとliを追加(ol>li)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-admin-appearance"></span>:背景色を追加(span.my-plugin-bg)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-editor-underline"></span>:下線を追加(span.my-plugin-underline)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-visibility"></span>:強調表現を追加(span.my-plugin-strong)</p>
                <hr />
              </li>
            </ul>
          </PanelBody>
        </InspectorControls>
        <div className="wp-block-html">
          <BlockControls>
            <div className="components-toolbar">
              <DropdownMenu
                icon="media-default"
                label="ブロックタグ"
                controls={ controlsArrayBlock }
         className="components-toolbar"
              />
              <DropdownMenu
                icon="media-code"
                label="インラインタグ"
                controls={ controlsArrayInline }
         className="components-toolbar"
              />
              <Toolbar
                controls={ controlsArrayAll }
              />
              <div className="components-toolbar">
                <button
                  className={ `components-tab-button components-toolbar ${ ! isPreview ? 'is-active' : '' }` }
                  onClick={ () => setState( { isPreview: false } ) }
                >
                  <span>HTML</span>
                </button>
                <button
                  className={ `components-tab-button components-toolbar ${ isPreview ? 'is-active' : '' }` }
                  onClick={ () => setState( { isPreview: true } ) }
                >
                  <span>{ __( 'Preview' ) }</span>
                </button>
              </div>
            </div>
          </BlockControls>
          <Disabled.Consumer>
            { ( isDisabled ) => (
              ( isPreview || isDisabled ) ? (
                <SandBox html={ attributes.content } />
              ) : (
                <PlainText
                  id={ id }
                  value={ attributes.content }
                  focus={ isSelected }
                  onFocus={ toggleSelection }
                  onChange={ ( content ) => setAttributes( { content } ) }
                  placeholder='HTMLで入力してください'
                />
              )
            ) }
          </Disabled.Consumer>
        </div>
      </Fragment>
    );
  } ),

  save( { attributes } ) {
    return <RawHTML>{ attributes.content }</RawHTML>;
  },
} );

ユニークなID

今回はtextareaidを指定して動作させる必要がありました。また、同じブロックが複数存在する可能性を考えると重複が問題になるため、個々にユニークなidを付ける必要があります。

対策としてブロックごとに自動で割り振られるidを利用できないかと考え、最終的には以下のように作ってtextareaに設定しています。


//clientIdでブロックに自動で割り振られるユニークID(data-blockにセットされている値)を取得可能
let id = 'my-plugin-textarea-' + clientId;

なお実際にブロックをラッピングしているdivには「block-(clientIdの文字列)」のようなidが使われているため、こちらも重複を避ける意味で先頭に専用の文字列をつけて使用しています。

サンプル2:シンプル版

シンプル版カスタムHTML
シンプル版カスタムHTML

//初期設定
add_action( 'enqueue_block_editor_assets', function() {
  wp_enqueue_script(
    'myplugin-gutenberge',
    plugins_url( 'block.js', __FILE__ ),
    [ 'wp-blocks', 'wp-element', 'wp-components', 'wp-editor' ]
  );
} );

const { registerBlockType } = wp.blocks;
const { Fragment } = wp.element;
const {
 RichText,
 BlockControls,
 PlainText,
 InspectorControls,
} = wp.editor;
const {
  PanelBody,
  DropdownMenu,
  Toolbar,
} = wp.components;

registerBlockType( 'my-plugin/test-toolbar', {
  title: 'test-toolbar',
  icon: 'universal-access-alt',
  category: 'my-category',

  attributes: {
    content: {
      type: 'string',
      placeholder: {
        type: 'string',
      },
    },
  },
  edit( { attributes, setAttributes, clientId, placeholder } ) {
    const { content } = attributes;
    const id = 'my-plugin-textarea-' + clientId;

    const controlsArrayBlock = [
      {
        title: 'p',
        icon: 'editor-paragraph',
        onClick: () => surroundHTML('p',false),
      },
      {
        title: 'ul',
        icon: 'editor-ul',
        onClick: () => surroundHTML('ul',false)
      },
      {
        title: 'ol',
        icon: 'editor-ol',
        onClick: () => surroundHTML('ol',false)
      },
    ];

    const controlsArrayInline = [
      {
        title: '背景色',
        icon: 'admin-appearance',
        onClick: () => surroundHTML('span','my-plugin-bg'),
      },
      {
        title: '下線',
        icon: 'editor-underline',
        onClick: () => surroundHTML('span','my-plugin-underline')
      },
      {
        title: '強調',
        icon: 'visibility',
        onClick: () => surroundHTML('span','my-plugin-strong')
      },
    ];

    const controlsArrayAll = controlsArrayBlock.concat(controlsArrayInline);

    const onChangeContent = newContent => {
      setAttributes( { content: newContent } );
    };

    const surroundHTML = (tag, name) => {
      let target = document.getElementById(id);

      let pos = getAreaRange(target);

      let val        = target.value;
      let range      = val.slice(pos.start, pos.end);
      let beforeNode = val.slice(0, pos.start);
      let afterNode  = val.slice(pos.end);
      let insertNode;
      let afterValue;
      let tagStart;
      let tagEnd;

      if(name){
        tagStart = tag + ' class="' + name + '"';
        tagEnd   = tag;
      } else {
        tagStart = tag;
        tagEnd   = tag;
      }

      if (range || pos.start != pos.end) {
        if(isList(tag)){
          insertNode   = '<' + tagStart + '>\n<li>' + range + '</li>\n</' + tagEnd + '>';
        } else {
          insertNode   = '<' + tagStart + '>' + range + '</' + tagEnd + '>';
        }
      } else if (pos.start == pos.end) {
        if(isList(tag)){
          insertNode   = '<' + tagStart + '>\n<li>' + range + '</li>\n</' + tagEnd + '>';
        } else {
          insertNode   = '<' + tagStart + '></' + tagEnd + '>';
        }
      } else {
        return;
      }

      target.value = beforeNode + insertNode + afterNode;
      afterValue = target.value;

      onChangeContent(afterValue);
    };

    const isList = tag => {
      if(tag == 'ul' || tag == 'ol') { return true; }
      return false;
    }

    const getAreaRange = id => {
      let pos   = new Object();
      pos.start = id.selectionStart;
      pos.end   = id.selectionEnd;
      return pos;
    };

    return (
      <Fragment>
        <InspectorControls>
          <PanelBody title="ブロック詳細">
            <p>任意のタグを範囲指定で挿入します。ドロップダウン内のと項目とボタンは同じ動作をするため、どちらでも利用可能です。</p>
            <p>ドロップダウンは<span class="dashicons dashicons-media-default"></span>がブロック要素で、<span class="dashicons dashicons-media-code"></span>がインライン(span)にクラスを付与した形で出力されます。</p>
          </PanelBody>
          <PanelBody title="アイコンの意味とタグ">
            <ul>
              <li>
                <p><span class="dashicons dashicons-editor-paragraph"></span>:pタグを追加(p)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-editor-ul"></span>:ulとliを追加(ul>li)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-editor-uol"></span>:olとliを追加(ol>li)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-admin-appearance"></span>:背景色を追加(span.my-plugin-bg)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-editor-underline"></span>:下線を追加(span.my-plugin-underline)</p>
                <hr />
              </li>
              <li>
                <p><span class="dashicons dashicons-visibility"></span>:強調表現を追加(span.my-plugin-strong)</p>
                <hr />
              </li>
            </ul>
          </PanelBody>
        </InspectorControls>
        <BlockControls>
          <DropdownMenu
            icon="media-default"
            label="ブロックタグ"
            controls={ controlsArrayBlock }
       className="components-toolbar"
          />
          <DropdownMenu
            icon="media-code"
            label="インラインタグ"
            controls={ controlsArrayInline }
       className="components-toolbar"
          />
          <Toolbar
            controls={ controlsArrayAll }
          />
        </BlockControls>
          <PlainText
            value={ attributes.content }
            id={ id }
            onChange={ onChangeContent }
            placeholder='HTMLで入力してください'
          />
      </Fragment>
    )
  },
  save( { attributes, className } ) {
    const { content } = attributes;
    return (
      <div className={ className }>
        <RawHTML>{content}</RawHTML>
      </div>
    );
  },
} );

結び

把握している範囲では、Gutenbergでは範囲選択でタグを入れられるのはごく一部(強調/斜体/リンク/など)しかなく、任意のクラスを付けたspanを使うためには手動でいちいちタグやクラスを入れなければなりません。

これでは運用にそれなりの問題がでるのではと懸念していて、今回はこの懸念の解消を意図しています。

RitcTextへのツールバー追加

ほとんど参考にしたコードのままですが、以下の記事でRichText対応版をご覧いただけます。

ただし、上記は既存のツールバーに任意のボタンを追加する方法であり、RitcTextブロックを独自で追加してツールバーをつけるという動作ではありません。

BlockControlと表示される情報量とボタンを追加すること自体の問題

BlockControlは基本的にアイコンしか表示できないため、伝達可能な情報量は相当に少ない状態です。
一応マウスオーバーで設定したテキストを表示可能ですが、マウスオーバーしないと見えない情報を頼りにするのは問題があると考えているので、やはり情報量が少ないと言わざるを得ません。

今回試してDropdownMenuにすると文字を追加することもできましたが、DropdownMenuを開くためのボタンはやはりアイコンだけなので不便に感じます。

そのためサイドバーに説明情報を追加する形で不足している情報を補うようにしています。

この方策はショートコードのカスタムを試した時(「Gutenbergメモ: 登録されているショートコードの一覧を表示したブロックを作る」)に検討した方法と同じであり、現状では他の良い案が思いついていません。もっと使いやすい案があれば良いのですが…。

しかしながら、これらは問題点ではないのではとも考えています。
Gutenbergを見ているともとからこういう情報の追加(やそれが必要になるようなカスタム)は想定外、もっと言えば「やってはいけないこと」なのではないかと。

そう考えると、趣旨的にも今後のアップデートに備える意味でも当記事のようなことはしない方がよいかもしれません。

関連記事

Gutenberg(ブロックエディタ)に関連する記事一覧。

1人がこの記事を評価

役に立ったよという方は上の「記事を評価する」ボタンをクリックしてもらえると嬉しいです。

連投防止のためにCookie使用。SNSへの投稿など他サービスとの連動は一切ありません。

コメント欄