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

A caixa de diálogo Insert Gallery mostrando as pastas blog, products e team e quatro imagens da pasta uploads do exemplo em Express, uma delas selecionada

A caixa de diálogo Insert Gallery do RichTextEditor pode mostrar as imagens que já estão no seu servidor, para que as pessoas escolham uma imagem existente em vez de enviá-la de novo. A caixa de diálogo precisa de uma URL que responda com a listagem de uma pasta em JSON. Esta página cria esse endpoint no Express 5 usando apenas node:fs e conecta a caixa de diálogo a ele.

Apontar a caixa de diálogo para o servidor

Uma opção do editor faz a galeria trocar as imagens de exemplo embutidas pelo seu 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>

Com galleryEndpoint definido, a caixa de diálogo envia GET /rte/gallery?folder= ao abrir e, de novo, com ?folder=blog quando alguém abre uma pasta. O botão Upload da caixa de diálogo precisa do rte-files.js do guia de upload. Sem ele, a caixa de diálogo só permite navegar.

O que o endpoint retorna

A listagem informa a pasta atual, a pasta pai, as subpastas e as imagens. Cada imagem tem uma URL que o editor pode inserir, um nome de exibição, sua pasta e seu tamanho em bytes. Esta é uma resposta real do exemplo:

O JSON retornado por GET /rte/gallery?folder=blog no exemplo em Express: currentFolder blog, parentFolder vazio, nenhuma subpasta e entradas de imagem com url, name, folder e size
GET /rte/gallery?folder=blog, respondido pelo exemplo em Express.

parentFolder é null na raiz. É assim que a caixa de diálogo sabe que deve desativar o botão Up. As pastas são caminhos virtuais com separadores /, como blog/2026, nunca caminhos em disco.

A rota e a listagem

A rota do Express tem uma linha. O Express 5 repassa os erros do handler async para o middleware de erro:

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(), em gallery.mjs, lê a pasta com readdir(dir, { withFileTypes: true }) e monta o 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;
}

Algumas escolhas nele são importantes:

  • Somente arquivos com extensão de imagem são listados, e nada que comece com ponto.
  • Somente pastas com nome válido são listadas, então toda pasta que a caixa de diálogo mostra também pode ser aberta.
  • A ordenação é uma comparação simples em minúsculas, e não localeCompare. A ordem é a mesma em qualquer máquina e em todas as linguagens em que os exemplos foram escritos.
  • As URLs são montadas a partir do caminho abaixo de uploads/, com cada parte passada por encodeURIComponent. Uma pasta chamada My Photos gera /uploads/My%20Photos/....

Antes de tudo isso, resolveFolder() transforma ?folder= em um caminho em disco e recusa qualquer coisa que saia de uploads/. Pastas da galeria em Node.js explica essa verificação passo a passo.

Uma query string pode ser um array

O Express converte ?folder=a&folder=b em ["a", "b"]. Isso vale tanto no Express 4 quanto no Express 5. O que mudou no 5 foi a sintaxe aninhada: ?a[b]=1 gerou { a: { b: "1" } } no Express 4.22 e { "a[b]": "1" } no Express 5.3 no nosso teste, porque o Express 5 usa o parser de query simples por padrão. De qualquer forma, req.query.folder nem sempre é uma string. resolveFolder() recusa com um 400 tudo o que não for string, então um array nunca chega a path.resolve.

O que a caixa de diálogo mostra, e o que não mostra

A imagem no topo é a caixa de diálogo neste servidor: três pastas e quatro imagens na raiz, uma delas selecionada. Vale a pena conhecer duas limitações:

  • A dica da galeria na versão lançada lista "JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF" como formatos aceitos. Este servidor aceita apenas .jpg, .jpeg, .png, .gif e .webp. Um arquivo .bmp escolhido na caixa de diálogo é enviado, recusado com um 400, e a caixa de diálogo informa "Upload failed for <name>."
  • As miniaturas são as imagens completas, carregadas de /uploads/. Uma pasta com muitas fotos grandes carrega todas elas. O exemplo não gera cópias menores.

Teste

Inicie o projeto de exemplo deste guia com npm install e npm start, envie uma imagem para uma pasta e depois peça a listagem:

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

A última resposta é um 400. Depois, abra http://localhost:5101, clique em Insert Gallery na barra de ferramentas e abra a pasta blog. A imagem que você enviou está na lista.

O mesmo guia para outras plataformas

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

Leia a seguir