Ajouter une galerie d’images à un éditeur de texte enrichi avec Next.js

RichTextEditor est un script navigateur, pas un composant React : dans un projet Next.js avec l’App Router, il demande donc un peu de soin. Il doit se charger uniquement dans le navigateur, dans le bon ordre, et être détruit quand React le démonte, ce qui, en développement, se produit juste après le premier montage. Cette page présente un composant client qui fait tout cela, et le gestionnaire de route qui répond à la boîte de dialogue Insert Gallery.
Les options côté serveur, l’éditeur dans le navigateur
La page est un composant serveur. Elle contient la liste des scripts et les options de l’éditeur, et les transmet en props à un composant client :
// 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,
};Avec galleryEndpoint défini, la boîte de dialogue Insert Gallery demande GET /rte/gallery?folder= à son ouverture. /rte-files.js fournit la fonction d’envoi qu’appelle le bouton Upload de la boîte de dialogue. Les fichiers de l’éditeur eux-mêmes sont de simples fichiers dans public/richtexteditor/. Les commandes npm run dev et npm run build de l’exemple les y copient d’abord.
Le composant client
// 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} />;
}Trois choix y figurent :
- Les scripts se chargent l’un après l’autre, une fois par page.
rte.jsdoit s’exécuter avantall_plugins.js, et les deux avantrte-files.js. La promesse est conservée dans une variable de module : un second montage attend donc le même chargement au lieu d’ajouter de nouveau les scripts. - Pas de
next/scriptavecbeforeInteractive. La documentation de Next.js indique que ces scripts sont récupérés tôt, mais que leur exécution « ne bloque pas l’hydratation de la page ». L’effet pourrait alors s’exécuter avant quewindow.RichTextEditorn’existe. Attendre l'onloaddes scripts évite cette situation de concurrence. - Le nettoyage détruit l’éditeur. Avec
next dev, le Strict Mode de React monte le composant, le démonte, puis le monte de nouveau. Le nettoyage détruit la première instance avant la création de la seconde. Notre vérification dans le navigateur a trouvé exactement un éditeur sur la page, aussi bien avecnext devqu’avecnext start. Ici, les scripts ne finissent généralement de se charger qu’après le premier démontage : le premier montage ne crée donc souvent aucun éditeur. L’indicateurunmountedcouvre ce cas.
Le gestionnaire de route de la galerie
// 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 lit ?folder= avec URLSearchParams. Un paramètre folder répété est refusé avec un 400 : la liste ne reçoit donc jamais qu’une seule valeur. Dans Next.js 16, les gestionnaires de route ne sont pas mis en cache par défaut, et next build indique celui-ci comme dynamique (ƒ). La liste est lue sur le disque à chaque requête : un nouvel envoi apparaît donc à la prochaine ouverture de la boîte de dialogue.
La liste elle-même est du Node.js simple, dans 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;
}Elle ne liste que les fichiers d’images et les noms de dossiers valides, les trie avec une simple comparaison en minuscules, et construit des URL sous /uploads/. Ces URL sont servies par app/uploads/[...path]/route.ts, et non par public/. next start ne sert pas les fichiers ajoutés à public/ après le build (nous avons obtenu un 404) : les nouveaux envois n’apparaîtraient donc pas. L’envoi d’images avec Next.js traite de cette route.
Ce qu’affiche la boîte de dialogue
L’image en haut de la page montre la boîte de dialogue sur cet exemple : trois dossiers et quatre images à la racine. L’indication de la galerie dans la version publiée mentionne JFIF, BMP et AVIF parmi les formats acceptés, mais ce serveur n’accepte que .jpg, .jpeg, .png, .gif et .webp. Un fichier refusé affiche le message propre à la boîte de dialogue, « Upload failed for <name>. », et non le texte du serveur.
Tester
Téléchargez le projet d’exemple de ce guide. Copiez le dossier richtexteditor de l’éditeur dans public/richtexteditor, ou faites pointer RTE_DIR vers lui. Ensuite :
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."}Ouvrez http://localhost:5102 et cliquez sur Insert Gallery. Ouvrez blog, sélectionnez l’image et cliquez sur Insert. L’image figure dans le texte avec src="/uploads/blog/...".
Le même guide pour d’autres plateformes
JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms