JavaScript input file multiple sin repeticiones con previa

9 minutos

Multiple files

Las limitaciones de un input de tipo file son múltiples: no es acumulativo, no evita repeticiones, no deja intercambiar el orden, no permite eliminar archivos adjuntados de forma individual y muchos otros defectos adquiridos por las exigencias actuales. Por ello mismo os dejo un ejemplo para disponer de un selector múltiple vitaminado con varios superpoderes.

Sus características son:

  • Muestra una previa de cada archivo.
  • Al añadir nuevos archivos no se eliminan los anteriores, guardando un histórico.
  • Permite selección múltiple de archivos de golpe.
  • Evita las repeticiones comprobando que cada archivo sea único.
  • Posibilidad de eliminar de forma individual.
  • Capacidad para cambiar el orden con drag and drop.
  • Todo los cambios modifican en caliente la selección nativa del input.
  • Compatible con cualquier framework JavaScript.
  • Código sólido al usar un paradigma funcional.

DEMO

¿Quieres probarlo por ti mismo?

    HTML

    Necesitarás un input de tipo file con el atributo multiple y un elemento, como una lista desordenada, para ir listando los archivos que se adjunten.

    <section id="multi-selector-uniq">
        <input id="files" type="file" multiple>
        <ul id="preview"></ul>
    </section>
    

    En este ejemplo, dentro del <ul>, crearemos por medio de JavaScript la siguiente estructura por cada archivo.

    <li data-key="key" draggable="true">Nombre del archivo <button>Quitar</button></li>
    

    Es importante el atributo draggable, nos dará acceso a poder arrastrar y soltar cada <li>. Además data-key debe estar presente con un valor único ya que lo usaremos para localizar ele elemento que estamos arrastrando en cada momento. Puedes usar el propio nombre del archivo si lo deseas.

    CSS

    Intencionadamente no tiene ni una sola línea de CSS. Integra o añade todos los estilos que creas necesarios, el objetivo de este artículo no es estético.

    JavaScript

    En el ejemplo he usando el siguiente código JavaScript Vainilla. Si quieres integrarlo con tu framework solo debes buscar en los comentarios la palabra Demo y sustituirlo con la herramienta propia del renderizado. Cada framework es un mundo.

    /*
     * Variables
     */
    
    let filesList = [];
    const classDragOver = "drag-over";
    const fileInputMulti = document.querySelector("#multi-selector-uniq #files");
    // DEMO Preview
    const multiSelectorUniqPreview = document.querySelector("#multi-selector-uniq #preview");
    
    /*
     * Functions
     */
    
    /**
     * Returns the index of an Array of Files from its name. If there are multiple files with the same name, the last one will be returned.
     * @param {string} name - Name file.
     * @param {Array<File>} list - List of files.
     * @return number
     */
    function getIndexOfFileList(name, list) {
        return list.reduce(
            (position, file, index) => (file.name === name ? index : position),
            -1
        );
    }
    
    /**
     * Returns a File in text.
     * @param {File} file
     * @return {Promise<string>}
     */
    async function encodeFileToText(file) {
        return file.text().then((text) => {
            return text;
        });
    }
    
    /**
     * Returns an Array from the union of 2 Arrays of Files avoiding repetitions.
     * @param {Array<File>} newFiles
     * @param {Array<File>} currentListFiles
     * @return Promise<File[]>
     */
    async function getUniqFiles(newFiles, currentListFiles) {
        return new Promise((resolve) => {
            Promise.all(newFiles.map((inputFile) => encodeFileToText(inputFile))).then(
                (inputFilesText) => {
                    // Check all the files to save
                    Promise.all(
                        currentListFiles.map((savedFile) => encodeFileToText(savedFile))
                    ).then((savedFilesText) => {
                        let newFileList = currentListFiles;
                        inputFilesText.forEach((inputFileText, index) => {
                            if (!savedFilesText.includes(inputFileText)) {
                                newFileList = newFileList.concat(newFiles[index]);
                            }
                        });
                        resolve(newFileList);
                    });
                }
            );
        });
    }
    
    /**
     * Only DEMO. Render preview.
     * @param currentFileList
     * @Only .EMO> param target.
     * @
     */
    function renderPreviews(currentFileList, target, inputFile) {
        //
        target.textContent = "";
        currentFileList.forEach((file, index) => {
            const myLi = document.createElement("li");
            myLi.textContent = file.name;
            myLi.setAttribute("draggable", 'true');
            myLi.dataset.key = file.name;
            myLi.addEventListener("drop", eventDrop);
            myLi.addEventListener("dragover", eventDragOver);
            const myButtonRemove = document.createElement("button");
            myButtonRemove.textContent = "Quitar";
            myButtonRemove.addEventListener("click", () => {
                filesList = deleteArrayElementByIndex(currentFileList, index);
                inputFile.files = arrayFilesToFileList(filesList);
                return renderPreviews(filesList, multiSelectorUniqPreview, inputFile);
            });
            myLi.appendChild(myButtonRemove);
            target.appendChild(myLi);
        });
    }
    
    /**
     * Returns a copy of the array by removing one position by index.
     * @param {Array<any>} list
     * @param {number} index
     * @return {Array<any>} list
     */
    function deleteArrayElementByIndex(list, index) {
        return list.filter((item, itemIndex) => itemIndex !== index);
    }
    
    /**
     * Returns a FileLists from an array containing Files.
     * @param {Array<File>} filesList
     * @return {FileList}
     */
    function arrayFilesToFileList(filesList) {
        return filesList.reduce(function (dataTransfer, file) {
            dataTransfer.items.add(file);
            return dataTransfer;
        }, new DataTransfer()).files;
    }
    
    
    /**
     * Returns a copy of the Array by swapping 2 indices.
     * @param {number} firstIndex
     * @param {number} secondIndex
     * @param {Array<any>} list
     */
    function arraySwapIndex(firstIndex, secondIndex, list) {
        const tempList = list.slice();
        const tmpFirstPos = tempList[firstIndex];
        tempList[firstIndex] = tempList[secondIndex];
        tempList[secondIndex] = tmpFirstPos;
        return tempList;
    }
    
    /*
     * Events
     */
    
    // Input file
    fileInputMulti.addEventListener("input", async () => {
        // Get files list from <input>
        const newFilesList = Array.from(fileInputMulti.files);
        // Update list files
        filesList = await getUniqFiles(newFilesList, filesList);
        // Only DEMO. Redraw
        renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
        // Set data to input
        fileInputMulti.files = arrayFilesToFileList(filesList);
    });
    
    // Drag and drop
    
    // Drag Start - Moving element.
    let myDragElement = undefined;
    document.addEventListener("dragstart", (event) => {
        // Saves which element is moving.
        myDragElement = event.target;
    });
    
    // Drag over - Element that is below the element that is moving.
    function eventDragOver(event) {
        // Remove from all elements the class that will show that it is a drop zone.
        event.preventDefault();
        multiSelectorUniqPreview
            .querySelectorAll("li")
            .forEach((item) => item.classList.remove(classDragOver));
    
        // On the element above it, the class is added to show that it is a drop zone.
        event.target.classList.add(classDragOver);
    }
    
    // Drop - Element on which it is dropped.
    function eventDrop(event) {
        // The element that is underneath the element that is moving when it is released is captured.
        const myDropElement = event.target;
        // The positions of the elements in the array are swapped. The dataset key is used as an index.
        filesList = arraySwapIndex(
            getIndexOfFileList(myDragElement.dataset.key, filesList),
            getIndexOfFileList(myDropElement.dataset.key, filesList),
            filesList
        );
        // The content of the input file is updated.
        fileInputMulti.files = arrayFilesToFileList(filesList);
        // Only DEMO. Changes are redrawn.
        renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
    }
    

    Extra: Versión Typescript

    Si quieres trabajar con Typescript, a continuación dejo una versión para importar en el proyecto que estés desarrollando.

    /*
     * Variables
     */
    
    let filesList: Array<File> = [];
    const classDragOver: string = "drag-over";
    const fileInputMulti: HTMLInputElement = document.querySelector("#multi-selector-uniq #files");
    // DEMO Preview
    const multiSelectorUniqPreview: HTMLElement = document.querySelector("#multi-selector-uniq #preview");
    
    /*
     * Functions
     */
    
    /**
     * Returns the index of an Array of Files from its name. If there are multiple files with the same name, the last one will be returned.
     * @param {string} name - Name file.
     * @param {Array<File>} list - List of files.
     * @return number
     */
    function getIndexOfFileList(name: string, list: Array<File>): number {
        return list.reduce(
            (position, file, index) => (file.name === name ? index : position),
            -1
        );
    }
    
    /**
     * Returns a File in text.
     * @param {File} file
     * @return {Promise<string>}
     */
    async function encodeFileToText(file: File): Promise<string> {
        return file.text().then((text) => {
            return text;
        });
    }
    
    /**
     * Returns an Array from the union of 2 Arrays of Files avoiding repetitions.
     * @param {Array<File>} newFiles
     * @param {Array<File>} currentListFiles
     * @return Promise<File[]>
     */
    async function getUniqFiles(newFiles: Array<File>, currentListFiles: Array<File>): Promise<File[]> {
        return new Promise((resolve) => {
            Promise.all(newFiles.map((inputFile) => encodeFileToText(inputFile))).then(
                (inputFilesText) => {
                    // Check all the files to save
                    Promise.all(
                        currentListFiles.map((savedFile) => encodeFileToText(savedFile))
                    ).then((savedFilesText) => {
                        let newFileList = currentListFiles;
                        inputFilesText.forEach((inputFileText, index) => {
                            if (!savedFilesText.includes(inputFileText)) {
                                newFileList = newFileList.concat(newFiles[index]);
                            }
                        });
                        resolve(newFileList);
                    });
                }
            );
        });
    }
    
    /**
     * Only DEMO. Render preview.
     * @param currentFileList
     * @Only .EMO> param target.
     * @
     */
    function renderPreviews(currentFileList, target, inputFile) {
        //
        target.textContent = "";
        currentFileList.forEach((file, index) => {
            const myLi = document.createElement("li");
            myLi.textContent = file.name;
            myLi.setAttribute("draggable", 'true');
            myLi.dataset.key = file.name;
            myLi.addEventListener("drop", eventDrop);
            myLi.addEventListener("dragover", eventDragOver);
            const myButtonRemove = document.createElement("button");
            myButtonRemove.textContent = "X";
            myButtonRemove.addEventListener("click", () => {
                filesList = deleteArrayElementByIndex(currentFileList, index);
                inputFile.files = arrayFilesToFileList(filesList);
                return renderPreviews(filesList, multiSelectorUniqPreview, inputFile);
            });
            myLi.appendChild(myButtonRemove);
            target.appendChild(myLi);
        });
    }
    
    /**
     * Returns a copy of the array by removing one position by index.
     * @param {Array<any>} list
     * @param {number} index
     * @return {Array} list
     */
    function deleteArrayElementByIndex(list: Array<any>, index: number) {
        return list.filter((item, itemIndex) => itemIndex !== index);
    }
    
    /**
     * Returns a FileLists from an array containing Files.
     * @param {Array<File>} filesList
     * @return {FileList}
     */
    function arrayFilesToFileList(filesList): FileList {
        return filesList.reduce(function (dataTransfer, file) {
            dataTransfer.items.add(file);
            return dataTransfer;
        }, new DataTransfer()).files;
    }
    
    
    /**
     * Returns a copy of the Array by swapping 2 indices.
     * @param {number} firstIndex
     * @param {number} secondIndex
     * @param {Array<any>} list
     */
    function arraySwapIndex(firstIndex: number, secondIndex: number, list: Array<any>): Array<any> {
        const tempList = list.slice();
        const tmpFirstPos = tempList[firstIndex];
        tempList[firstIndex] = tempList[secondIndex];
        tempList[secondIndex] = tmpFirstPos;
        return tempList;
    }
    
    /*
     * Events
     */
    
    // Input file
    fileInputMulti.addEventListener("input", async () => {
        // Get files list from <input>
        const newFilesList = Array.from(fileInputMulti.files);
        // Update list files
        filesList = await getUniqFiles(newFilesList, filesList);
        // Only DEMO. Redraw
        renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
        // Set data to input
        fileInputMulti.files = arrayFilesToFileList(filesList);
    });
    
    // Drag and drop
    
    // Drag Start - Moving element.
    let myDragElement = undefined;
    document.addEventListener("dragstart", (event) => {
        // Saves which element is moving.
        myDragElement = event.target;
    });
    
    // Drag over - Element that is below the element that is moving.
    function eventDragOver(event) {
        // Remove from all elements the class that will show that it is a drop zone.
        event.preventDefault();
        multiSelectorUniqPreview
            .querySelectorAll("li")
            .forEach((item) => item.classList.remove(classDragOver));
    
        // On the element above it, the class is added to show that it is a drop zone.
        event.target.classList.add(classDragOver);
    }
    
    // Drop - Element on which it is dropped.
    function eventDrop(event) {
        // The element that is underneath the element that is moving when it is released is captured.
        const myDropElement = event.target;
        // The positions of the elements in the array are swapped. The dataset key is used as an index.
        filesList = arraySwapIndex(
            getIndexOfFileList(myDragElement.dataset.key, filesList),
            getIndexOfFileList(myDropElement.dataset.key, filesList),
            filesList
        );
        // The content of the input file is updated.
        fileInputMulti.files = arrayFilesToFileList(filesList);
        // Only DEMO. Changes are redrawn.
        renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
    }
    

    Completo

    Todo el código unificado quedaría de la siguiente forma.

    <html>
            <body>
                    <section id="multi-selector-uniq">
                        <input id="files" type="file" multiple>
                        <ul id="preview"></ul>
                    </section>
                    <script>
    /*
     * Variables
     */
    
    let filesList = [];
    const classDragOver = "drag-over";
    const fileInputMulti = document.querySelector("#multi-selector-uniq #files");
    // DEMO Preview
    const multiSelectorUniqPreview = document.querySelector("#multi-selector-uniq #preview");
    
    /*
     * Functions
     */
    
    /**
     * Returns the index of an Array of Files from its name. If there are multiple files with the same name, the last one will be returned.
     * @param {string} name - Name file.
     * @param {Array<File>} list - List of files.
     * @return number
     */
    function getIndexOfFileList(name, list) {
        return list.reduce(
            (position, file, index) => (file.name === name ? index : position),
            -1
        );
    }
    
    /**
     * Returns a File in text.
     * @param {File} file
     * @return {Promise<string>}
     */
    async function encodeFileToText(file) {
        return file.text().then((text) => {
            return text;
        });
    }
    
    /**
     * Returns an Array from the union of 2 Arrays of Files avoiding repetitions.
     * @param {Array<File>} newFiles
     * @param {Array<File>} currentListFiles
     * @return Promise<File[]>
     */
    async function getUniqFiles(newFiles, currentListFiles) {
        return new Promise((resolve) => {
            Promise.all(newFiles.map((inputFile) => encodeFileToText(inputFile))).then(
                (inputFilesText) => {
                    // Check all the files to save
                    Promise.all(
                        currentListFiles.map((savedFile) => encodeFileToText(savedFile))
                    ).then((savedFilesText) => {
                        let newFileList = currentListFiles;
                        inputFilesText.forEach((inputFileText, index) => {
                            if (!savedFilesText.includes(inputFileText)) {
                                newFileList = newFileList.concat(newFiles[index]);
                            }
                        });
                        resolve(newFileList);
                    });
                }
            );
        });
    }
    
    /**
     * Only DEMO. Render preview.
     * @param currentFileList
     * @Only .EMO> param target.
     * @
     */
    function renderPreviews(currentFileList, target, inputFile) {
        //
        target.textContent = "";
        currentFileList.forEach((file, index) => {
            const myLi = document.createElement("li");
            myLi.textContent = file.name;
            myLi.setAttribute("draggable", 'true');
            myLi.dataset.key = file.name;
            myLi.addEventListener("drop", eventDrop);
            myLi.addEventListener("dragover", eventDragOver);
            const myButtonRemove = document.createElement("button");
            myButtonRemove.textContent = "X";
            myButtonRemove.addEventListener("click", () => {
                filesList = deleteArrayElementByIndex(currentFileList, index);
                inputFile.files = arrayFilesToFileList(filesList);
                return renderPreviews(filesList, multiSelectorUniqPreview, inputFile);
            });
            myLi.appendChild(myButtonRemove);
            target.appendChild(myLi);
        });
    }
    
    /**
     * Returns a copy of the array by removing one position by index.
     * @param {Array<any>} list
     * @param {number} index
     * @return {Array<any>} list
     */
    function deleteArrayElementByIndex(list, index) {
        return list.filter((item, itemIndex) => itemIndex !== index);
    }
    
    /**
     * Returns a FileLists from an array containing Files.
     * @param {Array<File>} filesList
     * @return {FileList}
     */
    function arrayFilesToFileList(filesList) {
        return filesList.reduce(function (dataTransfer, file) {
            dataTransfer.items.add(file);
            return dataTransfer;
        }, new DataTransfer()).files;
    }
    
    
    /**
     * Returns a copy of the Array by swapping 2 indices.
     * @param {number} firstIndex
     * @param {number} secondIndex
     * @param {Array<any>} list
     */
    function arraySwapIndex(firstIndex, secondIndex, list) {
        const tempList = list.slice();
        const tmpFirstPos = tempList[firstIndex];
        tempList[firstIndex] = tempList[secondIndex];
        tempList[secondIndex] = tmpFirstPos;
        return tempList;
    }
    
    /*
     * Events
     */
    
    // Input file
    fileInputMulti.addEventListener("input", async () => {
        // Get files list from <input>
        const newFilesList = Array.from(fileInputMulti.files);
        // Update list files
        filesList = await getUniqFiles(newFilesList, filesList);
        // Only DEMO. Redraw
        renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
        // Set data to input
        fileInputMulti.files = arrayFilesToFileList(filesList);
    });
    
    // Drag and drop
    
    // Drag Start - Moving element.
    let myDragElement = undefined;
    document.addEventListener("dragstart", (event) => {
        // Saves which element is moving.
        myDragElement = event.target;
    });
    
    // Drag over - Element that is below the element that is moving.
    function eventDragOver(event) {
        // Remove from all elements the class that will show that it is a drop zone.
        event.preventDefault();
        multiSelectorUniqPreview
            .querySelectorAll("li")
            .forEach((item) => item.classList.remove(classDragOver));
    
        // On the element above it, the class is added to show that it is a drop zone.
        event.target.classList.add(classDragOver);
    }
    
    // Drop - Element on which it is dropped.
    function eventDrop(event) {
        // The element that is underneath the element that is moving when it is released is captured.
        const myDropElement = event.target;
        // The positions of the elements in the array are swapped. The dataset key is used as an index.
        filesList = arraySwapIndex(
            getIndexOfFileList(myDragElement.dataset.key, filesList),
            getIndexOfFileList(myDropElement.dataset.key, filesList),
            filesList
        );
        // The content of the input file is updated.
        fileInputMulti.files = arrayFilesToFileList(filesList);
        // Only DEMO. Changes are redrawn.
        renderPreviews(filesList, multiSelectorUniqPreview, fileInputMulti);
    }
                    </script>
            </body>
    </html>
    

    Agradecimientos

    Quiero destacar que sin la ayuda de Valentina Rubane, @varu_nyan en Twitter, no hubiera sido posible. ¡Gracias!

    Esta obra está bajo una Licencia Creative Commons Atribución-NoComercial-SinDerivadas 4.0 Internacional.

    Atribución/Reconocimiento-NoComercial-SinDerivados 4.0 Internacional

    Donación con recompensa

    • 1 café: Respondo a tu duda en los comentarios.
    • 2 cafés: Respondo en menos de 24h a tu comentario.
    • 3 cafés: Todo lo anterior y además te doy las gracias en mis redes.
    Comprame un café

    Comentarios

    {{ comments.length }} comentarios

    Nuevo comentario

    Nueva replica  {{ formatEllipsisAuthor(replyComment.author) }}

    Acepto la política de Protección de Datos.

    Escribe el primer comentario

    Tal vez también te interese...