reader/android: adds retina support to the web-view

A few items from the TODOs in #44:

* Render calls are now queued in two places: 1. via hg.app (only called once
every raf) 2. There is a special async render queue for PDF.js.
* Scale is set on the state (although there are no controls for it yet).
* Canvas width, height, and pixel backing ratio are managed via the state atom.

Change-Id: I893643436fcad5240529beaa32f7cdc65c9cefdd
diff --git a/android/app/src/main/java/io/v/android/apps/reader/PdfViewWrapper.java b/android/app/src/main/java/io/v/android/apps/reader/PdfViewWrapper.java
index 4af173b..7ceb503 100644
--- a/android/app/src/main/java/io/v/android/apps/reader/PdfViewWrapper.java
+++ b/android/app/src/main/java/io/v/android/apps/reader/PdfViewWrapper.java
@@ -73,7 +73,7 @@
      * NOTE: must be called after the page loading is finished.
      */
     public void loadPdfFile(String filePath) {
-        evaluateJavascript("window.atom.href.set(\"" + filePath + "\");", null);
+        evaluateJavascript("window.client.open(\"" + filePath + "\");", null);
 
         // leave the page count as 0 until the page count value is properly set from JS side.
         mPageCount = 0;
@@ -85,7 +85,7 @@
      * @param page the page number to jump to. Page number is one-based.
      */
     public void setPage(int page) {
-        evaluateJavascript("window.atom.pages.current.set(" + page + ");", null);
+        evaluateJavascript("window.client.page(" + page + ");", null);
     }
 
     public int getPageCount() {
diff --git a/web/Makefile b/web/Makefile
index 3adc980..9d2c4ee 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -43,7 +43,7 @@
 		$< 1> $@
 
 .DELETE_ON_ERROR:
-public/pdf-web-view.js: browser/pdf-web-view.js node_modules
+public/pdf-web-view.js: browser/pdf-web-view.js $(js_files) node_modules
 	browserify --debug $< 1> $@
 
 .PHONY:
diff --git a/web/browser/dom/raf-queue.js b/web/browser/dom/raf-queue.js
new file mode 100644
index 0000000..212bf32
--- /dev/null
+++ b/web/browser/dom/raf-queue.js
@@ -0,0 +1,42 @@
+// 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 raf = require('raf');
+
+var jobs = [];
+var id = null;
+
+module.exports = queue;
+
+// Queue asynchronous workers to fire on the next available animation frame.
+function queue(job) {
+  jobs.push(job);
+
+  // If the id is set there is a job execution happening, don't invoke next in
+  // this case, it will be called when the currently executing job is done.
+  if (!id) {
+    id = raf(next);
+  }
+
+}
+
+// Call the next job in the queue (if available).
+function next() {
+  if (jobs.length === 0) {
+    // Allows the next call to `queue(...)` to kick off the newly added job.
+    id = null;
+    return;
+  }
+
+  var job = jobs.shift();
+  job(done);
+
+  function done(err) {
+    if (err) {
+      throw err;
+    }
+
+    id = raf(next);
+  }
+}
\ No newline at end of file
diff --git a/web/browser/pdf-web-view.js b/web/browser/pdf-web-view.js
index 42071d0..af1f231 100644
--- a/web/browser/pdf-web-view.js
+++ b/web/browser/pdf-web-view.js
@@ -2,69 +2,24 @@
 // 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 canvas = require('./widgets/canvas-widget');
 var document = require('global/document');
 var domready = require('domready');
-var struct = require('observ-struct');
-var value = require('observ');
+var format = require('format');
+var h = require('mercury').h;
+var hg = require('mercury');
 var window = require('global/window');
 
-var atom = struct({
-  debug: value(true),
-  href: value(null),
-  pdf: struct({
-    document: value(null),
-    page: value(null)
-  }),
-  pages: struct({
-    current: value(1),
-    total: value(0),
-  }),
-  scale: value(1),
-  progress: value(0),
-});
-
-window.atom = atom;
-
-// Global cache of the canvas element.
-var canvas = null;
+// Allow debugging in a normal browser.
+window.android = window.android || {
+  setPageCount: noop
+};
 
 domready(function ondomready() {
   debug('domready');
 
-  // Initial DOM Node setup.
-  canvas = document.createElement('canvas');
-  canvas.setAttribute('class','pdf-canvas');
-  document.body.style.margin = '0px';
-  document.body.style.padding = '0px';
-  document.body.appendChild(canvas);
-
-  // Watch for changes on the atom.href value, when it updates load the PDF file
-  // located at that location.
-  // Trigger with: atom.href.set(value)
-  atom.href(function hrefchange(href) {
-    debug('loading pdf file: %s', href);
-    PDFJS
-      .getDocument(href, null, password, progress)
-      .then(setPDF, error);
-  });
-
-  // Watch for page number changes and asyncronosly load the page from PDF.js
-  // APIs.
-  // Trigger with: atom.pages.current.set(value)
-  atom.pages.current(function pagechange(current) {
-    var total = atom.pages.total();
-
-    // Skip invalid operations.
-    if (current === 0 || !atom.pdf.document() || current > total) {
-      return;
-    }
-
-    debug('loading page: %s of %s', current, total);
-
-    var pdf = atom.pdf.document();
-    var success = atom.pdf.page.set.bind(null);
-    pdf.getPage(current).then(success, error);
-  });
+  var atom = state({});
 
   // Watch for the total page number changes and give the new value to the
   // Android client.
@@ -72,63 +27,156 @@
     window.android.setPageCount(current);
   });
 
-  // Watch for changes on the PDF.js page object. When it is updated trigger a
-  // render.
-  // TODO(jasoncampbell): To prevent rendering errors with frequent state
-  // updates renders should be queued in a raf.
-  atom.pdf.page(function pagechange(page) {
-    debug('rendering page');
-    var ratio = window.devicePixelRatio || 1.0;
-    // TODO(jasoncampbell): Use state set scale instead of defaulting to 1.0.
-    var scale = window.innerWidth/page.getViewport(ratio).width;
-    var viewport = page.getViewport(scale);
+  window.atom = atom;
+  window.client = {
+    open: function openPDF(href) {
+      open(atom, { href: href });
+    },
+    page: function pagePDF(number) {
+      page(atom, { number: number });
+    }
+  };
 
-    canvas.height = viewport.height;
-    canvas.width = viewport.width;
+  hg.app(document.body, atom, render);
 
-    page.render({
-      canvasContext: canvas.getContext('2d'),
-      viewport: viewport
-    }).promise.then(noop, error);
-  });
+  return;
 });
 
-function setPDF(pdf) {
-  atom.pdf.document.set(pdf);
-  atom.pages.total.set(pdf.numPages);
-  atom.pages.current.set(1);
+function state(options) {
+  var atom = hg.state({
+    debug: hg.value(options.debug || true),
+    progress: hg.value(0),
+    pdf: hg.struct({
+      document: hg.value(null),
+      page: hg.value(null),
+    }),
+    pages: hg.struct({
+      current: hg.value(1),
+      total: hg.value(0),
+    }),
+    scale: hg.value(1),
+    ratio: hg.value(window.devicePixelRatio || 1),
+    width: hg.value(window.innerWidth),
+    height: hg.value(window.innerHeight),
+    channels: {
+      open: open,
+      page: page
+    }
+  });
+
+  return atom;
 }
 
-function progress(update) {
-  var float = (update.loaded/update.total) * 100;
-  var value = Math.floor(float);
+function open(state, data) {
+  assert.ok(data.href, 'data.href required');
+  debug('opening PDF file: %s', data.href);
 
-  // For some reason the update.loaded value above can be higher than the
-  // update.total value, in that case we can assume the progress is 100%.
-  if (value > 100) {
-    value = 100;
+  var promise = PDFJS.getDocument(data.href, null, password, progress);
+  promise.then(success, error);
+
+  function password() {
+    var message = format('Password required to open: "%s"', data.href);
+    var err = new Error(message);
+    error(err);
   }
 
-  atom.progress.set(value);
+  function progress(update) {
+    // Some servers or situations might not return the content-length header
+    // which is proxied to update.total. Skip updating the progress if this
+    // value is not set.
+    if (!update.total) {
+      return;
+    }
+
+    var float = (update.loaded/update.total) * 100;
+    var value = Math.floor(float);
+
+    // For some reason the update.loaded value above can be higher than the
+    // update.total value, in that case we can assume the progress is 100%.
+    if (value > 100) {
+      value = 100;
+    }
+
+    state.progress.set(value);
+  }
+
+  function success(pdf) {
+    state.pdf.document.set(pdf);
+    state.pages.total.set(pdf.numPages);
+    page(state, { number: 1 });
+  }
 }
 
-function password() {
-  debug('password required');
+function page(state, data) {
+  assert.ok(data.number, 'data.number required');
+
+  var pdf = state.pdf.document();
+  var total = state.pages.total();
+  var number = data.number;
+
+  // Skip invalid operations.
+  if (number === 0 || !pdf || number > total) {
+    return;
+  }
+
+  debug('loading page "%s"', number);
+
+  pdf.getPage(number).then(success, error);
+
+  function success(page) {
+    debug('loaded page "%s"', number);
+    var width = page.getViewport(1).width;
+    var scale = state.width() / width;
+    var viewport = page.getViewport(scale);
+
+    // Reset the scroll position on page change.
+    window.scroll(0, 0) ;
+
+    // Update the state.
+    state.pdf.page.set(page);
+    state.scale.set(scale);
+    state.height.set(viewport.height);
+    state.pages.current.set(number);
+  }
+}
+
+function render(state) {
+  return h('.pdf-viewer', [
+    canvas(draw, state)
+  ]);
+}
+
+
+function draw(context, state, done) {
+  // Skip render if missing the PDFJS page object.
+  if (!state.pdf.page) {
+    done();
+    return;
+  }
+
+  state.pdf.page.render({
+    canvasContext: context,
+    viewport: state.pdf.page.getViewport(state.scale)
+  }).promise.then(done, error);
 }
 
 // TODO(jasoncampbell): Add better error reporting and exception capturing.
 function error(err) {
-  debug('error: %s', err.stack);
+  throw err;
 }
 
 function noop() {}
 
-function debug(template, args) {
+function debug(template, value) {
   // Noop if debugging is disabled.
-  if (!atom.debug()) {
+  if (typeof window.atom === 'undefined' || !window.atom.debug()) {
     return;
   }
 
+  // The logging in Android Studio only shows the template string when calling
+  // console.log directly, pre-fromatting allows the logs to show the correct
+  // information.
   template = 'pdf-viewer: ' + template;
-  console.log.apply(console, arguments);
+  var message = format.apply(null, arguments);
+  console.log(message);
 }
diff --git a/web/browser/widgets/canvas-widget.js b/web/browser/widgets/canvas-widget.js
new file mode 100644
index 0000000..0250f04
--- /dev/null
+++ b/web/browser/widgets/canvas-widget.js
@@ -0,0 +1,103 @@
+// 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 document = require('global/document');
+var queue = require('../dom/raf-queue');
+
+module.exports = CanvasWidget;
+
+// # var widget = CanvasWidget(<draw function>, <state object>)
+//
+// A virtual-dom widget for creating and managing a canvas element which is
+// optimized for high density displays. The constructor takes two arguments:
+//
+// * draw: Function - called every update/render, the function will be called
+// with the arguments `context`, `state`, and `done`.
+// * state: Object - the
+// state object passed in during virtual-dom creation
+//
+// This widget is optimized for usage with PDF.js which has an async render
+// method. Render updates can happen at about 60 fps so some book keeping needs
+// to be done to queue PDF.js render calls to prevent multiple renders from
+// happening simultaneously (triggering weird pdf render bugs like upside down
+// text etc.). The queueing mechanism is simple but requires the `draw` function
+// to fire a callback when it's work is done.
+//
+// Example:
+//
+//     var state = {
+//       width: window.innerWidth,
+//       height: window.innerHeight,
+//       ratio: window.devicePixelRatio
+//     }
+//
+//     h('.pdf-viewer', [
+//       canvas(draw, state)
+//     ]);
+//
+//     function draw(context, state, done) {
+//       // Simulated async method which draws to the canvas context.
+//       setTimeout(function(){
+//         ctx.fillStyle = 'rgb(200,0,0)'
+//         ctx.fillRect(10, 10, 55, 50)
+//         done()
+//       }, 120)
+//     }
+//
+function CanvasWidget(draw, state) {
+  if (!(this instanceof CanvasWidget)) {
+    return new CanvasWidget(draw, state);
+  }
+
+  assert.ok(state.ratio, 'state.ratio is required');
+  assert.ok(state.width, 'state.width is required');
+  assert.ok(state.height, 'state.height is required');
+
+  this.draw = draw;
+  this.state = state;
+}
+
+CanvasWidget.prototype.type = 'Widget';
+
+CanvasWidget.prototype.init = function() {
+  var widget = this;
+  var canvas = document.createElement('canvas');
+  widget.update(null, canvas);
+  return canvas;
+};
+
+CanvasWidget.prototype.update = function(previous, element) {
+  var widget = this;
+  var state = widget.state;
+  var context = element.getContext('2d');
+
+  // In order to render appropriately on retina devices it is important to
+  // increase the size the the canvas element by the `window.devicePixelRatio`
+  // and then shrink the element back down to normal size with CSS. This will
+  // sharpen the image rendered by the canvas but decrease the rendered size. To
+  // get the size of the image back up to where it needs to be the canvas
+  // context needs to be scaled up by the `window.devicePixelRatio`. Simple.
+  //
+  // SEE: http://www.html5rocks.com/en/tutorials/canvas/hidpi/
+  //
+  // NOTE: This is done on update, which fires for every render call instead of
+  // in widget.init(), which will fire only when the element is first created
+  // and inserted into the DOM. Allowing this resizing to happen in the render
+  // loop makes it possible to handle resize events and update anytime the state
+  // values are updated.
+  element.width = Math.floor(state.width * state.ratio);
+  element.height = Math.floor(state.height * state.ratio);
+  element.style.width = Math.floor(state.width) + 'px';
+  element.style.height = Math.floor(state.height) + 'px';
+
+  // Scale the canvas to the the correct ratio. This must directly follow
+  // resizing.
+  context.scale(state.ratio, state.ratio);
+
+  // Queue the widget.draw function into the next available animation frame.
+  queue(function worker(done) {
+    widget.draw(context, widget.state, done);
+  });
+};
diff --git a/web/public/pdf-web-view.html b/web/public/pdf-web-view.html
index 701a420..b2bb1b7 100644
--- a/web/public/pdf-web-view.html
+++ b/web/public/pdf-web-view.html
@@ -1,2 +1,14 @@
 <script type="text/javascript" src="./pdf.js"></script>
-<script type="text/javascript" src="./pdf-web-view.js"></script>
\ No newline at end of file
+<script type="text/javascript" src="./pdf-web-view.js"></script>
+<meta name='viewport'
+  content='width=device-width,
+    initial-scale=1.0,
+    maximum-scale=1.0,
+    user-scalable=no' />
+
+<style media="screen">
+  html, body {
+    padding: 0;
+    margin: 0;
+  }
+</style>
\ No newline at end of file