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

「Insert Gallery」ダイアログ。Express のサンプルの uploads フォルダーにある blog、products、team の各フォルダーと 4 つの画像が表示され、1 つが選択されている

RichTextEditor の「Insert Gallery」ダイアログには、すでにサーバーにある画像を表示できます。そのため、同じ画像をもう一度アップロードしなくても、既存の画像から選べます。ダイアログに必要なのは、フォルダーの一覧を JSON で返す URL 1 つだけです。このページでは、node:fs だけを使ってそのエンドポイントを Express 5 で作り、ダイアログと接続します。

ダイアログの接続先をサーバーにする

エディターのオプションを 1 つ設定すると、ギャラリーは組み込みのサンプル画像ではなく、サーバーの画像を表示するようになります。

HTML · public/index.html
<div id="editor"><p>Hello from Express.</p></div>
<script>
  var editor = new RichTextEditor("#editor", {
    toolbar: "full",
    galleryEndpoint: "/rte/gallery",
    maxUploadFileSize: 4000000
  });
</script>

galleryEndpoint を設定すると、ダイアログは開いたときに GET /rte/gallery?folder= を送信し、フォルダーが開かれると ?folder=blog を付けて再度送信します。ダイアログの「Upload」ボタンを使うには、アップロードのガイドで紹介している rte-files.js が必要です。これがないと、ダイアログは閲覧しかできません。

エンドポイントが返すもの

一覧には、現在のフォルダー、その親フォルダー、サブフォルダー、画像が含まれます。各画像には、エディターが挿入できる URL、表示名、所属するフォルダー、バイト単位のサイズがあります。以下はサンプルが実際に返したレスポンスです。

Express のサンプルで GET /rte/gallery?folder=blog が返した JSON。currentFolder は blog、parentFolder は空、サブフォルダーはなく、url、name、folder、size を持つ画像のエントリが並んでいる
Express のサンプルが返した GET /rte/gallery?folder=blog のレスポンス。

ルートでは parentFolder が null になります。ダイアログはこれを見て「Up」ボタンを無効にします。フォルダーは blog/2026 のように / で区切った仮想パスで、ディスク上のパスではありません。

ルートと一覧の作成

Express のルートは 1 行です。Express 5 は、async ハンドラーで発生したエラーをエラーミドルウェアに渡します。

JavaScript · server.mjs
// Every /rte request gets its storage root first, before multer reads the body.
app.use("/rte", (req, res, next) => {
  req.storageRoot = rootFor(req);
  next();
});

// GET /rte/gallery?folder=products  -> listing
app.get("/rte/gallery", async (req, res) => {
  res.json(await listFolder(req.storageRoot, req.query.folder ?? ""));
});

// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
app.post("/rte/gallery", parseForm.none(), async (req, res) => {
  const { action, folder, name } = req.body ?? {};
  if (action !== "create-folder") throw new GalleryError(400, "Unknown action.");
  res.json(await createFolder(req.storageRoot, folder ?? "", name));
});

// POST /rte/upload  file, folder  -> { url }
app.post("/rte/upload", parseForm.single("file"), async (req, res) => {
  if (!req.file) throw new GalleryError(400, "No file was uploaded.");
  const url = await saveImage(req.storageRoot, req.body.folder ?? "", req.file.originalname, req.file.buffer);
  res.json({ url });
});

gallery.mjs の listFolder() は、readdir(dir, { withFileTypes: true }) でフォルダーを読み取り、JSON を組み立てます。

JavaScript · gallery.mjs
// The JSON the gallery dialog reads (see CONTRACT.md).
export async function listFolder(root, 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) => (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, b) {
  const x = a.name.toLowerCase();
  const y = b.name.toLowerCase();
  return x < y ? -1 : x > y ? 1 : 0;
}

この中には、重要な判断がいくつかあります。

  • 一覧に含めるのは画像の拡張子を持つファイルだけで、ドットで始まるものは含めません。
  • 一覧に含めるのは有効な名前のフォルダーだけです。そのため、ダイアログに表示されるフォルダーはすべて開くことができます。
  • 並べ替えには localeCompare ではなく、小文字にしての単純な比較を使っています。これにより、どのマシンでも、サンプルを書いたどの言語でも、同じ順序になります。
  • URL は uploads/ 以下のパスから作り、各部分を encodeURIComponent に通します。My Photos というフォルダーなら /uploads/My%20Photos/... になります。

これらの処理の前に、resolveFolder() が ?folder= をディスク上のパスに変換し、uploads/ の外に出るものはすべて拒否します。このチェックについては、Node.js のギャラリーフォルダーで詳しく説明しています。

クエリ文字列は配列になることがある

Express は ?folder=a&folder=b を ["a", "b"] として解析します。これは Express 4 でも Express 5 でも同じです。Express 5 で変わったのはネストした構文です。当社のテストでは、?a[b]=1 は Express 4.22 では { a: { b: "1" } } に、Express 5.3 では { "a[b]": "1" } になりました。Express 5 が既定で simple クエリパーサーを使うためです。いずれにしても、req.query.folder が常に文字列とは限りません。resolveFolder() は文字列以外を 400 で拒否するため、配列が path.resolve に渡ることはありません。

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

ページ上部の画像は、このサーバーに接続したダイアログです。ルートに 3 つのフォルダーと 4 つの画像があり、そのうち 1 つが選択されています。知っておくべき制限が 2 つあります。

  • リリース版のギャラリーのヒントには、受け付ける形式として「JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF」と表示されます。一方、このサーバーが受け付けるのは .jpg、.jpeg、.png、.gif、.webp だけです。ダイアログで .bmp ファイルを選ぶと送信はされますが、400 で拒否され、ダイアログには「Upload failed for <name>.」と表示されます。
  • サムネイルは /uploads/ から読み込まれる元の画像そのものです。大きな写真が多いフォルダーでは、そのすべてが読み込まれます。サンプルでは縮小版を作成していません。

テストする

このガイドのサンプルプロジェクトを npm install と npm start で起動し、フォルダーに画像をアップロードしてから、一覧を要求します。

Shell
curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5101/rte/upload
{"url":"/uploads/blog/photo-ce99c014.jpg"}

curl "http://localhost:5101/rte/gallery?folder=blog"
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[],"images":[...]}

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

最後のレスポンスは 400 です。次に http://localhost:5101 を開き、ツールバーの Insert Gallery をクリックして blog フォルダーを開きます。アップロードした画像が一覧に表示されます。

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

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

次に読む