Adicionar uma galeria de imagens a um editor de texto rico em Next.js

A caixa de diálogo Insert Gallery no exemplo em Next.js, mostrando as pastas blog, products e team e quatro imagens, uma delas selecionada

O RichTextEditor é um script de navegador, não um componente React, então em um projeto Next.js com App Router ele exige um pouco de cuidado: precisa carregar apenas no navegador, na ordem certa, e ser destruído quando o React o desmonta, o que em desenvolvimento acontece logo após a primeira montagem. Esta página mostra um componente de cliente que faz isso e o route handler que responde à caixa de diálogo Insert Gallery.

Opções no servidor, o editor no navegador

A página é um componente de servidor. Ela guarda a lista de scripts e as opções do editor e as passa como props para um componente de cliente:

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

Com galleryEndpoint definido, a caixa de diálogo Insert Gallery requisita GET /rte/gallery?folder= ao abrir. /rte-files.js fornece a função de upload que o botão Upload da caixa de diálogo chama. Os arquivos do editor em si são arquivos comuns em public/richtexteditor/. Os comandos npm run dev e npm run build do exemplo os copiam para lá primeiro.

O componente de cliente

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

Três escolhas nele:

  • Os scripts carregam um após o outro, uma vez por página. rte.js precisa rodar antes de all_plugins.js, e ambos antes de rte-files.js. A promise fica em uma variável do módulo, então uma segunda montagem aguarda o mesmo carregamento em vez de adicionar os scripts de novo.
  • Não next/script com beforeInteractive. A documentação do Next.js diz que esses scripts são buscados cedo, mas que a execução deles “não bloqueia a hidratação da página”. O efeito poderia então rodar antes de window.RichTextEditor existir. Esperar o onload dos scripts evita essa condição de corrida.
  • A limpeza destrói o editor. No next dev, o Strict Mode do React monta o componente, o desmonta e o monta de novo. A limpeza destrói a primeira instância antes que a segunda seja criada. Nossa verificação no navegador encontrou exatamente um editor na página, tanto no next dev quanto no next start. Aqui os scripts geralmente só terminam de carregar depois da primeira desmontagem, então a primeira montagem muitas vezes nem chega a criar um editor. A flag unmounted cobre esse caso.

O route handler da galeria

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 lê ?folder= com URLSearchParams. Um parâmetro folder repetido é recusado com 400, então a listagem sempre recebe um único valor. No Next.js 16, os route handlers não são armazenados em cache por padrão, e o next build lista este como dinâmico (ƒ). A listagem é lida do disco a cada requisição, então um novo upload aparece na próxima vez que a caixa de diálogo abrir.

A listagem em si é Node.js puro, em 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;
}

Ela lista apenas arquivos de imagem e nomes de pasta válidos, ordena com uma comparação simples em minúsculas e monta as URLs sob /uploads/. Essas URLs são servidas por app/uploads/[...path]/route.ts, não por public/. O next start não serve arquivos adicionados a public/ depois do build (recebemos um 404), então os novos uploads não apareceriam. Enviar imagens em Next.js trata dessa rota.

O que a caixa de diálogo mostra

A imagem no topo é a caixa de diálogo neste exemplo: três pastas e quatro imagens na raiz. A dica da galeria lançada lista JFIF, BMP e AVIF entre os formatos aceitos, mas este servidor aceita apenas .jpg, .jpeg, .png, .gif e .webp. Um arquivo recusado mostra a mensagem da própria caixa de diálogo, “Upload failed for <name>.”, e não o texto do servidor.

Teste

Baixe o projeto de exemplo deste guia. Copie a pasta richtexteditor do editor para public/richtexteditor, ou aponte RTE_DIR para ela. Depois:

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

Abra http://localhost:5102 e clique em Insert Gallery. Abra blog, selecione a imagem e clique em Insert. A imagem aparece no texto com src="/uploads/blog/...".

O mesmo guia para outras plataformas

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

Leia a seguir