Caso de éxito: Descarga con arrastrar y soltar en Chrome

Introducción

Arrastrar y soltar (DnD) es una de las excelentes funciones de HTML 5 y es compatible con Firefox 3.5, Safari, Chrome e IE. Recientemente, Google lanzó una función nueva que permite a los usuarios de Google Chrome arrastrar y soltar archivos del navegador al escritorio. Es una función muy útil, pero no fue muy conocida hasta que Ryan Seddon publicó un artículo sobre los descubrimientos de su ingeniería inversa sobre esta función nueva.

En Box.net, estamos muy contentos con el modo en que estas nuevas capacidades nos permiten mejorar nuestra solución de administración de contenido en la nube y, además, contribuir más a la comunidad de desarrolladores. Me complace anunciar que la función de descarga DnD se integró a nuestro producto. Ahora, los usuarios de Box pueden arrastrar archivos directamente desde un navegador Chrome a su escritorio para descargarlos y guardarlos.

Me gustaría compartir cómo fui por varias iteraciones durante el desarrollo de esta nueva función.

Verifica la compatibilidad de la API de arrastrar y soltar

Lo primero que debes hacer es comprobar que tu navegador sea completamente compatible con la función de arrastrar y soltar en HTML5. Una forma fácil de hacerlo es usar una biblioteca llamada Modernizr para buscar un atributo determinado:

if (Modernizr.draganddrop) {
// Browser supports native HTML5 DnD.
} else {
// Fallback to a library solution.
}

Iteración 1

Primero probé el enfoque que Seddon encontró en Gmail. Agregué un nuevo atributo llamado 'data-downloadurl' para anclar vínculos de archivos. Este proceso utiliza atributos de datos personalizados de HTML5. En data-downloadurl, debes incluir el tipo de MIME del archivo, el nombre del archivo de destino (el nombre deseado del archivo descargado) y la URL de descarga del archivo. Por lo tanto, se agrega a la plantilla HTML:

<a href="#" class="dnd"
data-downloadurl="{$item.mime}:{$item.filename}:{$item.url}"></a>

Esto generaría un resultado como el siguiente:

<a href="#" class="dnd" data-downloadurl=
"image/jpeg:Penguins.jpg:https://www.box.net/box_download_file?file_id=f66690"></a>

Sobre la base de un plugin de jQuery que creó von Schorsch, que se basa en el artículo de Seddon, agregué un complemento de jQuery que detecta un poco las funciones del navegador. Se destacan las líneas que agregué a la versión de von Schorsch:

(function($) {

$.fn.extend({
dragout: function() {
var files = this;
if (files.length > 0) {
    $(files).each(function() {
    var url = (this.dataset && this.dataset.downloadurl) ||
                this.getAttribute("data-downloadurl");
    if (this.addEventListener) {
        this.addEventListener("dragstart", function(e) {
        if (e.dataTransfer && e.dataTransfer.constructor == Clipboard &&
            e.dataTransfer.setData('DownloadURL', 'http://www.box.net')) {
            e.dataTransfer.setData("DownloadURL", url);
        }
        },false);
    }
    });
}
}
});

})(jQuery);

Hice esto porque, sin la detección previa del navegador, hacer addEventListener() a un elemento HTML en IE creará un error de JavaScript porque IE usa su propio método attachEvent(). e.dataTransfer no está definido en IE (a partir de ahora), e.dataTransfer.constructor devuelve DataTransfer en Firefox (Mozilla), mientras que los navegadores Webkit (Chrome y Safari) implementan el constructor del Portapapeles. En Safari, e.dataTransfer.setData('DownloadURL','http://www.box.net') muestra un valor falso y Chrome, verdadero para esta declaración. Si realizas todas las pruebas mencionadas anteriormente, la función solo estará disponible para Chrome. Se podría argumentar que simplemente puedo hacer lo siguiente:

/chrome/.test( navigator.userAgent.toLowerCase() )

Sin embargo, prefiero la detección de funciones antes que la de navegador, aunque técnicamente no detecta que la descarga de DnD funcione.

Problemas de la iteración 1

1) Debido a que actualmente el DnD en la página está habilitado para mover o copiar archivos entre carpetas, necesitamos una forma de distinguir los DnD en la página y los de descarga DnD. Técnicamente, no podemos combinar estas dos acciones. No podemos predecir si el usuario desea mover un archivo a otra carpeta dentro de la cuenta de Box.net o arrastrarlo a su escritorio. Estas dos acciones son completamente diferentes. Además, no hay una manera fácil de detectar si el cursor está fuera de la ventana del navegador. Puedes usar window.onmouseout (IE) y document.onmouseout (otros navegadores) para adjuntar un evento de mouseout al documento y verificar si e.relatedTarget.nodeName == "HTML" (e es el evento mouseout o window.event, lo que esté disponible). Pero esto es bastante difícil debido al burbujeamiento de eventos. El evento puede activarse de forma aleatoria cuando estás sobre una imagen o capa, especialmente en una app web compleja como Box.net.

2) Queremos que el usuario haga algo de forma explícita para evitar que arrastre algo al escritorio por error. Es posible que un editor de una carpeta de Box pueda subir un archivo ejecutable que haga algo no deseado en la computadora de quien lo descargue. Queremos que el usuario sepa exactamente cuándo se descargará un archivo en el escritorio.

Iteración 2

Decidimos experimentar con Control + arrastrar (arrastrar un archivo cuando se presiona la tecla Ctrl de Windows). Esta acción es coherente con lo que las personas pueden hacer en un escritorio con Windows para duplicar un archivo. También requiere trabajo adicional (pero no un paso adicional) por parte del usuario para evitar que los archivos se descarguen por error.

Ahora, se abandona el complemento de jQuery en la iteración 1 porque necesitamos integrar estrechamente la descarga DnD con el DnD en la página. Si estás interesado, usamos una versión modificada del complemento Draggable de la IU de jQuery. Dentro del evento mousedown de un elemento de destino, colocamos el siguiente código:

// DnD to desktop when the Ctrl key is pressed while dragging
if (e.ctrlKey) {
var that = $(e.target);
// make sure it is not IE (attachEvent).
if (that[0].addEventListener) {
    that[0].addEventListener("dragstart",function(e) {
        // e.dataTransfer in Firefox uses the DataTransfer constructor
        // instead of Clipboard
        // make sure it's Chrome and not Safari (both webkit-based).
        // setData on DownloadURL returns true on Chrome, and false on Safari
        if (e.dataTransfer && e.dataTransfer.constructor == Clipboard &&
            e.dataTransfer.setData('DownloadURL','http://www.box.net')) {
        var url = (this.dataset && this.dataset.downloadurl) ||
                    this.getAttribute("data-downloadurl");
        e.dataTransfer.setData("DownloadURL", url);
        }
    }, false);
    return;
}
}

Además de habilitar la tecla Ctrl, también agregamos un pequeño cuadro de información sobre la tostadora, que aparece cuando el usuario realiza un arrastre regular en la página. Indica al usuario que los archivos se pueden descargar si se arrastra el ícono de archivo al escritorio mientras se mantiene presionada la tecla Ctrl.

Problemas de la iteración 2

Por cuestiones de seguridad, Box.net no expone URL permanentes para acceder directamente a los archivos estáticos. Esta acción no es exclusiva de Box.net. Ningún servicio de almacenamiento en línea debe exponer URL permanentes sin una capa de seguridad adicional para verificar si el archivo es público y si un usuario con los permisos adecuados solicita la descarga deseada.

Cuando sigue la "URL de descarga" (p.ej., https://www.box.net/box_download_file?file_id=f_60466690) de un elemento, muestra el código de estado "302 Encontrado" y lo redirecciona a una URL aleatoria (p.ej., https://www.box.net/dl/6045?a=1f1207a084&m=168299,11211&t=2&b=aca15820d924e3b) que es la "URL real" temporal del archivo. El desafío es que expira cada pocos minutos, por lo que colocarlo en el resultado HTML no es práctico. Puede mostrar "404" cuando el usuario intenta descargar el archivo a través del vínculo en el resultado HTML generado hace varios minutos.

La descarga DnD solo funciona en URLs reales que dirigen directamente a un recurso. Si implica el redireccionamiento, por el momento, no es lo suficientemente inteligente como para seguir la cadena (y nunca debe hacerlo por cuestiones de seguridad). Por lo tanto, aunque el vínculo https://www.box.net/box_download_file?file_id=f_60466690 de arriba te permitiría descargar el archivo cuando lo ingresas en la barra de ubicación del navegador, no funcionaría con DnD.

Para ilustrar mejor las diferencias entre una "URL real" y una "URL de redireccionamiento", consulta las siguientes capturas de pantalla:

URL de redireccionamiento 302
URL de redireccionamiento 302
URL real
URL real

Iteración 3

Probemos con Ajax.

Modificamos ligeramente el código en la iteración anterior y obtuvimos lo siguiente:

// DnD to desktop when the Ctrl key is pressed while dragging
if (e.ctrlKey) {
var that = $(e.target);
// make sure it is not IE (attachEvent).
if (that[0].addEventListener) {
that[0].addEventListener("dragstart", function(e) {
    // e.dataTransfer in Firefox uses the DataTransfer constructor
    // instead of Clipboard
    // make sure it's Chrome and not Safari (both webkit-based).
    // setData on DownloadURL returns true on Chrome, and false on Safari
    if (e.dataTransfer && e.dataTransfer.constructor == Clipboard &&
        e.dataTransfer.setData('DownloadURL', 'http://www.box.net')) {
    var url = (this.dataset && this.dataset.downloadurl) ||
                this.getAttribute("data-downloadurl");
    $.ajax({
        complete: function(data) {
        e.dataTransfer.setData("DownloadURL", data.responseText);
        },
        type:'GET',
        url: url
    });
    }
}, false);
return;
}
}

Esto tiene sentido. Después de dragstart, realiza una llamada Ajax de inmediato al servidor para recuperar la URL de descarga más reciente del archivo. Sin embargo, no funciona.

Resulta que necesita ser una llamada síncrona (o, como a mí me gusta llamarla, Sjax). Parece que setData debe realizarse en el momento en que se adjunta el objeto de escucha de eventos. Según la API de jQuery, las líneas resaltadas se convierten en las siguientes:

$.ajax({
async: false,
complete: function(data) {
e.dataTransfer.setData("DownloadURL", data.responseText);
},
type: 'GET',
url: url
});

Funciona bien hasta que desenchufa la conexión de red. Debido a que realiza una llamada síncrona, el navegador se bloquea hasta que la llamada se ejecuta de forma correcta. Si la llamada a Ajax falla (404 o si no responde), el navegador no se descongelaría en absoluto como si se hubiera fallado.

Es mucho más seguro hacer algo como lo siguiente:

$.ajax({
async: false,
complete: function(data) {
e.dataTransfer.setData("DownloadURL", data.responseText);
},
error: function(xhr) {
if (xhr.status == 404) {
    xhr.abort();
}
},
type: 'GET',
timeout: 3000,
url: url
});

Para ver una demostración de esta función, puedes cargar un archivo estático en una cuenta de Box.net. Arrastra el ícono de archivo a tu escritorio mientras mantienes presionada la tecla Ctrl. Si no tienes una cuenta, crear una te llevará menos de 30 segundos.

Con esta función, puedes ser creativo y posibilitar muchas cosas. Si arrastras una imagen a un cuadro de diálogo de impresora de Windows, la imagen se imprimirá de inmediato. Puedes copiar una canción de Box a la unidad de tu teléfono celular, arrastrar un archivo de Box a tu cliente de IM para transferirla directamente a tu amigo... Esto abre un sinfín de posibilidades para aumentar tu productividad.

arrastrar un archivo a la impresora
Arrastra un archivo a la impresora.
Cómo arrastrar un archivo al cliente de IM
Arrastra un archivo al cliente de IM.

Reflexiones y mejoras futuras

Esto no es del ideal, ya que una llamada síncrona podría bloquear el navegador durante un momento breve. El trabajador web HTML5 tampoco ayuda, ya que debe ser asíncrono. Parece que setData debe hacerse en el momento en que se adjunta el objeto de escucha de eventos.

En realidad, el rendimiento es bastante aceptable. La llamada Ajax (Sjax) síncrona solo recupera una cadena de URL, lo cual debería ser bastante rápido. Sin embargo, tiene una gran sobrecarga en el encabezado HTTP, que es posible que funcione con WebSockets. Sin embargo, hasta que veamos un mayor uso de este tipo de tecnología, no vale la pena usar WebSockets para enviar todas las actualizaciones al cliente.

También espero que la capacidad de descarga de varios archivos se agregue a la API en el futuro. Esto sería increíble, junto con casillas de verificación personalizadas para seleccionar varios archivos en la interfaz de usuario. Además, sería aún mejor si los archivos generados por el cliente, como los archivos de texto generados a partir del resultado de un formulario enviado, se pueden descargar de esta manera.

  • Columna DND
  • Reorganizar lista
  • Crea una galería de imágenes
  • Exporta una imagen de lienzo

Referencias