gecko-dev/browser/base/content/browser-sidebar.js

674 строки
20 KiB
JavaScript

/* 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,
_icon: null,
_reversePositionButton: null,
_switcherPanel: null,
_switcherTarget: null,
_switcherArrow: null,
_inited: false,
/**
* @type {MutationObserver | null}
*/
_observer: null,
_initDeferred: PromiseUtils.defer(),
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._icon = document.getElementById("sidebar-icon");
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._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, "width");
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._show(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();
}
},
hideSwitcherPanel() {
this._switcherPanel.hidePopup();
},
showSwitcherPanel() {
this._ensureShortcutsShown();
this._switcherPanel.addEventListener(
"popuphiding",
() => {
this._switcherTarget.classList.remove("active");
},
{ 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);
this._switcherPanel.hidden = false;
this._switcherPanel.openPopup(this._icon);
this._switcherTarget.classList.add("active");
},
updateShortcut({ button, key }) {
// If the shortcuts haven't been rendered yet then it will be set correctly
// on the first render so there's nothing to do now.
if (!this._addedShortcuts) {
return;
}
if (key) {
let keyId = key.getAttribute("id");
button = this._switcherPanel.querySelector(`[key="${keyId}"]`);
} else if (button) {
let keyId = button.getAttribute("key");
key = document.getElementById(keyId);
}
if (!button || !key) {
return;
}
button.setAttribute("shortcut", ShortcutUtils.prettifyShortcut(key));
},
_addedShortcuts: false,
_ensureShortcutsShown() {
if (this._addedShortcuts) {
return;
}
this._addedShortcuts = true;
for (let button of this._switcherPanel.querySelectorAll(
"toolbarbutton[key]"
)) {
this.updateShortcut({ button });
}
},
/**
* 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.MozBoxOrdinalGroup = 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.MozBoxOrdinalGroup;
this._box.style.MozBoxOrdinalGroup = appcontent.style.MozBoxOrdinalGroup;
appcontent.style.MozBoxOrdinalGroup = 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.setAttribute(
"width",
sourceUI._box.getBoundingClientRect().width
);
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.createAboutBlankContentViewer(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)
);