ブロックエディターで絵文字を入力できるプラグイン「BlockEditorEmoji」を作りました
目次
a-blog cms のブロックエディターに、絵文字を入力する機能を追加するプラグイン(拡張アプリ)「BlockEditorEmoji」を作りました。本文で : や : に続けて入力すると候補が出て、ツールバーのボタンからは検索付きのピッカーを開けます。後半の「作りかた」は、これからプラグインを作る方の参考になるよう、リポジトリの構成からビルド、読み込み順、テスト、配布までをまとめました。
できること
:smileや:ねこのように入力すると、絵文字の候補が出ます(サジェスト)ツールバーのボタンか
⌘⌥E(Windows はCtrl+Alt+E)で、検索付きのピッカーを開けます管理画面の設定で、ブログごとにオン・オフを切り替えられます
絵文字は Unicode の文字そのものとして本文に入ります。保存される HTML に余計な要素は増えないので、プラグインを無効にしても本文は変わりません。
サジェストで入力する
段落で : に続けて名前を入力すると、絵文字・名前・ショートコードを並べた縦のリストが、最大 8 件で出ます。↑↓ で選び、Enter か Tab で確定し、Esc で閉じます。日本語入力の変換中のキー操作は無視するので、変換を確定する Enter で絵文字が入ることはありません。
日本語入力のまま使えるよう、全角の : にも対応しています。ひらがな・カタカナの名前でも、英語のショートコードでも探せます。
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 でお知らせください。