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