Bug 1338095 - [Mortar] Implement "View bookmark" button in PDF viewer. f=lchang, f=lochang, r=ehung draft
authorRex Lee <rexboy@mozilla.com>
Wed, 22 Feb 2017 19:51:52 +0800
changeset 495636 225d387fe5cfe798a5cbfaa7e88790c5a83425ec
parent 493721 8d026c60151005ad942e3d4389318fe28a0c8c54
child 548430 7fc505dc238541b5d8000c91690bc27072e2d4e3
push id48392
push userbmo:rexboy@mozilla.com
push dateThu, 09 Mar 2017 04:14:56 +0000
reviewersehung
bugs1338095
milestone54.0a1
Bug 1338095 - [Mortar] Implement "View bookmark" button in PDF viewer. f=lchang, f=lochang, r=ehung MozReview-Commit-ID: 2W0Fqy9KGuP
browser/extensions/mortar/host/common/ppapi-runtime.jsm
browser/extensions/mortar/host/pdf/chrome/js/toolbar.js
browser/extensions/mortar/host/pdf/chrome/js/viewport.js
browser/extensions/mortar/host/pdf/chrome/style/viewer.css
browser/extensions/mortar/host/pdf/chrome/viewer.html
browser/extensions/mortar/host/pdf/ppapi-content-sandbox.js
--- a/browser/extensions/mortar/host/common/ppapi-runtime.jsm
+++ b/browser/extensions/mortar/host/common/ppapi-runtime.jsm
@@ -6,16 +6,17 @@
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/ctypes.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://ppapi.js/opengles2-utils.jsm");
+Cu.importGlobalProperties(['URL']);
 
 const PP_OK = 0;
 const PP_OK_COMPLETIONPENDING = -1;
 const PP_ERROR_FAILED = -2;
 const PP_ERROR_ABORTED = -3;
 const PP_ERROR_BADARGUMENT = -4;
 const PP_ERROR_BADRESOURCE = -5;
 const PP_ERROR_NOINTERFACE = -6;
@@ -1533,22 +1534,39 @@ class PPAPIInstance {
     this.mm = mm;
     this.eventHandlers = 0;
     this.filteringEventHandlers = 0;
     this.throttled_ = false;
     this.cachedImageData = null;
     this.viewport = new PPAPIViewport(this);
     this.selectedText = "";
 
+    this.notifyHashChange(info.url);
+
     this.mm.addMessageListener("ppapi.js:fullscreenchange", (evt) => {
       this.viewport.notify({
         type: "fullscreenChange",
         fullscreen: evt.data.fullscreen
       });
     });
+
+    this.mm.addMessageListener("ppapipdf.js:hashchange", (evt) => {
+      this.notifyHashChange(evt.data.url);
+    });
+  }
+
+  notifyHashChange(url) {
+    let location = new URL(url);
+    if (location.hash) {
+      this.viewport.notify({
+        type: "hashChange",
+        // substring(1) for getting rid of the first '#' character
+        hash: location.hash.substring(1)
+      });
+    }
   }
 
   bindGraphics(graphicsDevice) {
     if (graphicsDevice) {
       let canvas = graphicsDevice.canvas;
 
       // FIXME This size should be adjusted according to devicePixelRatio.
       canvas.style.width = canvas.width;
@@ -1668,16 +1686,19 @@ class PPAPIInstance {
   viewportActionHandler(message) {
     switch(message.type) {
       case 'setFullscreen':
         this.mm.sendAsyncMessage("ppapi.js:setFullscreen", message.fullscreen);
         break;
       case 'save':
         this.mm.sendAsyncMessage("ppapipdf.js:save");
         break;
+      case 'setHash':
+        this.mm.sendAsyncMessage("ppapipdf.js:setHash", message.hash);
+        break;
       case 'viewport':
       case 'rotateClockwise':
       case 'rotateCounterclockwise':
       case 'selectAll':
       case 'getSelectedText':
       case 'getNamedDestination':
       case 'getPasswordComplete':
         let data = PP_Var.fromJSValue(new Dictionary(message), this);
--- a/browser/extensions/mortar/host/pdf/chrome/js/toolbar.js
+++ b/browser/extensions/mortar/host/pdf/chrome/js/toolbar.js
@@ -205,16 +205,20 @@ class Toolbar {
         break;
       case 'presentationMode':
       case 'secondaryPresentationMode':
         this._viewport.fullscreen = true;
         break;
       case 'secondaryToolbarToggle':
         this._secondaryToolbar.toggle();
         break;
+      case 'viewBookmark':
+      case 'secondaryViewBookmark':
+        this._viewport.createBookmarkHash();
+        break;
     }
   }
 
   _pageNumberChanged() {
     let newPage = parseFloat(this._elements.pageNumber.value);
     if (!Number.isInteger(newPage) ||
         newPage < 1 || newPage > this._viewport.pageCount) {
       this._elements.pageNumber.value = this._viewport.page + 1;
--- a/browser/extensions/mortar/host/pdf/chrome/js/viewport.js
+++ b/browser/extensions/mortar/host/pdf/chrome/js/viewport.js
@@ -1,16 +1,23 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 'use strict';
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
+// When handling Bookmark View, we use Cartesian coordinate system which is
+// different from PDFium engine. Our origin is at bottom-left of every page,
+// while PDFium counts y position continuously from the top of page 1.
+// Moreover, the coordinate used in PDF.js is scaled by 0.75 for some reason,
+// so we keep it here for backward compability.
+const PAGE_COORDINATE_RATIO = 0.75;
+
 class Viewport {
   constructor() {
     this._viewerContainer = document.getElementById('viewerContainer');
     this._fullscreenWrapper = document.getElementById('fullscreenWrapper');
     this._canvasContainer = document.getElementById('canvasContainer');
     this._viewportController = document.getElementById('viewportController');
     this._sizer = document.getElementById('sizer');
 
@@ -29,16 +36,20 @@ class Viewport {
     // if "scroll" event gives us a different value.
     this._runtimePosition = this.getScrollOffset();
 
     // Similar to above. Will notify runtime only if "_notifyRuntimeOfResized()"
     // gets a different value.
     this._runtimeSize = this.getBoundingClientRect();
     this._runtimeOnResizedListener = [];
 
+    // If the document is opened with a bookmarkView hash, we save it until
+    // the document dimension is revealed to move the view.
+    this._initPosition = null;
+
     this.onProgressChanged = null;
     this.onZoomChanged = null;
     this.onDimensionChanged = null;
     this.onPageChanged = null;
     this.onPasswordRequest = null;
 
     this._viewportController.addEventListener('scroll', this);
     this._viewportController.addEventListener('copy', this);
@@ -59,18 +70,17 @@ class Viewport {
     }
   }
 
   get fitting() {
     return this._fitting;
   }
 
   set fitting(newFitting) {
-    let VALID_VALUE = ['none', 'auto', 'page-actual', 'page-width', 'page-fit'];
-    if (!VALID_VALUE.includes(newFitting)) {
+    if (!this._isValidFitting(newFitting)) {
       return;
     }
 
     if (newFitting != this._fitting) {
       this._fitting = newFitting;
       this._setZoom(this._computeFittingZoom());
       this._refresh();
     }
@@ -121,16 +131,21 @@ class Viewport {
     //      we need to worry about though.
     this._fullscreenStatus = 'changing';
     this._doAction({
       type: 'setFullscreen',
       fullscreen: enable
     });
   }
 
+  _isValidFitting(fitting) {
+    let VALID_VALUE = ['none', 'auto', 'page-actual', 'page-width', 'page-fit'];
+    return VALID_VALUE.includes(fitting);
+  }
+
   _getScrollbarWidth() {
     let div = document.createElement('div');
     div.style.visibility = 'hidden';
     div.style.overflow = 'scroll';
     div.style.width = '50px';
     div.style.height = '50px';
     div.style.position = 'absolute';
     document.body.appendChild(div);
@@ -170,17 +185,23 @@ class Viewport {
   _setDocumentDimensions(documentDimensions) {
     this._documentDimensions = documentDimensions;
     this._pageDimensions = documentDimensions.pageDimensions;
     this._setZoom(this._computeFittingZoom());
     this._setPage(this._page);
     if (typeof this.onDimensionChanged === 'function') {
       this.onDimensionChanged();
     }
-    this._refresh();
+
+    if (this._initPosition) {
+      this._jumpToBookmark(this._initPosition);
+      this._initPosition = null;
+    } else {
+      this._refresh();
+    }
   }
 
   _computeFittingZoom(pageIndex) {
     let newZoom = this._zoom;
     let fitting = this._fitting;
 
     if (pageIndex === undefined) {
       pageIndex = this._page;
@@ -482,16 +503,102 @@ class Viewport {
     if (!text) {
       return;
     }
     const gClipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"]
                                 .getService(Ci.nsIClipboardHelper);
     gClipboardHelper.copyString(text);
   }
 
+  _getPageCoordinate() {
+    let currentPos = this._nextPosition || this.getScrollOffset();
+    let pageDimension = this._pageDimensions[this._page];
+
+    let pageOrigin = {
+      x: pageDimension.x,
+      y: pageDimension.y + pageDimension.height
+    };
+    currentPos.x /= this._zoom;
+    currentPos.y /= this._zoom;
+
+    let pageCoordinate = {
+      x: Math.round((currentPos.x - pageOrigin.x) * PAGE_COORDINATE_RATIO),
+      y: Math.round((pageOrigin.y - currentPos.y) * PAGE_COORDINATE_RATIO)
+    };
+    return pageCoordinate;
+  }
+
+  _getScreenCooridnate(pageNo, pageX, pageY) {
+    let pageDimension = this._pageDimensions[pageNo];
+    // Both pageX and pageY are omittable, and in this case, we assume the most
+    // top and left corner of the page as their default values.
+    pageX = Number.isInteger(pageX) ? pageX : 0;
+    pageY = Number.isInteger(pageY) ? pageY : pageDimension.height *
+                                              PAGE_COORDINATE_RATIO;
+    pageX /= PAGE_COORDINATE_RATIO;
+    pageY /= PAGE_COORDINATE_RATIO;
+
+    let pageOrigin = {
+      x: pageDimension.x,
+      y: pageDimension.y + pageDimension.height
+    };
+
+    return {
+      x: Math.round((pageX - pageOrigin.x) * this._zoom),
+      y: Math.round((pageOrigin.y - pageY) * this._zoom)
+    };
+  }
+
+  /**
+   * @param hash
+   *        contains page and zoom parameters which should be in the following
+   *        format: page={page}&zoom={scale},{x},{y}
+   *        for example the following hashes are valid:
+   *        page=1&zoom=auto,100,100
+   *        page=3&zoom=300,10,-50
+   */
+  _jumpToBookmark(hash) {
+    let params = {};
+    hash.split('&').forEach(param => {
+      let [name, value] = param.split('=');
+      params[name.toLowerCase()] = value.toLowerCase();
+    });
+
+    let pageNo = parseInt(params.page, 10);
+    pageNo = Number.isNaN(pageNo) ? this._page : pageNo;
+    pageNo = Math.max(0, Math.min(this.pageCount - 1, pageNo - 1));
+
+    params.zoom = (typeof(params.zoom) == 'string') ? params.zoom : "";
+    let [scale, pageX, pageY] = params.zoom.split(',');
+    pageX = parseInt(pageX, 10);
+    pageY = parseInt(pageY, 10);
+
+    if (this._isValidFitting(scale)) {
+      this._fitting = scale;
+    } else {
+      this._fitting = 'none';
+      let zoom = parseFloat(scale);
+      zoom = (Number.isNaN(zoom) || zoom <= 0) ? 100 : zoom;
+      this._zoom = zoom / 100;
+    }
+
+    let screenPos = this._getScreenCooridnate(pageNo, pageX, pageY);
+    this._setPosition(screenPos.x, screenPos.y);
+    this._setZoom(this._computeFittingZoom());
+    this._refresh();
+  }
+
+  _handleHashChange(hash) {
+    if (!this._documentDimensions) {
+      this._initPosition = hash;
+    } else {
+      this._jumpToBookmark(hash);
+    }
+  }
+
   verifyPassword(password) {
     this._doAction({
       type: 'getPasswordComplete',
       password: password
     });
   }
 
   handleEvent(evt) {
@@ -545,16 +652,31 @@ class Viewport {
 
   // A handler for delivering messages to runtime.
   registerActionHandler(handler) {
     if (typeof handler === 'function') {
       this._actionHandler = handler;
     }
   }
 
+  createBookmarkHash() {
+    let pagePosition = this._getPageCoordinate();
+    let scale = this._fitting == 'none' ?
+                  Math.round(this._zoom * 100) :
+                  this._fitting;
+    let hash = "page=" + (this._page + 1) +
+               "&zoom=" + scale +
+               "," + pagePosition.x +
+               "," + pagePosition.y;
+    this._doAction({
+      type: 'setHash',
+      hash: hash
+    })
+  }
+
   /***************************/
   /* PPAPIViewport Interface */
   /***************************/
 
   addView(canvas) {
     this._canvasContainer.appendChild(canvas);
   }
 
@@ -626,11 +748,14 @@ class Viewport {
       case 'getPassword':
         this.onPasswordRequest && this.onPasswordRequest();
         break;
       case 'getSelectedTextReply':
         // For now this message is used only by text copy so we handle just
         // that case.
         this._copyToClipboard(message.selectedText);
         break;
+      case 'hashChange':
+        this._handleHashChange(message.hash);
+        break;
     }
   }
 }
--- a/browser/extensions/mortar/host/pdf/chrome/style/viewer.css
+++ b/browser/extensions/mortar/host/pdf/chrome/style/viewer.css
@@ -884,21 +884,16 @@ html[dir='rtl'] .toolbarButton.pageDown:
   outline: none;
   padding-top: 4px;
   text-decoration: none;
 }
 .secondaryToolbarButton.bookmark {
   padding-top: 5px;
 }
 
-.bookmark[href='#'] {
-  opacity: .5;
-  pointer-events: none;
-}
-
 .toolbarButton.bookmark::before,
 .secondaryToolbarButton.bookmark::before {
   content: url(images/toolbarButton-bookmark.png);
 }
 html[dir="ltr"] #viewOutline.toolbarButton::before {
   content: url(images/toolbarButton-viewOutline.png);
 }
 html[dir="rtl"] #viewOutline.toolbarButton::before {
--- a/browser/extensions/mortar/host/pdf/chrome/viewer.html
+++ b/browser/extensions/mortar/host/pdf/chrome/viewer.html
@@ -69,19 +69,19 @@
             <button id="secondaryPrint" class="secondaryToolbarButton print visibleMediumView" title="Print" tabindex="53" data-l10n-id="print">
               <span data-l10n-id="print_label">Print</span>
             </button>
 
             <button id="secondaryDownload" class="secondaryToolbarButton download visibleMediumView" title="Download" tabindex="54" data-l10n-id="download">
               <span data-l10n-id="download_label">Download</span>
             </button>
 
-            <a href="#" id="secondaryViewBookmark" class="secondaryToolbarButton bookmark visibleSmallView" title="Current view (copy or open in new window)" tabindex="55" data-l10n-id="bookmark">
+            <button href="#" id="secondaryViewBookmark" class="secondaryToolbarButton bookmark visibleSmallView" title="Current view (copy or open in new window)" tabindex="55" data-l10n-id="bookmark">
               <span data-l10n-id="bookmark_label">Current View</span>
-            </a>
+            </button>
 
             <div class="horizontalToolbarSeparator visibleLargeView"></div>
 
             <button id="firstPage" class="secondaryToolbarButton firstPage" title="Go to First Page" tabindex="56" data-l10n-id="first_page">
               <span data-l10n-id="first_page_label">Go to First Page</span>
             </button>
             <button id="lastPage" class="secondaryToolbarButton lastPage" title="Go to Last Page" tabindex="57" data-l10n-id="last_page">
               <span data-l10n-id="last_page_label">Go to Last Page</span>
@@ -130,19 +130,19 @@
 
                 <button id="print" class="toolbarButton print hiddenMediumView" title="Print" tabindex="33" data-l10n-id="print">
                   <span data-l10n-id="print_label">Print</span>
                 </button>
 
                 <button id="download" class="toolbarButton download hiddenMediumView" title="Download" tabindex="34" data-l10n-id="download">
                   <span data-l10n-id="download_label">Download</span>
                 </button>
-                <a href="#" id="viewBookmark" class="toolbarButton bookmark hiddenSmallView" title="Current view (copy or open in new window)" tabindex="35" data-l10n-id="bookmark">
+                <button target="_blank" id="viewBookmark" class="toolbarButton bookmark hiddenMediumView" title="Current view (copy or open in new window)" tabindex="35" data-l10n-id="bookmark">
                   <span data-l10n-id="bookmark_label">Current View</span>
-                </a>
+                </button>
 
                 <div class="verticalToolbarSeparator hiddenSmallView"></div>
 
                 <button id="secondaryToolbarToggle" class="toolbarButton" title="Tools" tabindex="36" data-l10n-id="tools">
                   <span data-l10n-id="tools_label">Tools</span>
                 </button>
               </div>
               <div class="outerCenter">
--- a/browser/extensions/mortar/host/pdf/ppapi-content-sandbox.js
+++ b/browser/extensions/mortar/host/pdf/ppapi-content-sandbox.js
@@ -84,26 +84,37 @@ mm.addMessageListener("ppapi.js:frameLoa
 
   mm.sendAsyncMessage("ppapi.js:createInstance",  { type: "pdf", info },
                       { pluginWindow: containerWindow });
 
   containerWindow.document.addEventListener("fullscreenchange", () => {
     let fullscreen = (containerWindow.document.fullscreenElement == pluginElement);
     mm.sendAsyncMessage("ppapi.js:fullscreenchange", { fullscreen });
   });
+
+  containerWindow.addEventListener("hashchange", () => {
+    let url = containerWindow.location.href;
+    mm.sendAsyncMessage("ppapipdf.js:hashchange", { url });
+  })
 });
 
 mm.addMessageListener("ppapi.js:setFullscreen", ({ data }) => {
   if (data) {
     pluginElement.requestFullscreen();
   } else {
     containerWindow.document.exitFullscreen();
   }
 });
 
+mm.addMessageListener("ppapipdf.js:setHash", ({ data }) => {
+  if (data) {
+    containerWindow.location.hash = data;
+  }
+});
+
 mm.addMessageListener("ppapipdf.js:save", () => {
   let url = containerWindow.document.location;
   let filename = "document.pdf";
   let regex = /[^\/#\?]+\.pdf$/i;
 
   let result = regex.exec(url.hash) ||
                regex.exec(url.search) ||
                regex.exec(url.pathname);