在 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} />;
}其中有三个关键选择:
- 脚本逐个加载,每个页面只加载一次。
rte.js必须在all_plugins.js之前运行,两者都必须在rte-files.js之前运行。Promise 保存在一个模块变量中,因此第二次挂载会等待同一次加载,而不会再次添加脚本。 - 不使用带
beforeInteractive的next/script。Next.js 文档说明,这类脚本会被提前获取,但它们的执行 "does not block page hydration"(不会阻塞页面水合)。这样一来,effect 可能在window.RichTextEditor存在之前就运行。等待脚本的onload可以避免这种竞争。 - 清理函数会销毁编辑器。在
next dev中,React 严格模式会挂载组件、卸载它,然后再次挂载。清理函数会在创建第二个实例之前销毁第一个实例。我们的浏览器检查发现,在next dev和next start中,页面上都恰好只有一个编辑器。在这里,脚本通常要到第一次卸载之后才加载完成,因此第一次挂载往往根本不会创建编辑器。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 被拒绝,因此列表只会收到一个值。在 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 由 app/uploads/[...path]/route.ts 提供,而不是由 public/ 提供。next start 不会提供构建之后才添加到 public/ 的文件(我们得到了 404),所以新上传的图片不会出现。在 Next.js 中上传图片介绍了这个路由。
对话框显示什么
顶部的图片是这个示例中的对话框:根目录下有三个文件夹和四张图片。已发布版本的图片库提示中,接受的格式包括 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