Cómo hacer un cargador de archivos de arrastrar y soltar con Vanilla JavaScript
Publicado: 2022-03-10Es un hecho conocido que las entradas de selección de archivos son difíciles de diseñar de la manera que los desarrolladores quieren, por lo que muchos simplemente las ocultan y crean un botón que abre el cuadro de diálogo de selección de archivos. Hoy en día, sin embargo, tenemos una forma aún más elegante de manejar la selección de archivos: arrastrar y soltar.
Técnicamente, esto ya era posible porque la mayoría (si no todas ) las implementaciones de la entrada de selección de archivos le permitían arrastrar archivos para seleccionarlos, pero esto requiere que muestre el elemento del file
. Entonces, usemos las API que nos proporciona el navegador para implementar un selector y cargador de archivos de arrastrar y soltar.
En este artículo, usaremos JavaScript ES2015+ "vainilla" (sin marcos ni bibliotecas) para completar este proyecto, y se supone que tiene un conocimiento práctico de JavaScript en el navegador. Este ejemplo, aparte de la sintaxis ES2015+, que puede cambiarse fácilmente a la sintaxis ES5 o transpilarse por Babel, debería ser compatible con todos los navegadores perennes más IE 10 y 11.
Aquí hay un vistazo rápido a lo que estarás haciendo:
Eventos de arrastrar y soltar
Lo primero que debemos discutir son los eventos relacionados con arrastrar y soltar porque son la fuerza impulsora detrás de esta característica. En total, hay ocho eventos que dispara el navegador relacionados con arrastrar y soltar: drag
, dragend
, dragenter
, dragexit
, dragleave
, dragover
, dragstart
y drop
. No los repasaremos todos porque drag
, dragend
, dragexit
y dragstart
se activan en el elemento que se está arrastrando y, en nuestro caso, arrastraremos archivos desde nuestro sistema de archivos en lugar de elementos DOM. , por lo que estos eventos nunca aparecerán.
Si tiene curiosidad acerca de ellos, puede leer alguna documentación sobre estos eventos en MDN.
Como era de esperar, puede registrar controladores de eventos para estos eventos de la misma manera que registra controladores de eventos para la mayoría de los eventos del navegador: a través de addEventListener
.
let dropArea = document.getElementById('drop-area') dropArea.addEventListener('dragenter', handlerFunction, false) dropArea.addEventListener('dragleave', handlerFunction, false) dropArea.addEventListener('dragover', handlerFunction, false) dropArea.addEventListener('drop', handlerFunction, false)
Aquí hay una pequeña tabla que describe lo que hacen estos eventos, usando dropArea
del ejemplo de código para que el lenguaje sea más claro:
Evento | ¿Cuándo se despide? |
---|---|
dragenter | El elemento arrastrado se arrastra sobre dropArea, lo que lo convierte en el objetivo del evento de soltar si el usuario lo suelta allí. |
dragleave | El elemento arrastrado se saca de dropArea y se coloca en otro elemento, lo que lo convierte en el destino del evento de soltar. |
dragover | Cada pocos cientos de milisegundos, mientras el elemento arrastrado está sobre dropArea y se está moviendo. |
drop | El usuario suelta el botón del mouse y suelta el elemento arrastrado en dropArea. |
Tenga en cuenta que el elemento arrastrado se arrastra sobre un elemento secundario de dropArea
, dragleave
se activará en dropArea
y dragenter
se activará en ese elemento secundario porque es el nuevo target
. El evento drop
se propagará hasta dropArea
(a menos que un detector de eventos diferente detenga la propagación antes de que llegue allí), por lo que aún se activará en dropArea
a pesar de que no es el target
del evento.
También tenga en cuenta que para crear interacciones personalizadas de arrastrar y soltar, deberá llamar a event.preventDefault()
en cada uno de los oyentes de estos eventos. Si no lo hace, el navegador terminará abriendo el archivo que soltó en lugar de enviarlo al drop
eventos de eliminación.
Configurando nuestro formulario
Antes de comenzar a agregar la funcionalidad de arrastrar y soltar, necesitaremos un formulario básico con una entrada de file
estándar. Técnicamente esto no es necesario, pero es una buena idea proporcionarlo como alternativa en caso de que el usuario tenga un navegador sin soporte para la API de arrastrar y soltar.
<div> <form class="my-form"> <p>Upload multiple files with the file dialog or by dragging and dropping images onto the dashed region</p> <input type="file" multiple accept="image/*" onchange="handleFiles(this.files)"> <label class="button" for="fileElem">Select some files</label> </form> </div>
Estructura bastante simple. Puede notar un controlador onchange
en la input
. Echaremos un vistazo a eso más tarde. También sería una buena idea agregar una action
al form
y un botón de submit
para ayudar a aquellas personas que no tienen habilitado JavaScript. Luego puede usar JavaScript para deshacerse de ellos y obtener una forma más limpia. En cualquier caso, necesitará un script del lado del servidor para aceptar la carga, ya sea algo desarrollado internamente o si está utilizando un servicio como Cloudinary para que lo haga por usted. Aparte de esas notas, no hay nada especial aquí, así que agreguemos algunos estilos:
#drop-area { border: 2px dashed #ccc; border-radius: 20px; width: 480px; font-family: sans-serif; margin: 100px auto; padding: 20px; } #drop-area.highlight { border-color: purple; } p { margin-top: 0; } .my-form { margin-bottom: 10px; } #gallery { margin-top: 10px; } #gallery img { width: 150px; margin-bottom: 10px; margin-right: 10px; vertical-align: middle; } .button { display: inline-block; padding: 10px; background: #ccc; cursor: pointer; border-radius: 5px; border: 1px solid #ccc; } .button:hover { background: #ddd; } #fileElem { display: none; }
Muchos de estos estilos aún no están entrando en juego, pero está bien. Lo más destacado, por ahora, es que la entrada del file
está oculta, pero su label
tiene un estilo que parece un botón, por lo que las personas se darán cuenta de que pueden hacer clic en él para abrir el cuadro de diálogo de selección de archivos. También estamos siguiendo una convención al delinear el área de colocación con líneas discontinuas.
Agregar la funcionalidad de arrastrar y soltar
Ahora llegamos al meollo de la situación: arrastrar y soltar. Incluyamos un script en la parte inferior de la página, o en un archivo separado, como quieras hacerlo. Lo primero que necesitamos en el script es una referencia al área de colocación para que podamos adjuntarle algunos eventos:
let dropArea = document.getElementById('drop-area')
Ahora agreguemos algunos eventos. Comenzaremos agregando controladores a todos los eventos para evitar comportamientos predeterminados y evitar que los eventos aumenten más de lo necesario:
;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, preventDefaults, false) }) function preventDefaults (e) { e.preventDefault() e.stopPropagation() }
Ahora agreguemos un indicador para que el usuario sepa que, de hecho, ha arrastrado el elemento sobre el área correcta usando CSS para cambiar el color del borde del área de colocación. Los estilos ya deberían estar allí debajo del selector #drop-area.highlight
, así que usemos JS para agregar y eliminar esa clase highlight
cuando sea necesario.
;['dragenter', 'dragover'].forEach(eventName => { dropArea.addEventListener(eventName, highlight, false) }) ;['dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, unhighlight, false) }) function highlight(e) { dropArea.classList.add('highlight') } function unhighlight(e) { dropArea.classList.remove('highlight') }
Tuvimos que usar dragenter
y dragover
para resaltar debido a lo que mencioné anteriormente. Si comienza pasando el cursor directamente sobre dropArea
y luego pasa el cursor sobre uno de sus elementos secundarios, se activará dragleave
y se eliminará el resaltado. El evento dragover
se activa después de los eventos dragleave
dragenter
por lo que el resaltado se agregará nuevamente a dropArea
antes de que veamos que se elimina.
También eliminamos el resaltado cuando el elemento arrastrado sale del área designada o cuando suelta el elemento.
Ahora todo lo que tenemos que hacer es averiguar qué hacer cuando se sueltan algunos archivos:
dropArea.addEventListener('drop', handleDrop, false) function handleDrop(e) { let dt = e.dataTransfer let files = dt.files handleFiles(files) }
Esto no nos acerca a la finalización, pero hace dos cosas importantes:
- Muestra cómo obtener los datos de los archivos que se descartaron.
- Nos lleva al mismo lugar en el que estaba la
input
delfile
con su controladoronchange
: esperandohandleFiles
.
Tenga en cuenta que los files
no son una matriz, sino una FileList
. Entonces, cuando implementemos handleFiles
, necesitaremos convertirlo en una matriz para iterarlo más fácilmente:
function handleFiles(files) { ([...files]).forEach(uploadFile) }
Eso fue anticlimático. Vayamos a uploadFile
para ver las cosas realmente sustanciosas.
function uploadFile(file) { let url = 'YOUR URL HERE' let formData = new FormData() formData.append('file', file) fetch(url, { method: 'POST', body: formData }) .then(() => { /* Done. Inform the user */ }) .catch(() => { /* Error. Inform the user */ }) }
Aquí usamos FormData
, una API de navegador integrada para crear datos de formulario para enviar al servidor. Luego usamos la API de fetch
para enviar la imagen al servidor. Asegúrese de cambiar la URL para que funcione con su back-end o servicio, y formData.append
. agregue cualquier dato de formulario adicional que pueda necesitar para brindarle al servidor toda la información que necesita. Alternativamente, si desea admitir Internet Explorer, puede usar XMLHttpRequest
, lo que significa que uploadFile
se vería así:
function uploadFile(file) { var url = 'YOUR URL HERE' var xhr = new XMLHttpRequest() var formData = new FormData() xhr.open('POST', url, true) xhr.addEventListener('readystatechange', function(e) { if (xhr.readyState == 4 && xhr.status == 200) { // Done. Inform the user } else if (xhr.readyState == 4 && xhr.status != 200) { // Error. Inform the user } }) formData.append('file', file) xhr.send(formData) }
Dependiendo de cómo esté configurado su servidor, es posible que desee verificar diferentes rangos de números de status
en lugar de solo 200
, pero para nuestros propósitos, esto funcionará.
Características adicionales
Esa es toda la funcionalidad básica, pero a menudo queremos más funcionalidad. Específicamente, en este tutorial, agregaremos un panel de vista previa que muestra todas las imágenes elegidas al usuario, luego agregaremos una barra de progreso que le permite al usuario ver el progreso de las cargas. Entonces, comencemos con la vista previa de las imágenes.
Vista previa de la imagen
Hay un par de formas en que puede hacer esto: puede esperar hasta que la imagen se haya cargado y pedirle al servidor que envíe la URL de la imagen, pero eso significa que debe esperar y las imágenes pueden ser bastante grandes a veces. La alternativa, que exploraremos hoy, es usar la API de drop
en los datos del archivo que recibimos del evento de eliminación. Esto es asincrónico y, como alternativa, podría usar FileReaderSync, pero podríamos estar tratando de leer varios archivos grandes seguidos, por lo que esto podría bloquear el hilo durante bastante tiempo y realmente arruinar la experiencia. Así que vamos a crear una función previewFile
y ver cómo funciona:
function previewFile(file) { let reader = new FileReader() reader.readAsDataURL(file) reader.onloadend = function() { let img = document.createElement('img') img.src = reader.result document.getElementById('gallery').appendChild(img) } }
Aquí creamos un new FileReader
y lo llamamos readAsDataURL
con el objeto File
. Como se mencionó, esto es asincrónico, por lo que debemos agregar un controlador de eventos onloadend
para obtener el resultado de la lectura. Luego usamos la URL de datos base 64 como el src
para un nuevo elemento de imagen y lo agregamos al elemento de la gallery
. Solo hay dos cosas que deben hacerse para que esto funcione ahora: agregue el elemento de la gallery
y asegúrese de que se llame a previewFile
.
Primero, agregue el siguiente código HTML justo después del final de la etiqueta del form
:
<div></div>
Nada especial; es solo un div. Los estilos ya están especificados para él y las imágenes en él, por lo que no queda nada por hacer allí. Ahora cambiemos la función handleFiles
a lo siguiente:
function handleFiles(files) { files = [...files] files.forEach(uploadFile) files.forEach(previewFile) }
Hay algunas formas en que podría haber hecho esto, como la composición o una sola devolución de llamada a forEach
que ejecutó uploadFile
y previewFile
en él, pero esto también funciona. Y con eso, cuando sueltas o seleccionas algunas imágenes, deberían aparecer casi instantáneamente debajo del formulario. Lo interesante de esto es que, en ciertas aplicaciones, es posible que en realidad no desee cargar imágenes, sino almacenar las URL de datos de ellas en localStorage
o en algún otro caché del lado del cliente para que la aplicación acceda a ellas más adelante. Personalmente, no puedo pensar en ningún buen caso de uso para esto, pero estoy dispuesto a apostar que hay algunos.
Seguimiento del progreso
Si algo puede tomar un tiempo, una barra de progreso puede ayudar al usuario a darse cuenta de que realmente se está progresando y dar una indicación de cuánto tiempo llevará completarlo. Agregar un indicador de progreso es bastante fácil gracias a la etiqueta progress
de HTML5. Comencemos agregando eso al código HTML esta vez.
<progress max=100 value=0></progress>
Puede colocarlo justo después de la label
o entre el form
y la galería div
, lo que más le apetezca. De hecho, puede colocarlo donde desee dentro de las etiquetas del body
. No se agregaron estilos para este ejemplo, por lo que mostrará la implementación predeterminada del navegador, que es útil. Ahora trabajemos en agregar el JavaScript. Primero veremos la implementación usando fetch
y luego mostraremos una versión para XMLHttpRequest
. Para comenzar, necesitaremos un par de variables nuevas en la parte superior del script:
let filesDone = 0 let filesToDo = 0 let progressBar = document.getElementById('progress-bar')
Cuando usamos fetch
, solo podemos determinar cuándo finaliza una carga, por lo que la única información que rastreamos es cuántos archivos se seleccionaron para cargar (como filesToDo
) y la cantidad de archivos que terminaron de cargarse (como filesDone
). También mantenemos una referencia al elemento #progress-bar
para que podamos actualizarlo rápidamente. Ahora vamos a crear un par de funciones para gestionar el progreso:
function initializeProgress(numfiles) { progressBar.value = 0 filesDone = 0 filesToDo = numfiles } function progressDone() { filesDone++ progressBar.value = filesDone / filesToDo * 100 }
Cuando comencemos a cargar, se llamará a initializeProgress
para restablecer la barra de progreso. Luego, con cada carga completa, llamaremos a progressDone
para incrementar el número de cargas completas y actualizar la barra de progreso para mostrar el progreso actual. Así que llamemos a estas funciones actualizando un par de funciones antiguas:
function handleFiles(files) { files = [...files] initializeProgress(files.length) // <- Add this line files.forEach(uploadFile) files.forEach(previewFile) } function uploadFile(file) { let url = 'YOUR URL HERE' let formData = new FormData() formData.append('file', file) fetch(url, { method: 'POST', body: formData }) .then(progressDone) // <- Add `progressDone` call here .catch(() => { /* Error. Inform the user */ }) }
Y eso es. Ahora echemos un vistazo a la implementación de XMLHttpRequest
. Podríamos simplemente hacer una actualización rápida de uploadFile
, pero XMLHttpRequest
en realidad nos brinda más funciones que fetch
, es decir, podemos agregar un detector de eventos para el progreso de carga en cada solicitud, lo que periódicamente nos brindará información sobre la cantidad de la solicitud. finalizado. Debido a esto, necesitamos realizar un seguimiento del porcentaje de finalización de cada solicitud en lugar de cuántas se realizan. Entonces, comencemos reemplazando las declaraciones de filesDone
y filesToDo
con lo siguiente:
let uploadProgress = []
Entonces necesitamos actualizar nuestras funciones también. Cambiaremos el nombre de progressDone
a updateProgress
y los cambiaremos para que sean los siguientes:
function initializeProgress(numFiles) { progressBar.value = 0 uploadProgress = [] for(let i = numFiles; i > 0; i--) { uploadProgress.push(0) } } function updateProgress(fileNumber, percent) { uploadProgress[fileNumber] = percent let total = uploadProgress.reduce((tot, curr) => tot + curr, 0) / uploadProgress.length progressBar.value = total }
Ahora initializeProgress
inicializa una matriz con una longitud igual a numFiles
que se llena con ceros, lo que indica que cada archivo está completo en un 0 %. En updateProgress
averiguamos qué imagen tiene su progreso actualizado y cambiamos el valor en ese índice al percent
proporcionado. Luego calculamos el porcentaje de progreso total tomando un promedio de todos los porcentajes y actualizamos la barra de progreso para reflejar el total calculado. Seguimos llamando a initializeProgress
en handleFiles
de la misma manera que lo hicimos en el ejemplo de fetch
, por lo que ahora todo lo que necesitamos actualizar es uploadFile
para llamar a updateProgress
.
function uploadFile(file, i) { // <- Add `i` parameter var url = 'YOUR URL HERE' var xhr = new XMLHttpRequest() var formData = new FormData() xhr.open('POST', url, true) // Add following event listener xhr.upload.addEventListener("progress", function(e) { updateProgress(i, (e.loaded * 100.0 / e.total) || 100) }) xhr.addEventListener('readystatechange', function(e) { if (xhr.readyState == 4 && xhr.status == 200) { // Done. Inform the user } else if (xhr.readyState == 4 && xhr.status != 200) { // Error. Inform the user } }) formData.append('file', file) xhr.send(formData) }
Lo primero a tener en cuenta es que agregamos un parámetro i
. Este es el índice del archivo en la lista de archivos. No necesitamos actualizar handleFiles
para pasar este parámetro porque está usando forEach
, que ya proporciona el índice del elemento como el segundo parámetro para las devoluciones de llamada. También agregamos el detector de eventos de progress
a xhr.upload
para que podamos llamar a updateProgress
con el progreso. El objeto de evento (denominado e
en el código) tiene dos datos pertinentes: loaded
, que contiene la cantidad de bytes que se han cargado hasta el momento, y total
, que contiene la cantidad de bytes que tiene el archivo en total.
el || 100
|| 100
piezas están ahí porque a veces, si hay un error, e.loaded
y e.total
serán cero, lo que significa que el cálculo saldrá como NaN
, por lo que se usa 100
para informar que el archivo está listo. También podrías usar 0
. En cualquier caso, el error aparecerá en el controlador readystatechange
para que pueda informar al usuario sobre ellos. Esto es simplemente para evitar que se generen excepciones por tratar de hacer operaciones matemáticas con NaN
.
Conclusión
Esa es la pieza final. Ahora tiene una página web donde puede cargar imágenes arrastrando y soltando, obtener una vista previa de las imágenes que se cargan inmediatamente y ver el progreso de la carga en una barra de progreso. Puede ver la versión final (con XMLHttpRequest
) en acción en CodePen, pero tenga en cuenta que el servicio al que cargo los archivos tiene límites, por lo que si mucha gente lo prueba, puede fallar por un tiempo.