在 Next.js 中为富文本编辑器添加图片库

Next.js 示例中的 Insert Gallery 对话框,显示 blog、products 和 team 文件夹以及四张图片,其中一张处于选中状态

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} />;
}

其中有三个关键选择:

  • 脚本逐个加载,每个页面只加载一次。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 标志处理的就是这种情况。

图片库路由处理程序

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 被拒绝,因此列表只会收到一个值。在 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 由 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 设置为该文件夹。然后执行:

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

延伸阅读