summaryrefslogtreecommitdiffstats
path: root/browser/base/content/browser-sidebar.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/base/content/browser-sidebar.js')
-rw-r--r--browser/base/content/browser-sidebar.js674
1 files changed, 674 insertions, 0 deletions
diff --git a/browser/base/content/browser-sidebar.js b/browser/base/content/browser-sidebar.js
new file mode 100644
index 0000000000..ea6457a5c4
--- /dev/null
+++ b/browser/base/content/browser-sidebar.js
@@ -0,0 +1,674 @@
+/* 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/. */
+
+/**
+ * SidebarUI controls showing and hiding the browser sidebar.
+ */
+var SidebarUI = {
+ get sidebars() {
+ if (this._sidebars) {
+ return this._sidebars;
+ }
+
+ function makeSidebar({ elementId, ...rest }) {
+ return {
+ get sourceL10nEl() {
+ return document.getElementById(elementId);
+ },
+ get title() {
+ return document.getElementById(elementId).getAttribute("label");
+ },
+ ...rest,
+ };
+ }
+
+ return (this._sidebars = new Map([
+ [
+ "viewBookmarksSidebar",
+ makeSidebar({
+ elementId: "sidebar-switcher-bookmarks",
+ url: "chrome://browser/content/places/bookmarksSidebar.xhtml",
+ menuId: "menu_bookmarksSidebar",
+ }),
+ ],
+ [
+ "viewHistorySidebar",
+ makeSidebar({
+ elementId: "sidebar-switcher-history",
+ url: "chrome://browser/content/places/historySidebar.xhtml",
+ menuId: "menu_historySidebar",
+ triggerButtonId: "appMenuViewHistorySidebar",
+ }),
+ ],
+ [
+ "viewTabsSidebar",
+ makeSidebar({
+ elementId: "sidebar-switcher-tabs",
+ url: "chrome://browser/content/syncedtabs/sidebar.xhtml",
+ menuId: "menu_tabsSidebar",
+ }),
+ ],
+ ]));
+ },
+
+ // Avoid getting the browser element from init() to avoid triggering the
+ // <browser> constructor during startup if the sidebar is hidden.
+ get browser() {
+ if (this._browser) {
+ return this._browser;
+ }
+ return (this._browser = document.getElementById("sidebar"));
+ },
+ POSITION_START_PREF: "sidebar.position_start",
+ DEFAULT_SIDEBAR_ID: "viewBookmarksSidebar",
+
+ // lastOpenedId is set in show() but unlike currentID it's not cleared out on hide
+ // and isn't persisted across windows
+ lastOpenedId: null,
+
+ _box: null,
+ // The constructor of this label accesses the browser element due to the
+ // control="sidebar" attribute, so avoid getting this label during startup.
+ get _title() {
+ if (this.__title) {
+ return this.__title;
+ }
+ return (this.__title = document.getElementById("sidebar-title"));
+ },
+ _splitter: null,
+ _reversePositionButton: null,
+ _switcherPanel: null,
+ _switcherTarget: null,
+ _switcherArrow: null,
+ _inited: false,
+
+ /**
+ * @type {MutationObserver | null}
+ */
+ _observer: null,
+
+ _initDeferred: Promise.withResolvers(),
+
+ get promiseInitialized() {
+ return this._initDeferred.promise;
+ },
+
+ get initialized() {
+ return this._inited;
+ },
+
+ init() {
+ this._box = document.getElementById("sidebar-box");
+ this._splitter = document.getElementById("sidebar-splitter");
+ this._reversePositionButton = document.getElementById(
+ "sidebar-reverse-position"
+ );
+ this._switcherPanel = document.getElementById("sidebarMenu-popup");
+ this._switcherTarget = document.getElementById("sidebar-switcher-target");
+ this._switcherArrow = document.getElementById("sidebar-switcher-arrow");
+
+ this._switcherTarget.addEventListener("command", () => {
+ this.toggleSwitcherPanel();
+ });
+ this._switcherTarget.addEventListener("keydown", event => {
+ this.handleKeydown(event);
+ });
+
+ this._inited = true;
+
+ Services.obs.addObserver(this, "intl:app-locales-changed");
+
+ this._initDeferred.resolve();
+ },
+
+ uninit() {
+ // If this is the last browser window, persist various values that should be
+ // remembered for after a restart / reopening a browser window.
+ let enumerator = Services.wm.getEnumerator("navigator:browser");
+ if (!enumerator.hasMoreElements()) {
+ let xulStore = Services.xulStore;
+ xulStore.persist(this._box, "sidebarcommand");
+
+ if (this._box.hasAttribute("positionend")) {
+ xulStore.persist(this._box, "positionend");
+ } else {
+ xulStore.removeValue(
+ document.documentURI,
+ "sidebar-box",
+ "positionend"
+ );
+ }
+ if (this._box.hasAttribute("checked")) {
+ xulStore.persist(this._box, "checked");
+ } else {
+ xulStore.removeValue(document.documentURI, "sidebar-box", "checked");
+ }
+
+ xulStore.persist(this._box, "style");
+ xulStore.persist(this._title, "value");
+ }
+
+ Services.obs.removeObserver(this, "intl:app-locales-changed");
+
+ if (this._observer) {
+ this._observer.disconnect();
+ this._observer = null;
+ }
+ },
+
+ /**
+ * The handler for Services.obs.addObserver.
+ **/
+ observe(_subject, topic, _data) {
+ switch (topic) {
+ case "intl:app-locales-changed": {
+ if (this.isOpen) {
+ // The <tree> component used in history and bookmarks, but it does not
+ // support live switching the app locale. Reload the entire sidebar to
+ // invalidate any old text.
+ this.hide();
+ this.showInitially(this.lastOpenedId);
+ break;
+ }
+ }
+ }
+ },
+
+ /**
+ * Ensure the title stays in sync with the source element, which updates for
+ * l10n changes.
+ *
+ * @param {HTMLElement} [element]
+ */
+ observeTitleChanges(element) {
+ if (!element) {
+ return;
+ }
+ let observer = this._observer;
+ if (!observer) {
+ observer = new MutationObserver(() => {
+ this.title = this.sidebars.get(this.lastOpenedId).title;
+ });
+ // Re-use the observer.
+ this._observer = observer;
+ }
+ observer.disconnect();
+ observer.observe(element, {
+ attributes: true,
+ attributeFilter: ["label"],
+ });
+ },
+
+ /**
+ * Opens the switcher panel if it's closed, or closes it if it's open.
+ */
+ toggleSwitcherPanel() {
+ if (
+ this._switcherPanel.state == "open" ||
+ this._switcherPanel.state == "showing"
+ ) {
+ this.hideSwitcherPanel();
+ } else if (this._switcherPanel.state == "closed") {
+ this.showSwitcherPanel();
+ }
+ },
+
+ /**
+ * Handles keydown on the the switcherTarget button
+ * @param {Event} event
+ */
+ handleKeydown(event) {
+ switch (event.key) {
+ case "Enter":
+ case " ": {
+ this.toggleSwitcherPanel();
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ case "Escape": {
+ this.hideSwitcherPanel();
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ }
+ },
+
+ hideSwitcherPanel() {
+ this._switcherPanel.hidePopup();
+ },
+
+ showSwitcherPanel() {
+ this._switcherPanel.addEventListener(
+ "popuphiding",
+ () => {
+ this._switcherTarget.classList.remove("active");
+ this._switcherTarget.setAttribute("aria-expanded", false);
+ },
+ { once: true }
+ );
+
+ // Combine start/end position with ltr/rtl to set the label in the popup appropriately.
+ let label =
+ this._positionStart == RTL_UI
+ ? gNavigatorBundle.getString("sidebar.moveToLeft")
+ : gNavigatorBundle.getString("sidebar.moveToRight");
+ this._reversePositionButton.setAttribute("label", label);
+
+ // Open the sidebar switcher popup, anchored off the switcher toggle
+ this._switcherPanel.hidden = false;
+ this._switcherPanel.openPopup(this._switcherTarget);
+
+ this._switcherTarget.classList.add("active");
+ this._switcherTarget.setAttribute("aria-expanded", true);
+ },
+
+ updateShortcut({ keyId }) {
+ let menuitem = this._switcherPanel.querySelector(`[key="${keyId}"]`);
+ if (!menuitem) {
+ // If the menu item doesn't exist yet then the accel text will be set correctly
+ // upon creation so there's nothing to do now.
+ return;
+ }
+ menuitem.removeAttribute("acceltext");
+ },
+
+ /**
+ * Change the pref that will trigger a call to setPosition
+ */
+ reversePosition() {
+ Services.prefs.setBoolPref(this.POSITION_START_PREF, !this._positionStart);
+ },
+
+ /**
+ * Read the positioning pref and position the sidebar and the splitter
+ * appropriately within the browser container.
+ */
+ setPosition() {
+ // First reset all ordinals to match DOM ordering.
+ let browser = document.getElementById("browser");
+ [...browser.children].forEach((node, i) => {
+ node.style.order = i + 1;
+ });
+
+ if (!this._positionStart) {
+ // DOM ordering is: | sidebar-box | splitter | appcontent |
+ // Want to display as: | appcontent | splitter | sidebar-box |
+ // So we just swap box and appcontent ordering
+ let appcontent = document.getElementById("appcontent");
+ let boxOrdinal = this._box.style.order;
+ this._box.style.order = appcontent.style.order;
+ appcontent.style.order = boxOrdinal;
+ // Indicate we've switched ordering to the box
+ this._box.setAttribute("positionend", true);
+ } else {
+ this._box.removeAttribute("positionend");
+ }
+
+ this.hideSwitcherPanel();
+
+ let content = SidebarUI.browser.contentWindow;
+ if (content && content.updatePosition) {
+ content.updatePosition();
+ }
+ },
+
+ /**
+ * Try and adopt the status of the sidebar from another window.
+ * @param {Window} sourceWindow - Window to use as a source for sidebar status.
+ * @return true if we adopted the state, or false if the caller should
+ * initialize the state itself.
+ */
+ adoptFromWindow(sourceWindow) {
+ // If the opener had a sidebar, open the same sidebar in our window.
+ // The opener can be the hidden window too, if we're coming from the state
+ // where no windows are open, and the hidden window has no sidebar box.
+ let sourceUI = sourceWindow.SidebarUI;
+ if (!sourceUI || !sourceUI._box) {
+ // no source UI or no _box means we also can't adopt the state.
+ return false;
+ }
+
+ // Set sidebar command even if hidden, so that we keep the same sidebar
+ // even if it's currently closed.
+ let commandID = sourceUI._box.getAttribute("sidebarcommand");
+ if (commandID) {
+ this._box.setAttribute("sidebarcommand", commandID);
+ }
+
+ if (sourceUI._box.hidden) {
+ // just hidden means we have adopted the hidden state.
+ return true;
+ }
+
+ // dynamically generated sidebars will fail this check, but we still
+ // consider it adopted.
+ if (!this.sidebars.has(commandID)) {
+ return true;
+ }
+
+ this._box.style.width = sourceUI._box.getBoundingClientRect().width + "px";
+ this.showInitially(commandID);
+
+ return true;
+ },
+
+ windowPrivacyMatches(w1, w2) {
+ return (
+ PrivateBrowsingUtils.isWindowPrivate(w1) ===
+ PrivateBrowsingUtils.isWindowPrivate(w2)
+ );
+ },
+
+ /**
+ * If loading a sidebar was delayed on startup, start the load now.
+ */
+ startDelayedLoad() {
+ let sourceWindow = window.opener;
+ // No source window means this is the initial window. If we're being
+ // opened from another window, check that it is one we might open a sidebar
+ // for.
+ if (sourceWindow) {
+ if (
+ sourceWindow.closed ||
+ sourceWindow.location.protocol != "chrome:" ||
+ !this.windowPrivacyMatches(sourceWindow, window)
+ ) {
+ return;
+ }
+ // Try to adopt the sidebar state from the source window
+ if (this.adoptFromWindow(sourceWindow)) {
+ return;
+ }
+ }
+
+ // If we're not adopting settings from a parent window, set them now.
+ let wasOpen = this._box.getAttribute("checked");
+ if (!wasOpen) {
+ return;
+ }
+
+ let commandID = this._box.getAttribute("sidebarcommand");
+ if (commandID && this.sidebars.has(commandID)) {
+ this.showInitially(commandID);
+ } else {
+ this._box.removeAttribute("checked");
+ // Remove the |sidebarcommand| attribute, because the element it
+ // refers to no longer exists, so we should assume this sidebar
+ // panel has been uninstalled. (249883)
+ // We use setAttribute rather than removeAttribute so it persists
+ // correctly.
+ this._box.setAttribute("sidebarcommand", "");
+ // On a startup in which the startup cache was invalidated (e.g. app update)
+ // extensions will not be started prior to delayedLoad, thus the
+ // sidebarcommand element will not exist yet. Store the commandID so
+ // extensions may reopen if necessary. A startup cache invalidation
+ // can be forced (for testing) by deleting compatibility.ini from the
+ // profile.
+ this.lastOpenedId = commandID;
+ }
+ },
+
+ /**
+ * Fire a "SidebarShown" event on the sidebar to give any interested parties
+ * a chance to update the button or whatever.
+ */
+ _fireShowEvent() {
+ let event = new CustomEvent("SidebarShown", { bubbles: true });
+ this._switcherTarget.dispatchEvent(event);
+ },
+
+ /**
+ * Fire a "SidebarFocused" event on the sidebar's |window| to give the sidebar
+ * a chance to adjust focus as needed. An additional event is needed, because
+ * we don't want to focus the sidebar when it's opened on startup or in a new
+ * window, only when the user opens the sidebar.
+ */
+ _fireFocusedEvent() {
+ let event = new CustomEvent("SidebarFocused", { bubbles: true });
+ this.browser.contentWindow.dispatchEvent(event);
+ },
+
+ /**
+ * True if the sidebar is currently open.
+ */
+ get isOpen() {
+ return !this._box.hidden;
+ },
+
+ /**
+ * The ID of the current sidebar.
+ */
+ get currentID() {
+ return this.isOpen ? this._box.getAttribute("sidebarcommand") : "";
+ },
+
+ get title() {
+ return this._title.value;
+ },
+
+ set title(value) {
+ this._title.value = value;
+ },
+
+ /**
+ * Toggle the visibility of the sidebar. If the sidebar is hidden or is open
+ * with a different commandID, then the sidebar will be opened using the
+ * specified commandID. Otherwise the sidebar will be hidden.
+ *
+ * @param {string} commandID ID of the sidebar.
+ * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the
+ * visibility toggling of the sidebar.
+ * @return {Promise}
+ */
+ toggle(commandID = this.lastOpenedId, triggerNode) {
+ if (
+ CustomizationHandler.isCustomizing() ||
+ CustomizationHandler.isExitingCustomizeMode
+ ) {
+ return Promise.resolve();
+ }
+ // First priority for a default value is this.lastOpenedId which is set during show()
+ // and not reset in hide(), unlike currentID. If show() hasn't been called and we don't
+ // have a persisted command either, or the command doesn't exist anymore, then
+ // fallback to a default sidebar.
+ if (!commandID) {
+ commandID = this._box.getAttribute("sidebarcommand");
+ }
+ if (!commandID || !this.sidebars.has(commandID)) {
+ commandID = this.DEFAULT_SIDEBAR_ID;
+ }
+
+ if (this.isOpen && commandID == this.currentID) {
+ this.hide(triggerNode);
+ return Promise.resolve();
+ }
+ return this.show(commandID, triggerNode);
+ },
+
+ _loadSidebarExtension(commandID) {
+ let sidebar = this.sidebars.get(commandID);
+ let { extensionId } = sidebar;
+ if (extensionId) {
+ SidebarUI.browser.contentWindow.loadPanel(
+ extensionId,
+ sidebar.panel,
+ sidebar.browserStyle
+ );
+ }
+ },
+
+ /**
+ * Show the sidebar.
+ *
+ * This wraps the internal method, including a ping to telemetry.
+ *
+ * @param {string} commandID ID of the sidebar to use.
+ * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the
+ * showing of the sidebar.
+ * @return {Promise<boolean>}
+ */
+ async show(commandID, triggerNode) {
+ let panelType = commandID.substring(4, commandID.length - 7);
+ Services.telemetry.keyedScalarAdd("sidebar.opened", panelType, 1);
+
+ // Extensions without private window access wont be in the
+ // sidebars map.
+ if (!this.sidebars.has(commandID)) {
+ return false;
+ }
+ return this._show(commandID).then(() => {
+ this._loadSidebarExtension(commandID);
+
+ if (triggerNode) {
+ updateToggleControlLabel(triggerNode);
+ }
+
+ this._fireFocusedEvent();
+ return true;
+ });
+ },
+
+ /**
+ * Show the sidebar, without firing the focused event or logging telemetry.
+ * This is intended to be used when the sidebar is opened automatically
+ * when a window opens (not triggered by user interaction).
+ *
+ * @param {string} commandID ID of the sidebar.
+ * @return {Promise<boolean>}
+ */
+ async showInitially(commandID) {
+ let panelType = commandID.substring(4, commandID.length - 7);
+ Services.telemetry.keyedScalarAdd("sidebar.opened", panelType, 1);
+
+ // Extensions without private window access wont be in the
+ // sidebars map.
+ if (!this.sidebars.has(commandID)) {
+ return false;
+ }
+ return this._show(commandID).then(() => {
+ this._loadSidebarExtension(commandID);
+ return true;
+ });
+ },
+
+ /**
+ * Implementation for show. Also used internally for sidebars that are shown
+ * when a window is opened and we don't want to ping telemetry.
+ *
+ * @param {string} commandID ID of the sidebar.
+ * @return {Promise<void>}
+ */
+ _show(commandID) {
+ return new Promise(resolve => {
+ this.selectMenuItem(commandID);
+
+ this._box.hidden = this._splitter.hidden = false;
+ this.setPosition();
+
+ this.hideSwitcherPanel();
+
+ this._box.setAttribute("checked", "true");
+ this._box.setAttribute("sidebarcommand", commandID);
+ this.lastOpenedId = commandID;
+
+ let { url, title, sourceL10nEl } = this.sidebars.get(commandID);
+ this.title = title;
+ // Keep the title element in sync with any l10n changes.
+ this.observeTitleChanges(sourceL10nEl);
+ this.browser.setAttribute("src", url); // kick off async load
+
+ if (this.browser.contentDocument.location.href != url) {
+ this.browser.addEventListener(
+ "load",
+ event => {
+ // We're handling the 'load' event before it bubbles up to the usual
+ // (non-capturing) event handlers. Let it bubble up before resolving.
+ setTimeout(() => {
+ resolve();
+
+ // Now that the currentId is updated, fire a show event.
+ this._fireShowEvent();
+ }, 0);
+ },
+ { capture: true, once: true }
+ );
+ } else {
+ resolve();
+
+ // Now that the currentId is updated, fire a show event.
+ this._fireShowEvent();
+ }
+ });
+ },
+
+ /**
+ * Hide the sidebar.
+ *
+ * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the
+ * hiding of the sidebar.
+ */
+ hide(triggerNode) {
+ if (!this.isOpen) {
+ return;
+ }
+
+ this.hideSwitcherPanel();
+
+ this.selectMenuItem("");
+
+ // Replace the document currently displayed in the sidebar with about:blank
+ // so that we can free memory by unloading the page. We need to explicitly
+ // create a new content viewer because the old one doesn't get destroyed
+ // until about:blank has loaded (which does not happen as long as the
+ // element is hidden).
+ this.browser.setAttribute("src", "about:blank");
+ this.browser.docShell.createAboutBlankDocumentViewer(null, null);
+
+ this._box.removeAttribute("checked");
+ this._box.hidden = this._splitter.hidden = true;
+
+ let selBrowser = gBrowser.selectedBrowser;
+ selBrowser.focus();
+ if (triggerNode) {
+ updateToggleControlLabel(triggerNode);
+ }
+ },
+
+ /**
+ * Sets the checked state only on the menu items of the specified sidebar, or
+ * none if the argument is an empty string.
+ */
+ selectMenuItem(commandID) {
+ for (let [id, { menuId, triggerButtonId }] of this.sidebars) {
+ let menu = document.getElementById(menuId);
+ let triggerbutton =
+ triggerButtonId && document.getElementById(triggerButtonId);
+ if (id == commandID) {
+ menu.setAttribute("checked", "true");
+ if (triggerbutton) {
+ triggerbutton.setAttribute("checked", "true");
+ updateToggleControlLabel(triggerbutton);
+ }
+ } else {
+ menu.removeAttribute("checked");
+ if (triggerbutton) {
+ triggerbutton.removeAttribute("checked");
+ updateToggleControlLabel(triggerbutton);
+ }
+ }
+ }
+ },
+};
+
+// Add getters related to the position here, since we will want them
+// available for both startDelayedLoad and init.
+XPCOMUtils.defineLazyPreferenceGetter(
+ SidebarUI,
+ "_positionStart",
+ SidebarUI.POSITION_START_PREF,
+ true,
+ SidebarUI.setPosition.bind(SidebarUI)
+);