TBR reader/web: adds UI for drag and drop device-set management.

Adds event handling from initial work done by @jwnichls so that drag and drop
interactions are possible on lists of devices presented via a modal overlay.

There is still some work left which is documented in #38.

Change-Id: I347195ba1447913382e013e99f9bf4cecadc12aa
diff --git a/web/Makefile b/web/Makefile
index e32d7f8..18f1b35 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -125,12 +125,10 @@
 		--v23.credentials="credentials" \
 		--v23.permissions.literal='{"Admin":{"In":["..."]},"Write":{"In":["..."]},"Read":{"In":["..."]},"Resolve":{"In":["..."]},"Debug":{"In":["..."]}}'
 
-.PHONY:
 vdl: browser/vanadium/vdl/index.js
-	@true  # silence "make: Nothing to be done for `vdl'."
 
 browser/vanadium/vdl/index.js: $(vdl_files)
 	VDLPATH=$(VDLPATH) vdl generate --lang javascript \
 		-js-out-dir ./browser/vanadium \
 		vdl
-
+	@touch $@
diff --git a/web/browser/components/device-set/index.js b/web/browser/components/device-set/index.js
index 5b60ea5..bc1ad58 100644
--- a/web/browser/components/device-set/index.js
+++ b/web/browser/components/device-set/index.js
@@ -7,7 +7,9 @@
 var extend = require('xtend');
 var file = require('../file');
 var hg = require('mercury');
+var modal = require('../modal');
 var read = require('../../dom/read-blob');
+var util = require('../../util');
 var uuid = require('uuid').v4;
 
 module.exports = {
@@ -30,6 +32,8 @@
   var atom = hg.state({
     id: hg.value(options.id),
     error: hg.value(null),
+    modal: modal.state(options.modal),
+
     file: file.state(options.file),
     pdf: hg.value(null),
     pages: hg.varhash({
@@ -37,12 +41,27 @@
       current: options.pages.current || 1,
     }),
     progress: hg.value(0),
+
     devices: hg.varhash(options.devices, device.state),
+
+    manager: hg.struct({
+      active: hg.value(true),
+      dragID: hg.value(''),
+      overID: hg.value(''),
+    }),
     channels: {
       load: load,
       previous: previous,
       next: next,
-      manage: manage
+
+      manage: manage,
+
+      unlink: unlink,
+      link: link,
+
+      drag: drag,
+      reorder: reorder,
+      reset: reset
     }
   });
 
@@ -130,10 +149,85 @@
 }
 
 function manage(state, data) {
-  debug('manage device set: %s', state.id());
+  state.modal.active.set(true);
+}
+
+function unlink(state, data) {
+  var device = state.devices.get(data.id);
+  device.linked.set(false);
+  device.index.set(null);
+
+  // TODO(jasoncampbell): Refactor so that re-indexing is not required.
+  util
+  .toArray(state.devices)
+  .sort(function sortByIndex(a, b) {
+    var value = 0;
+
+    if (a.index > b.index) {
+      value = 1;
+    }
+
+    if (a.index < b.index) {
+      value = -1;
+    }
+
+    return value;
+  }).filter(function filter(device) {
+    return device.linked();
+  }).forEach(function (device, index) {
+    debug('reindexing: %s', device.id());
+    device.index.set(index);
+  });
+
+  // Reset the manager.
+  reset(state, data);
+}
+
+function link(state, data) {
+  var device = state.devices.get(data.id);
+
+  // This could go away once the drag handlers broadcast correctly.
+  if (! data.id || !device || device.linked()) {
+    return;
+  }
+
+  // TODO(jasoncampbell): Refactor so that re-indexing is not required.
+  var linked = util.toArray(state.devices()).filter(function(device) {
+    return device.linked;
+  });
+
+  device.linked.set(true);
+  device.index.set(linked.length);
 }
 
 // Prevent circular references when serializing state.
 function _PDFDocumentProxyToJSON() {
   return {};
 }
+
+function drag(state, data) {
+  if (data.dragging) {
+    state.manager.dragID.set(data.id);
+  } else {
+    state.manager.dragID.set('');
+  }
+}
+
+function reorder(state, data) {
+  if (!data.dragging) {
+    return;
+  }
+
+  var droptarget = state.devices.get(data.droptarget);
+  var dragtarget = state.devices.get(data.dragtarget);
+  var index = droptarget.index();
+
+  // Swap drag and drop target indexes.
+  droptarget.index.set(dragtarget.index());
+  dragtarget.index.set(index);
+}
+
+function reset(state, data) {
+  state.manager.overID.set('');
+  state.manager.dragID.set('');
+}
diff --git a/web/browser/components/device-set/manager.css b/web/browser/components/device-set/manager.css
new file mode 100644
index 0000000..63b6b98
--- /dev/null
+++ b/web/browser/components/device-set/manager.css
@@ -0,0 +1,31 @@
+/* Copyright 2015 The Vanadium Authors. All rights reserved. */
+/* Use of this source code is governed by a BSD-style */
+/* license that can be found in the LICENSE file. */
+
+@import "../base/variables.css";
+@import "../base/typography.css";
+
+.manager .devices-linked,
+.manager .devices-unlinked {
+  margin-bottom: var(--gutter);
+}
+
+.manager .devices-unlinked {
+  border: 1px solid yellow;
+}
+
+.manager .title {
+  inherits: .type-title;
+  margin-bottom: var(--gutter);
+}
+
+.manager .list {
+  border: 1px solid red;
+}
+
+.manager .item {
+  margin: var(--gutter);
+  padding: var(--gutter);
+  cursor: move;
+  border: 1px solid magenta;
+}
diff --git a/web/browser/components/device-set/pdf-viewer.css b/web/browser/components/device-set/pdf-viewer.css
index 662cb45..4e20723 100644
--- a/web/browser/components/device-set/pdf-viewer.css
+++ b/web/browser/components/device-set/pdf-viewer.css
@@ -41,7 +41,7 @@
   align-items: center;
   background-color: var(--white);
   box-shadow: var(--drop-shadow);
-  z-index: 900;
+  z-index: 4;
   display: flex;
 }
 
diff --git a/web/browser/components/device-set/render.js b/web/browser/components/device-set/render.js
index 296ae3b..1ed0566 100644
--- a/web/browser/components/device-set/render.js
+++ b/web/browser/components/device-set/render.js
@@ -5,27 +5,35 @@
 var click = require('../../events/click');
 var css = require('./pdf-viewer.css');
 var debug = require('debug')('reader:device-set');
+var drag = require('../../events/drag');
+var dragover = require('../../events/dragover');
+var drop = require('../../events/drop');
 var format = require('format');
 var h = require('mercury').h;
 var hg = require('mercury');
 var insert = require('insert-css');
+var managerCSS = require('./manager.css');
+var modal = require('../modal');
 var PDFWidget = require('./pdf-widget');
 
 module.exports = render;
 
 function render(state, channels) {
+  debug('render: %o', state);
+
   insert(css);
 
+  var node = manager(state, channels);
+
   return h('.pdf-viewer', [
     hg.partial(progress, state.progress),
     hg.partial(controls, state, channels),
-    h('.pdf-widget', new PDFWidget(state))
+    h('.pdf-widget', new PDFWidget(state)),
+    hg.partial(modal.render, state.modal, node)
   ]);
 }
 
 function progress(state) {
-  debug('progress: %s', state);
-
   if (state >= 100) {
     return h('.progress.hidden');
   }
@@ -76,3 +84,104 @@
   ]);
 }
 
+function manager(state, channels) {
+  // If the initiator is the current device show the device list manager UI.
+  // Else show the remote control manager.
+  insert(managerCSS);
+
+  // TODO(jasoncampbell): Refactor so that re-indexing is not required.
+  // Use a single loop to create each linked, and unlinked vnode children arrays
+  // which are sorted in the correct order.
+  var linked = [];
+  var unlinked = [];
+  var keys = Object.keys(state.devices);
+  var length = keys.length;
+  for (var i = 0; i < length; i++) {
+    var id = keys[i];
+    var device = state.devices[id];
+    if (device.linked) {
+      // Add the node to the linked array at the correct index to preserve the
+      // order.
+      linked.push(device);
+    } else {
+      unlinked.push(device);
+    }
+  }
+
+  linked = linked.sort(function sort(a, b){
+    var value = 0;
+
+    if (a.index > b.index) {
+      value = 1;
+    }
+
+    if (a.index < b.index) {
+      value = -1;
+    }
+
+    return value;
+  });
+
+  return h('.manager', [
+    h('.devices-linked', {
+      'ev-event': [
+        dragover(channels.link, {
+          id: state.manager.dragID
+        }),
+        drop(channels.reset)
+      ]
+    }, [
+      h('.title', 'Linked devices (drag to reorder)'),
+      h('.list', linked.map(function (device) {
+        return hg.partial(item, device, state, channels);
+      }))
+    ]),
+    h('.devices-unlinked', {
+      'ev-event': drop(channels.unlink, {
+        id: state.manager.dragID
+      })
+    }, [
+      h('.title', 'Unlinked devices (drag here to unlink)'),
+      h('.list', unlinked.map(function (device) {
+        return hg.partial(item, device, state, channels);
+      }))
+    ])
+  ]);
+}
+
+function item(device, state, channels) {
+  if (state.manager.dragID === device.id) {
+    return h('.item.placeholder', {
+      'ev-event': drop(channels.reset)
+    }, format('PLACEHOLDER: %s - index: %s', device.id, device.index));
+  }
+
+  var current = device.current ? 'CURRENT' : '';
+
+  var classNames = [];
+  if (state.manager.overID) {
+    classNames.push('over');
+  }
+
+  if (device.current) {
+    classNames.push('current');
+  }
+
+  var events = [
+    drag(channels.drag, { id: device.id }),
+  ];
+
+  if (device.linked) {
+    // Only reorder linked devices.
+    events.push(dragover(channels.reorder, {
+      droptarget: device.id,
+      dragtarget: state.manager.dragID
+    }));
+  }
+
+  return h('.item', {
+    className: classNames.join(' '),
+    draggable: true,
+    'ev-event': events,
+  }, format('%s - index: %s %s', device.id, device.index, current));
+}
diff --git a/web/browser/components/device-sets/index.js b/web/browser/components/device-sets/index.js
index 5867b40..515e271 100644
--- a/web/browser/components/device-sets/index.js
+++ b/web/browser/components/device-sets/index.js
@@ -63,6 +63,13 @@
 
   ds.devices.put(d.id(), d);
   state.collection.put(ds.id(), ds);
+
+  // TODO(jasoncampbell): Remove once syncbase is hooked back up.
+  debug('adding 3 more fake devices for debugging');
+  for (var i = 0; i < 3; i++) {
+    d = device.state({ index: i + 1 });
+    ds.devices.put(d.id(), d);
+  }
 }
 
 function remove(state, data) {
diff --git a/web/browser/components/device/index.js b/web/browser/components/device/index.js
index 0d3b036..aee0031 100644
--- a/web/browser/components/device/index.js
+++ b/web/browser/components/device/index.js
@@ -17,12 +17,17 @@
 function state(options, key) {
   options = extend({
     id: key || uuid(),
-    screen: {}
+    linked: true,
+    screen: {},
+    index: 0
   }, options);
+
   debug('init: %o', options);
 
   var atom = hg.state({
     id: hg.value(options.id),
+    linked: hg.value(options.linked),
+    index: hg.value(options.index),
     current: hg.value(options.current || false),
     type: hg.value(options.type),
     alias: hg.value(options.alias),
diff --git a/web/browser/components/header/index.css b/web/browser/components/header/index.css
index 9a1ca7f..aa45eb8 100644
--- a/web/browser/components/header/index.css
+++ b/web/browser/components/header/index.css
@@ -14,7 +14,7 @@
   background-color: var(--cyan-800);
   box-shadow: var(--drop-shadow);
   color: var(--white);
-  z-index: 900;
+  z-index: 2;
 }
 
 header.hidden {
diff --git a/web/browser/components/modal/index.js b/web/browser/components/modal/index.js
new file mode 100644
index 0000000..92112f2
--- /dev/null
+++ b/web/browser/components/modal/index.js
@@ -0,0 +1,56 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var assert = require('assert');
+var click = require('../../events/click');
+var css = require('./modal.css');
+var extend = require('xtend');
+var h = require('mercury').h;
+var hg = require('mercury');
+var insert = require('insert-css');
+
+module.exports = {
+  state: state,
+  render: render
+};
+
+var defaults = {
+  active: false
+};
+
+function state(options) {
+  options = extend(defaults, options);
+
+  return hg.state({
+    active: hg.value(!!options.active),
+    channels: {
+      show: show,
+      hide: hide
+    }
+  });
+}
+
+function show(state, data) {
+  state.active.set(true);
+}
+
+function hide(state, data) {
+  state.active.set(false);
+}
+
+function render(state, vnode) {
+  assert.equal(vnode.constructor.name,
+    'VirtualNode',
+    'The second argument must be a VirtualNode.');
+  insert(css);
+
+  return h('.modal', {
+    className: state.active ? 'active' : 'hidden'
+  }, [
+    h('.modal-blocker', {
+      'ev-click': click(state.channels.hide)
+    }),
+    h('.modal-dialog', vnode)
+  ]);
+}
\ No newline at end of file
diff --git a/web/browser/components/modal/modal.css b/web/browser/components/modal/modal.css
new file mode 100644
index 0000000..78190db
--- /dev/null
+++ b/web/browser/components/modal/modal.css
@@ -0,0 +1,41 @@
+/* Copyright 2015 The Vanadium Authors. All rights reserved. */
+/* Use of this source code is governed by a BSD-style */
+/* license that can be found in the LICENSE file. */
+
+@import "../base/variables.css";
+
+.modal {
+  visibility: visible;
+}
+
+.modal.hidden {
+  visibility: hidden;
+}
+
+.modal-blocker {
+  position: fixed;
+  z-index: 888;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background-color: rgba(0, 0, 0, 0.24);
+}
+
+.modal-dialog {
+  position: absolute;
+  z-index: 999;
+  /* HACK: Header's title line-height + 2x the gutters. */
+  top: calc(20px + (var(--gutter) * 3));
+  right: 0;
+  bottom: 0;
+  left: 0;
+  border-radius: 2px;
+  box-shadow: var(--drop-shadow);
+  margin: var(--gutter);
+  padding: var(--gutter);
+  background-color: var(--blue-grey-25);
+  overflow: scroll;
+}
\ No newline at end of file
diff --git a/web/browser/components/mover/index.css b/web/browser/components/mover/index.css
deleted file mode 100644
index b220a53..0000000
--- a/web/browser/components/mover/index.css
+++ /dev/null
@@ -1,141 +0,0 @@
-/* Copyright 2015 The Vanadium Authors. All rights reserved. */
-/* Use of this source code is governed by a BSD-style */
-/* license that can be found in the LICENSE file. */
-
-@import "../base/variables.css";
-@import "../base/typography.css";
-
-.overlayBackground {
-  position: fixed;
-  top: 0;
-  left: 0;
-  background: rgba(0,0,0,0.75);
-  z-index: 100;
-  width: 100%;
-  height: 100%;
-}
-
-.addButton {
-  position: fixed;
-  background-color: var(--deeporange-A200);
-  color: var(--white);
-  z-index: 150;
-  width: 5%;
-  height: 96%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-size: 2em;
-}
-
-.addBeforeButton {
-  top: 2%;
-  left: 2%;
-}
-
-.addAfterButton {
-  top: 2%;
-  left: 93%;
-}
-
-.textButton {
-  background-color: var(--blue-grey-800);
-  color: var(--white-54);
-  z-index: 150;
-  width: 10%;
-  height: 3em;
-  margin: 20px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-size: 1em;
-}
-
-.cancelBeforeAfterButton {
-  position: fixed;
-  top: 40%;
-  left: 45%;
-}
-
-.movingThisDevice {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-  height: 100%;
-  width: 100%;
-}
-
-.moveDeviceRow {
-  display: flex;
-  flex-direction: row;
-  justify-content: center;
-  align-items: center;
-  width: 100%;
-  height: 150px;
-}
-
-.deviceMovementSpace {
-  display: flex;
-  flex-direction: row;
-  justify-content: center;
-  align-items: center;
-  width: 100%;
-}
-
-.buttonRow {
-  display: flex;
-  flex-direction: row;
-  justify-content: center;
-  align-items: center;
-  width: 100%;
-  height: 150px;
-}
-
-.moveDevice {
-  border: 2px solid var(--deeporange-A200);
-  background-color: var(--grey-700);
-  color: var(--deeporange-A200);
-  z-index: 200;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-size: 2em;
-  height: 75px;
-  width: 50px;
-  margin: 20px;
-}
-
-.moveTarget {
-  height: 25px;
-  width: 25px;
-  border-radius: 50%;
-  background-color: var(--grey-700);
-  z-index: 150;
-  margin: 20px;
-}
-
-.overMoveTarget {
-  height: 35px;
-  width: 35px;
-  background-color: var(--deeporange-A200);
-  margin: 15px;
-}
-
-.deviceTile {
-  border: 2px solid var(--white-54);
-  background-color: var(--grey-700);
-  color: var(--deeporange-A200);
-  z-index: 150;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  font-size: 2em;
-  height: 75px;
-  width: 50px;
-  margin: 20px;
-}
-
-.linkLabel {
-  color: var(--white-54);
-}
diff --git a/web/browser/components/mover/index.js b/web/browser/components/mover/index.js
deleted file mode 100644
index 80408b6..0000000
--- a/web/browser/components/mover/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-module.exports = {
-  state: require('./state'),
-  render: require('./render')
-};
diff --git a/web/browser/components/mover/render.js b/web/browser/components/mover/render.js
deleted file mode 100644
index c7233f1..0000000
--- a/web/browser/components/mover/render.js
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var hg = require('mercury');
-var h = require('mercury').h;
-var insert = require('insert-css');
-var css = require('./index.css');
-var dragdrop = require('../../events/dragdrop');
-var droptarget = require('../../events/droptarget');
-
-module.exports = render;
-
-function render(state, channels) {
-  insert(css);
-
-  var moving = state.moving;
-  var movingThisDevice = state.movingThisDevice;
-
-  return h('.moveOverlay', {
-      hidden: !moving,
-    }, [
-    h('.overlayBackground', [
-      movingThisDevice ?
-        hg.partial(moveThisDeviceControls, state, channels) :
-        hg.partial(moveOtherDeviceControls, state, channels)
-    ])
-  ]);
-}
-
-function moveOtherDeviceControls(state, channels) {
-  return h('.movingOtherDevice', [
-    h('button.addBeforeButton .addButton', {
-      'ev-click': hg.send(channels.before)
-    }, '+'),
-    h('button.textButton .cancelBeforeAfterButton', {
-      'ev-click': hg.send(channels.cancel)
-    }, 'Cancel'),
-    h('button.addAfterButton .addButton', {
-      'ev-click': hg.send(channels.after)
-    }, '+')
-  ]);
-}
-
-function moveThisDeviceControls(state, channels) {
-  var numDevicesInLinkedSet = state.numDevicesInLinkedSet;
-  var firstPageNum = state.firstPageNum;
-  var insertAtIndex = state.insertAtIndex;
-  var highlightAtIndex = state.highlightAtIndex;
-
-  var deviceTargetArray = [];
-  // This is Used to adjust page numbers of devices after an insert
-  var newPageOffset = 0;
-
-  // Create a move target and device tile for each device already in the set
-  for(var i = 0; i < numDevicesInLinkedSet; i++) {
-    if (i === insertAtIndex) {
-      // As long as the first page isn't page 1, put the new device before
-      // the first device page
-      var devicePageNum = firstPageNum + i;
-      if (i === 0 && firstPageNum !== 1) {
-        devicePageNum = firstPageNum - 1;
-      } else {
-        newPageOffset = 1;
-      }
-      deviceTargetArray.push(moveDevice(String(devicePageNum), channels));
-    } else {
-      deviceTargetArray.push(moveTarget(i,
-                             (i === highlightAtIndex),
-                             channels));
-    }
-    deviceTargetArray.push(h('.deviceTile',
-                             String(firstPageNum + i + newPageOffset)));
-  }
-
-  // Create the final item in the list of devices and targets
-  // ---
-  // If the insert index is equal to the number of devices in the set,
-  // then it will be added at the end of the existing set. Otherwise
-  // we show the final drop target
-  var finalItem;
-  if (insertAtIndex === numDevicesInLinkedSet) {
-    var pageNum = firstPageNum + numDevicesInLinkedSet;
-    finalItem = moveDevice(String(pageNum), channels);
-  } else {
-    var highlightLast = numDevicesInLinkedSet === highlightAtIndex;
-    finalItem = moveTarget(numDevicesInLinkedSet, highlightLast, channels);
-  }
-  deviceTargetArray.push(finalItem);
-
-  // The starting position of the device is above the device list, and
-  // can either be the device or a move target
-  var startNode;
-  if (insertAtIndex < 0) {
-    startNode = moveDevice('D', channels);
-  } else {
-    // We the number of devices + 1 as the index for the start move target
-    // to enable proper highlighting
-    var startTargetIndex = numDevicesInLinkedSet + 1;
-    var highlightStart = (numDevicesInLinkedSet + 1) === highlightAtIndex;
-    startNode = moveTarget(startTargetIndex, highlightStart, channels);
-  }
-
-  return h('.movingThisDevice', [
-    h('.moveDeviceRow', [
-      startNode,
-      h('.linkLabel', (insertAtIndex < 0) ? 'Unlinked' : 'Linked')
-    ]),
-    h('.deviceMovementSpace', deviceTargetArray),
-    h('.buttonRow', [
-      h('button.textButton', {
-        'ev-click': hg.send(channels.cancel)
-      }, 'Cancel'),
-      h('button.textButton', {
-        'ev-click': hg.send(channels.commit)
-      }, 'OK')
-    ])
-  ]);
-}
-
-function moveDevice(label, channels) {
-  return h('.moveDevice', {
-    draggable: true,
-    'ev-mousedown': dragdrop(channels.dragend)
-  }, label);
-}
-
-function moveTarget(index, highlightFlag, channels) {
-  var classNames = '.moveTarget' + (highlightFlag ? ' .overMoveTarget' : '');
-
-  return h(classNames, {
-    'ev-dragenter': droptarget(channels.dragenter, { index: index }),
-    'ev-dragover': droptarget(channels.dragover, { index: index }),
-    'ev-dragleave': hg.send(channels.dragleave, { index: index }),
-    'ev-drop': hg.send(channels.selected, { index: index })
-  },[]);
-}
diff --git a/web/browser/components/mover/state.js b/web/browser/components/mover/state.js
deleted file mode 100644
index 9cd5090..0000000
--- a/web/browser/components/mover/state.js
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var hg = require('mercury');
-var debug = require('debug')('reader:mover');
-
-module.exports = create;
-
-function create(options) {
-  debug('creating Mover state: %o', options);
-
-  var state = hg.state({
-    error: hg.value(null),
-    moving: hg.value(false),
-    movingThisDevice: hg.value(false),
-    insertAtIndex: hg.value(-1),
-    highlightAtIndex: hg.value(-1),
-    channels: {
-      // User clicks to add/move the device before this one
-      before: previous,
-      // User clicks to add/move the device after this one
-      after: next,
-      // User clicks the cancel button
-      cancel: cancel,
-      // User selects location for new device manually (only on moving device)
-      selected: link,
-      // User clicks ok after selecting a new device location
-      commit: commit,
-      // Drop target channels
-      dragenter: highlightTarget,
-      dragleave: unhighlightTarget,
-      // 'dragenter' and 'dragover' events that occur on valid drop targets
-      // need to have event.preventDefault() called.  This happens in the
-      // droptarget event handler, which then broadcasts to a channel.
-      // In the case of dropover, nothing else needs to happen, thus the
-      // need for this channel handler.
-      dragover: noop,
-      // 'dragend' is also caught by the dragdrop event handler and broadcast
-      // to a channel, but we do not currently have anything to do when this
-      // happens.  A successful drop is handled by the drop event, which is
-      // communicated via the commit channel.
-      dragend: noop
-    },
-    // These states should be linked into syncbase, but are here now until
-    // that's ready
-    numDevicesInLinkedSet: hg.value(4),
-    firstPageNum: hg.value(2)
-  });
-
-  state.error(function(err) {
-    if (!err) {
-      return;
-    }
-
-    console.error('TODO: add an error component');
-    console.error(err.stack);
-  });
-
-  return state;
-}
-
-function previous(state, data) {
-  // TODO(jwnichols): This result of this decision needs to be determined and
-  // then echoed to other devices
-  state.moving.set(false);
-}
-
-function next(state, data) {
-  // TODO(jwnichols): This result of this decision needs to be determined and
-  // then echoed to other devices
-  state.moving.set(false);
-}
-
-function cancel(state, data) {
-  // Leave the move state without making any changes
-  // TODO(jwnichols): This may need to be echoed to other devices
-  state.moving.set(false);
-}
-
-function link(state, data) {
-  if (data.index > state.numDevicesInLinkedSet()) {
-    state.insertAtIndex.set(-1);
-  } else {
-    state.insertAtIndex.set(data.index);
-  }
-}
-
-function commit(state, data) {
-  // TODO(jwnichols): This result of this decision needs to be determined and
-  // then echoed to other devices
-  state.moving.set(false);
-}
-
-function highlightTarget(state, data) {
-  state.highlightAtIndex.set(data.index);
-}
-
-function unhighlightTarget(state, data) {
-  state.highlightAtIndex.set(-1);
-}
-
-function noop(state, data) {
-  // The equivalent of a dev/null channel method
-}
diff --git a/web/browser/events/drag.js b/web/browser/events/drag.js
new file mode 100644
index 0000000..d5ba0ed
--- /dev/null
+++ b/web/browser/events/drag.js
@@ -0,0 +1,91 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var BaseEvent = require('mercury').BaseEvent;
+var extend = require('xtend');
+var hg = require('mercury');
+
+var delegator = hg.Delegator();
+
+// Listen to the low frequency, drag related events. The high frequency "data"
+// event is managed directly during event registration and removal in the drag
+// handler.
+delegator.listenTo('dragstart');
+delegator.listenTo('dragend');
+
+module.exports = BaseEvent(handleDrag); // jshint ignore:line
+
+// # var drag = require('./events/drag')
+//
+// Use as a drag handler in virtual-dom using either ev-event or ev-mousedown.
+// NOTE: The `draggable` attribute must be set to true on the vnode.
+//
+//     h('.drag-me', {
+//       draggable: true,
+//       'ev-event': drag(sink, { foo: 'bar' })
+//     })
+//
+// The `drag` handler will manage all event registration and listening across
+// the lifespan of dragging on the target element. The broadcast to the `sink`
+// will happen with an extra attribute merged into the passed in data:
+//
+// * dragstart: { dragging: true }
+// * dragend: { dragging: false }
+//
+// Additionally, on the dragstart event any passed in data will be JSON encoded
+// and attached to the native event's dataTransfer object. This enables it's
+// retrieval from the drop DOM event (handled by `./events/drop`).
+//
+// NOTE: Currently this handle does not broadcast on high-frequency drag events,
+// the necessary code has been stubbed out below for easy modification in the
+// future.
+function handleDrag(ev, broadcast) {
+  // Only handle mousedown events, allows usage of ev-events for simplicity.
+  if (ev.type !== 'mousedown') {
+    return;
+  }
+
+  var data = this.data;
+  var element = ev.target;
+
+  delegator.listenTo('drag');
+  delegator.addEventListener(element, 'dragstart', dragstart);
+  delegator.addEventListener(element, 'drag', drag);
+  delegator.addEventListener(element, 'dragend', dragend);
+
+  // NOTE: Do not broadcast until the actual drag events have been fired.
+  return;
+
+  // Fired when the user starts dragging the target element.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/dragstart
+  function dragstart(event) {
+    // Using the raw DOM Event to access the DataTransfer object to set
+    // draggable data and the drag effect. This makes it possible for drop
+    // targets to access the data later.
+    //
+    // NOTE: dragover events do not have access to the data set below.
+    // SEE: https://goo.gl/fpwfP3
+    var raw = event._rawEvent;
+    raw.dataTransfer.setData('application/json', JSON.stringify(data));
+    raw.dataTransfer.effectAllowed = 'move';
+
+    broadcast(extend(data, { dragging: true }));
+  }
+
+  // Fires when the element is being dragged every few hundred ms. Currently a
+  // noop.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/drag
+  function drag(event) {}
+
+  // Fired when dragging has ended.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/dragend
+  function dragend(event) {
+    delegator.unlistenTo('drag');
+    delegator.removeEventListener(element, 'dragstart', dragstart);
+    delegator.removeEventListener(element, 'drag', drag);
+    delegator.removeEventListener(element, 'dragend', dragend);
+
+    broadcast(extend(data, { dragging: false }));
+  }
+}
diff --git a/web/browser/events/dragdrop.js b/web/browser/events/dragdrop.js
deleted file mode 100644
index 3e2a9c6..0000000
--- a/web/browser/events/dragdrop.js
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var hg = require('mercury');
-var BaseEvent = require('mercury').BaseEvent;
-
-module.exports = BaseEvent(dragHandler); // jshint ignore:line
-
-function dragHandler(ev, broadcast) {
-  var delegator = hg.Delegator();
-  var events = ['dragstart', 'dragenter', 'dragover', 'drop',
-                'dragend', 'dragleave'];
-
-  function onstart(ev2) {
-    // get the dataTransfer object out of the underlying event
-    var dt = ev2._rawEvent.dataTransfer;
-
-    // Set the data type to something unique to ensure our device cannot
-    // be dragged to anything else.  Despite the name, no real data is
-    // being transferred here.
-    dt.setData('application/vnd.vanadium.device', 'vanadium device');
-
-    // Set the drag effect
-    dt.effectAllowed = 'move';
-
-    // Remove the drag start listener
-    delegator.removeGlobalEventListener('dragstart', onstart);
-  }
-
-  function oncomplete(ev2) {
-    // Merges passed in data and broadcasts
-    broadcast(this.data);
-
-    events.forEach(function(arg) { delegator.unlistenTo(arg); });
-    delegator.removeGlobalEventListener('mouseup', oncomplete);
-    delegator.removeGlobalEventListener('dragend', oncomplete);
-    delegator.removeGlobalEventListener('dragstart', onstart);
-  }
-
-  events.forEach(function(arg) { delegator.listenTo(arg); });
-  delegator.addGlobalEventListener('dragstart', onstart);
-  delegator.addGlobalEventListener('mouseup', oncomplete);
-  delegator.addGlobalEventListener('dragend', oncomplete);
-}
diff --git a/web/browser/events/dragover.js b/web/browser/events/dragover.js
new file mode 100644
index 0000000..474b4ae
--- /dev/null
+++ b/web/browser/events/dragover.js
@@ -0,0 +1,59 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var BaseEvent = require('mercury').BaseEvent;
+var extend = require('xtend');
+var hg = require('mercury');
+
+// Listen to the low frequency, drop related events.
+var delegator = hg.Delegator();
+delegator.listenTo('dragenter');
+delegator.listenTo('dragleave');
+delegator.listenTo('drop');
+
+module.exports = BaseEvent(handleDragover); // jshint ignore:line
+
+function handleDragover(ev, broadcast) {
+  // Only handle dragenter events, allows usage of ev-events for simplicity.
+  if (ev.type !== 'dragenter') {
+    return;
+  }
+
+  var element = ev.target;
+  var data = this.data;
+
+  delegator.listenTo('dragover');
+  delegator.addEventListener(element, 'dragover', dragover);
+  delegator.addEventListener(element, 'dragleave', dragleave);
+  delegator.addEventListener(element, 'drop', drop);
+
+  broadcast(extend(data, {
+    dragging: true
+  }));
+
+  return;
+
+  // Attached to the dragover event, this handler fires every few hundred ms.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/dragover
+  function dragover(event) {
+    // Prevent default in order to allow/enable the drop event to fire.
+    event.preventDefault();
+  }
+
+  function dragleave(event) {
+    delegator.unlistenTo('dragleave');
+    delegator.removeEventListener(element, 'dragover', dragover);
+    delegator.removeEventListener(element, 'dragleave', dragleave);
+    delegator.removeEventListener(element, 'drop', drop);
+
+    broadcast(extend(data, {
+      dragging: false
+    }));
+  }
+
+  function drop(event) {
+    dragleave(event);
+  }
+}
+
diff --git a/web/browser/events/drop.js b/web/browser/events/drop.js
new file mode 100644
index 0000000..997e41f
--- /dev/null
+++ b/web/browser/events/drop.js
@@ -0,0 +1,76 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var BaseEvent = require('mercury').BaseEvent;
+var extend = require('xtend');
+var hg = require('mercury');
+
+// Listen to the low frequency, drop related events.
+var delegator = hg.Delegator();
+delegator.listenTo('dragenter');
+delegator.listenTo('dragleave');
+delegator.listenTo('drop');
+
+module.exports = BaseEvent(handleDrop); // jshint ignore:line
+
+// # var drop = require('./events/drop')
+//
+// Use as a drop event handler in virtual-dom using ev-event or ev-dragenter.
+//
+//     h('.drop-here', {
+//       'ev-event': drop(sink, data)
+//     })
+//
+// The drop handler will only broadcast on a successful drop and will send a
+// merged data object containing any dragged data added by any drag handlers.
+function handleDrop(ev, broadcast) {
+  // Only handle dragenter events, allows usage of ev-events for simplicity.
+  if (ev.type !== 'dragenter') {
+    return;
+  }
+
+  var element = ev.target;
+  var data = this.data;
+
+  delegator.listenTo('dragover');
+  delegator.addEventListener(element, 'dragover', dragover);
+  delegator.addEventListener(element, 'dragleave', dragleave);
+  delegator.addEventListener(element, 'drop', drop);
+
+  // Attached to the dragover event, this handler fires every few hundred ms.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/dragover
+  function dragover(event) {
+    // Prevent default in order to allow/enable the drop event to fire.
+    event.preventDefault();
+  }
+
+  // Fires on on drop, will broadcast dropped data and detach all listeners.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/drop
+  function drop(event) {
+    var raw = event._rawEvent;
+    var json = raw.dataTransfer.getData('application/json');
+    var dropped;
+
+    try {
+      dropped = JSON.parse(json);
+    } catch (e) {
+      throw new Error('Error parsing JSON "' + json + '"');
+    }
+
+    // Since the passed data is derived from application state it is given
+    // precedence over the dataTransfer.
+    broadcast(extend(dropped, data));
+    dragleave(event);
+  }
+
+  // Fired when dragging is no longer over the droptarget.
+  // SEE: https://developer.mozilla.org/en-US/docs/Web/Events/dragleave
+  function dragleave(event) {
+    delegator.unlistenTo('dragover');
+    delegator.removeEventListener(element, 'dragover', dragover);
+    delegator.removeEventListener(element, 'dragleave', dragleave);
+    delegator.removeEventListener(element, 'drop', drop);
+  }
+}
+
diff --git a/web/browser/events/droptarget.js b/web/browser/events/droptarget.js
deleted file mode 100644
index aa46035..0000000
--- a/web/browser/events/droptarget.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var BaseEvent = require('mercury').BaseEvent;
-
-module.exports = BaseEvent(dropTargetHandler); // jshint ignore:line
-
-function dropTargetHandler(ev, broadcast) {
-  // Get the dataTransfer object out of the underlying event
-  var dt = ev._rawEvent.dataTransfer;
-
-  // Only fire this event if a Vanadium device is being dragged
-  if (dt.types.indexOf('application/vnd.vanadium.device') >= 0) {
-    ev.preventDefault();
-    broadcast(this.data);
-  }
-}
diff --git a/web/package.json b/web/package.json
index e8a8c4a..094463c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,7 +12,7 @@
   },
   "license": "BSD",
   "devDependencies": {
-    "browserify": "^11.0.1",
+    "browserify": "^12.0.1",
     "concat-stream": "^1.5.0",
     "dependency-check": "^2.5.1",
     "disc": "^1.3.2",