• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2014 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('cr.ui.pageManager', function() {
6  /** @const */ var FocusOutlineManager = cr.ui.FocusOutlineManager;
7
8  /**
9   * PageManager contains a list of root Page and overlay Page objects and
10   * handles "navigation" by showing and hiding these pages and overlays. On
11   * initial load, PageManager can use the path to open the correct hierarchy
12   * of pages and overlay(s). Handlers for user events, like pressing buttons,
13   * can call into PageManager to open a particular overlay or cancel an
14   * existing overlay.
15   */
16  var PageManager = {
17    /**
18     * True if page is served from a dialog.
19     * @type {boolean}
20     */
21    isDialog: false,
22
23    /**
24     * Offset of page container in pixels. Uber pages that use the side menu
25     * can override this with the setter.
26     * The default (23) comes from -webkit-margin-start in uber_shared.css.
27     * @type {number}
28     */
29    horizontalOffset_: 23,
30
31    /**
32     * Root pages. Maps lower-case page names to the respective page object.
33     * @type {!Object.<string, !cr.ui.pageManager.Page>}
34     */
35    registeredPages: {},
36
37    /**
38     * Pages which are meant to behave like modal dialogs. Maps lower-case
39     * overlay names to the respective overlay object.
40     * @type {!Object.<string, !cr.ui.pageManager.Page>}
41     * @private
42     */
43    registeredOverlayPages: {},
44
45    /**
46     * Observers will be notified when opening and closing overlays.
47     * @type {!Array.<!cr.ui.pageManager.PageManager.Observer>}
48     */
49    observers_: [],
50
51    /**
52     * Initializes the complete page.
53     * @param {cr.ui.pageManager.Page} defaultPage The page to be shown when no
54     *     page is specified in the path.
55     */
56    initialize: function(defaultPage) {
57      this.defaultPage_ = defaultPage;
58
59      FocusOutlineManager.forDocument(document);
60      document.addEventListener('scroll', this.handleScroll_.bind(this));
61
62      // Trigger the scroll handler manually to set the initial state.
63      this.handleScroll_();
64
65      // Shake the dialog if the user clicks outside the dialog bounds.
66      var containers = document.querySelectorAll('body > .overlay');
67      for (var i = 0; i < containers.length; i++) {
68        var overlay = containers[i];
69        cr.ui.overlay.setupOverlay(overlay);
70        overlay.addEventListener('cancelOverlay',
71                                 this.cancelOverlay.bind(this));
72      }
73
74      cr.ui.overlay.globalInitialization();
75    },
76
77    /**
78     * Registers new page.
79     * @param {!cr.ui.pageManager.Page} page Page to register.
80     */
81    register: function(page) {
82      this.registeredPages[page.name.toLowerCase()] = page;
83      page.initializePage();
84    },
85
86    /**
87     * Registers a new Overlay page.
88     * @param {!cr.ui.pageManager.Page} overlay Overlay to register.
89     * @param {cr.ui.pageManager.Page} parentPage Associated parent page for
90     *     this overlay.
91     * @param {Array} associatedControls Array of control elements associated
92     *     with this page.
93     */
94    registerOverlay: function(overlay,
95                              parentPage,
96                              associatedControls) {
97      this.registeredOverlayPages[overlay.name.toLowerCase()] = overlay;
98      overlay.parentPage = parentPage;
99      if (associatedControls) {
100        overlay.associatedControls = associatedControls;
101        if (associatedControls.length) {
102          overlay.associatedSection =
103              this.findSectionForNode_(associatedControls[0]);
104        }
105
106        // Sanity check.
107        for (var i = 0; i < associatedControls.length; ++i) {
108          assert(associatedControls[i], 'Invalid element passed.');
109        }
110      }
111
112      overlay.tab = undefined;
113      overlay.isOverlay = true;
114
115      // Reverse the button strip for Windows and CrOS. See the documentation of
116      // cr.ui.pageManager.Page.reverseButtonStrip() for an explanation of why
117      // this is done.
118      if (cr.isWindows || cr.isChromeOS)
119        overlay.reverseButtonStrip();
120
121      overlay.initializePage();
122    },
123
124    /**
125     * Shows the default page.
126     * @param {boolean=} opt_updateHistory If we should update the history after
127     *     showing the page (defaults to true).
128     */
129    showDefaultPage: function(opt_updateHistory) {
130      assert(this.defaultPage_ instanceof cr.ui.pageManager.Page,
131             'PageManager must be initialized with a default page.');
132      this.showPageByName(this.defaultPage_.name, opt_updateHistory);
133    },
134
135    /**
136     * Shows a registered page. This handles both root and overlay pages.
137     * @param {string} pageName Page name.
138     * @param {boolean=} opt_updateHistory If we should update the history after
139     *     showing the page (defaults to true).
140     * @param {Object=} opt_propertyBag An optional bag of properties including
141     *     replaceState (if history state should be replaced instead of pushed).
142     *     hash (a hash state to attach to the page).
143     */
144    showPageByName: function(pageName,
145                             opt_updateHistory,
146                             opt_propertyBag) {
147      opt_updateHistory = opt_updateHistory !== false;
148      opt_propertyBag = opt_propertyBag || {};
149
150      // If a bubble is currently being shown, hide it.
151      this.hideBubble();
152
153      // Find the currently visible root-level page.
154      var rootPage = null;
155      for (var name in this.registeredPages) {
156        var page = this.registeredPages[name];
157        if (page.visible && !page.parentPage) {
158          rootPage = page;
159          break;
160        }
161      }
162
163      // Find the target page.
164      var targetPage = this.registeredPages[pageName.toLowerCase()];
165      if (!targetPage || !targetPage.canShowPage()) {
166        // If it's not a page, try it as an overlay.
167        var hash = opt_propertyBag.hash || '';
168        if (!targetPage && this.showOverlay_(pageName, hash, rootPage)) {
169          if (opt_updateHistory)
170            this.updateHistoryState_(!!opt_propertyBag.replaceState);
171          this.updateTitle_();
172          return;
173        }
174        targetPage = this.defaultPage_;
175      }
176
177      pageName = targetPage.name.toLowerCase();
178      var targetPageWasVisible = targetPage.visible;
179
180      // Determine if the root page is 'sticky', meaning that it
181      // shouldn't change when showing an overlay. This can happen for special
182      // pages like Search.
183      var isRootPageLocked =
184          rootPage && rootPage.sticky && targetPage.parentPage;
185
186      // Notify pages if they will be hidden.
187      this.forEachPage_(!isRootPageLocked, function(page) {
188        if (page.name != pageName && !this.isAncestorOfPage(page, targetPage))
189          page.willHidePage();
190      });
191
192      // Update the page's hash.
193      targetPage.hash = opt_propertyBag.hash || '';
194
195      // Update visibilities to show only the hierarchy of the target page.
196      this.forEachPage_(!isRootPageLocked, function(page) {
197        page.visible = page.name == pageName ||
198                       this.isAncestorOfPage(page, targetPage);
199      });
200
201      // Update the history and current location.
202      if (opt_updateHistory)
203        this.updateHistoryState_(!!opt_propertyBag.replaceState);
204
205      // Update focus if any other control was focused on the previous page,
206      // or the previous page is not known.
207      if (document.activeElement != document.body &&
208          (!rootPage || rootPage.pageDiv.contains(document.activeElement))) {
209        targetPage.focus();
210      }
211
212      // Notify pages if they were shown.
213      this.forEachPage_(!isRootPageLocked, function(page) {
214        if (!targetPageWasVisible &&
215            (page.name == pageName ||
216             this.isAncestorOfPage(page, targetPage))) {
217          page.didShowPage();
218        }
219      });
220
221      // If the target page was already visible, notify it that its hash
222      // changed externally.
223      if (targetPageWasVisible)
224        targetPage.didChangeHash();
225
226      // Update the document title. Do this after didShowPage was called, in
227      // case a page decides to change its title.
228      this.updateTitle_();
229    },
230
231    /**
232     * Returns the name of the page from the current path.
233     * @return {string} Name of the page specified by the current path.
234     */
235    getPageNameFromPath: function() {
236      var path = location.pathname;
237      if (path.length <= 1)
238        return this.defaultPage_.name;
239
240      // Skip starting slash and remove trailing slash (if any).
241      return path.slice(1).replace(/\/$/, '');
242    },
243
244    /**
245     * Gets the level of the page. Root pages (e.g., BrowserOptions) are at
246     * level 0.
247     * @return {number} How far down this page is from the root page.
248     */
249    getNestingLevel: function(page) {
250      var level = 0;
251      var parent = page.parentPage;
252      while (parent) {
253        level++;
254        parent = parent.parentPage;
255      }
256      return level;
257    },
258
259    /**
260     * Checks whether one page is an ancestor of the other page in terms of
261     * subpage nesting.
262     * @param {cr.ui.pageManager.Page} potentialAncestor Potential ancestor.
263     * @param {cr.ui.pageManager.Page} potentialDescendent Potential descendent.
264     * @return {boolean} True if |potentialDescendent| is nested under
265     *     |potentialAncestor|.
266     */
267    isAncestorOfPage: function(potentialAncestor, potentialDescendent) {
268      var parent = potentialDescendent.parentPage;
269      while (parent) {
270        if (parent == potentialAncestor)
271          return true;
272        parent = parent.parentPage;
273      }
274      return false;
275    },
276
277    /**
278     * Returns true if the page is a direct descendent of a root page, or if
279     * the page is considered always on top. Doesn't consider visibility.
280     * @param {cr.ui.pageManager.Page} page Page to check.
281     * @return {boolean} True if |page| is a top-level overlay.
282     */
283    isTopLevelOverlay: function(page) {
284      return page.isOverlay &&
285            (page.alwaysOnTop || this.getNestingLevel(page) == 1);
286    },
287
288    /**
289     * Called when an page is shown or hidden to update the root page
290     * based on the page's new visibility.
291     * @param {cr.ui.pageManager.Page} page The page being made visible or
292     *     invisible.
293     */
294    onPageVisibilityChanged: function(page) {
295      this.updateRootPageFreezeState();
296
297      for (var i = 0; i < this.observers_.length; ++i)
298        this.observers_[i].onPageVisibilityChanged(page);
299
300      if (!page.visible && this.isTopLevelOverlay(page))
301        this.updateScrollPosition_();
302    },
303
304    /**
305     * Called when a page's hash changes. If the page is the topmost visible
306     * page, the history state is updated.
307     * @param {cr.ui.pageManager.Page} page The page whose hash has changed.
308     */
309    onPageHashChanged: function(page) {
310      if (page == this.getTopmostVisiblePage())
311        this.updateHistoryState_(false);
312    },
313
314    /**
315     * Returns the topmost visible page, or null if no page is visible.
316     * @return {cr.ui.pageManager.Page} The topmost visible page.
317     */
318    getTopmostVisiblePage: function() {
319      // Check overlays first since they're top-most if visible.
320      return this.getVisibleOverlay_() ||
321          this.getTopmostVisibleNonOverlayPage_();
322    },
323
324    /**
325     * Closes the visible overlay. Updates the history state after closing the
326     * overlay.
327     */
328    closeOverlay: function() {
329      var overlay = this.getVisibleOverlay_();
330      if (!overlay)
331        return;
332
333      overlay.visible = false;
334      overlay.didClosePage();
335
336      this.updateHistoryState_(false);
337      this.updateTitle_();
338
339      this.restoreLastFocusedElement_();
340    },
341
342    /**
343     * Closes all overlays and updates the history after each closed overlay.
344     */
345    closeAllOverlays: function() {
346      while (this.isOverlayVisible_()) {
347        this.closeOverlay();
348      }
349    },
350
351    /**
352     * Cancels (closes) the overlay, due to the user pressing <Esc>.
353     */
354    cancelOverlay: function() {
355      // Blur the active element to ensure any changed pref value is saved.
356      document.activeElement.blur();
357      var overlay = this.getVisibleOverlay_();
358      if (!overlay)
359        return;
360      // Let the overlay handle the <Esc> if it wants to.
361      if (overlay.handleCancel) {
362        overlay.handleCancel();
363        this.restoreLastFocusedElement_();
364      } else {
365        this.closeOverlay();
366      }
367    },
368
369    /**
370     * Shows an informational bubble displaying |content| and pointing at the
371     * |target| element. If |content| has focusable elements, they join the
372     * current page's tab order as siblings of |domSibling|.
373     * @param {HTMLDivElement} content The content of the bubble.
374     * @param {HTMLElement} target The element at which the bubble points.
375     * @param {HTMLElement} domSibling The element after which the bubble is
376     *     added to the DOM.
377     * @param {cr.ui.ArrowLocation} location The arrow location.
378     */
379    showBubble: function(content, target, domSibling, location) {
380      this.hideBubble();
381
382      var bubble = new cr.ui.AutoCloseBubble;
383      bubble.anchorNode = target;
384      bubble.domSibling = domSibling;
385      bubble.arrowLocation = location;
386      bubble.content = content;
387      bubble.show();
388      this.bubble_ = bubble;
389    },
390
391    /**
392     * Hides the currently visible bubble, if any.
393     */
394    hideBubble: function() {
395      if (this.bubble_)
396        this.bubble_.hide();
397    },
398
399    /**
400     * Returns the currently visible bubble, or null if no bubble is visible.
401     * @return {cr.ui.AutoCloseBubble} The bubble currently being shown.
402     */
403    getVisibleBubble: function() {
404      var bubble = this.bubble_;
405      return bubble && !bubble.hidden ? bubble : null;
406    },
407
408    /**
409     * Callback for window.onpopstate to handle back/forward navigations.
410     * @param {string} pageName The current page name.
411     * @param {string} hash The hash to pass into the page.
412     * @param {Object} data State data pushed into history.
413     */
414    setState: function(pageName, hash, data) {
415      var currentOverlay = this.getVisibleOverlay_();
416      var lowercaseName = pageName.toLowerCase();
417      var newPage = this.registeredPages[lowercaseName] ||
418                    this.registeredOverlayPages[lowercaseName] ||
419                    this.defaultPage_;
420      if (currentOverlay && !this.isAncestorOfPage(currentOverlay, newPage)) {
421        currentOverlay.visible = false;
422        currentOverlay.didClosePage();
423      }
424      this.showPageByName(pageName, false, {hash: hash});
425    },
426
427
428    /**
429     * Whether the page is still loading (i.e. onload hasn't finished running).
430     * @return {boolean} Whether the page is still loading.
431     */
432    isLoading: function() {
433      return document.documentElement.classList.contains('loading');
434    },
435
436    /**
437     * Callback for window.onbeforeunload. Used to notify overlays that they
438     * will be closed.
439     */
440    willClose: function() {
441      var overlay = this.getVisibleOverlay_();
442      if (overlay)
443        overlay.didClosePage();
444    },
445
446    /**
447     * Freezes/unfreezes the scroll position of the root page based on the
448     * current page stack.
449     */
450    updateRootPageFreezeState: function() {
451      var topPage = this.getTopmostVisiblePage();
452      if (topPage)
453        this.setRootPageFrozen_(topPage.isOverlay);
454    },
455
456    /**
457     * Change the horizontal offset used to reposition elements while showing an
458     * overlay from the default.
459     */
460    set horizontalOffset(value) {
461      this.horizontalOffset_ = value;
462    },
463
464    /**
465     * @param {!cr.ui.pageManager.PageManager.Observer} observer The observer to
466     *     register.
467     */
468    addObserver: function(observer) {
469      this.observers_.push(observer);
470    },
471
472    /**
473     * Shows a registered overlay page. Does not update history.
474     * @param {string} overlayName Page name.
475     * @param {string} hash The hash state to associate with the overlay.
476     * @param {cr.ui.pageManager.Page} rootPage The currently visible root-level
477     *     page.
478     * @return {boolean} Whether we showed an overlay.
479     * @private
480     */
481    showOverlay_: function(overlayName, hash, rootPage) {
482      var overlay = this.registeredOverlayPages[overlayName.toLowerCase()];
483      if (!overlay || !overlay.canShowPage())
484        return false;
485
486      // Save the currently focused element in the page for restoration later.
487      var currentPage = this.getTopmostVisiblePage();
488      if (currentPage)
489        currentPage.lastFocusedElement = document.activeElement;
490
491      if ((!rootPage || !rootPage.sticky) &&
492          overlay.parentPage &&
493          !overlay.parentPage.visible) {
494        this.showPageByName(overlay.parentPage.name, false);
495      }
496
497      overlay.hash = hash;
498      if (!overlay.visible) {
499        overlay.visible = true;
500        overlay.didShowPage();
501      } else {
502        overlay.didChangeHash();
503      }
504
505      // Change focus to the overlay if any other control was focused by
506      // keyboard before. Otherwise, no one should have focus.
507      if (document.activeElement != document.body) {
508        if (FocusOutlineManager.forDocument(document).visible) {
509          overlay.focus();
510        } else if (!overlay.pageDiv.contains(document.activeElement)) {
511          document.activeElement.blur();
512        }
513      }
514
515      if ($('search-field') && $('search-field').value == '') {
516        var section = overlay.associatedSection;
517        if (section)
518          options.BrowserOptions.scrollToSection(section);
519      }
520
521      return true;
522    },
523
524    /**
525     * Returns whether or not an overlay is visible.
526     * @return {boolean} True if an overlay is visible.
527     * @private
528     */
529    isOverlayVisible_: function() {
530      return this.getVisibleOverlay_() != null;
531    },
532
533    /**
534     * Returns the currently visible overlay, or null if no page is visible.
535     * @return {cr.ui.pageManager.Page} The visible overlay.
536     * @private
537     */
538    getVisibleOverlay_: function() {
539      var topmostPage = null;
540      for (var name in this.registeredOverlayPages) {
541        var page = this.registeredOverlayPages[name];
542        if (!page.visible)
543          continue;
544
545        if (page.alwaysOnTop)
546          return page;
547
548        if (!topmostPage ||
549             this.getNestingLevel(page) > this.getNestingLevel(topmostPage)) {
550          topmostPage = page;
551        }
552      }
553      return topmostPage;
554    },
555
556    /**
557     * Returns the topmost visible page (overlays excluded).
558     * @return {cr.ui.pageManager.Page} The topmost visible page aside from any
559     *     overlays.
560     * @private
561     */
562    getTopmostVisibleNonOverlayPage_: function() {
563      for (var name in this.registeredPages) {
564        var page = this.registeredPages[name];
565        if (page.visible)
566          return page;
567      }
568
569      return null;
570    },
571
572    /**
573     * Scrolls the page to the correct position (the top when opening an
574     * overlay, or the old scroll position a previously hidden overlay
575     * becomes visible).
576     * @private
577     */
578    updateScrollPosition_: function() {
579      var container = $('page-container');
580      var scrollTop = container.oldScrollTop || 0;
581      container.oldScrollTop = undefined;
582      window.scroll(scrollLeftForDocument(document), scrollTop);
583    },
584
585    /**
586     * Updates the title to the title of the current page, or of the topmost
587     * visible page with a non-empty title.
588     * @private
589     */
590    updateTitle_: function() {
591      var page = this.getTopmostVisiblePage();
592      while (page) {
593        if (page.title) {
594          for (var i = 0; i < this.observers_.length; ++i) {
595            this.observers_[i].updateTitle(page.title);
596          }
597          return;
598        }
599        page = page.parentPage;
600      }
601    },
602
603    /**
604     * Constructs a new path to push onto the history stack, using observers
605     * to update the history.
606     * @param {boolean} replace If true, handlers should replace the current
607     *     history event rather than create new ones.
608     * @private
609     */
610    updateHistoryState_: function(replace) {
611      if (this.isDialog)
612        return;
613
614      var page = this.getTopmostVisiblePage();
615      var path = window.location.pathname + window.location.hash;
616      if (path) {
617        // Remove trailing slash.
618        path = path.slice(1).replace(/\/(?:#|$)/, '');
619      }
620
621      // If the page is already in history (the user may have clicked the same
622      // link twice, or this is the initial load), do nothing.
623      var newPath = (page == this.defaultPage_ ? '' : page.name) + page.hash;
624      if (path == newPath)
625        return;
626
627      for (var i = 0; i < this.observers_.length; ++i) {
628        this.observers_[i].updateHistory(newPath, replace);
629      }
630    },
631
632    /**
633     * Restores the last focused element on a given page.
634     * @private
635     */
636    restoreLastFocusedElement_: function() {
637      var currentPage = this.getTopmostVisiblePage();
638      if (currentPage.lastFocusedElement)
639        currentPage.lastFocusedElement.focus();
640    },
641
642    /**
643     * Find an enclosing section for an element if it exists.
644     * @param {Node} node Element to search.
645     * @return {Node} The section element, or null.
646     * @private
647     */
648    findSectionForNode_: function(node) {
649      while (node = node.parentNode) {
650        if (node.nodeName == 'SECTION')
651          return node;
652      }
653      return null;
654    },
655
656    /**
657     * Freezes/unfreezes the scroll position of the root page container.
658     * @param {boolean} freeze Whether the page should be frozen.
659     * @private
660     */
661    setRootPageFrozen_: function(freeze) {
662      var container = $('page-container');
663      if (container.classList.contains('frozen') == freeze)
664        return;
665
666      if (freeze) {
667        // Lock the width, since auto width computation may change.
668        container.style.width = window.getComputedStyle(container).width;
669        container.oldScrollTop = scrollTopForDocument(document);
670        container.classList.add('frozen');
671        var verticalPosition =
672            container.getBoundingClientRect().top - container.oldScrollTop;
673        container.style.top = verticalPosition + 'px';
674        this.updateFrozenElementHorizontalPosition_(container);
675      } else {
676        container.classList.remove('frozen');
677        container.style.top = '';
678        container.style.left = '';
679        container.style.right = '';
680        container.style.width = '';
681      }
682    },
683
684    /**
685     * Called when the page is scrolled; moves elements that are position:fixed
686     * but should only behave as if they are fixed for vertical scrolling.
687     * @private
688     */
689    handleScroll_: function() {
690      this.updateAllFrozenElementPositions_();
691    },
692
693    /**
694     * Updates all frozen pages to match the horizontal scroll position.
695     * @private
696     */
697    updateAllFrozenElementPositions_: function() {
698      var frozenElements = document.querySelectorAll('.frozen');
699      for (var i = 0; i < frozenElements.length; i++)
700        this.updateFrozenElementHorizontalPosition_(frozenElements[i]);
701    },
702
703    /**
704     * Updates the given frozen element to match the horizontal scroll position.
705     * @param {HTMLElement} e The frozen element to update.
706     * @private
707     */
708    updateFrozenElementHorizontalPosition_: function(e) {
709      if (isRTL()) {
710        e.style.right = this.horizontalOffset + 'px';
711      } else {
712        var scrollLeft = scrollLeftForDocument(document);
713        e.style.left = this.horizontalOffset - scrollLeft + 'px';
714      }
715    },
716
717    /**
718     * Calls the given callback with each registered page.
719     * @param {boolean} includeRootPages Whether the callback should be called
720     *     for the root pages.
721     * @param {function(cr.ui.pageManager.Page)} callback The callback.
722     * @private
723     */
724    forEachPage_: function(includeRootPages, callback) {
725      var pageNames = Object.keys(this.registeredOverlayPages);
726      if (includeRootPages)
727        pageNames = Object.keys(this.registeredPages).concat(pageNames);
728
729      pageNames.forEach(function(name) {
730        callback.call(this, this.registeredOverlayPages[name] ||
731                            this.registeredPages[name]);
732      }, this);
733    },
734  };
735
736  /**
737   * An observer of PageManager.
738   * @interface
739   */
740  PageManager.Observer = function() {}
741
742  PageManager.Observer.prototype = {
743    /**
744     * Called when a page is being shown or has been hidden.
745     * @param {cr.ui.pageManager.Page} page The page being shown or hidden.
746     */
747    onPageVisibilityChanged: function(page) {},
748
749    /**
750     * Called when a new title should be set.
751     * @param {string} title The title to set.
752     */
753    updateTitle: function(title) {},
754
755    /**
756     * Called when a page is navigated to.
757     * @param {string} path The path of the page being visited.
758     * @param {boolean} replace If true, allow no history events to be created.
759     */
760    updateHistory: function(path, replace) {},
761  };
762
763  // Export
764  return {
765    PageManager: PageManager
766  };
767});
768