jhollinger

code et al
jordan at jordanhollinger dot com
powered by eridu

Async, ajax uploads with HTML5

July 27, 2012 | ajax, html5, javascript

HTML5 is orange! Everyone loves Gmail's async-drag-and-drop-with-progress bar attachment UI. While I'd heard that HTML5 supports this type of upload, I found myself sticking with the nasty old form submission model.

While writing a media manager for eridu, I decided to finally look into it. My research into the progress element, the drop event, and the FileReader object bore a tiny ~180 line Sinatra app. Download it, run ruby dropbox.rb, and you have a complete reference implementation of a Gmail-like uploader.

A few similar libraries already existed, but I wanted to fully understand the HTML5 elements involved. While it's not too complicated, it's not too obvious either. So here's a breakdown of the critical pieces.

Outline

Create dropbox HTML elements

Two regular divs - one to serve as the dropbox, the other as a place for upload progress bars.

<div id="dropbox">Drop files here...</div>
<div id="log"></div>

Bind event handlers to drop

Functions to accept files and pass them off to a handler.

var dropbox = document.getElementById('dropbox')
// Handle each file that was dropped (you can drop multiple at once)
function drop(e) {
  noop(e)
  var files = e.dataTransfer.files
  for ( var i=0; i < files.length; i++ )
    handle_file(files[i])
}
function handle_file(file) {
  // We'll write this later
}
// Prevent event from bubbling up
function noop(e) {
  e.stopPropagation()
  e.preventDefault()
}
// Bind event listeners
dropbox.addEventListener("drop", drop, false);
dropbox.addEventListener("dragleave", noop, false);
dropbox.addEventListener("dragexit", noop, false)
dropbox.addEventListener("dragover", noop, false);

Read the file contents

Read the file contents and pass it off to the uploader.

function handle_file(file) {
  var reader = new FileReader();
  reader.onload = function(e) {
    // A base64 encoded string
    var file_contents = e.target.result.split(',')[1]
    // Upload it
    upload_file(file, file_contents)
  }
  // Read the file
  reader.readAsDataURL(file);
}

POST the file contents with Ajax, update progress bar

Put the file contents into a form, send the form over ajax, and periodically update a progress bar.

var log = document.getElementById('log')
function upload_file(file, file_contents) {
  // Make a progress bar
  var label = document.createElement('div')
  label.innerHTML = '<progress></progress> ' + file.name
  log.insertBefore(label, null)
  // Build a form for the data
  var data = new FormData()
  data.append('filename', file.name)
  data.append('mimetype', file.type)
  data.append('data', file_contents)
  data.append('size', file.size)
  // Create a new XHR object and assign its callbacks
  var xhr = new XMLHttpRequest()
  // Periodically update progress bar
  if ( xhr.upload )
    xhr.upload.addEventListener('progress', function(e) { update_progress(e, label) }, false)
  xhr.open('POST', '/ajax-upload')
  xhr.onreadystatechange = function(e) {
    if ( xhr.readyState === 4 ) {
      if ( xhr.status === 200 ) {
        // Success! Response is in xhr.responseText
      } else {
        // Error! Look in xhr.statusText
      }
    }
  }
  // Send the ajax request
  xhr.send(data)
}
// Update the progress bar
function update_progress(e, label) {
  if ( e.lengthComputable ) {
    var progress = label.getElementsByTagName('progress')[0]
    progress.setAttribute('value', e.loaded)
    progress.setAttribute('max', e.total)
  }
}

Receive the file on the server

Here's a simple Sinatra app that receives the data and writes it to disk. Note that this is very different than a traditional upload, where a tmpfile is written to disk for you. Instead, you simply receive the file contents (as a base64 string) through a form field, just like any other form data.

require 'sinatra'
require 'base64'
post '/ajax-upload' do
  File.open("/tmp/upload-#{params[:filename]}", 'w') do |f|
    f.puts Base64.decode64(params[:data])
  end
  'huzzah!'
end

That's it. My example app adds a few bells and whistles, but these basics are the meat of it. (Of course a production-ready implementation would need to provide some kind of fallback for older browsers.) Now go forth and make uploads better for everyone!

comments powered by Disqus