preview_area.js revision 5821806d5e7f356e8fa4b058a389a808ea183019
1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5cr.define('print_preview', function() {
6  'use strict';
7
8  /**
9   * Creates a PreviewArea object. It represents the area where the preview
10   * document is displayed.
11   * @param {!print_preview.DestinationStore} destinationStore Used to get the
12   *     currently selected destination.
13   * @param {!print_preview.PrintTicketStore} printTicketStore Used to get
14   *     information about how the preview should be displayed.
15   * @param {!print_preview.NativeLayer} nativeLayer Needed to communicate with
16   *     Chromium's preview generation system.
17   * @constructor
18   * @extends {print_preview.Component}
19   */
20  function PreviewArea(destinationStore, printTicketStore, nativeLayer) {
21    print_preview.Component.call(this);
22
23    /**
24     * Used to get the currently selected destination.
25     * @type {!print_preview.DestinationStore}
26     * @private
27     */
28    this.destinationStore_ = destinationStore;
29
30    /**
31     * Used to get information about how the preview should be displayed.
32     * @type {!print_preview.PrintTicketStore}
33     * @private
34     */
35    this.printTicketStore_ = printTicketStore;
36
37    /**
38     * Used to contruct the preview generator.
39     * @type {!print_preview.NativeLayer}
40     * @private
41     */
42    this.nativeLayer_ = nativeLayer;
43
44    /**
45     * Used to read generated page previews.
46     * @type {print_preview.PreviewGenerator}
47     * @private
48     */
49    this.previewGenerator_ = null;
50
51    /**
52     * The embedded pdf plugin object. It's value is null if not yet loaded.
53     * @type {HTMLEmbedElement}
54     * @private
55     */
56    this.plugin_ = null;
57
58    /**
59     * Custom margins component superimposed on the preview plugin.
60     * @type {!print_preview.MarginControlContainer}
61     * @private
62     */
63    this.marginControlContainer_ =
64        new print_preview.MarginControlContainer(this.printTicketStore_);
65    this.addChild(this.marginControlContainer_);
66
67    /**
68     * Current zoom level as a percentage.
69     * @type {?number}
70     * @private
71     */
72    this.zoomLevel_ = null;
73
74    /**
75     * Current page offset which can be used to calculate scroll amount.
76     * @type {print_preview.Coordinate2d}
77     * @private
78     */
79    this.pageOffset_ = null;
80
81    /**
82     * Whether the plugin has finished reloading.
83     * @type {boolean}
84     * @private
85     */
86    this.isPluginReloaded_ = false;
87
88    /**
89     * Whether the document preview is ready.
90     * @type {boolean}
91     * @private
92     */
93    this.isDocumentReady_ = false;
94
95    /**
96     * Timeout object used to display a loading message if the preview is taking
97     * a long time to generate.
98     * @type {?number}
99     * @private
100     */
101    this.loadingTimeout_ = null;
102
103    /**
104     * Overlay element.
105     * @type {HTMLElement}
106     * @private
107     */
108    this.overlayEl_ = null;
109
110    /**
111     * The "Open system dialog" button.
112     * @type {HTMLButtonElement}
113     * @private
114     */
115    this.openSystemDialogButton_ = null;
116  };
117
118  /**
119   * Event types dispatched by the preview area.
120   * @enum {string}
121   */
122  PreviewArea.EventType = {
123    // Dispatched when the "Open system dialog" button is clicked.
124    OPEN_SYSTEM_DIALOG_CLICK:
125        'print_preview.PreviewArea.OPEN_SYSTEM_DIALOG_CLICK',
126
127    // Dispatched when the document preview is complete.
128    PREVIEW_GENERATION_DONE:
129        'print_preview.PreviewArea.PREVIEW_GENERATION_DONE',
130
131    // Dispatched when the document preview failed to be generated.
132    PREVIEW_GENERATION_FAIL:
133        'print_preview.PreviewArea.PREVIEW_GENERATION_FAIL',
134
135    // Dispatched when a new document preview is being generated.
136    PREVIEW_GENERATION_IN_PROGRESS:
137        'print_preview.PreviewArea.PREVIEW_GENERATION_IN_PROGRESS'
138  };
139
140  /**
141   * CSS classes used by the preview area.
142   * @enum {string}
143   * @private
144   */
145  PreviewArea.Classes_ = {
146    COMPATIBILITY_OBJECT: 'preview-area-compatibility-object',
147    CUSTOM_MESSAGE_TEXT: 'preview-area-custom-message-text',
148    MESSAGE: 'preview-area-message',
149    INVISIBLE: 'invisible',
150    OPEN_SYSTEM_DIALOG_BUTTON: 'preview-area-open-system-dialog-button',
151    OPEN_SYSTEM_DIALOG_BUTTON_THROBBER:
152        'preview-area-open-system-dialog-button-throbber',
153    OVERLAY: 'preview-area-overlay-layer'
154  };
155
156  /**
157   * Enumeration of IDs shown in the preview area.
158   * @enum {string}
159   * @private
160   */
161  PreviewArea.MessageId_ = {
162    CUSTOM: 'custom',
163    LOADING: 'loading',
164    PREVIEW_FAILED: 'preview-failed'
165  };
166
167  /**
168   * Maps message IDs to the CSS class that contains them.
169   * @type {object.<PreviewArea.MessageId_, string>}
170   * @private
171   */
172  PreviewArea.MessageIdClassMap_ = {};
173  PreviewArea.MessageIdClassMap_[PreviewArea.MessageId_.CUSTOM] =
174      'preview-area-custom-message';
175  PreviewArea.MessageIdClassMap_[PreviewArea.MessageId_.LOADING] =
176      'preview-area-loading-message';
177  PreviewArea.MessageIdClassMap_[PreviewArea.MessageId_.PREVIEW_FAILED] =
178      'preview-area-preview-failed-message';
179
180  /**
181   * Amount of time in milliseconds to wait after issueing a new preview before
182   * the loading message is shown.
183   * @type {number}
184   * @const
185   * @private
186   */
187  PreviewArea.LOADING_TIMEOUT_ = 200;
188
189  PreviewArea.prototype = {
190    __proto__: print_preview.Component.prototype,
191
192    /**
193     * Should only be called after calling this.render().
194     * @return {boolean} Whether the preview area has a compatible plugin to
195     *     display the print preview in.
196     */
197    get hasCompatiblePlugin() {
198      return this.previewGenerator_ != null;
199    },
200
201    /**
202     * Processes a keyboard event that could possibly be used to change state of
203     * the preview plugin.
204     * @param {MouseEvent} e Mouse event to process.
205     */
206    handleDirectionalKeyEvent: function(e) {
207      // Make sure the PDF plugin is there.
208      // We only care about: PageUp, PageDown, Left, Up, Right, Down.
209      // If the user is holding a modifier key, ignore.
210      if (!this.plugin_ ||
211          !arrayContains([33, 34, 37, 38, 39, 40], e.keyCode) ||
212          e.metaKey || e.altKey || e.shiftKey || e.ctrlKey) {
213        return;
214      }
215
216      // Don't handle the key event for these elements.
217      var tagName = document.activeElement.tagName;
218      if (arrayContains(['INPUT', 'SELECT', 'EMBED'], tagName)) {
219        return;
220      }
221
222      // For the most part, if any div of header was the last clicked element,
223      // then the active element is the body. Starting with the last clicked
224      // element, and work up the DOM tree to see if any element has a
225      // scrollbar. If there exists a scrollbar, do not handle the key event
226      // here.
227      var element = e.target;
228      while (element) {
229        if (element.scrollHeight > element.clientHeight ||
230            element.scrollWidth > element.clientWidth) {
231          return;
232        }
233        element = element.parentElement;
234      }
235
236      // No scroll bar anywhere, or the active element is something else, like a
237      // button. Note: buttons have a bigger scrollHeight than clientHeight.
238      this.plugin_.sendKeyEvent(e.keyCode);
239      e.preventDefault();
240    },
241
242    /**
243     * Shows a custom message on the preview area's overlay.
244     * @param {string} message Custom message to show.
245     */
246    showCustomMessage: function(message) {
247      this.showMessage_(PreviewArea.MessageId_.CUSTOM, message);
248    },
249
250    /** @override */
251    enterDocument: function() {
252      print_preview.Component.prototype.enterDocument.call(this);
253      this.tracker.add(
254          this.openSystemDialogButton_,
255          'click',
256          this.onOpenSystemDialogButtonClick_.bind(this));
257
258      this.tracker.add(
259          this.printTicketStore_,
260          print_preview.PrintTicketStore.EventType.INITIALIZE,
261          this.onTicketChange_.bind(this));
262      this.tracker.add(
263          this.printTicketStore_,
264          print_preview.PrintTicketStore.EventType.TICKET_CHANGE,
265          this.onTicketChange_.bind(this));
266      this.tracker.add(
267          this.printTicketStore_,
268          print_preview.PrintTicketStore.EventType.CAPABILITIES_CHANGE,
269          this.onTicketChange_.bind(this));
270      this.tracker.add(
271          this.printTicketStore_,
272          print_preview.PrintTicketStore.EventType.DOCUMENT_CHANGE,
273          this.onTicketChange_.bind(this));
274
275      if (this.checkPluginCompatibility_()) {
276        this.previewGenerator_ = new print_preview.PreviewGenerator(
277            this.destinationStore_, this.printTicketStore_, this.nativeLayer_);
278        this.tracker.add(
279            this.previewGenerator_,
280            print_preview.PreviewGenerator.EventType.PREVIEW_START,
281            this.onPreviewStart_.bind(this));
282        this.tracker.add(
283            this.previewGenerator_,
284            print_preview.PreviewGenerator.EventType.PAGE_READY,
285            this.onPagePreviewReady_.bind(this));
286        this.tracker.add(
287            this.previewGenerator_,
288            print_preview.PreviewGenerator.EventType.FAIL,
289            this.onPreviewGenerationFail_.bind(this));
290        this.tracker.add(
291            this.previewGenerator_,
292            print_preview.PreviewGenerator.EventType.DOCUMENT_READY,
293            this.onDocumentReady_.bind(this));
294      } else {
295        this.showCustomMessage(localStrings.getString('noPlugin'));
296      }
297    },
298
299    /** @override */
300    exitDocument: function() {
301      print_preview.Component.prototype.exitDocument.call(this);
302      if (this.previewGenerator_) {
303        this.previewGenerator_.removeEventListeners();
304      }
305      this.overlayEl_ = null;
306      this.openSystemDialogButton_ = null;
307    },
308
309    /** @override */
310    decorateInternal: function() {
311      this.marginControlContainer_.decorate(this.getElement());
312      this.overlayEl_ = this.getElement().getElementsByClassName(
313          PreviewArea.Classes_.OVERLAY)[0];
314      this.openSystemDialogButton_ = this.getElement().getElementsByClassName(
315          PreviewArea.Classes_.OPEN_SYSTEM_DIALOG_BUTTON)[0];
316    },
317
318    /**
319     * Checks to see if a suitable plugin for rendering the preview exists. If
320     * one does not exist, then an error message will be displayed.
321     * @return {boolean} Whether Chromium has a suitable plugin for rendering
322     *     the preview.
323     * @private
324     */
325    checkPluginCompatibility_: function() {
326      var compatObj = this.getElement().getElementsByClassName(
327          PreviewArea.Classes_.COMPATIBILITY_OBJECT)[0];
328      var isCompatible =
329          compatObj.onload &&
330          compatObj.goToPage &&
331          compatObj.removePrintButton &&
332          compatObj.loadPreviewPage &&
333          compatObj.printPreviewPageCount &&
334          compatObj.resetPrintPreviewUrl &&
335          compatObj.onPluginSizeChanged &&
336          compatObj.onScroll &&
337          compatObj.pageXOffset &&
338          compatObj.pageYOffset &&
339          compatObj.setZoomLevel &&
340          compatObj.setPageNumbers &&
341          compatObj.setPageXOffset &&
342          compatObj.setPageYOffset &&
343          compatObj.getHorizontalScrollbarThickness &&
344          compatObj.getVerticalScrollbarThickness &&
345          compatObj.getPageLocationNormalized &&
346          compatObj.getHeight &&
347          compatObj.getWidth;
348      compatObj.parentElement.removeChild(compatObj);
349      return isCompatible;
350    },
351
352    /**
353     * Shows a given message on the overlay.
354     * @param {!print_preview.PreviewArea.MessageId_} messageId ID of the
355     *     message to show.
356     * @param {string=} opt_message Optional message to show that can be used
357     *     by some message IDs.
358     * @private
359     */
360    showMessage_: function(messageId, opt_message) {
361      // Hide all messages.
362      var messageEls = this.getElement().getElementsByClassName(
363          PreviewArea.Classes_.MESSAGE);
364      for (var i = 0, messageEl; messageEl = messageEls[i]; i++) {
365        setIsVisible(messageEl, false);
366      }
367      // Disable jumping animation to conserve cycles.
368      var jumpingDotsEl = this.getElement().querySelector(
369          '.preview-area-loading-message-jumping-dots');
370      jumpingDotsEl.classList.remove('jumping-dots');
371
372      // Show specific message.
373      if (messageId == PreviewArea.MessageId_.CUSTOM) {
374        var customMessageTextEl = this.getElement().getElementsByClassName(
375            PreviewArea.Classes_.CUSTOM_MESSAGE_TEXT)[0];
376        customMessageTextEl.textContent = opt_message;
377      } else if (messageId == PreviewArea.MessageId_.LOADING) {
378        jumpingDotsEl.classList.add('jumping-dots');
379      }
380      var messageEl = this.getElement().getElementsByClassName(
381            PreviewArea.MessageIdClassMap_[messageId])[0];
382      setIsVisible(messageEl, true);
383
384      // Show overlay.
385      this.overlayEl_.classList.remove(PreviewArea.Classes_.INVISIBLE);
386    },
387
388    /**
389     * Hides the message overlay.
390     * @private
391     */
392    hideOverlay_: function() {
393      this.overlayEl_.classList.add(PreviewArea.Classes_.INVISIBLE);
394      // Disable jumping animation to conserve cycles.
395      var jumpingDotsEl = this.getElement().querySelector(
396          '.preview-area-loading-message-jumping-dots');
397      jumpingDotsEl.classList.remove('jumping-dots');
398    },
399
400    /**
401     * Creates a preview plugin and adds it to the DOM.
402     * @param {string} srcUrl Initial URL of the plugin.
403     * @private
404     */
405    createPlugin_: function(srcUrl) {
406      if (this.plugin_) {
407        console.warn('Pdf preview plugin already created');
408        return;
409      }
410      this.plugin_ = document.createElement('embed');
411      // NOTE: The plugin's 'id' field must be set to 'pdf-viewer' since
412      // chrome/renderer/print_web_view_helper.cc actually references it.
413      this.plugin_.setAttribute('id', 'pdf-viewer');
414      this.plugin_.setAttribute('class', 'preview-area-plugin');
415      this.plugin_.setAttribute(
416          'type', 'application/x-google-chrome-print-preview-pdf');
417      this.plugin_.setAttribute('src', srcUrl);
418      this.plugin_.setAttribute('aria-live', 'polite');
419      this.plugin_.setAttribute('aria-atomic', 'true');
420      this.getChildElement('.preview-area-plugin-wrapper').
421          appendChild(this.plugin_);
422
423      global['onPreviewPluginLoad'] = this.onPluginLoad_.bind(this);
424      this.plugin_.onload('onPreviewPluginLoad()');
425
426      global['onPreviewPluginVisualStateChange'] =
427          this.onPreviewVisualStateChange_.bind(this);
428      this.plugin_.onScroll('onPreviewPluginVisualStateChange()');
429      this.plugin_.onPluginSizeChanged('onPreviewPluginVisualStateChange()');
430
431      this.plugin_.removePrintButton();
432      this.plugin_.grayscale(!this.printTicketStore_.isColorEnabled());
433    },
434
435    /**
436     * Dispatches a PREVIEW_GENERATION_DONE event if all conditions are met.
437     * @private
438     */
439    dispatchPreviewGenerationDoneIfReady_: function() {
440      if (this.isDocumentReady_ && this.isPluginReloaded_) {
441        cr.dispatchSimpleEvent(
442            this, PreviewArea.EventType.PREVIEW_GENERATION_DONE);
443        this.marginControlContainer_.showMarginControlsIfNeeded();
444      }
445    },
446
447    /**
448     * Called when the open-system-dialog button is clicked. Disables the
449     * button, shows the throbber, and dispatches the OPEN_SYSTEM_DIALOG_CLICK
450     * event.
451     * @private
452     */
453    onOpenSystemDialogButtonClick_: function() {
454      this.openSystemDialogButton_.disabled = true;
455      var openSystemDialogThrobber = this.getElement().getElementsByClassName(
456          PreviewArea.Classes_.OPEN_SYSTEM_DIALOG_BUTTON_THROBBER)[0];
457      setIsVisible(openSystemDialogThrobber, true);
458      cr.dispatchSimpleEvent(
459          this, PreviewArea.EventType.OPEN_SYSTEM_DIALOG_CLICK);
460    },
461
462    /**
463     * Called when the print ticket changes. Updates the preview.
464     * @private
465     */
466    onTicketChange_: function() {
467      if (this.previewGenerator_ && this.previewGenerator_.requestPreview()) {
468        if (this.loadingTimeout_ == null) {
469          this.loadingTimeout_ = setTimeout(
470              this.showMessage_.bind(this, PreviewArea.MessageId_.LOADING),
471              PreviewArea.LOADING_TIMEOUT_);
472        }
473      } else {
474        this.marginControlContainer_.showMarginControlsIfNeeded();
475      }
476    },
477
478    /**
479     * Called when the preview generator begins loading the preview.
480     * @param {cr.Event} Contains the URL to initialize the plugin to.
481     * @private
482     */
483    onPreviewStart_: function(event) {
484      this.isDocumentReady_ = false;
485      this.isPluginReloaded_ = false;
486      if (!this.plugin_) {
487        this.createPlugin_(event.previewUrl);
488      }
489      this.plugin_.goToPage('0');
490      this.plugin_.resetPrintPreviewUrl(event.previewUrl);
491      this.plugin_.reload();
492      this.plugin_.grayscale(!this.printTicketStore_.isColorEnabled());
493      cr.dispatchSimpleEvent(
494          this, PreviewArea.EventType.PREVIEW_GENERATION_IN_PROGRESS);
495    },
496
497    /**
498     * Called when a page preview has been generated. Updates the plugin with
499     * the new page.
500     * @param {cr.Event} event Contains information about the page preview.
501     * @private
502     */
503    onPagePreviewReady_: function(event) {
504      this.plugin_.loadPreviewPage(event.previewUrl, event.previewIndex);
505    },
506
507    /**
508     * Called when the preview generation is complete and the document is ready
509     * to print.
510     * @private
511     */
512    onDocumentReady_: function(event) {
513      this.isDocumentReady_ = true;
514      this.dispatchPreviewGenerationDoneIfReady_();
515    },
516
517    /**
518     * Called when the generation of a preview fails. Shows an error message.
519     * @private
520     */
521    onPreviewGenerationFail_: function() {
522      this.showMessage_(PreviewArea.MessageId_.PREVIEW_FAILED);
523      cr.dispatchSimpleEvent(
524          this, PreviewArea.EventType.PREVIEW_GENERATION_FAIL);
525    },
526
527    /**
528     * Called when the plugin loads. This is a consequence of calling
529     * plugin.reload(). Certain plugin state can only be set after the plugin
530     * has loaded.
531     * @private
532     */
533    onPluginLoad_: function() {
534      if (this.loadingTimeout_) {
535        clearTimeout(this.loadingTimeout_);
536        this.loadingTimeout_ = null;
537      }
538      // Setting the plugin's page count can only be called after the plugin is
539      // loaded and the document must be modifiable.
540      if (this.printTicketStore_.isDocumentModifiable) {
541        this.plugin_.printPreviewPageCount(
542            this.printTicketStore_.getPageNumberSet().size);
543      }
544      this.plugin_.setPageNumbers(JSON.stringify(
545          this.printTicketStore_.getPageNumberSet().asArray()));
546      if (this.zoomLevel_ != null && this.pageOffset_ != null) {
547        this.plugin_.setZoomLevel(this.zoomLevel_);
548        this.plugin_.setPageXOffset(this.pageOffset_.x);
549        this.plugin_.setPageYOffset(this.pageOffset_.y);
550      } else {
551        this.plugin_.fitToHeight();
552      }
553      this.hideOverlay_();
554      this.isPluginReloaded_ = true;
555      this.dispatchPreviewGenerationDoneIfReady_();
556    },
557
558    /**
559     * Called when the preview plugin's visual state has changed. This is a
560     * consequence of scrolling or zooming the plugin. Updates the custom
561     * margins component if shown.
562     * @private
563     */
564    onPreviewVisualStateChange_: function() {
565      if (this.isPluginReloaded_) {
566        this.zoomLevel_ = this.plugin_.getZoomLevel();
567        this.pageOffset_ = new print_preview.Coordinate2d(
568            this.plugin_.pageXOffset(), this.plugin_.pageYOffset());
569      }
570      var pageLocationNormalizedStr = this.plugin_.getPageLocationNormalized();
571      if (!pageLocationNormalizedStr) {
572        return;
573      }
574      var normalized = pageLocationNormalizedStr.split(';');
575      var pluginWidth = this.plugin_.getWidth();
576      var pluginHeight = this.plugin_.getHeight();
577      var translationTransform = new print_preview.Coordinate2d(
578          parseFloat(normalized[0]) * pluginWidth,
579          parseFloat(normalized[1]) * pluginHeight);
580      this.marginControlContainer_.updateTranslationTransform(
581          translationTransform);
582      var pageWidthInPixels = parseFloat(normalized[2]) * pluginWidth;
583      this.marginControlContainer_.updateScaleTransform(
584          pageWidthInPixels / this.printTicketStore_.pageSize.width);
585      this.marginControlContainer_.updateClippingMask(
586          new print_preview.Size(
587              pluginWidth - this.plugin_.getVerticalScrollbarThickness(),
588              pluginHeight - this.plugin_.getHorizontalScrollbarThickness()));
589    }
590  };
591
592  // Export
593  return {
594    PreviewArea: PreviewArea
595  };
596});
597