Next.js でリッチテキストエディターに画像ギャラリーを追加する

Next.js のサンプルで開いた「Insert Gallery」ダイアログ。blog、products、team フォルダーと 4 つの画像が表示され、1 つが選択されている

RichTextEditor は React コンポーネントではなくブラウザー用のスクリプトなので、Next.js の App Router プロジェクトでは少し注意が必要です。ブラウザーでだけ、正しい順序で読み込み、React がアンマウントしたときに破棄しなければなりません。開発時には、このアンマウントが最初のマウントの直後に起こります。このページでは、それを行うクライアントコンポーネントと、「Insert Gallery」ダイアログに応答するルートハンドラーを紹介します。

オプションはサーバーで、エディターはブラウザーで

ページはサーバーコンポーネントです。スクリプトの一覧とエディターのオプションを持ち、それらを props としてクライアントコンポーネントに渡します。

TypeScript · app/page.tsx
// The editor and rte-files.js are plain browser scripts, served from public/
// (copied there by scripts/copy-editor.mjs).
const scripts = [
  "/richtexteditor/rte.js",
  "/richtexteditor/plugins/all_plugins.js",
  "/rte-files.js",
];

const config = {
  toolbar: "full",
  galleryEndpoint: "/rte/gallery",
  maxUploadFileSize: 4000000,
};

galleryEndpoint を設定すると、「Insert Gallery」ダイアログは開いたときに GET /rte/gallery?folder= をリクエストします。/rte-files.js は、ダイアログの「Upload」ボタンが呼び出すアップロード関数を提供します。エディターのファイル自体は、public/richtexteditor/ に置かれた通常のファイルです。サンプルの npm run dev と npm run build は、最初にそれらをそこへコピーします。

クライアントコンポーネント

TypeScript · app/editor.tsx
// Loads the scripts one after another, once per page, however often the component mounts.
let loading: Promise<void> | undefined;
function loadScripts(sources: string[]): Promise<void> {
  loading ??= sources.reduce(
    (previous, src) =>
      previous.then(
        () =>
          new Promise<void>((resolve, reject) => {
            const script = document.createElement("script");
            script.src = src;
            script.onload = () => resolve();
            script.onerror = () => reject(new Error("Could not load " + src));
            document.head.appendChild(script);
          }),
      ),
    Promise.resolve(),
  );
  return loading;
}

export default function Editor({ scripts, config, initialHtml = "" }: EditorProps) {
  const container = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let editor: RichTextEditorInstance | undefined;
    let unmounted = false;
    loadScripts(scripts).then(() => {
      if (unmounted || !container.current) return;
      editor = new window.RichTextEditor(container.current, config);
      editor.setHTMLCode(initialHtml);
    });
    // React's development mode mounts twice; destroy the first editor before the second.
    return () => {
      unmounted = true;
      editor?.destroy();
    };
  }, [scripts, config, initialHtml]);

  return <div ref={container} />;
}

この中には 3 つの判断があります。

  • スクリプトは 1 つずつ順番に、ページごとに 1 回だけ読み込みます。rte.js は all_plugins.js より前に、そして両方とも rte-files.js より前に実行する必要があります。Promise をモジュール変数に保持しているため、2 回目のマウントではスクリプトを再度追加せず、同じ読み込みの完了を待ちます。
  • beforeInteractive を指定した next/script は使いません。Next.js のドキュメントによると、そうしたスクリプトは早い段階で取得されますが、その実行は "does not block page hydration"(ページのハイドレーションをブロックしない)とされています。そのため、window.RichTextEditor が存在する前に effect が実行される可能性があります。スクリプトの onload を待つことで、この競合を避けられます。
  • クリーンアップでエディターを破棄します。next dev では、React の Strict Mode がコンポーネントをマウントし、アンマウントし、もう一度マウントします。クリーンアップは、2 つ目のインスタンスが作成される前に 1 つ目を破棄します。ブラウザーで確認したところ、next dev と next start のどちらでも、ページ上のエディターはちょうど 1 つでした。この構成では、スクリプトの読み込みが終わるのは通常、最初のアンマウントの後なので、最初のマウントではエディターがまったく作成されないこともよくあります。unmounted フラグはそのケースに対応しています。

ギャラリー用のルートハンドラー

TypeScript · app/rte/gallery/route.ts
// GET  /rte/gallery?folder=products                      -> listing
// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
import { GalleryError, checkOrigin, createFolder, errorResponse, listFolder, readForm } from "@/lib/gallery";
import { rootFor } from "@/lib/root-for";

export const runtime = "nodejs"; // the default, written out: this route uses node:fs

export async function GET(request: Request) {
  try {
    const params = new URL(request.url).searchParams;
    if (params.getAll("folder").length > 1) throw new GalleryError(400, "Invalid folder.");
    return Response.json(await listFolder(await rootFor(request), params.get("folder") ?? ""));
  } catch (error) {
    return errorResponse(error);
  }
}

export async function POST(request: Request) {
  try {
    checkOrigin(request);
    const form = await readForm(request);
    if (form.get("action") !== "create-folder") throw new GalleryError(400, "Unknown action.");
    return Response.json(await createFolder(await rootFor(request), form.get("folder") ?? "", form.get("name")));
  } catch (error) {
    return errorResponse(error);
  }
}

GET は URLSearchParams で ?folder= を読み取ります。folder パラメーターが繰り返されている場合は 400 で拒否するため、一覧の処理に渡される値は常に 1 つだけです。Next.js 16 では、ルートハンドラーは既定でキャッシュされず、next build はこのルートを動的(ƒ)として表示します。一覧はリクエストのたびにディスクから読み取られるため、新しくアップロードした画像は、次にダイアログを開いたときに表示されます。

一覧の処理自体は、lib/gallery.ts にある通常の Node.js のコードです。

TypeScript · lib/gallery.ts
// The JSON the gallery dialog reads (see CONTRACT.md).
export async function listFolder(root: string, folder = "") {
  if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
  const dir = await resolveFolder(root, folder);
  const entries = await readdir(dir, { withFileTypes: true });
  const join = (name: string) => (folder ? folder + "/" + name : name);

  const folders = entries
    .filter((entry) => entry.isDirectory() && isValidName(entry.name) && !isUsersFolder(root, join(entry.name)))
    .map((entry) => ({ folder: join(entry.name), name: entry.name }));

  const images = [];
  for (const entry of entries) {
    if (!entry.isFile() || !isImageName(entry.name)) continue;
    const file = path.join(dir, entry.name);
    const { size } = await lstat(file);
    images.push({ url: publicUrl(file), name: entry.name, folder, size });
  }

  return {
    currentFolder: folder,
    currentFolderDisplay: "/" + folder,
    parentFolder: folder === "" ? null : folder.split("/").slice(0, -1).join("/"),
    folders: folders.sort(byName),
    images: images.sort(byName),
  };
}

// Plain lower-case comparison, so every server sample sorts the same way.
function byName(a: { name: string }, b: { name: string }) {
  const x = a.name.toLowerCase();
  const y = b.name.toLowerCase();
  return x < y ? -1 : x > y ? 1 : 0;
}

画像ファイルと有効なフォルダー名だけを一覧に含め、小文字にしての単純な比較で並べ替え、/uploads/ 以下の URL を作ります。これらの URL は public/ ではなく app/uploads/[...path]/route.ts が配信します。next start は、ビルド後に public/ に追加されたファイルを配信しないため(当社のテストでは 404 になりました)、新しいアップロードが表示されなくなるからです。このルートについては、Next.js での画像アップロードで説明しています。

ダイアログに表示されるもの

ページ上部の画像は、このサンプルで開いたダイアログです。ルートに 3 つのフォルダーと 4 つの画像があります。リリース版のギャラリーのヒントには、受け付ける形式として JFIF、BMP、AVIF も含まれていますが、このサーバーが受け付けるのは .jpg、.jpeg、.png、.gif、.webp だけです。拒否されたファイルについては、サーバーのテキストではなく、ダイアログ独自のメッセージ「Upload failed for <name>.」が表示されます。

テストする

このガイドのサンプルプロジェクトをダウンロードします。エディターの richtexteditor フォルダーを public/richtexteditor にコピーするか、RTE_DIR にそのフォルダーを設定します。次に、以下を実行します。

Shell
npm install
npm run build
npm start

curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5102/rte/upload
{"url":"/uploads/blog/photo-2cb4607d.jpg"}

curl "http://localhost:5102/rte/gallery?folder=../"
{"error":"Invalid folder."}

http://localhost:5102 を開き、Insert Gallery をクリックします。blog を開いて画像を選択し、Insert をクリックします。画像が src="/uploads/blog/..." で本文に挿入されます。

ほかのプラットフォーム向けの同じガイド

JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms

次に読む