Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(559)

Unified Diff: ui/webui/resources/js/cr/ui/page_manager/page_manager.js

Issue 410293004: Split OptionsPage into Page and PageManager (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/src
Patch Set: ugh just no Created 6 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
« no previous file with comments | « ui/webui/resources/js/cr/ui/page_manager/page.js ('k') | ui/webui/resources/webui_resources.grd » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
Index: ui/webui/resources/js/cr/ui/page_manager/page_manager.js
diff --git a/ui/webui/resources/js/cr/ui/page_manager/page_manager.js b/ui/webui/resources/js/cr/ui/page_manager/page_manager.js
new file mode 100644
index 0000000000000000000000000000000000000000..df1b8ad224f56a0ba625a418ac9e2f86cd2e6a15
--- /dev/null
+++ b/ui/webui/resources/js/cr/ui/page_manager/page_manager.js
@@ -0,0 +1,724 @@
+// Copyright 2014 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+cr.define('cr.ui.pageManager', function() {
+ /** @const */ var FocusOutlineManager = cr.ui.FocusOutlineManager;
+
+ /**
+ * PageManager contains a list of root Page and overlay Page objects and
+ * handles "navigation" by showing and hiding these pages and overlays. On
+ * initial load, PageManager can use the path to open the correct hierarchy
+ * of pages and overlay(s). Handlers for user events, like pressing buttons,
+ * can call into PageManager to open a particular overlay or cancel an
+ * existing overlay.
+ */
+ var PageManager = {
+ /**
+ * True if page is served from a dialog.
+ * @type {boolean}
+ */
+ isDialog: false,
+
+ /**
+ * Offset of page container in pixels, to allow room for side menu.
+ * Simplified settings pages can override this if they don't use the menu.
+ * The default (155) comes from -webkit-margin-start in uber_shared.css
+ * TODO(michaelpg): Remove dependency on uber menu (crbug.com/313244).
+ * @type {number}
+ */
+ horizontalOffset: 155,
+
+ /**
+ * Root pages. Maps lower-case page names to the respective page object.
+ * @type {!Object.<string, !cr.ui.pageManager.Page>}
+ */
+ registeredPages: {},
+
+ /**
+ * Pages which are meant to behave like modal dialogs. Maps lower-case
+ * overlay names to the respective overlay object.
+ * @type {!Object.<string, !cr.ui.pageManager.Page>}
+ * @private
+ */
+ registeredOverlayPages: {},
+
+ /**
+ * Initializes the complete page.
+ * @param {cr.ui.pageManager.Page} defaultPage The page to be shown when no
+ * page is specified in the path.
+ */
+ initialize: function(defaultPage) {
+ this.defaultPage_ = defaultPage;
+
+ FocusOutlineManager.forDocument(document);
+ document.addEventListener('scroll', this.handleScroll_.bind(this));
+
+ // Trigger the scroll handler manually to set the initial state.
+ this.handleScroll_();
+
+ // Shake the dialog if the user clicks outside the dialog bounds.
+ var containers = [$('overlay-container-1'), $('overlay-container-2')];
+ for (var i = 0; i < containers.length; i++) {
+ var overlay = containers[i];
+ cr.ui.overlay.setupOverlay(overlay);
+ overlay.addEventListener('cancelOverlay',
+ this.cancelOverlay.bind(this));
+ }
+
+ cr.ui.overlay.globalInitialization();
+ },
+
+ /**
+ * Registers new page.
+ * @param {cr.ui.page_manager.Page} page Page to register.
+ */
+ register: function(page) {
+ this.registeredPages[page.name.toLowerCase()] = page;
+ page.initializePage();
+ },
+
+ /**
+ * Registers a new Overlay page.
+ * @param {cr.ui.page_manager.Page} overlay Overlay to register.
+ * @param {cr.ui.page_manager.Page} parentPage Associated parent page for
+ * this overlay.
+ * @param {Array} associatedControls Array of control elements associated
+ * with this page.
+ */
+ registerOverlay: function(overlay,
+ parentPage,
+ associatedControls) {
+ this.registeredOverlayPages[overlay.name.toLowerCase()] = overlay;
+ overlay.parentPage = parentPage;
+ if (associatedControls) {
+ overlay.associatedControls = associatedControls;
+ if (associatedControls.length) {
+ overlay.associatedSection =
+ this.findSectionForNode_(associatedControls[0]);
+ }
+
+ // Sanity check.
+ for (var i = 0; i < associatedControls.length; ++i) {
+ assert(associatedControls[i], 'Invalid element passed.');
+ }
+ }
+
+ overlay.tab = undefined;
+ overlay.isOverlay = true;
+
+ // Reverse the button strip for Windows and CrOS. See the documentation of
+ // cr.ui.pageManager.Page.reverseButtonStrip() for an explanation of why
+ // this is done.
+ if (cr.isWindows || cr.isChromeOS)
+ overlay.reverseButtonStrip();
+
+ overlay.initializePage();
+ },
+
+ /**
+ * Shows the default page.
+ * @param {boolean=} opt_updateHistory If we should update the history after
+ * showing the page (defaults to true).
+ */
+ showDefaultPage: function(opt_updateHistory) {
+ assert(this.defaultPage_ instanceof cr.ui.pageManager.Page,
+ 'PageManager must be initialized with a default page.');
+ this.showPageByName(this.defaultPage_.name, opt_updateHistory);
+ },
+
+ /**
+ * Shows a registered page. This handles both root and overlay pages.
+ * @param {string} pageName Page name.
+ * @param {boolean=} opt_updateHistory If we should update the history after
+ * showing the page (defaults to true).
+ * @param {Object=} opt_propertyBag An optional bag of properties including
+ * replaceState (if history state should be replaced instead of pushed).
+ */
+ showPageByName: function(pageName,
+ opt_updateHistory,
+ opt_propertyBag) {
+ opt_updateHistory = opt_updateHistory !== false;
+ opt_propertyBag = opt_propertyBag || {};
+
+ // If a bubble is currently being shown, hide it.
+ this.hideBubble();
+
+ // Find the currently visible root-level page.
+ var rootPage = null;
+ for (var name in this.registeredPages) {
+ var page = this.registeredPages[name];
+ if (page.visible && !page.parentPage) {
+ rootPage = page;
+ break;
+ }
+ }
+
+ // Find the target page.
+ var targetPage = this.registeredPages[pageName.toLowerCase()];
+ if (!targetPage || !targetPage.canShowPage()) {
+ // If it's not a page, try it as an overlay.
+ if (!targetPage && this.showOverlay_(pageName, rootPage)) {
+ if (opt_updateHistory)
+ this.updateHistoryState_(!!opt_propertyBag.replaceState);
+ this.updateTitle_();
+ return;
+ }
+ targetPage = this.defaultPage_;
+ }
+
+ pageName = targetPage.name.toLowerCase();
+ var targetPageWasVisible = targetPage.visible;
+
+ // Determine if the root page is 'sticky', meaning that it
+ // shouldn't change when showing an overlay. This can happen for special
+ // pages like Search.
+ var isRootPageLocked =
+ rootPage && rootPage.sticky && targetPage.parentPage;
+
+ var allPageNames = Array.prototype.concat.call(
+ Object.keys(this.registeredPages),
+ Object.keys(this.registeredOverlayPages));
+
+ // Notify pages if they will be hidden.
+ // TODO(michaelpg): Resolve code duplication.
+ for (var i = 0; i < allPageNames.length; ++i) {
+ var name = allPageNames[i];
+ var page = this.registeredPages[name] ||
+ this.registeredOverlayPages[name];
+ if (!page.parentPage && isRootPageLocked)
+ continue;
+ if (page.willHidePage && name != pageName &&
+ !this.isAncestorOfPage(page, targetPage)) {
+ page.willHidePage();
+ }
+ }
+
+ // Update visibilities to show only the hierarchy of the target page.
+ for (var i = 0; i < allPageNames.length; ++i) {
+ var name = allPageNames[i];
+ var page = this.registeredPages[name] ||
+ this.registeredOverlayPages[name];
+ if (!page.parentPage && isRootPageLocked)
+ continue;
+ page.visible = name == pageName ||
+ this.isAncestorOfPage(page, targetPage);
+ }
+
+ // Update the history and current location.
+ if (opt_updateHistory)
+ this.updateHistoryState_(!!opt_propertyBag.replaceState);
+
+ // Update focus if any other control was focused on the previous page,
+ // or the previous page is not known.
+ if (document.activeElement != document.body &&
+ (!rootPage || rootPage.pageDiv.contains(document.activeElement))) {
+ targetPage.focus();
+ }
+
+ // Notify pages if they were shown.
+ for (var i = 0; i < allPageNames.length; ++i) {
+ var name = allPageNames[i];
+ var page = this.registeredPages[name] ||
+ this.registeredOverlayPages[name];
+ if (!page.parentPage && isRootPageLocked)
+ continue;
+ if (!targetPageWasVisible && page.didShowPage &&
+ (name == pageName || this.isAncestorOfPage(page, targetPage))) {
+ page.didShowPage();
+ }
+ }
+
+ // Update the document title. Do this after didShowPage was called, in
+ // case a page decides to change its title.
+ this.updateTitle_();
+ },
+
+ /**
+ * Returns the name of the page from the current path.
+ * @return {string} Name of the page specified by the current path.
+ */
+ getPageNameFromPath: function() {
+ var path = location.pathname;
+ if (path.length <= 1)
+ return this.defaultPage_.name;
+
+ // Skip starting slash and remove trailing slash (if any).
+ return path.slice(1).replace(/\/$/, '');
+ },
+
+ /**
+ * Gets the level of the page. The root page (e.g., BrowserOptions) has
+ * level 0.
+ * @return {number} How far down this page is from the root page.
+ */
+ getNestingLevel: function(page) {
+ if (typeof page.nestingLevelOverride == "number")
+ return page.nestingLevelOverride;
+
+ var level = 0;
+ var parent = page.parentPage;
+ while (parent) {
+ level++;
+ parent = parent.parentPage;
+ }
+ return level;
+ },
+
+ /**
+ * Checks whether one page is an ancestor of the other page in terms of
+ * subpage nesting.
+ * @param {cr.ui.pageManager.Page} potentialAncestor Potential ancestor.
+ * @param {cr.ui.pageManager.Page} potentialDescendent Potential descendent.
+ * @return {boolean} True if |potentialDescendent| is nested under
+ * |potentialAncestor|.
+ */
+ isAncestorOfPage: function(potentialAncestor, potentialDescendent) {
+ var parent = potentialDescendent.parentPage;
+ while (parent) {
+ if (parent == potentialAncestor)
+ return true;
+ parent = parent.parentPage;
+ }
+ return false;
+ },
+
+ /**
+ * Called when an page is shown or hidden to update the root page
+ * based on the page's new visibility.
+ * @param {cr.ui.pageManager.Page} page The page being made visible or
+ * invisible.
+ */
+ onPageVisibilityChanged: function(page) {
+ this.updateRootPageFreezeState();
+
+ if (page.isOverlay && !page.visible)
+ this.updateScrollPosition_();
+ },
+
+ /**
+ * Returns the topmost visible page, or null if no page is visible.
+ * @return {cr.ui.pageManager.Page} The topmost visible page.
+ */
+ getTopmostVisiblePage: function() {
+ // Check overlays first since they're top-most if visible.
+ return this.getVisibleOverlay_() ||
+ this.getTopmostVisibleNonOverlayPage_();
+ },
+
+ /**
+ * Closes the visible overlay. Updates the history state after closing the
+ * overlay.
+ */
+ closeOverlay: function() {
+ var overlay = this.getVisibleOverlay_();
+ if (!overlay)
+ return;
+
+ overlay.visible = false;
+
+ if (overlay.didClosePage)
+ overlay.didClosePage();
+ this.updateHistoryState_(false, {ignoreHash: true});
+ this.updateTitle_();
+
+ this.restoreLastFocusedElement_();
+ },
+
+ /**
+ * Closes all overlays and updates the history after each closed overlay.
+ */
+ closeAllOverlays: function() {
+ while (this.isOverlayVisible_()) {
+ this.closeOverlay();
+ }
+ },
+
+ /**
+ * Cancels (closes) the overlay, due to the user pressing <Esc>.
+ */
+ cancelOverlay: function() {
+ // Blur the active element to ensure any changed pref value is saved.
+ document.activeElement.blur();
+ var overlay = this.getVisibleOverlay_();
+ if (!overlay)
+ return;
+ // Let the overlay handle the <Esc> if it wants to.
+ if (overlay.handleCancel) {
+ overlay.handleCancel();
+ this.restoreLastFocusedElement_();
+ } else {
+ this.closeOverlay();
+ }
+ },
+
+ /**
+ * Shows an informational bubble displaying |content| and pointing at the
+ * |target| element. If |content| has focusable elements, they join the
+ * current page's tab order as siblings of |domSibling|.
+ * @param {HTMLDivElement} content The content of the bubble.
+ * @param {HTMLElement} target The element at which the bubble points.
+ * @param {HTMLElement} domSibling The element after which the bubble is
+ * added to the DOM.
+ * @param {cr.ui.ArrowLocation} location The arrow location.
+ */
+ showBubble: function(content, target, domSibling, location) {
+ this.hideBubble();
+
+ var bubble = new cr.ui.AutoCloseBubble;
+ bubble.anchorNode = target;
+ bubble.domSibling = domSibling;
+ bubble.arrowLocation = location;
+ bubble.content = content;
+ bubble.show();
+ this.bubble_ = bubble;
+ },
+
+ /**
+ * Hides the currently visible bubble, if any.
+ */
+ hideBubble: function() {
+ if (this.bubble_)
+ this.bubble_.hide();
+ },
+
+ /**
+ * Returns the currently visible bubble, or null if no bubble is visible.
+ * @return {AutoCloseBubble} The bubble currently being shown.
+ */
+ getVisibleBubble: function() {
+ var bubble = this.bubble_;
+ return bubble && !bubble.hidden ? bubble : null;
+ },
+
+ /**
+ * Callback for window.onpopstate to handle back/forward navigations.
+ * @param {string} pageName The current page name.
+ * @param {Object} data State data pushed into history.
+ */
+ setState: function(pageName, data) {
+ var currentOverlay = this.getVisibleOverlay_();
+ var lowercaseName = pageName.toLowerCase();
+ var newPage = this.registeredPages[lowercaseName] ||
+ this.registeredOverlayPages[lowercaseName] ||
+ this.defaultPage_;
+ if (currentOverlay && !this.isAncestorOfPage(currentOverlay, newPage)) {
+ currentOverlay.visible = false;
+ if (currentOverlay.didClosePage) currentOverlay.didClosePage();
+ }
+ this.showPageByName(pageName, false);
+ },
+
+
+ /**
+ * Whether the page is still loading (i.e. onload hasn't finished running).
+ * @return {boolean} Whether the page is still loading.
+ */
+ isLoading: function() {
+ return document.documentElement.classList.contains('loading');
+ },
+
+ /**
+ * Callback for window.onbeforeunload. Used to notify overlays that they
+ * will be closed.
+ */
+ willClose: function() {
+ var overlay = this.getVisibleOverlay_();
+ if (overlay && overlay.didClosePage)
+ overlay.didClosePage();
+ },
+
+ /**
+ * Freezes/unfreezes the scroll position of the root page based on the
+ * current page stack.
+ */
+ updateRootPageFreezeState: function() {
+ var topPage = this.getTopmostVisiblePage();
+ if (topPage)
+ this.setRootPageFrozen_(topPage.isOverlay);
+ },
+
+ /**
+ * Change the horizontal offset used to reposition elements while showing an
+ * overlay from the default.
+ */
+ setHorizontalOffset: function(value) {
+ this.horizontalOffset = value;
+ },
+
+ /**
+ * Shows a registered overlay page. Does not update history.
+ * @param {string} overlayName Page name.
+ * @param {cr.ui.pageManager.Page} rootPage The currently visible root-level
+ * page.
+ * @return {boolean} Whether we showed an overlay.
+ * @private
+ */
+ showOverlay_: function(overlayName, rootPage) {
+ var overlay = this.registeredOverlayPages[overlayName.toLowerCase()];
+ if (!overlay || !overlay.canShowPage())
+ return false;
+
+ // Save the currently focused element in the page for restoration later.
+ var currentPage = this.getTopmostVisiblePage();
+ if (currentPage)
+ currentPage.lastFocusedElement = document.activeElement;
+
+ if ((!rootPage || !rootPage.sticky) &&
+ overlay.parentPage &&
+ !overlay.parentPage.visible) {
+ this.showPageByName(overlay.parentPage.name, false);
+ }
+
+ if (!overlay.visible) {
+ overlay.visible = true;
+ if (overlay.didShowPage)
+ overlay.didShowPage();
+ }
+
+ // Change focus to the overlay if any other control was focused by
+ // keyboard before. Otherwise, no one should have focus.
+ if (document.activeElement != document.body) {
+ if (FocusOutlineManager.forDocument(document).visible) {
+ overlay.focus();
+ } else if (!overlay.pageDiv.contains(document.activeElement)) {
+ document.activeElement.blur();
+ }
+ }
+
+ if ($('search-field') && $('search-field').value == '') {
+ var section = overlay.associatedSection;
+ if (section)
+ options.BrowserOptions.scrollToSection(section);
+ }
+
+ return true;
+ },
+
+ /**
+ * Returns whether or not an overlay is visible.
+ * @return {boolean} True if an overlay is visible.
+ * @private
+ */
+ isOverlayVisible_: function() {
+ return this.getVisibleOverlay_() != null;
+ },
+
+ /**
+ * Returns the currently visible overlay, or null if no page is visible.
+ * @return {cr.ui.pageManager.Page} The visible overlay.
+ * @private
+ */
+ getVisibleOverlay_: function() {
+ var topmostPage = null;
+ for (var name in this.registeredOverlayPages) {
+ var page = this.registeredOverlayPages[name];
+ if (page.visible &&
+ (!topmostPage || this.getNestingLevel(page) >
+ this.getNestingLevel(topmostPage))) {
+ topmostPage = page;
+ }
+ }
+ return topmostPage;
+ },
+
+ /**
+ * Hides the visible overlay. Does not affect the history state.
+ * @private
+ */
+ hideOverlay_: function() {
+ var overlay = this.getVisibleOverlay_();
+ if (overlay)
+ overlay.visible = false;
+ },
+
+ /**
+ * Returns the pages which are currently visible, ordered by nesting level
+ * (ascending).
+ * @return {Array.Page} The pages which are currently visible, ordered
+ * by nesting level (ascending).
+ * @private
+ */
+ getVisiblePages_: function() {
+ var visiblePages = [];
+ for (var name in this.registeredPages) {
+ var page = this.registeredPages[name];
+ if (page.visible)
+ visiblePages[this.getNestingLevel(page)] = page;
+ }
+ return visiblePages;
+ },
+
+ /**
+ * Returns the topmost visible page (overlays excluded).
+ * @return {cr.ui.pageManager.Page} The topmost visible page aside from any
+ * overlays.
+ * @private
+ */
+ getTopmostVisibleNonOverlayPage_: function() {
+ var topPage = null;
+ for (var name in this.registeredPages) {
+ var page = this.registeredPages[name];
+ if (page.visible &&
+ (!topPage || this.getNestingLevel(page) >
+ this.getNestingLevel(topPage))) {
+ topPage = page;
+ }
+ }
+
+ return topPage;
+ },
+
+ /**
+ * Scrolls the page to the correct position (the top when opening an
+ * overlay, or the old scroll position a previously hidden overlay
+ * becomes visible).
+ * @private
+ */
+ updateScrollPosition_: function() {
+ var container = $('page-container');
+ var scrollTop = container.oldScrollTop || 0;
+ container.oldScrollTop = undefined;
+ window.scroll(scrollLeftForDocument(document), scrollTop);
+ },
+
+ /**
+ * Updates the title to the title of the current page, or of the topmost
+ * visible page with a non-empty title.
+ * @private
+ */
+ updateTitle_: function() {
+ var page = this.getTopmostVisiblePage();
+ // TODO(michaelpg): Remove dependency on uber (crbug.com/313244).
+ while (page) {
+ if (page.title) {
+ uber.setTitle(page.title);
+ return;
+ }
+ page = page.parentPage;
+ }
+ },
+
+ /**
+ * Pushes the current page onto the history stack, replacing the current
+ * entry if appropriate.
+ * @param {boolean} replace If true, allow no history events to be created.
+ * @param {object=} opt_params A bag of optional params, including:
+ * {boolean} ignoreHash Whether to include the hash or not.
+ * @private
+ */
+ updateHistoryState_: function(replace, opt_params) {
+ if (this.isDialog)
+ return;
+
+ var page = this.getTopmostVisiblePage();
+ var path = window.location.pathname + window.location.hash;
+ if (path) {
+ // Remove trailing slash.
+ path = path.slice(1).replace(/\/(?:#|$)/, '');
+ }
+
+ // If the page is already in history (the user may have clicked the same
+ // link twice, or this is the initial load), do nothing.
+ var hash = opt_params && opt_params.ignoreHash ?
+ '' : window.location.hash;
+ var newPath = (page == this.defaultPage_ ? '' : page.name) + hash;
+ if (path == newPath)
+ return;
+
+ // TODO(michaelpg): Remove dependency on uber (crbug.com/313244).
+ var historyFunction = replace ? uber.replaceState : uber.pushState;
+ historyFunction.call(uber, {}, newPath);
+ },
+
+ /**
+ * Restores the last focused element on a given page.
+ * @private
+ */
+ restoreLastFocusedElement_: function() {
+ var currentPage = this.getTopmostVisiblePage();
+ if (currentPage.lastFocusedElement)
+ currentPage.lastFocusedElement.focus();
+ },
+
+ /**
+ * Find an enclosing section for an element if it exists.
+ * @param {Element} element Element to search.
+ * @return {Element} The section element, or null.
+ * @private
+ */
+ findSectionForNode_: function(node) {
+ while (node = node.parentNode) {
+ if (node.nodeName == 'SECTION')
+ return node;
+ }
+ return null;
+ },
+
+ /**
+ * Freezes/unfreezes the scroll position of the root page container.
+ * @param {boolean} freeze Whether the page should be frozen.
+ * @private
+ */
+ setRootPageFrozen_: function(freeze) {
+ var container = $('page-container');
+ if (container.classList.contains('frozen') == freeze)
+ return;
+
+ if (freeze) {
+ // Lock the width, since auto width computation may change.
+ container.style.width = window.getComputedStyle(container).width;
+ container.oldScrollTop = scrollTopForDocument(document);
+ container.classList.add('frozen');
+ var verticalPosition =
+ container.getBoundingClientRect().top - container.oldScrollTop;
+ container.style.top = verticalPosition + 'px';
+ this.updateFrozenElementHorizontalPosition_(container);
+ } else {
+ container.classList.remove('frozen');
+ container.style.top = '';
+ container.style.left = '';
+ container.style.right = '';
+ container.style.width = '';
+ }
+ },
+
+ /**
+ * Called when the page is scrolled; moves elements that are position:fixed
+ * but should only behave as if they are fixed for vertical scrolling.
+ * @private
+ */
+ handleScroll_: function() {
+ this.updateAllFrozenElementPositions_();
+ },
+
+ /**
+ * Updates all frozen pages to match the horizontal scroll position.
+ * @private
+ */
+ updateAllFrozenElementPositions_: function() {
+ var frozenElements = document.querySelectorAll('.frozen');
+ for (var i = 0; i < frozenElements.length; i++)
+ this.updateFrozenElementHorizontalPosition_(frozenElements[i]);
+ },
+
+ /**
+ * Updates the given frozen element to match the horizontal scroll position.
+ * @param {HTMLElement} e The frozen element to update.
+ * @private
+ */
+ updateFrozenElementHorizontalPosition_: function(e) {
+ if (isRTL()) {
+ e.style.right = this.horizontalOffset + 'px';
+ } else {
+ var scrollLeft = scrollLeftForDocument(document);
+ e.style.left = this.horizontalOffset - scrollLeft + 'px';
+ }
+ },
+ };
+
+ // Export
+ return {
+ PageManager: PageManager
+ };
+});
« no previous file with comments | « ui/webui/resources/js/cr/ui/page_manager/page.js ('k') | ui/webui/resources/webui_resources.grd » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698