Eine Bildergalerie für einen Rich-Text-Editor in Next.js

Der Dialog „Insert Gallery“ im Next.js-Beispiel mit den Ordnern blog, products und team und vier Bildern, eines davon ausgewählt

RichTextEditor ist ein Browserskript, keine React-Komponente. In einem Next.js-Projekt mit App Router braucht er daher etwas Sorgfalt: Er darf nur im Browser geladen werden, in der richtigen Reihenfolge, und muss zerstört werden, wenn React ihn unmountet, was in der Entwicklung direkt nach dem ersten Mounten passiert. Diese Seite zeigt eine Client Component, die das erledigt, und den Route Handler, der dem Dialog „Insert Gallery“ antwortet.

Optionen vom Server, der Editor im Browser

Die Seite ist eine Server Component. Sie enthält die Skriptliste und die Editor-Optionen und übergibt sie als Props an eine Client Component:

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

Ist galleryEndpoint gesetzt, fordert der Dialog „Insert Gallery“ beim Öffnen GET /rte/gallery?folder= an. /rte-files.js stellt die Upload-Funktion bereit, die die Upload-Schaltfläche des Dialogs aufruft. Die Editor-Dateien selbst sind einfache Dateien in public/richtexteditor/. npm run dev und npm run build des Beispiels kopieren sie zuerst dorthin.

Die Client Component

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

Drei Entscheidungen darin:

  • Die Skripte werden nacheinander geladen, einmal pro Seite. rte.js muss vor all_plugins.js laufen und beide vor rte-files.js. Das Promise wird in einer Modulvariablen gehalten, sodass ein zweites Mounten auf denselben Ladevorgang wartet, statt die Skripte erneut hinzuzufügen.
  • Nicht next/script mit beforeInteractive. Laut Next.js-Dokumentation werden diese Skripte früh abgerufen, ihre Ausführung blockiert die Hydration der Seite aber nicht („does not block page hydration“). Der Effect könnte dann laufen, bevor window.RichTextEditor existiert. Das Warten auf onload der Skripte vermeidet diese Race Condition.
  • Die Cleanup-Funktion zerstört den Editor. In next dev mountet der Strict Mode von React die Komponente, unmountet sie und mountet sie erneut. Die Cleanup-Funktion zerstört die erste Instanz, bevor die zweite erstellt wird. Unsere Prüfung im Browser fand sowohl in next dev als auch in next start genau einen Editor auf der Seite. Hier sind die Skripte meist erst nach dem ersten Unmounten fertig geladen, sodass beim ersten Mounten oft gar kein Editor erstellt wird. Das Flag unmounted deckt diesen Fall ab.

Der Route Handler für die Galerie

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 liest ?folder= mit URLSearchParams. Ein wiederholter Parameter folder wird mit einem 400 abgelehnt, sodass die Liste immer nur einen Wert erhält. Route Handler werden in Next.js 16 standardmäßig nicht gecacht, und next build führt diesen als dynamisch (ƒ). Die Liste wird bei jedem Request von der Festplatte gelesen, sodass ein neuer Upload beim nächsten Öffnen des Dialogs erscheint.

Die Liste selbst ist reines Node.js in lib/gallery.ts:

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

Sie führt nur Bilddateien und gültige Ordnernamen auf, sortiert sie mit einem einfachen Vergleich in Kleinbuchstaben und bildet URLs unter /uploads/. Diese URLs liefert app/uploads/[...path]/route.ts aus, nicht public/. next start liefert keine Dateien aus, die nach dem Build in public/ hinzugekommen sind (wir erhielten ein 404), neue Uploads würden also nicht erscheinen. Bilder in Next.js hochladen behandelt diese Route.

Was der Dialog anzeigt

Das Bild oben zeigt den Dialog in diesem Beispiel: drei Ordner und vier Bilder im Stammordner. Der Hinweis der ausgelieferten Galerie nennt JFIF, BMP und AVIF unter den akzeptierten Formaten, dieser Server nimmt aber nur .jpg, .jpeg, .png, .gif und .webp an. Eine abgelehnte Datei zeigt die eigene Meldung des Dialogs, „Upload failed for <name>.“, nicht den Text des Servers.

Testen

Laden Sie das Beispielprojekt zu dieser Anleitung herunter. Kopieren Sie den Ordner richtexteditor des Editors nach public/richtexteditor oder setzen Sie RTE_DIR darauf. Dann:

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."}

Öffnen Sie http://localhost:5102 und klicken Sie auf Insert Gallery. Öffnen Sie blog, wählen Sie das Bild aus und klicken Sie auf Insert. Das Bild steht im Text, mit src="/uploads/blog/...".

Dieselbe Anleitung für andere Plattformen

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

Weiterlesen