ブロックエディターで絵文字を入力できるプラグイン「BlockEditorEmoji」を作りました

公開日:

目次

a-blog cms のブロックエディターに、絵文字を入力する機能を追加するプラグイン(拡張アプリ)「BlockEditorEmoji」を作りました。本文で : や : に続けて入力すると候補が出て、ツールバーのボタンからは検索付きのピッカーを開けます。後半の「作りかた」は、これからプラグインを作る方の参考になるよう、リポジトリの構成からビルド、読み込み順、テスト、配布までをまとめました。

できること

  • :smile や :ねこ のように入力すると、絵文字の候補が出ます(サジェスト)

  • ツールバーのボタンか ⌘⌥E(Windows は Ctrl+Alt+E)で、検索付きのピッカーを開けます

  • 管理画面の設定で、ブログごとにオン・オフを切り替えられます

絵文字は Unicode の文字そのものとして本文に入ります。保存される HTML に余計な要素は増えないので、プラグインを無効にしても本文は変わりません。

サジェストで入力する

段落で : に続けて名前を入力すると、絵文字・名前・ショートコードを並べた縦のリストが、最大 8 件で出ます。↑↓ で選び、Enter か Tab で確定し、Esc で閉じます。日本語入力の変換中のキー操作は無視するので、変換を確定する Enter で絵文字が入ることはありません。

「:smile」と入力すると、候補が縦のリストで出る

日本語入力のまま使えるよう、全角の : にも対応しています。ひらがな・カタカナの名前でも、英語のショートコードでも探せます。

全角の「:ねこ」で、猫の絵文字が候補に出る

10:30 や https:// のように、直前が英数字のときは候補を出しません。コードブロックとインラインコードの中でも出さないので、コードの途中で邪魔になることはありません。

ピッカーで探す

名前が思い出せない絵文字は、ピッカーで探します。ツールバーの絵文字ボタン、または ⌘⌥E で開くと、カーソル位置の近くに表示されます。

ツールバーのボタンで開いたピッカー。検索欄と、最近使った絵文字、カテゴリごとの一覧が並ぶ

検索欄に入力して絞り込めます。ひらがなで入力しても、カタカナで書かれた名前の絵文字が見つかります。そのほか、カテゴリごとの一覧、肌の色の選択、最近使った絵文字に対応していて、選んだ絵文字はピッカーを開く前のカーソル位置に入ります。

「ねこ」で検索すると、猫の絵文字が絞り込まれる

設定画面でオン・オフ

管理画面の「プラグイン」から設定画面を開くと、ブログごとに絵文字の入力をオン・オフできます。初期値はオンです。

設定画面。絵文字の入力をオンかオフで選ぶ

動作条件

  • a-blog cms: Ver.3.2.38 以上

  • PHP: 8.1 から 8.5(PHP 8.6 以降は未検証です)

絵文字を保存できるのは、DB の接続文字コードが utf8mb4 のときだけです。コアのライトエディターの絵文字ピッカーと同じ条件で、そうでない DB では機能が自動的に無効になります。保存した絵文字が ? などに化けるのを防ぐためです。

config.server.php に次の設定があることと、DB のカラムが utf8mb4 になっていることを確認してください。

define('DB_CONNECTION_CHARSET', 'utf8mb4');

インストール

リリースページから zip をダウンロードし、解凍して extension/plugins/ に置きます。「管理画面 > プラグイン(拡張アプリ)」に BlockEditorEmoji が表示されるので、インストールをクリックします。

作りかた

ここからは、a-blog cms のプラグインを作ったことがない方に向けて、土台から順に書きます。プログラムを書ける方を想定しているので、a-blog cms 側の決まりごとと、ブロックエディターを拡張する部分に絞って説明します。

全体像

プラグインは PHP の部分と JS の部分でできています。

  • PHP の部分はプラグインの入口です。a-blog cms に自分を登録し、管理画面に JS と CSS を差し込みます。

  • JS の部分はブロックエディターを拡張します。ブロックエディターは Tiptap というエディターのライブラリで作られているので、Tiptap の Extension を 1 つ作って渡します。

動く順に並べると、次のようになります。

管理画面を開く
  → PHP が JS と CSS の読み込みタグを差し込む
  → JS が ACMS.plugins に自分の名前を登録する
  → ブロックエディターが読み込まれたあとで、登録したファクトリが呼ばれる
  → ファクトリが Tiptap の Extension を返し、エディターに組み込まれる

リポジトリの構成

ひな型には a-blog cms 公式の acms-plugin-skeleton を使いました。src/ がプラグインの本体で、そのまま extension/plugins/BlockEditorEmoji/ に置かれます。テストや設定ファイルはリポジトリの直下にあり、PHPUnit、PHPStan、PHP_CodeSniffer、GitHub Actions(テストとリリース)、zip を作るスクリプトがそろっています。ここに JS のビルド一式を足しました。

acms-block-editor-emoji/       リポジトリ
├── frontend/                  JS と CSS のソース(配布する zip には入らない)
├── src/                       プラグインの本体
│   ├── ServiceProvider.php    入口
│   ├── Hook.php               フック
│   ├── Services/              ロジック
│   ├── template/admin/        設定画面(Twig)
│   └── assets/                ビルドした JS と CSS(Git には含めない)
├── tests/                     PHPUnit と vitest
├── vite.config.ts             ビルド設定
└── package.json

名前は場所ごとに次のように付けています。

  • GitHub のリポジトリ名は uidev1116/acms-block-editor-emoji です。

  • PHP のディレクトリ、名前空間、$name は BlockEditorEmoji(名前空間は Acms\Plugins\BlockEditorEmoji)です。

  • JS で register() に渡す名前は block-editor-emoji で、翻訳の名前空間も同じです。

PHP の入口 ServiceProvider

ACMS_App を継承した ServiceProvider がプラグインの入口になります。バージョンや名前などの情報と、a-blog cms から呼ばれるメソッドを持ちます。

class ServiceProvider extends ACMS_App
{
    public $version = '0.1.0';
    public $name = 'BlockEditorEmoji';
    public $author = 'uidev1116';
    public $module = false;
    public $menu = 'block_editor_emoji_index'; // 設定画面の名前。画面がなければ false
    public $desc = 'ブロックエディターに絵文字のサジェストとピッカーを追加します。';

    public function init()
    {
        HookFactory::singleton()->attach('BlockEditorEmojiHook', new Hook());
    }

    public function checkRequirements()
    {
        return Requirements::isSupported(defined('VERSION') ? VERSION : null);
    }

    // install / uninstall / update / activate / deactivate は、このプラグインでは何もしない
}

init() は、プラグインが有効な間、リクエストのたびに呼ばれます。ここで重い処理はせず、フックの登録だけをします。checkRequirements() は、インストール時に動作条件を確かめるメソッドです。a-blog cms 3.2.38 未満では満たさないと返し、インストールさせません。$version は、リリースのタグと同じ値にそろえます。

JS と CSS を読み込むフック

管理画面に JS と CSS を差し込むには、アセット API(Acms\Services\Facades\Asset)を使います。呼ぶ場所は init() ではなく、フックの Hook::afterBuild() にしました。init() はどのページでも実行されるので、ACMS.plugins がない公開側のページでも JS が読み込まれ、エラーになるためです。

public function afterBuild(&$res)
{
    $registrar = AssetRegistrar::forDirOffset(DIR_OFFSET);
    if (!$registrar->shouldRegister(ADMIN, $this->isContributor()) || !$this->isEnabled()) {
        return;
    }
    $registrar->register(Asset::getInstance());
}

実際に登録するのは AssetRegistrar です。判断のロジックをフックから出しておくと、PHPUnit で試しやすくなります。

public function register(AssetManager $assets): void
{
    $assets->style('block-editor-emoji-style', $this->baseUrl . 'index.css');
    $assets->script('block-editor-emoji', $this->baseUrl . 'index.js', [
        'position' => 'body_end',
        'attrs' => ['type' => 'module'],
    ]);
}

覚えておきたいのは、次の 3 点です。

  • type="module" を付けると、プラグインの登録が管理画面の処理より先に実行されることが、公式のドキュメントで保証されています。ただし、実際には保証どおりにならない場合があり、あとで書く対策が要りました。

  • パスを '/' . DIR_OFFSET から始めると、ローカルのファイルとして扱われ、更新日時のクエリが自動で付きます。

  • 登録するのは、管理画面と、ログイン中の投稿者以上の公開画面(ダイレクト編集用)だけです。未ログインの訪問者には JS も CSS も読み込ませません。

JS の入口 register()

JS の入口は @ablogcms/plugins の register() です。名前を渡して登録し、setup() の中でエディターへの組み込みを宣言します。

import { register } from '@ablogcms/plugins';

register('block-editor-emoji', {
  version: '0.1.0',
  setup(ctx) {
    ctx.i18n.addResources('ja', resources.ja);
    ctx.keyboardShortcuts.registerShortcut('open-emoji-picker', {
      label: resources.ja.shortcutOpenPicker,
      commands: ['Alt', 'Mod', 'E'],
      categoryId: 'block-editor',
    });
    ctx.blockEditor.registerExtension('emojiInput', (tiptap) => createEmojiExtension(tiptap, deps));
  },
});

ctx からは、翻訳の登録(i18n)、ショートカット一覧への登録(keyboardShortcuts)、エディターの拡張の登録(blockEditor)ができます。

ここで大事なのは registerExtension に渡すのが Extension そのものではなく、Extension を返す関数(ファクトリ)だということです。setup() が呼ばれる時点では、まだエディター本体が読み込まれておらず、Tiptap が使えません。ファクトリは、エディター本体が読み込まれたあとに、Tiptap の実体を引数にして呼ばれます。そのため、Tiptap が要る処理はすべてファクトリの中に置きます。

Tiptap と ProseMirror は、コアが読み込んでいるものをそのまま使い、同梱しません。同梱すると ProseMirror が二重になり、instanceof の判定やスキーマの互換が壊れるためです。

Tiptap の Extension を書く

Tiptap のエディターは Extension の集まりでできています。Extension にはツールバーのボタン、コマンド、キーボードショートカット、ProseMirror のプラグインなどを持たせられます。絵文字の Extension は次の 4 つで構成しました。

export function createEmojiExtension(tiptap: Tiptap, deps: Deps) {
  const { core, suggestion: { Suggestion }, pm: { state: { PluginKey } } } = tiptap;

  return core.Extension.create({
    name: 'emojiInput',

    // ツールバーのボタン
    addOptions() {
      return {
        blockMenu: [{
          scope: 'text',
          order: 50,
          icon: 'add_reaction',
          tooltip: t('block-editor-emoji:openPicker', { shortcut: shortcutLabel() }),
          onSelect: (editor) => editor.commands.openEmojiPicker(),
        }],
      };
    },

    // コマンド(一部)
    addCommands() {
      return {
        openEmojiPicker: () => ({ editor }) => { openPicker(tiptap, editor, deps); return true; },
      };
    },

    // ショートカット
    addKeyboardShortcuts() {
      return { 'Mod-Alt-e': () => this.editor.commands.openEmojiPicker() };
    },

    // ':' と ':' で出るサジェスト
    addProseMirrorPlugins() {
      return [':', ':'].map((char, i) =>
        Suggestion({
          editor: this.editor,
          pluginKey: new PluginKey(`emojiSuggestion${i}`),
          char,
          allowedPrefixes: null,
          allow: ({ state, range }) => isAllowedPosition(state, range),
          items: async ({ query }) => searchEmoji(await loadEmojiData(deps.emojibaseUrl), query, 8),
          command: ({ editor, range, props }) => editor.chain().focus().insertContentAt(range, props.emoji).run(),
          render: () => createSuggestionRender(tiptap),
        }),
      );
    },
  });
}

core や Suggestion は、import ではなく、ファクトリの引数 tiptap から取り出します。コアが読み込んでいる実体を使うためです。

ツールバーのボタンは blockMenu に宣言するだけで、ホストが描画します。サジェストでは、Tiptap の Suggestion がトリガーの文字(char)の検出と、入力中の文字(query)の管理を受け持ちます。候補は items で返し、選ばれたときの処理は command、候補の見た目は render で書きます。

候補のリストは React のコンポーネントです。ピッカーは frimousse で作り、ポップアップの外枠には、コアが公開しているデザインシステムの Popover を使っています。

保存形式は Unicode の文字そのまま

絵文字は insertContent でプレーンテキストとして挿入します。Tiptap 公式の @tiptap/extension-emoji のように専用のノードを作ると、保存される HTML が <span data-type="emoji" ...> になり、プラグインを外したときに未知の要素として残ってしまいます。文字そのものなら、スキーマを変えずに済むので、プラグインを無効にしても本文は変わりません。ヘッドレスで配信するフロント側でも、追加の処理は要りません。

ビルドの設定

JS と CSS は Vite のライブラリモードで、IIFE 形式の 1 つのファイル(src/assets/index.js と index.css)にまとめます。React、react-dom、Tiptap、デザインシステムの部品は、どれもコアが ACMS.Library で公開しているので、バンドルに含めず external にして、グローバルの参照に置き換えます。

export const globals: Record<string, string> = {
  react: 'ACMS.Library.React',
  'react/jsx-runtime': 'ACMS.Library.ReactJsxRuntime',
  'react-dom': 'ACMS.Library.ReactDom',
  '@tiptap/core': 'ACMS.Library.tiptap.core',
  // ...
};
// `@ablogcms/components/popover` は ACMS.Library.components['popover'] に置き換える
export const isExternal = (id: string) => id in globals || id.startsWith('@ablogcms/components/');

ReactDom とデザインシステムの部品が公開されたのは、a-blog cms 3.2.38 からです。これを動作条件にしました。公開される前は、createPortal と flushSync だけの代用品を用意し、Popover の実装(@floating-ui/react など)も同梱していて、バンドルは約 91KB でした。公開されてからはすべて external にして、約 38KB になっています。絵文字のデータ(emojibase)は JS に含めず、ビルドのあとに静的ファイルとしてコピーしています。

つまずいた点

  • 読み込み順: 管理画面の admin.js は async で読み込まれるため、プラグインの JS が先に実行されることがあります。その時点では ACMS.plugins も ACMS.Library.React もありません。IIFE は external を読み込み時に束縛するので、Cannot read properties of undefined (reading 'React') で落ちました。ビルドの出力全体を関数で包み、コアが公開している ACMS.adminReady() の中で実行して解決しました。admin.js の初期化が終わってから呼ばれ、すでに終わっていれば即時に実行されます。似た名前の ACMS.Ready は admin.js を待たないので、使えません。

  • process.env.NODE_ENV: ライブラリモードは置き換えないので、frimousse の中の参照がそのまま残り、ブラウザーで ReferenceError になりました。define で固定して解決し、process. が残っていないことをテストで確かめています。

  • 古いコア: ACMS.Library.ReactDom や components が無いコア(3.2.37 まで)では、何も登録せず、console.info で必要なバージョンを案内します。

  • DB の文字コード: 絵文字を保存できるのは、DB の接続文字コードが utf8mb4 のときだけです。JS 側で ACMS.Config.dbCharset を見て、そうでなければ何も登録しません。

日本語入力まわり

  • 全角の : にも対応するため、半角と全角で Suggestion を 2 つ登録しています。直前の文字の判定は allowedPrefixes ではなく allow に集めました。allowedPrefixes に空白などを並べると、日本語の直後(今日は:ねこ)で出なくなるためです。コードブロックとインラインコードの中、直前が英数字のときも、allow の中で除外しています。

  • Suggestion は変換中の文字にも反応します。変換中も表示したまま、isComposing と keyCode 229 のキー操作を無視して、確定の Enter で絵文字が入らないようにしました。ピッカーの検索欄も同じです。

  • ひらがなで入力してもカタカナの名前が見つかるようにしました。frimousse の検索はひらがなとカタカナを区別し、「ねこ」で「ネコ」が出ないので、名前とタグのひらがな・カタカナ表記を検索用のタグに足しています。表示は変えません。

  • 検索結果ではショートコードの完全一致を最優先にしています。順位は完全一致、ショートコードの前方一致、名前の前方一致、タグの前方一致、部分一致の順です。:smile で、???? が ???? より上に出ます。

設定画面

設定画面は Twig で作り、標準の ACMS_POST_Config でブログごとに block_editor_emoji_enabled を保存しています。ServiceProvider の $menu に画面の名前を入れると、プラグインの一覧に「設定」が出ます。オフのときは、Hook::afterBuild() が JS と CSS を登録しません。DB の接続文字コードが utf8mb4 でないときは、画面に警告を出します。

動かして確かめる

開発中は、リポジトリを extension/plugins/BlockEditorEmoji/ に置くか、そこへシンボリックリンクを張って使います。npm run dev を動かしておくと、frontend/ を変えるたびに src/assets/ へビルドし直してくれます。管理画面の「プラグイン(拡張アプリ)」からインストールしたら、エントリーの編集画面を開いて試せます。

テストと配布

フロントエンドは vitest と jsdom で、本物の Tiptap エディターを使って試しています。ビルド成果物の読み込みテストでは admin.js より先に実行された場合、あとに実行された場合、管理画面以外の場合を確かめています。行カバレッジは 98% 超です。PHP は PHPUnit で、カバレッジは 100% です。CI は PHP 8.1 から 8.5 までの組み合わせで動かしています。

ビルド済みの JS は Git に含めていません。v で始まるタグを push すると、GitHub Actions がビルドして、配布用の zip にだけ含め、Release として公開します。

作り始める方へ

  • ひな型は acms-plugin-skeleton から始めると、テストと配布の仕組みがそろっています。

  • JS と CSS の読み込みは init() ではなく Hook::afterBuild() に置きましょう。

  • Tiptap が要る処理は、registerExtension のファクトリの中に置きましょう。

  • Tiptap、React、デザインシステムの部品は、コアの公開物を使い、external にしましょう。

  • JS の実行は ACMS.adminReady() まで待たせましょう。

  • 保存形式はプラグインを外しても壊れない形にしましょう。

  • 動作条件は checkRequirements() と JS の両方で確かめましょう。

ソースコードは GitHub で公開しています。使ってみて気づいたことがあれば、Issue でお知らせください。

uidev1116/acms-block-editor-emoji