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

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:
// 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
// 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.jsprecisa rodar antes deall_plugins.js, e ambos antes derte-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/scriptcombeforeInteractive. 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 dewindow.RichTextEditorexistir. Esperar oonloaddos 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 nonext devquanto nonext 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 flagunmountedcobre esse caso.
O route handler da galeria
// 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:
// 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:
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