/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; Cu.import("resource://gre/modules/ExtensionUtils.jsm"); Cu.import("resource://gre/modules/MatchPattern.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); var { EventManager, ExtensionError, IconDetails, } = ExtensionUtils; const ACTION_MENU_TOP_LEVEL_LIMIT = 6; // Map[Extension -> Map[ID -> MenuItem]] // Note: we want to enumerate all the menu items so // this cannot be a weak map. var gContextMenuMap = new Map(); // Map[Extension -> MenuItem] var gRootItems = new Map(); // If id is not specified for an item we use an integer. var gNextMenuItemID = 0; // Used to assign unique names to radio groups. var gNextRadioGroupID = 0; // The max length of a menu item's label. var gMaxLabelLength = 64; var gMenuBuilder = { // When a new contextMenu is opened, this function is called and // we populate the |xulMenu| with all the items from extensions // to be displayed. We always clear all the items again when // popuphidden fires. build(contextData) { let firstItem = true; let xulMenu = contextData.menu; xulMenu.addEventListener("popuphidden", this); this.xulMenu = xulMenu; for (let [, root] of gRootItems) { let rootElement = this.buildElementWithChildren(root, contextData); if (!rootElement.firstChild || !rootElement.firstChild.childNodes.length) { // If the root has no visible children, there is no reason to show // the root menu item itself either. continue; } rootElement.setAttribute("ext-type", "top-level-menu"); rootElement = this.removeTopLevelMenuIfNeeded(rootElement); // Display the extension icon on the root element. if (root.extension.manifest.icons) { let parentWindow = contextData.menu.ownerGlobal; let extension = root.extension; let {icon} = IconDetails.getPreferredIcon(extension.manifest.icons, extension, 16 * parentWindow.devicePixelRatio); // The extension icons in the manifest are not pre-resolved, since // they're sometimes used by the add-on manager when the extension is // not enabled, and its URLs are not resolvable. let resolvedURL = root.extension.baseURI.resolve(icon); if (rootElement.localName == "menu") { rootElement.setAttribute("class", "menu-iconic"); } else if (rootElement.localName == "menuitem") { rootElement.setAttribute("class", "menuitem-iconic"); } rootElement.setAttribute("image", resolvedURL); } if (firstItem) { firstItem = false; const separator = xulMenu.ownerDocument.createElement("menuseparator"); this.itemsToCleanUp.add(separator); xulMenu.append(separator); } xulMenu.appendChild(rootElement); this.itemsToCleanUp.add(rootElement); } }, // Builds a context menu for browserAction and pageAction buttons. buildActionContextMenu(contextData) { const {menu} = contextData; contextData.tab = TabManager.activeTab; contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec; const root = gRootItems.get(contextData.extension); const children = this.buildChildren(root, contextData); const visible = children.slice(0, ACTION_MENU_TOP_LEVEL_LIMIT); if (visible.length) { this.xulMenu = menu; menu.addEventListener("popuphidden", this); const separator = menu.ownerDocument.createElement("menuseparator"); menu.insertBefore(separator, menu.firstChild); this.itemsToCleanUp.add(separator); for (const child of visible) { this.itemsToCleanUp.add(child); menu.insertBefore(child, separator); } } }, buildElementWithChildren(item, contextData) { const element = this.buildSingleElement(item, contextData); const children = this.buildChildren(item, contextData); if (children.length) { element.firstChild.append(...children); } return element; }, buildChildren(item, contextData) { let groupName; let children = []; for (let child of item.children) { if (child.type == "radio" && !child.groupName) { if (!groupName) { groupName = `webext-radio-group-${gNextRadioGroupID++}`; } child.groupName = groupName; } else { groupName = null; } if (child.enabledForContext(contextData)) { children.push(this.buildElementWithChildren(child, contextData)); } } return children; }, removeTopLevelMenuIfNeeded(element) { // If there is only one visible top level element we don't need the // root menu element for the extension. let menuPopup = element.firstChild; if (menuPopup && menuPopup.childNodes.length == 1) { let onlyChild = menuPopup.firstChild; onlyChild.remove(); return onlyChild; } return element; }, buildSingleElement(item, contextData) { let doc = contextData.menu.ownerDocument; let element; if (item.children.length > 0) { element = this.createMenuElement(doc, item); } else if (item.type == "separator") { element = doc.createElement("menuseparator"); } else { element = doc.createElement("menuitem"); } return this.customizeElement(element, item, contextData); }, createMenuElement(doc, item) { let element = doc.createElement("menu"); // Menu elements need to have a menupopup child for its menu items. let menupopup = doc.createElement("menupopup"); element.appendChild(menupopup); return element; }, customizeElement(element, item, contextData) { let label = item.title; if (label) { if (contextData.isTextSelected && label.indexOf("%s") > -1) { let selection = contextData.selectionText; // The rendering engine will truncate the title if it's longer than 64 characters. // But if it makes sense let's try truncate selection text only, to handle cases like // 'look up "%s" in MyDictionary' more elegantly. let maxSelectionLength = gMaxLabelLength - label.length + 2; if (maxSelectionLength > 4) { selection = selection.substring(0, maxSelectionLength - 3) + "..."; } label = label.replace(/%s/g, selection); } element.setAttribute("label", label); } if (item.type == "checkbox") { element.setAttribute("type", "checkbox"); if (item.checked) { element.setAttribute("checked", "true"); } } else if (item.type == "radio") { element.setAttribute("type", "radio"); element.setAttribute("name", item.groupName); if (item.checked) { element.setAttribute("checked", "true"); } } if (!item.enabled) { element.setAttribute("disabled", "true"); } element.addEventListener("command", event => { // eslint-disable-line mozilla/balanced-listeners if (event.target !== event.currentTarget) { return; } const wasChecked = item.checked; if (item.type == "checkbox") { item.checked = !item.checked; } else if (item.type == "radio") { // Deselect all radio items in the current radio group. for (let child of item.parent.children) { if (child.type == "radio" && child.groupName == item.groupName) { child.checked = false; } } // Select the clicked radio item. item.checked = true; } item.tabManager.addActiveTabPermission(); let tab = item.tabManager.convert(contextData.tab); let info = item.getClickInfo(contextData, wasChecked); item.extension.emit("webext-contextmenu-menuitem-click", info, tab); }); return element; }, handleEvent(event) { if (this.xulMenu != event.target || event.type != "popuphidden") { return; } delete this.xulMenu; let target = event.target; target.removeEventListener("popuphidden", this); for (let item of this.itemsToCleanUp) { item.remove(); } this.itemsToCleanUp.clear(); }, itemsToCleanUp: new Set(), }; // Called from pageAction or browserAction popup. global.actionContextMenu = function(contextData) { gMenuBuilder.buildActionContextMenu(contextData); }; function getContexts(contextData) { let contexts = new Set(); if (contextData.inFrame) { contexts.add("frame"); } if (contextData.isTextSelected) { contexts.add("selection"); } if (contextData.onLink) { contexts.add("link"); } if (contextData.onEditableArea) { contexts.add("editable"); } if (contextData.onPassword) { contexts.add("password"); } if (contextData.onImage) { contexts.add("image"); } if (contextData.onVideo) { contexts.add("video"); } if (contextData.onAudio) { contexts.add("audio"); } if (contextData.onPageAction) { contexts.add("page_action"); } if (contextData.onBrowserAction) { contexts.add("browser_action"); } if (contexts.size === 0) { contexts.add("page"); } if (contextData.onTab) { contexts.add("tab"); } else { contexts.add("all"); } return contexts; } function MenuItem(extension, createProperties, isRoot = false) { this.extension = extension; this.children = []; this.parent = null; this.tabManager = TabManager.for(extension); this.setDefaults(); this.setProps(createProperties); if (!this.hasOwnProperty("_id")) { this.id = gNextMenuItemID++; } // If the item is not the root and has no parent // it must be a child of the root. if (!isRoot && !this.parent) { this.root.addChild(this); } } MenuItem.prototype = { setProps(createProperties) { for (let propName in createProperties) { if (createProperties[propName] === null) { // Omitted optional argument. continue; } this[propName] = createProperties[propName]; } if (createProperties.documentUrlPatterns != null) { this.documentUrlMatchPattern = new MatchPattern(this.documentUrlPatterns); } if (createProperties.targetUrlPatterns != null) { this.targetUrlMatchPattern = new MatchPattern(this.targetUrlPatterns); } // If a child MenuItem does not specify any contexts, then it should // inherit the contexts specified from its parent. if (createProperties.parentId && !createProperties.contexts) { this.contexts = this.parent.contexts; } }, setDefaults() { this.setProps({ type: "normal", checked: false, contexts: ["all"], enabled: true, }); }, set id(id) { if (this.hasOwnProperty("_id")) { throw new Error("Id of a MenuItem cannot be changed"); } let isIdUsed = gContextMenuMap.get(this.extension).has(id); if (isIdUsed) { throw new Error("Id already exists"); } this._id = id; }, get id() { return this._id; }, ensureValidParentId(parentId) { if (parentId === undefined) { return; } let menuMap = gContextMenuMap.get(this.extension); if (!menuMap.has(parentId)) { throw new Error("Could not find any MenuItem with id: " + parentId); } for (let item = menuMap.get(parentId); item; item = item.parent) { if (item === this) { throw new ExtensionError("MenuItem cannot be an ancestor (or self) of its new parent."); } } }, set parentId(parentId) { this.ensureValidParentId(parentId); if (this.parent) { this.parent.detachChild(this); } if (parentId === undefined) { this.root.addChild(this); } else { let menuMap = gContextMenuMap.get(this.extension); menuMap.get(parentId).addChild(this); } }, get parentId() { return this.parent ? this.parent.id : undefined; }, addChild(child) { if (child.parent) { throw new Error("Child MenuItem already has a parent."); } this.children.push(child); child.parent = this; }, detachChild(child) { let idx = this.children.indexOf(child); if (idx < 0) { throw new Error("Child MenuItem not found, it cannot be removed."); } this.children.splice(idx, 1); child.parent = null; }, get root() { let extension = this.extension; if (!gRootItems.has(extension)) { let root = new MenuItem(extension, {title: extension.name}, /* isRoot = */ true); gRootItems.set(extension, root); } return gRootItems.get(extension); }, remove() { if (this.parent) { this.parent.detachChild(this); } let children = this.children.slice(0); for (let child of children) { child.remove(); } let menuMap = gContextMenuMap.get(this.extension); menuMap.delete(this.id); if (this.root == this) { gRootItems.delete(this.extension); } }, getClickInfo(contextData, wasChecked) { let mediaType; if (contextData.onVideo) { mediaType = "video"; } if (contextData.onAudio) { mediaType = "audio"; } if (contextData.onImage) { mediaType = "image"; } let info = { menuItemId: this.id, editable: contextData.onEditableArea || contextData.onPassword, }; function setIfDefined(argName, value) { if (value !== undefined) { info[argName] = value; } } setIfDefined("parentMenuItemId", this.parentId); setIfDefined("mediaType", mediaType); setIfDefined("linkUrl", contextData.linkUrl); setIfDefined("srcUrl", contextData.srcUrl); setIfDefined("pageUrl", contextData.pageUrl); setIfDefined("frameUrl", contextData.frameUrl); setIfDefined("selectionText", contextData.selectionText); if ((this.type === "checkbox") || (this.type === "radio")) { info.checked = this.checked; info.wasChecked = wasChecked; } return info; }, enabledForContext(contextData) { let contexts = getContexts(contextData); if (!this.contexts.some(n => contexts.has(n))) { return false; } let docPattern = this.documentUrlMatchPattern; let pageURI = Services.io.newURI(contextData.pageUrl); if (docPattern && !docPattern.matches(pageURI)) { return false; } let targetPattern = this.targetUrlMatchPattern; if (targetPattern) { let targetUrls = []; if (contextData.onImage || contextData.onAudio || contextData.onVideo) { // TODO: double check if srcUrl is always set when we need it targetUrls.push(contextData.srcUrl); } if (contextData.onLink) { targetUrls.push(contextData.linkUrl); } if (!targetUrls.some(targetUrl => targetPattern.matches(NetUtil.newURI(targetUrl)))) { return false; } } return true; }, }; // While any extensions are active, this Tracker registers to observe/listen // for contex-menu events from both content and chrome. const contextMenuTracker = { register() { Services.obs.addObserver(this, "on-build-contextmenu", false); for (const window of WindowListManager.browserWindows()) { this.onWindowOpen(window); } WindowListManager.addOpenListener(this.onWindowOpen); }, unregister() { Services.obs.removeObserver(this, "on-build-contextmenu"); for (const window of WindowListManager.browserWindows()) { const menu = window.document.getElementById("tabContextMenu"); menu.removeEventListener("popupshowing", this); } WindowListManager.removeOpenListener(this.onWindowOpen); }, observe(subject, topic, data) { subject = subject.wrappedJSObject; gMenuBuilder.build(subject); }, onWindowOpen(window) { const menu = window.document.getElementById("tabContextMenu"); menu.addEventListener("popupshowing", contextMenuTracker); }, handleEvent(event) { const menu = event.target; if (menu.id === "tabContextMenu") { const trigger = menu.triggerNode; const tab = trigger.localName === "tab" ? trigger : TabManager.activeTab; const pageUrl = tab.linkedBrowser.currentURI.spec; gMenuBuilder.build({menu, tab, pageUrl, onTab: true}); } }, }; var gExtensionCount = 0; /* eslint-disable mozilla/balanced-listeners */ extensions.on("startup", (type, extension) => { gContextMenuMap.set(extension, new Map()); if (++gExtensionCount == 1) { contextMenuTracker.register(); } }); extensions.on("shutdown", (type, extension) => { gContextMenuMap.delete(extension); gRootItems.delete(extension); if (--gExtensionCount == 0) { contextMenuTracker.unregister(); } }); /* eslint-enable mozilla/balanced-listeners */ extensions.registerSchemaAPI("contextMenus", "addon_parent", context => { let {extension} = context; return { contextMenus: { createInternal: function(createProperties) { // Note that the id is required by the schema. If the addon did not set // it, the implementation of contextMenus.create in the child should // have added it. let menuItem = new MenuItem(extension, createProperties); gContextMenuMap.get(extension).set(menuItem.id, menuItem); }, update: function(id, updateProperties) { let menuItem = gContextMenuMap.get(extension).get(id); if (menuItem) { menuItem.setProps(updateProperties); } }, remove: function(id) { let menuItem = gContextMenuMap.get(extension).get(id); if (menuItem) { menuItem.remove(); } }, removeAll: function() { let root = gRootItems.get(extension); if (root) { root.remove(); } }, onClicked: new EventManager(context, "contextMenus.onClicked", fire => { let listener = (event, info, tab) => { fire(info, tab); }; extension.on("webext-contextmenu-menuitem-click", listener); return () => { extension.off("webext-contextmenu-menuitem-click", listener); }; }).api(), }, }; });