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

Introducción

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

En Box.net, nos entusiasma ver cómo estas nuevas funciones nos permiten mejorar nuestra solución de administración de contenido en la nube y contribuir más a la comunidad de desarrolladores. Me complace anunciar que DnD Download 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 pasé por varias iteraciones durante el desarrollo de esta nueva función.

Cómo comprobar la compatibilidad con la API de arrastrar y soltar

Lo primero que debes hacer es verificar que tu navegador admita por completo la función de arrastrar y soltar de HTML5. Una manera sencilla de hacerlo es usar una biblioteca llamada Modernizr para verificar una función específica:

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 atributo nuevo llamado "data-downloadurl" para anclar vínculos de archivos. Este proceso usa los 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>

lo que crearí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>

Basándome en un plugin de jQuery que creó von Schorsch, que se basa en el artículo de Seddon, agregué un complemento de jQuery que realiza un poco de detección de funciones del navegador. Las líneas que agregué a la versión de von Schorsch están destacadas:

(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);

El motivo por el que hice esto es porque, sin una detección de navegador previa, si se usa addEventListener() a un elemento HTML en IE, se creará un error de JavaScript porque IE usa su propio método attachEvent(). e.dataTransfer no está definido en IE (en este momento), e.dataTransfer.constructor muestra DataTransfer en Firefox (Mozilla), mientras que los navegadores Webkit (Chrome y Safari) implementan el constructor de Clipboard. En Safari, e.dataTransfer.setData('DownloadURL','http://www.box.net') muestra un valor falso, y Chrome muestra un valor verdadero para esta sentencia. Si realizas todas las pruebas mencionadas anteriormente, la función solo estará disponible para Chrome. Podrías argumentar que podría hacer lo siguiente:

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

Sin embargo, prefiero la detección de funciones a la detección de navegadores, aunque técnicamente no detecta que la descarga de DnD funcionará.

Problemas de la iteración 1

1) Debido a que actualmente tenemos habilitada la función de arrastrar y soltar en la página para mover o copiar archivos entre carpetas, necesitamos una forma de distinguir la descarga de arrastrar y soltar y la función de arrastrar y soltar en la página. Técnicamente, no podemos combinar estas dos acciones. No podemos predecir si el usuario quiere 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 forma 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 el evento mouseout al documento y verificar si e.relatedTarget.nodeName == "HTML" (e es el evento mouseout o window.event, lo que esté disponible). Sin embargo, esto es bastante difícil debido al burbujeo de eventos. El evento puede activarse de forma aleatoria cuando te encuentras sobre una imagen o capa, en especial en una aplicación 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. En teoría, un editor de una carpeta de Box puede 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 la computadora de 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 una computadora de escritorio con Windows para duplicar un archivo. También requiere trabajo adicional (pero no un paso adicional) del usuario para evitar que se descarguen archivos por error.

El complemento jQuery de la iteración 1 ahora se abandonó porque necesitamos integrar de forma más estricta la descarga de DnD con la DnD en la página. Para quienes estén interesados, usamos una versión modificada del complemento Draggable de jQuery UI. 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 una pequeña información sobre herramientas, que aparece cuando el usuario realiza un arrastre normal en la página. Le indica al usuario que se pueden descargar archivos si se arrastra el ícono del archivo al escritorio mientras se mantiene presionada la tecla Ctrl.

Problemas de la iteración 2

Debido a problemas de seguridad, Box.net no expone URLs permanentes para acceder directamente a archivos estáticos. Esto no es exclusivo de Box.net. Cualquier servicio de almacenamiento en línea no debe exponer URLs permanentes sin una capa adicional de seguridad para verificar si el archivo es público y si la descarga deseada la solicita un usuario con los permisos adecuados.

Cuando se sigue la "URL de descarga" (p.ej., https://www.box.net/box_download_file?file_id=f_60466690) de un elemento, se muestra un código de estado "302 Found" y se 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 vence cada pocos minutos, por lo que no es práctico colocarlo en el resultado HTML. Podría mostrar un error “404” cuando el usuario intente descargar el archivo en el vínculo del resultado HTML generado hace varios minutos.

La descarga de DnD solo funciona en URLs reales que apuntan directamente a un recurso. Si se incluye el redireccionamiento, por el momento, no es lo suficientemente inteligente como para seguir la cadena (y nunca debería seguirla por motivos de seguridad). Por lo tanto, aunque el vínculo https://www.box.net/box_download_file?file_id=f_60466690 anterior te permitiría descargar el archivo cuando lo ingreses en la barra de ubicación del navegador, no funcionaría con la función de arrastrar y soltar.

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

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

Iteración 3

Probemos Ajax.

Modificamos ligeramente el código de 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;
}
}

Tiene sentido. Cuando se inicia el arrastre, se realiza de inmediato una llamada Ajax al servidor para recuperar la URL de descarga más reciente del archivo. Sin embargo, no funciona.

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

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

Y funciona bien hasta que desconecto la conexión de red. Debido a que realiza una llamada síncrona, el navegador se bloquea hasta que la llamada se realiza correctamente. Si falla la llamada Ajax (404 o si no responde en absoluto), el navegador no se descongelaría, como si se hubiera producido una falla.

Es mucho más seguro hacer 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 obtener una demostración de esta función, no dudes en subir un archivo estático a una cuenta de Box.net. Arrastra el ícono del archivo a tu escritorio mientras mantienes presionada la tecla Ctrl. Si no tienes una cuenta, literalmente, te llevará menos de 30 segundos crear una.

Con esta función, puedes ser creativo y hacer posible muchas cosas. Si arrastras una imagen a un cuadro de diálogo de impresora de Windows, 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 MI para transferirlo directamente a tu amigo… Esto te abre un sinfín de posibilidades para aumentar tu productividad.

enviar un archivo a la impresora
Arrastrar un archivo a la impresora.
Arrastrar un archivo al cliente de MI
Arrastrar un archivo al cliente de MI.

Reflexiones y mejoras futuras

Esto aún no es lo ideal, ya que una llamada síncrona podría bloquear el navegador durante un breve momento. El trabajador web HTML 5 tampoco ayuda, ya que un trabajador web debe ser asíncrono. Parece que setData se debe realizar cuando se adjunta el objeto de escucha de eventos.

En realidad, el rendimiento es bastante aceptable. La llamada síncrona de Ajax (Sjax) solo recupera una cadena de URL, que debería ser bastante rápida. Sin embargo, tiene una gran sobrecarga en el encabezado HTTP, que WebSockets puede abordar. Sin embargo, hasta que veamos un mayor uso de este tipo de tecnología, no vale la pena usar WebSockets para enviar cada pequeña actualización al cliente.

También espero que se agregue la capacidad de descarga de varios archivos a la API en el futuro. Combinado con casillas de verificación personalizadas para seleccionar varios archivos en la interfaz de usuario, esto sería increíble. 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 pudieran descargar de esta manera.

  • Column dnd
  • Cómo reorganizar una lista
  • Cómo crear una galería de imágenes
  • Exporta una imagen de lienzo

Referencias