a-blog cms のブロックエディターに、画像のテキスト回り込みを追加するプラグインを作りました

公開日:

目次

a-blog cms のブロックエディターで、画像の横に本文を回り込ませたいことがありました。標準の画像ブロックには「左寄せ・中央・右寄せ」はありますが、どれも画像は段落の間に置かれ、文章は回り込みません。そこで、画像を左右に寄せて本文を回り込ませるプラグイン「BlockEditorImageFloat」を作り、MIT ライセンスで公開しました。この記事では使い方に加えて、コアを変更せずに実現した作りを、これからブロックエディター用のプラグインを作る人の参考になるように、順に説明します。

何ができるか

画像ブロックを選ぶと、ツールバーに「左に回り込み」「右に回り込み」の 2 つのボタンが出ます。

画像ブロックを選ぶと、ツールバーに「左に回り込み」「右に回り込み」が出る(左に回り込みを設定した状態)
  • 押すと画像が左(右)に寄り、後ろの段落・見出し・リストが反対側に回り込みます

  • もう一度押すと解除され、中央配置に戻ります

  • 画像の表示幅は変わりません。幅はこれまでどおり画像ツールバーの「表示幅」で調整します

  • Undo / Redo で、適用と解除を 1 ステップで戻せます

  • キャプション、リンク、拡大表示、メディアの差し替えは、回り込み中も使えます

回り込みと既存の左・中央・右寄せは、同じ配置の別の値なので、同時には使えません。どれか 1 つを選びます。

保存される HTML

回り込みは画像ブロックの align 属性に float-left / float-right を入れる形で保存されます。

<div class="media-image-block align-float-left" data-type="imageBlock" data-align="float-left" data-width="50%">
  <figure style="max-width: 50%;">…</figure>
</div>

新しい属性やスキーマは増やしていません。そのため、a-blog cms のコアは一切変更していません。プラグインを無効にしても、保存済みの HTML は壊れず、通常の画像として表示されます。

公開側の表示

プラグインは公開側の CSS を出力しません。ヘッドレス配信のフロントエンドなど、公開側の作りはサイトごとに違うためです。公開側で回り込ませるには、本文の CSS に、次のようなルールを追加します(README に全文を載せています)。

.acms-entry [data-type='imageBlock'][data-align='float-left'] > figure {
  float: left;
  margin-inline: 0 1.5em;
}

.acms-entry [data-type='imageBlock'][data-align='float-right'] > figure {
  float: right;
  margin-inline: 1.5em 0;
}
公開側の表示(左に回り込み)
公開側の表示(右に回り込み)

README の CSS には 600px 未満の画面で回り込みを解除し、全幅で表示する指定も入れています。

回り込みを止めたい位置があるときは、画像と本文をグループブロックにまとめ、そのグループに display: flow-root を指定する方法がおすすめです。区切り線(HR)ブロックに clear: both を指定する方法も使えます。見出しでは回り込みを止めません。

作り方

ここからは、プラグインの作りを順に説明します。想定している読者は、プログラムは書けるけれど、a-blog cms のプラグインはまだ作ったことがない人です。

まず全体像

ブロックエディターに機能を足すプラグインは、PHP と JavaScript の 2 つでできています。流れは次のとおりです。

  1. a-blog cms がプラグインの PHP(ServiceProvider)を読み込む

  2. PHP が管理画面に JavaScript と CSS を読み込ませる

  3. ブラウザで JavaScript が動き、ブロックエディターに「拡張」を登録する

  4. エディターが起動するときに、その拡張が組み込まれて、ツールバーにボタンが出る

PHP の役目は「JavaScript を読み込ませること」と「設定画面を出すこと」だけで、機能の本体は JavaScript にあります。以降のステップはこの順番に沿っています。

ステップ 1:プラグインとは

a-blog cms ではプラグインを「拡張アプリ」と呼びます。extension/plugins/ の下に、BlockEditorImageFloat のようなフォルダーを置くだけで、管理画面の「拡張アプリ」に表示され、そこからインストールや有効化ができます。

フォルダーの中心は ServiceProvider.php です。ACMS_App を継承したクラスで、プラグインの名前や作者といった情報と、インストール時や有効化時の処理を書きます。

class ServiceProvider extends ACMS_App
{
    public $version = '0.1.1';
    public $name = 'BlockEditorImageFloat';
    public $author = 'uidev1116';
    public $menu = 'block_editor_image_float_index'; // 設定画面の名前
    public $desc = 'ブロックエディターの画像ブロックにテキスト回り込み(フロート)配置を追加します。';

    // プラグインが有効なあいだ、毎回のリクエストで呼ばれる
    public function init()
    {
        HookFactory::singleton()->attach('BlockEditorImageFloatHook', new Hook());
    }

    // インストール前の環境チェック(false を返すとインストールできない)
    public function checkRequirements()
    {
        return Requirements::isSupportedAcmsVersion(defined('VERSION') ? VERSION : null);
    }
}

init() で「フック」を登録するのが、プラグインの入口です。フックは a-blog cms の処理の途中にプラグインの処理を差し込む仕組みです。このプラグインでは afterBuild(ページの HTML を組み立てたあと)に処理を差し込みます。

ステップ 2:ひな型と開発環境

最初から全部を書く必要はありません。appleple が公開している acms-plugin-skeleton(Packagist では ablogcms/plugin-skeleton)をひな型に使いました。composer create-project で作り、scripts/namespace.php でプラグイン名に置き換えます。src/ がそのまま配布する zip の中身になり、PHPUnit、PHPStan、PHP_CodeSniffer、GitHub Actions(テストとリリース)、zip を作るスクリプトが最初から入っています。ここに、フロントエンドの frontend/(TypeScript)と Vite のビルドを足しました。

frontend/        Tiptap の拡張、ツールバーのボタン、エディター用 CSS
src/             プラグイン本体(PHP。配布する zip の中身)
tests/js/        Vitest
tests/phpunit/   PHPUnit
scripts/         zip の作成とバージョン管理

開発は付属の docker-compose.yml で a-blog cms を起動して行います。src/ が、コンテナの extension/plugins/BlockEditorImageFloat に直接マウントされるので、PHP を編集すればすぐ動作に反映される作りです。フロントは npm run dev(vite build --watch)で、src/assets にビルドし続けます。ESLint と Prettier は Husky と lint-staged でコミット時に走らせ、Node のバージョンは package.json の volta.node に書いています。

ステップ 3:PHP で JS と CSS を読み込ませる

afterBuild のフックから、a-blog cms のアセット API(Asset::script() と Asset::style())を呼ぶと、ページに <script> と <link> が出力されます。

$base = '/' . DIR_OFFSET . 'extension/plugins/BlockEditorImageFloat/assets/';

Asset::style('block-editor-image-float-style', $base . 'index.css');
Asset::script('block-editor-image-float', $base . 'index.js', [
    'position' => 'body_end',
    'attrs' => ['type' => 'module'],
]);

パスを '/' . DIR_OFFSET から始めると、a-blog cms がローカルのファイルとして扱い、更新日時のクエリを自動で付けてくれます。ファイルを差し替えたときに、ブラウザのキャッシュが残る心配がありません。

読み込む場所は管理画面と、公開側でダイレクト編集が有効なときだけです。ブロックエディターがあるのはそこだけなので、それ以外のページには何も出しません。ビルドなしでデプロイされて assets/ が無いときも、何も出さないようにしてあります。

ステップ 4:JS からエディターの拡張基盤に登録する

管理画面の JavaScript(admin.js)は、ACMS.plugins という拡張基盤を持っています(a-blog cms 3.2.36 以降)。プラグインの JavaScript は、@ablogcms/plugins の register() で、ここに自分を登録します。

register('block-editor-image-float', {
  version: __PLUGIN_VERSION__,
  setup(ctx) {
    ctx.i18n.addResources('ja', resources.ja);
    ctx.i18n.addResources('en', resources.en);
    ctx.blockEditor.registerExtension('imageFloat', (tiptap) => createImageFloatExtension(tiptap));
  },
});

registerExtension() の第 2 引数は拡張を作って返す関数(ファクトリ)です。ここで気をつけたいのは、setup() が動く時点では、Tiptap がまだ読み込まれていないことです。Tiptap の値は、コアがエディターを作るときに呼ぶこのファクトリの中でしか使えません。

また、Tiptap、ProseMirror、React をプラグインに同梱してはいけません。同梱すると ProseMirror が二重になり、instanceof の判定やスキーマの互換が壊れます。コアが ACMS.Library で公開しているものをそのまま使いましょう。

ステップ 5:ブロックエディターの仕組みを調べる

a-blog cms のブロックエディターは Tiptap で作られています。Tiptap の文書はノード(段落、見出し、画像など)の木で、各ノードが属性を持つ構造です。機能の追加は「拡張」として書きます。拡張の役目はノードや属性の追加、コマンドの定義、ツールバーへのボタンの追加などです。

今回の「画像」は、コアが imageBlock というノードで持っています。その仕様を調べるには、次の 2 つが役に立ちました。

  • ブラウザの開発者ツールで、document.querySelector('.ProseMirror').editor を呼ぶと、動いている Tiptap のエディターが取れます。画像ブロックを選んでから editor.getAttributes('imageBlock') を呼べば、align や displayWidth といった属性の値が見えます

  • コアの @ablogcms/block-editor を npm で入れると、dist/ に読める形で実装があります。imageBlock や setImageBlockAlign で検索すれば、属性の定義、保存する HTML、既存のツールバーのコードが見つかります

調べて分かったのは、画像ブロックが align(既定値は center)という属性を持っていて、値の検証がないことでした。

ステップ 6:保存形式を決める

ここで、回り込みをどこに保存するかを決めます。いちばん悩んだところです。

align に float-left / float-right を入れれば、新しい属性もスキーマも要らず、コアを変えずに済みます。既存の寄せとも、同じ属性の別の値なので自然に排他になります。

ほかの案も考えました。ただ、グローバル属性 float を足して figure の class に混ぜると、再読み込みのときに class がユーザーのカスタムクラスに混ざり、ブロックメニューのクラス判定も狂います。imageBlock を extend して同名で差し替える方法は、同名の拡張の優先順位に頼ることになり、コアの更新で壊れやすそうでした。専用のノードを新しく作ると、画像メニューやメディアの差し替えといった、コアの機能とのつながりが切れます。

将来コアが align を left | center | right に絞ると動かなくなるので、回り込みの判定は floatSideOf() と isFloatAlign() の 2 つの関数にまとめました。

ステップ 7:コマンドとボタンを作る

拡張の中身は小さなものです。コマンドはコアの setImageBlockAlign と同じように imageBlock の align を書き換えるだけで、幅には触りません。

setImageFloat:
  (side) =>
  ({ commands }) =>
    commands.updateAttributes('imageBlock', { align: FLOAT_ALIGN[side] }),

ボタンは blockMenu にデータを宣言すれば、描画はコアがしてくれます。React のコンポーネントを書く必要はありません。

{
  scope: 'image',
  order: -90, // コアの画像セクション(-100)の直後
  icon: 'format_image_left',
  tooltip: t('block-editor-image-float:wrap.left'),
  isActive: (editor) => floatSideOf(editor.getAttributes('imageBlock').align) === 'left',
  onSelect: (editor) => {
    editor.chain().focus(undefined, { scrollIntoView: false }).toggleImageFloat('left').run();
  },
}

scope: 'image' は「画像ブロックを選んだときのツールバー」を表します。isActive が押下状態を返し、onSelect は、押されているボタンをもう一度押すと解除するトグルにしています。

ステップ 8:エディター上の見た目を CSS で作る

align の値が変わっても、エディター上の見た目は自動では変わりません。CSS が要ります。ここでつまずいたのが、float させる要素でした。

最初は画像ブロックの外側のラッパーを float させました。すると、幅が shrink-to-fit になり、<figure> に付いているインラインの max-width(%)が効かなくなって、表示幅の指定が崩れました。そこで、外側はそのままにして、内側の <figure> を float させています。

.acms-admin-block-editor .ProseMirror [data-type='imageBlock'][data-align='float-left'] > figure {
  float: left;
  margin-inline: 0 1.5em;
}

float がエディターの下にはみ出さないよう、.ProseMirror には display: flow-root を付けました。セレクターはコアのエディターのルート .acms-admin-block-editor の内側に限っています。

ステップ 9:読み込み順の問題に気づく

ここまでで、基本は動きました。ただ、実機で動かすと、ボタンが出たり出なかったりしました。

プラグインの JS は type="module" で読み込みます。一方、コアの admin.js は async で読み込まれるため、プラグインのほうが先に動いて、ACMS.plugins がまだ無いことがありました。そこで、ACMS.plugins がまだ無ければ、admin.js がそれを作ったあとに呼ばれる ACMS.adminReady を待って、登録するようにしました。管理画面以外のページにはどちらも無いので、何もしません。

ステップ 10:ビルドして配布する

ビルドは Vite 8 のライブラリモードで、出力は IIFE です。react と @tiptap/* は external にして、コアが公開している ACMS.Library.* を参照させています。誤って値を import してもバンドルに入らないので、JS は約 2KB に収まりました。

ビルド成果物の src/assets は Git に含めません。v* のタグを push すると、GitHub Actions がビルドして、src/ と README を zip にまとめ、GitHub Release に載せます。ユーザーはこの zip を extension/plugins/ に置くだけです。

ステップ 11:テストを書く

拡張のテストでは本物の Tiptap の Editor を作り、画像ブロックを模したノードで、コマンド、Undo / Redo、HTML の往復を確かめています。バンドルはテストの中で実際にビルドして実行します。Tiptap に触ると例外になる Proxy を渡して、ファクトリが呼ばれるまで触れていないことも検証しました。

もうひとつ入れたのが、コアの形を確かめるテストです。インストール済みの @ablogcms/block-editor を読み、align 属性の既定値、data-align、data-type="imageBlock" の直下に <figure> があることなど、このプラグインが頼っている形が残っているかを見るものです。コアを更新して形が変われば、ここが先に落ちます。PHP は PHPUnit と PHPStan(level max)で、CI では PHP 8.1〜8.5 で動かしています。

設定

「管理画面 > 拡張アプリ > BlockEditorImageFloat」の設定画面で、「回り込みの配置を使う」のチェックを外して保存すると、そのブログではボタンを表示せず、JS / CSS も読み込みません。既定はオンです。

設定画面

インストール

  1. 上のリンクから zip をダウンロードして解凍します

  2. BlockEditorImageFloat フォルダーを extension/plugins/ に置きます

  3. 「管理画面 > 拡張アプリ」に表示されるので、インストールをクリックします

必要な環境は a-blog cms 3.2.36 以上と PHP 8.1〜8.5 です(PHP 8.6 以降は未テストです)。

既知の制限

回り込み中は、ツールバーのコアの「配置」ボタンのアイコンが空になります。コアの配置ボタンが left / center / right の 3 値だけを前提に作られているためです。操作はできて、左・中央・右寄せを選べば回り込みは解除されます。

自分で作るときのチェックリスト

作ってみて、ほかのブロックエディター用プラグインにも当てはまりそうだと思った点です。

  • 保存形式はまず既存のノードの属性に載せられないかを考える

  • コアは変更せず、ACMS.plugins 経由で足す

  • Tiptap、ProseMirror、React は同梱せず、external にしてコアのものを使う

  • admin.js が async なので、登録のタイミングは ACMS.adminReady で守る

  • コアの内部実装に頼る箇所は 1 か所にまとめ、インストール済みのパッケージを読むテストで、コア更新に気づけるようにする

  • ビルド成果物を Git に含めないなら、デプロイでビルドが漏れても壊れない作りにする

  • @ablogcms/* は、組み込むコアの版に合わせて固定する

おわりに

不具合や要望があれば、GitHub の Issue でお知らせください。