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

El cuadro de diálogo Insert Gallery con las carpetas blog, products y team y cuatro imágenes de la carpeta uploads del ejemplo de Express, una de ellas seleccionada

El cuadro de diálogo Insert Gallery de RichTextEditor puede mostrar las imágenes que ya están en tu servidor, para que los usuarios elijan una imagen existente en lugar de volver a subirla. El cuadro de diálogo necesita una URL que responda con el listado de una carpeta en JSON. Esta página crea ese endpoint en Express 5 solo con node:fs y conecta el cuadro de diálogo con él.

Apunta el cuadro de diálogo al servidor

Una sola opción del editor hace que la galería pase de sus imágenes de ejemplo integradas a tu servidor:

HTML · public/index.html
<div id="editor"><p>Hello from Express.</p></div>
<script>
  var editor = new RichTextEditor("#editor", {
    toolbar: "full",
    galleryEndpoint: "/rte/gallery",
    maxUploadFileSize: 4000000
  });
</script>

Con galleryEndpoint definido, el cuadro de diálogo envía GET /rte/gallery?folder= al abrirse, y de nuevo con ?folder=blog cuando alguien abre una carpeta. El botón Upload del cuadro de diálogo necesita rte-files.js, de la guía de carga. Sin él, el cuadro de diálogo solo puede explorar.

Qué devuelve el endpoint

El listado indica la carpeta actual, su carpeta superior, las subcarpetas y las imágenes. Cada imagen tiene una URL que el editor puede insertar, un nombre visible, su carpeta y su tamaño en bytes. Esta es una respuesta real del ejemplo:

El JSON que devuelve GET /rte/gallery?folder=blog en el ejemplo de Express: currentFolder blog, parentFolder vacío, sin subcarpetas y entradas de imagen con url, name, folder y size
GET /rte/gallery?folder=blog, respondido por el ejemplo de Express.

parentFolder es null en la raíz. Así sabe el cuadro de diálogo que debe desactivar su botón Up. Las carpetas son rutas virtuales con separadores /, como blog/2026, nunca rutas de disco.

La ruta y el listado

La ruta de Express ocupa una línea. Express 5 pasa los errores del handler async al middleware de errores:

JavaScript · server.mjs
// Every /rte request gets its storage root first, before multer reads the body.
app.use("/rte", (req, res, next) => {
  req.storageRoot = rootFor(req);
  next();
});

// GET /rte/gallery?folder=products  -> listing
app.get("/rte/gallery", async (req, res) => {
  res.json(await listFolder(req.storageRoot, req.query.folder ?? ""));
});

// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
app.post("/rte/gallery", parseForm.none(), async (req, res) => {
  const { action, folder, name } = req.body ?? {};
  if (action !== "create-folder") throw new GalleryError(400, "Unknown action.");
  res.json(await createFolder(req.storageRoot, folder ?? "", name));
});

// POST /rte/upload  file, folder  -> { url }
app.post("/rte/upload", parseForm.single("file"), async (req, res) => {
  if (!req.file) throw new GalleryError(400, "No file was uploaded.");
  const url = await saveImage(req.storageRoot, req.body.folder ?? "", req.file.originalname, req.file.buffer);
  res.json({ url });
});

listFolder(), en gallery.mjs, lee la carpeta con readdir(dir, { withFileTypes: true }) y construye el JSON:

JavaScript · gallery.mjs
// The JSON the gallery dialog reads (see CONTRACT.md).
export async function listFolder(root, 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) => (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, b) {
  const x = a.name.toLowerCase();
  const y = b.name.toLowerCase();
  return x < y ? -1 : x > y ? 1 : 0;
}

Algunas decisiones de este código son importantes:

  • Solo se enumeran los archivos con extensión de imagen, y nada que empiece por punto.
  • Solo se enumeran las carpetas con un nombre válido, así que cualquier carpeta que muestre el cuadro de diálogo también se puede abrir.
  • El orden se obtiene con una simple comparación en minúsculas, no con localeCompare. El orden es el mismo en todas las máquinas y en todos los lenguajes en que están escritos los ejemplos.
  • Las URL se construyen a partir de la ruta bajo uploads/, pasando cada parte por encodeURIComponent. Una carpeta llamada My Photos da /uploads/My%20Photos/....

Antes de todo esto, resolveFolder() convierte ?folder= en una ruta de disco y rechaza cualquier cosa que saldría de uploads/. Carpetas de la galería en Node.js explica esa comprobación paso a paso.

Una cadena de consulta puede ser un array

Express convierte ?folder=a&folder=b en ["a", "b"]. Eso pasa tanto en Express 4 como en Express 5. Lo que cambió en la versión 5 es la sintaxis anidada: ?a[b]=1 dio { a: { b: "1" } } en Express 4.22 y { "a[b]": "1" } en Express 5.3 en nuestra prueba, porque Express 5 usa de forma predeterminada el analizador de consultas simple. En cualquier caso, req.query.folder no siempre es una cadena. resolveFolder() rechaza con un 400 todo lo que no sea una cadena, así que un array nunca llega a path.resolve.

Qué muestra el cuadro de diálogo y qué no

La imagen del principio es el cuadro de diálogo con este servidor: tres carpetas y cuatro imágenes en la raíz, una de ellas seleccionada. Conviene conocer dos limitaciones:

  • La indicación de la galería publicada enumera "JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF" como formatos aceptados. Este servidor solo acepta .jpg, .jpeg, .png, .gif y .webp. Un archivo .bmp elegido en el cuadro de diálogo se envía, se rechaza con un 400 y el cuadro de diálogo informa "Upload failed for <name>."
  • Las miniaturas son las imágenes completas, cargadas desde /uploads/. Una carpeta con muchas fotos grandes las carga todas. El ejemplo no crea copias más pequeñas.

Pruébalo

Inicia el proyecto de ejemplo de esta guía con npm install y npm start, sube una imagen a una carpeta y luego pide el listado:

Shell
curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5101/rte/upload
{"url":"/uploads/blog/photo-ce99c014.jpg"}

curl "http://localhost:5101/rte/gallery?folder=blog"
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[],"images":[...]}

curl "http://localhost:5101/rte/gallery?folder=../"
{"error":"Invalid folder."}

La última respuesta es un 400. Después abre http://localhost:5101, haz clic en Insert Gallery en la barra de herramientas y abre la carpeta blog. La imagen que subiste está en la lista.

La misma guía para otras plataformas

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

Para seguir leyendo