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

RichTextEditor は React コンポーネントではなくブラウザー用のスクリプトなので、Next.js の App Router プロジェクトでは少し注意が必要です。ブラウザーでだけ、正しい順序で読み込み、React がアンマウントしたときに破棄しなければなりません。開発時には、このアンマウントが最初のマウントの直後に起こります。このページでは、それを行うクライアントコンポーネントと、「Insert Gallery」ダイアログに応答するルートハンドラーを紹介します。
オプションはサーバーで、エディターはブラウザーで
ページはサーバーコンポーネントです。スクリプトの一覧とエディターのオプションを持ち、それらを props としてクライアントコンポーネントに渡します。
// 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 は、最初にそれらをそこへコピーします。
クライアントコンポーネント
// 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フラグはそのケースに対応しています。
ギャラリー用のルートハンドラー
// 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 のコードです。
// 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 にそのフォルダーを設定します。次に、以下を実行します。
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