React で RichTextEditor に画像ギャラリーを追加する

RichTextEditor の「Insert Gallery」ダイアログは、React アプリでも通常のページと同じように動作します。変わるのは組み込み方です。エディターは React が管理しないブラウザー用スクリプトであり、コンポーネントがその後始末をする必要があります。また、開発中はダイアログのリクエストを、開発サーバー経由で API サーバーに届ける必要があります。このページでは、この 3 つをすべて満たし、Express のサンプルを相手にテストした Vite + React の構成を紹介します。
アプリより前にエディターのスクリプトを読み込む
エディターは、window.RichTextEditor を定義する通常のスクリプトとして提供されています。Vite アプリでは、React を起動するモジュールスクリプトより前の index.html に置くのが、もっとも簡単で正しい方法です。
<!-- Plain browser scripts from public/, loaded before the React bundle. -->
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>ファイルは public/richtexteditor/ に置きます。Vite はここにあるファイルをそのまま配信します。rte-files.js は、エディターのアップロード用フックを /rte/upload に接続します。これがないと、ギャラリーで閲覧はできても、「Upload」ボタンから呼び出す先がありません。<head> 内の従来型のスクリプトはモジュールバンドルより前に実行されるため、コンポーネントがマウントされる時点で window.RichTextEditor は存在しています。
コンポーネント
// window.RichTextEditor comes from the scripts in index.html.
export default function RichTextEditor({ options, initialHtml = "", editorRef }) {
const container = useRef(null);
useEffect(() => {
const editor = new window.RichTextEditor(container.current, options);
editor.setHTMLCode(initialHtml);
if (editorRef) editorRef.current = editor;
// Runs on unmount, and once in development: Strict Mode mounts, unmounts, mounts again.
return () => {
if (editorRef) editorRef.current = null;
editor.destroy();
};
}, []); // create once; options are read at creation
return <div ref={container} />;
}エディターは useEffect の中で、ref が指す div に対して 1 回だけ作成されます。クリーンアップでは destroy() を呼び出します。これは開発時に重要です。React の Strict Mode では、すべてのコンポーネントがマウントされ、アンマウントされ、もう一度マウントされるからです。クリーンアップがあれば、2 つ目のエディターが作成される前に 1 つ目が破棄されます。<StrictMode> を有効にしてブラウザーで確認したところ、ページ上のエディターはちょうど 1 つで、コンソールにはエディターの試用版の通知が 2 回(作成ごとに 1 回)表示されました。クリーンアップの書き忘れは見落としやすい問題です。destroy() を呼ばずに同じ div に 2 つ目のエディターを作成したところ、ページ上のエディターは 1 つのままでしたが、1 つ目のインスタンスは後始末されないまま残りました。
依存配列が空なのは意図的です。オプションはエディターの作成時に読み取られるので、レンダリングのたびにエディターを作り直すと、ユーザーが入力した内容が失われます。コンテンツを読み取るには、インスタンスを ref に保持して getHTMLCode() を呼び出します。
const options = {
toolbar: "full",
galleryEndpoint: "/rte/gallery", // forwarded to Express by vite.config.js
maxUploadFileSize: 4000000,
};サンプルでは、「Show HTML」ボタンが editor.current.getHTMLCode() を呼び出し、<p>Hello from React.</p> と出力しました。ギャラリーから画像を挿入すると、HTML に <img src="/uploads/..."> が含まれます。
開発中に API サーバーへ届ける
galleryEndpoint: "/rte/gallery" はページからの相対パスで、そのページは Vite から配信されます。Vite の開発用プロキシは、/rte/(ギャラリーとアップロードのルート)と /uploads/(画像)を、ポート 5101 で動く Express のサンプルに転送します。
// /rte/ and /uploads/ go to the Express sample (node-express, port 5101).
// Keep the trailing slashes: "/rte" alone would also catch /rte-files.js.
proxy: {
"/rte/": "http://localhost:5101",
"/uploads/": "http://localhost:5101",
},ブラウザーからはオリジンが 1 つに見えるため、CORS ヘッダーを設定する必要も、エディターのオプションに絶対 URL を書く必要もありません。末尾のスラッシュは残してください。Vite は単純なキーを URL のプレフィックスとして照合するので、"/rte" というキーは /rte-files.js にも一致してしまいます。実際に試したところ、"/rte" では /rte-files.js が X-Powered-By: Express 付きで返され、アプリではなく API サーバーから配信されていました。"/rte/" にすると、Vite が自分のコピーを配信しました。
プロキシは開発用の機能です。本番環境では、ビルドしたアプリと API を 1 つのオリジンから配信してください。たとえば、Express でルートと並べて dist/ を配信するか、1 つのリバースプロキシの背後に置きます。
ダイアログに表示されるもの
ページ上部の画像は、この React アプリで開いたダイアログで、Express サーバーのフォルダーと画像が表示されています。サーバー側は Node.js のギャラリーガイドと同じです。React ではなく、リリース版のギャラリーに由来する制限が 2 つあります。形式のヒントには JFIF、BMP、AVIF が含まれていますが、サンプルのサーバーはこれらを拒否します。また、拒否されたファイルについては、サーバーのメッセージではなく「Upload failed for <name>.」と表示されます。
テストする
このガイドのサンプルプロジェクトと Express のサンプルをダウンロードします。エディターのダウンロードに含まれる richtexteditor フォルダーを Express のサンプルにコピーするか RTE_DIR を設定し、npm install と npm start で起動します。次に、React プロジェクトで以下を実行します。
npm install
npm run dev
VITE v8.3.5 ready in 440 ms
➜ Local: http://localhost:5111/
curl "http://localhost:5111/rte/gallery?folder="
{"currentFolder":"","currentFolderDisplay":"/","parentFolder":null,"folders":[...],"images":[...]}npm run dev は、最初にエディターのファイルを public/ にコピーします。RTE_DIR にエディターのフォルダーを設定するか、そのフォルダーを自分で public/richtexteditor に置いてください。http://localhost:5111 を開き、Insert Gallery をクリックして画像を選び、Insert をクリックします。