Estudo de caso - Download por arrastar e soltar no Chrome

Introdução

Arrastar e soltar (DnD) é um dos muitos recursos excelentes do HTML 5 e é compatível com Firefox 3.5, Safari, Chrome e IE. O Google lançou recentemente um novo recurso para os usuários do Chrome arrastarem e soltarem arquivos do navegador para a área de trabalho. É um recurso extremamente conveniente, mas não era amplamente conhecido até Ryan Seddon postar um artigo sobre as descobertas da engenharia reversa dele sobre esse novo recurso.

Na Box.net, estamos muito felizes com como esses novos recursos estão nos permitindo melhorar nossa solução de gerenciamento de conteúdo na nuvem e contribuir mais com a comunidade de desenvolvedores. Temos o prazer de anunciar que o Download DnD foi integrado em nosso produto. Agora, os usuários do Box podem arrastar os arquivos diretamente do navegador Chrome para a área de trabalho para fazer o download e salvar os arquivos.

Eu gostaria de compartilhar como passei por várias iterações durante o desenvolvimento desse novo recurso.

Verificar o suporte à API Drag and Drop

A primeira coisa a fazer é verificar se o navegador é totalmente compatível com o recurso de arrastar e soltar em HTML5. Uma maneira fácil de fazer isso é usar uma biblioteca chamada Modernizr para verificar se há um determinado recurso:

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

Iteração 1

Primeiro, tentei a abordagem que Seddon encontrou no Gmail. Adicionei um novo atributo chamado "data-downloadurl" aos links fixos de arquivos. Esse processo usa atributos de dados personalizados do HTML5. Em data-downloadurl, é necessário incluir o tipo MIME do arquivo, o nome do arquivo de destino (o nome desejado do arquivo transferido por download) e o URL de download do arquivo. Assim, isso é adicionado ao modelo HTML:

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

que criaria uma saída como esta:

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

Com base em um plugin do jQuery criado por von Schorsch e baseado no artigo do Seddon, adicionei um plug-in do jQuery que faz um pouco de detecção de recursos do navegador. Destacadas estão as linhas que adicionei à versão 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);

Fiz isso porque, sem a detecção prévia do navegador, fazer addEventListener() em um elemento HTML no IE cria um erro de JavaScript porque o IE usa o próprio métodoAttachEvent(). e.dataTransfer não está definido no IE (a partir de agora), e.dataTransfer.builder retorna DataTransfer no Firefox (Mozilla), enquanto os navegadores Webkit (Chrome e Safari) implementa o construtor da área de transferência. No Safari, e.dataTransfer.setData('DownloadURL','http://www.box.net') retorna "false" e o Chrome retorna "true" para essa instrução. Faça todos os testes mencionados acima e o recurso ficará disponível apenas para o Chrome. Você pode argumentar que eu poderia simplesmente fazer o seguinte:

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

Mas prefiro a detecção de recursos em vez da detecção do navegador, embora isso tecnicamente não detecte que o download do DnD funcionará.

Problemas da iteração 1

1) Como atualmente o DnD na página está ativado para mover/copiar arquivos entre pastas, precisamos de uma maneira de distinguir o download do DnD e o DnD na página. Tecnicamente, não é possível combinar essas duas ações. Não podemos prever se o usuário quer mover um arquivo para outra pasta na conta do Box.net ou arrastá-lo para a área de trabalho. Essas duas ações são completamente diferentes. Além disso, não há uma maneira fácil de detectar se o cursor está fora da janela do navegador. Você pode usar window.onmouseout (IE) e document.onmouseout (outros navegadores) para anexar o evento mouseout ao documento e verificar se e.relatedTarget.nodeName == "HTML" (e é o evento mouseout ou window.event, o que estiver disponível). Mas isso é bastante difícil devido à propagação de eventos. O evento pode ser acionado aleatoriamente quando você está sobre uma imagem ou camada, especialmente em um app da Web complexo, como o Box.net.

2) Queremos que o usuário faça algo explicitamente para evitar que ele arraste algo para a área de trabalho por engano. Um editor de uma pasta do Box pode fazer upload de um arquivo executável que faça algo indesejável no computador de quem fizer o download. Queremos que o usuário saiba exatamente quando um arquivo seria transferido por download para a área de trabalho.

Iteração 2

Decidimos experimentar com Ctrl + arrastar (arrastar um arquivo quando a tecla Ctrl do Windows é pressionada). Essa ação é consistente com o que as pessoas podem fazer em um computador Windows para duplicar um arquivo. Isso também exige trabalho extra (mas não uma etapa extra) do usuário para evitar que os arquivos sejam transferidos por download acidentalmente.

O plug-in jQuery na iteração 1 foi abandonado porque precisamos integrar perfeitamente o download do DnD ao DnD na página. Para quem estiver interessado, usamos uma versão modificada do plug-in Draggable da IU do jQuery. Dentro do evento mousedown de um elemento alvo, colocamos o seguinte 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;
}
}

Além de ativar a tecla Ctrl, também adicionamos uma pequena dica de aviso, que aparece quando o usuário arrasta regularmente a página. Ele informa ao usuário que é possível fazer o download de arquivos se o ícone do arquivo for arrastado para a área de trabalho enquanto a tecla Ctrl estiver pressionada.

Problemas da iteração 2

Por questões de segurança, o Box.net não expõe URLs permanentes para acessar diretamente arquivos estáticos. Isso não é exclusivo do Box.net. Nenhum serviço de armazenamento on-line deve expor URLs permanentes sem uma camada extra de segurança para verificar se o arquivo é público e se o download pretendido é solicitado por um usuário com as permissões adequadas.

Ao seguir o "URL de download" (por exemplo, https://www.box.net/box_download_file?file_id=f_60466690) de um item, ele retorna um código de status "302 Found" e redireciona para um URL aleatório (por exemplo, https://www.box.net/dl/6045?a=1f1207a084&m=168299,11211&t=2&b=aca15820d924e3b) que é o "URL real" temporário do arquivo. O desafio é que ele expira em intervalos de alguns minutos, e por isso colocá-lo na saída HTML é impraticável. Ele poderá retornar "404" quando o usuário tentar fazer o download do arquivo pelo link na saída HTML gerada há vários minutos.

Download do DnD funciona apenas em URLs reais que apontam diretamente para um recurso. Se houver redirecionamento envolvido, ele não é inteligente o suficiente para seguir a cadeia (e nunca deve seguir a cadeia devido à segurança). Portanto, embora o link https://www.box.net/box_download_file?file_id=f_60466690 acima permitisse o download do arquivo quando você o digitasse na barra de local do navegador, ele não funcionaria com DnD.

Para ilustrar melhor as diferenças entre um "URL real" e um "URL de redirecionamento", veja as capturas de tela:

URL de redirecionamento 302
URL de redirecionamento 302
URL real
URL real

Iteração 3

Vamos testar o Ajax.

Modificamos ligeiramente o código na iteração anterior e chegamos ao seguinte:

// 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;
}
}

Faz sentido. Na inicialização, ele faz uma chamada Ajax imediatamente ao servidor para recuperar o URL de download mais recente do arquivo. No entanto, ele não funciona.

Parece que precisa ser uma chamada síncrona (ou, como gosto de chamá-la, Sjax). Parece que setData precisa ser feito no momento em que o listener de eventos é anexado. De acordo com a API do jQuery, as linhas destacadas ficam:

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

E tudo continua funcionando até desconectar a conexão de rede. Como ela faz uma chamada síncrona, o navegador trava até que a chamada seja bem-sucedida. Se a chamada Ajax falhar (404 ou não responder), o navegador não descongelar como se tivesse falhado.

É muito mais seguro fazer algo como o seguinte:

$.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 obter uma demonstração desse recurso, envie um arquivo estático para uma conta do Box.net. Arraste o ícone do arquivo para a área de trabalho enquanto mantém pressionada a tecla Ctrl. Se você não tem uma conta, leva menos de 30 segundos para criar uma.

Com esse recurso, você pode usar a criatividade e fazer muitas coisas. Arrastar uma imagem para a caixa de diálogo de uma impressora do Windows faz a imagem ser impressa imediatamente. Você pode copiar uma música do Box para o drive do seu celular, arrastar um arquivo do Box para seu cliente de mensagens instantâneas a fim de transferi-lo diretamente para o seu amigo... Isso abre infinitas possibilidades de aumentar sua produtividade.

transferir um arquivo para a impressora
Arrastar um arquivo para a impressora
Como arrastar um arquivo para o cliente de mensagem instantânea
Arrastar um arquivo para o cliente de mensagens instantâneas.

Reflexões e melhorias futuras

Isso ainda não é o ideal, já que uma chamada síncrona pode bloquear o navegador por um breve momento. O Web Worker do HTML5 também não ajuda, já que ele precisa ser assíncrono. Parece que setData precisa ser feito no momento em que o listener de eventos está anexado.

Na realidade, o desempenho é bastante aceitável. A chamada síncrona Ajax (Sjax) só recupera uma string de URL, que é bem rápida. Ele vem com uma grande sobrecarga no cabeçalho HTTP, que pode ser resolvida por WebSockets. No entanto, até que haja mais uso desse tipo de tecnologia, não vale a pena usar WebSockets para enviar todas as pequenas atualizações ao cliente.

Também espero que o recurso de download de vários arquivos seja adicionado à API no futuro. Isso seria incrível, combinado com caixas de seleção personalizadas para selecionar vários arquivos na interface do usuário. Além disso, seria ainda melhor que o download de arquivos gerados pelo cliente, como arquivos de texto gerados a partir do resultado de um formulário enviado, pudesse ser feito dessa maneira.

  • Coluna "NP"
  • Reorganizar lista
  • Como criar uma galeria de imagens
  • Como exportar uma imagem da tela

Referências