Añadir una galería de imágenes a un editor de texto enriquecido en Next.js

RichTextEditor es un script del navegador, no un componente de React, así que en un proyecto de Next.js con App Router requiere un poco de cuidado: debe cargarse solo en el navegador, en el orden correcto, y destruirse cuando React lo desmonta, algo que en desarrollo ocurre justo después del primer montaje. Esta página muestra un componente de cliente que hace eso y el route handler que responde al cuadro de diálogo Insert Gallery.
Las opciones desde el servidor, el editor en el navegador
La página es un componente de servidor. Contiene la lista de scripts y las opciones del editor, y las pasa como props a un 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,
};Con galleryEndpoint definido, el cuadro de diálogo Insert Gallery solicita GET /rte/gallery?folder= al abrirse. /rte-files.js proporciona la función de carga a la que llama el botón Upload del cuadro de diálogo. Los archivos del editor son archivos normales en public/richtexteditor/. npm run dev y npm run build del ejemplo los copian ahí primero.
El 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} />;
}Tres decisiones de este código:
- Los scripts se cargan uno tras otro, una vez por página.
rte.jsdebe ejecutarse antes queall_plugins.js, y ambos antes querte-files.js. La promesa se guarda en una variable de módulo, así que un segundo montaje espera a la misma carga en lugar de volver a añadir los scripts. - No se usa
next/scriptconbeforeInteractive. La documentación de Next.js dice que esos scripts se descargan pronto, pero que su ejecución “no bloquea la hidratación de la página”. El efecto podría ejecutarse entonces antes de que existawindow.RichTextEditor. Esperar alonloadde los scripts evita esa condición de carrera. - La limpieza destruye el editor. En
next dev, el Strict Mode de React monta el componente, lo desmonta y lo vuelve a montar. La limpieza destruye la primera instancia antes de crear la segunda. Nuestra comprobación en el navegador encontró exactamente un editor en la página tanto connext devcomo connext start. Aquí, los scripts suelen terminar de cargarse después del primer desmontaje, así que a menudo el primer montaje ni siquiera llega a crear un editor. El indicadorunmountedcubre ese caso.
El route handler de la galería
// 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 lee ?folder= con URLSearchParams. Un parámetro folder repetido se rechaza con un 400, así que el listado siempre recibe un único valor. En Next.js 16 los route handlers no se almacenan en caché de forma predeterminada, y next build marca este como dinámico (ƒ). El listado se lee del disco en cada solicitud, así que una carga nueva aparece la próxima vez que se abre el cuadro de diálogo.
El listado en sí es Node.js simple, en 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;
}Solo enumera archivos de imagen y nombres de carpeta válidos, los ordena con una simple comparación en minúsculas y construye las URL bajo /uploads/. Esas URL las sirve app/uploads/[...path]/route.ts, no public/. next start no sirve los archivos añadidos a public/ después de la compilación (obtuvimos un 404), así que las cargas nuevas no aparecerían. Subir imágenes en Next.js explica esa ruta.
Qué muestra el cuadro de diálogo
La imagen del principio es el cuadro de diálogo con este ejemplo: tres carpetas y cuatro imágenes en la raíz. La indicación de la galería publicada incluye JFIF, BMP y AVIF entre los formatos aceptados, pero este servidor solo admite .jpg, .jpeg, .png, .gif y .webp. Un archivo rechazado muestra el mensaje propio del cuadro de diálogo, "Upload failed for <name>.", y no el texto del servidor.
Pruébalo
Descarga el proyecto de ejemplo de esta guía. Copia la carpeta richtexteditor del editor a public/richtexteditor, o define RTE_DIR con su ruta. Después:
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."}Abre http://localhost:5102 y haz clic en Insert Gallery. Abre blog, selecciona la imagen y haz clic en Insert. La imagen está en el texto con src="/uploads/blog/...".
La misma guía para otras plataformas
JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms