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

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 PHP, una de ellas seleccionada

El cuadro de diálogo Insert Gallery del editor puede mostrar las imágenes que ya están en tu servidor, para que los usuarios elijan una imagen en lugar de volver a subir la misma. Solo necesita una URL que responda con JSON. Esta página te da ese endpoint en PHP 8 simple: un script que lee una carpeta dentro de uploads/ con scandir() y enumera sus subcarpetas e imágenes.

Activa la galería

Define galleryEndpoint al crear el editor. A partir de ahí, el cuadro de diálogo pide un listado a esa URL cada vez que se abre o cambia de carpeta.

HTML · index.php
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>
<script>
    new RichTextEditor("#editor", {
        toolbar: "full",
        galleryEndpoint: "/rte/gallery",
        maxUploadFileSize: 4000000     // same limit as rte/upload.php, checked before sending
    });
</script>

rte-files.js es el pequeño archivo para el navegador que envía las cargas del cuadro de diálogo a /rte/upload. La guía de carga de imágenes muestra la parte del servidor.

La ruta

GET /rte/gallery?folder=blog devuelve el listado de una carpeta. El script es corto, porque el trabajo lo hacen dos funciones auxiliares:

PHP · rte/gallery.php
if ($method === 'GET') {
    return rte_listing(rte_resolve_folder($_GET['folder'] ?? ''));
}

rte_resolve_folder() convierte la carpeta virtual de la cadena de consulta en un directorio real dentro de la raíz de almacenamiento, o responde 400 o 404. La guía de carpetas lo explica. rte_listing() construye el JSON.

Qué recibe la galería

El cuadro de diálogo necesita la carpeta actual, su carpeta superior (null en la raíz), las subcarpetas y las imágenes con sus URL y tamaños:

La respuesta JSON de GET /rte/gallery?folder=blog del ejemplo de PHP: currentFolder blog, parentFolder vacío, sin subcarpetas e imágenes con url, name, folder y size
La respuesta real del ejemplo, recortada a dos imágenes.

Este es el código que la genera:

PHP · rte/files-lib.php
// The listing for one folder, in the shape the gallery dialog expects.
function rte_listing(array $folder): array
{
    $folders = [];
    $images = [];
    $names = scandir($folder['dir']);
    if ($names === false) {
        throw new RteHttpError(500, 'The folder cannot be read.');
    }
    foreach ($names as $name) {
        if ($name === '' || $name[0] === '.') {
            continue; // ".", "..", and hidden files such as .htaccess
        }
        $path = $folder['dir'] . DIRECTORY_SEPARATOR . $name;
        // Skip symlinks and Windows junctions. is_link() alone is not enough: PHP 8.2 on
        // Windows returns false for a junction, so also require realpath() to be unchanged.
        $real = realpath($path);
        $same = DIRECTORY_SEPARATOR === '\\' ? strcasecmp($real ?: '', $path) === 0 : $real === $path;
        if (is_link($path) || !$same) {
            continue;
        }
        $segments = [...$folder['segments'], $name];
        if (is_dir($path)) {
            if (rte_is_valid_name($name) && !rte_is_reserved($segments)) { // only folders the routes accept back
                $folders[] = ['folder' => implode('/', $segments), 'name' => $name];
            }
        } elseif (is_file($path) && in_array(rte_extension($name), RTE_IMAGE_EXTENSIONS, true)) {
            $images[] = [
                'url' => rte_url($folder['root'], $segments),
                'name' => $name,
                'folder' => $folder['virtual'],
                'size' => (int)filesize($path),
            ];
        }
    }
    $byName = fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']) ?: strcmp($a['name'], $b['name']);
    usort($folders, $byName);
    usort($images, $byName);

    $virtual = $folder['virtual'];
    $parent = null;
    if ($virtual !== '') {
        $cut = strrpos($virtual, '/');
        $parent = $cut === false ? '' : substr($virtual, 0, $cut);
    }
    return [
        'currentFolder' => $virtual,
        'currentFolderDisplay' => '/' . $virtual,
        'parentFolder' => $parent,
        'folders' => $folders,
        'images' => $images,
    ];
}

Conviene conocer algunas decisiones de este código:

  • Se omiten los nombres que empiezan por punto, así que uploads/.htaccess nunca aparece.
  • Solo se enumeran archivos .jpg .jpeg .png .gif .webp. Cualquier otra cosa de la carpeta queda invisible.
  • Se omiten los enlaces simbólicos y las junctions de Windows. En Windows, is_link() de PHP 8.2 devolvió false para una junction cuando lo probamos, así que el código también comprueba que realpath() deja la ruta sin cambios.
  • Cada parte de la URL de una imagen pasa por rawurlencode(), así que my photo.jpg aparece como /uploads/products/my%20photo.jpg.
  • Las carpetas y las imágenes se ordenan por nombre sin distinguir mayúsculas y minúsculas, con strcasecmp().

Formatos que ofrece el cuadro de diálogo y que acepta el servidor

El cuadro de diálogo de la galería publicado indica "JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF" como formatos aceptados, y su selector de archivos ofrece esos. Este servidor acepta cinco de ellos: .jpg .jpeg .png .gif .webp. Si alguien elige un archivo .bmp, el cuadro de diálogo lo envía, el servidor responde 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."} y el cuadro de diálogo muestra "Upload failed for photo.bmp.". El motivo del servidor está en el cuerpo de la respuesta, que puedes ver con las herramientas de desarrollo del navegador.

Con el tamaño es distinto: con maxUploadFileSize: 4000000, el cuadro de diálogo rechaza un archivo grande antes de enviarlo. Un archivo de 4.5 MB dio "big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB." (el cuadro de diálogo cuenta en megabytes binarios).

Servir las imágenes

El listado apunta a /uploads/..., así que esas URL deben devolver los archivos. En el proyecto de ejemplo, router.php los sirve para el servidor integrado de PHP. Solo envía extensiones de imagen, con un Content-Type fijo y X-Content-Type-Options: nosniff, y nunca ejecuta nada de esa carpeta. En Apache o nginx, el servidor web los sirve directamente. Mantén su regla que impide la ejecución, como se muestra en la guía de carga segura.

Pruébalo

Inicia el proyecto de ejemplo de esta guía con php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.php y pide un listado:

Shell
curl "http://localhost:5201/rte/gallery?folder=team"
{"currentFolder":"team","currentFolderDisplay":"/team","parentFolder":"","folders":[],"images":[]}

curl "http://localhost:5201/rte/gallery?folder=blog/2027"
{"error":"Folder not found."}

La segunda solicitud recibe HTTP 404. Abre http://localhost:5201/ y haz clic en Insert Gallery: el cuadro de diálogo muestra las carpetas y las imágenes de la raíz, y al hacer clic en una carpeta se abre.

La misma guía para otras plataformas

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

Para seguir leyendo