forked from mirrors/gecko-dev
		
	 ed54af49b2
			
		
	
	
		ed54af49b2
		
	
	
	
	
		
			
			MozReview-Commit-ID: 6GGwtFl1lUy --HG-- extra : rebase_source : 02e208ff7025174cb36dc4c9c70b17ce9986b9d6
		
			
				
	
	
		
			905 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			905 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 | |
| /* vim: set sts=2 sw=2 et tw=80: */
 | |
| "use strict";
 | |
| 
 | |
| // The ext-* files are imported into the same scopes.
 | |
| /* import-globals-from ext-browser.js */
 | |
| 
 | |
| ChromeUtils.import("resource://gre/modules/Services.jsm");
 | |
| 
 | |
| var {
 | |
|   DefaultMap,
 | |
|   ExtensionError,
 | |
| } = ExtensionUtils;
 | |
| 
 | |
| ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
 | |
| 
 | |
| var {
 | |
|   IconDetails,
 | |
| } = ExtensionParent;
 | |
| 
 | |
| 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 gMenuMap = new Map();
 | |
| 
 | |
| // Map[Extension -> MenuItem]
 | |
| var gRootItems = new Map();
 | |
| 
 | |
| // Map[Extension -> ID[]]
 | |
| // Menu IDs that were eligible for being shown in the current menu.
 | |
| var gShownMenuItems = new DefaultMap(() => []);
 | |
| 
 | |
| // Set of extensions that are listening to onShown.
 | |
| var gOnShownSubscribers = new Set();
 | |
| 
 | |
| // 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 menu 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 xulMenu = contextData.menu;
 | |
|     xulMenu.addEventListener("popuphidden", this);
 | |
|     this.xulMenu = xulMenu;
 | |
|     for (let [, root] of gRootItems) {
 | |
|       let rootElement = this.createTopLevelElement(root, contextData);
 | |
|       if (rootElement) {
 | |
|         this.appendTopLevelElement(rootElement);
 | |
|       }
 | |
|     }
 | |
|     this.afterBuildingMenu(contextData);
 | |
|   },
 | |
| 
 | |
|   // Builds a context menu for browserAction and pageAction buttons.
 | |
|   buildActionContextMenu(contextData) {
 | |
|     const {menu} = contextData;
 | |
| 
 | |
|     const root = gRootItems.get(contextData.extension);
 | |
|     if (!root) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const children = this.buildChildren(root, contextData);
 | |
|     const visible = children.slice(0, ACTION_MENU_TOP_LEVEL_LIMIT);
 | |
| 
 | |
|     this.xulMenu = menu;
 | |
|     menu.addEventListener("popuphidden", this);
 | |
| 
 | |
|     if (visible.length) {
 | |
|       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);
 | |
|       }
 | |
|     }
 | |
|     this.afterBuildingMenu(contextData);
 | |
|   },
 | |
| 
 | |
|   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;
 | |
|   },
 | |
| 
 | |
|   createTopLevelElement(root, contextData) {
 | |
|     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.
 | |
|       return null;
 | |
|     }
 | |
|     rootElement.setAttribute("ext-type", "top-level-menu");
 | |
|     rootElement = this.removeTopLevelMenuIfNeeded(rootElement);
 | |
| 
 | |
|     // Display the extension icon on the root element.
 | |
|     if (root.extension.manifest.icons) {
 | |
|       this.setMenuItemIcon(rootElement, root.extension, contextData, root.extension.manifest.icons);
 | |
|     }
 | |
|     return rootElement;
 | |
|   },
 | |
| 
 | |
|   appendTopLevelElement(rootElement) {
 | |
|     if (this.itemsToCleanUp.size === 0) {
 | |
|       const separator = this.xulMenu.ownerDocument.createElement("menuseparator");
 | |
|       this.itemsToCleanUp.add(separator);
 | |
|       this.xulMenu.append(separator);
 | |
|     }
 | |
| 
 | |
|     this.xulMenu.appendChild(rootElement);
 | |
|     this.itemsToCleanUp.add(rootElement);
 | |
|   },
 | |
| 
 | |
|   removeSeparatorIfNoTopLevelItems() {
 | |
|     if (this.itemsToCleanUp.size === 1) {
 | |
|       // Remove the separator if all extension menu items have disappeared.
 | |
|       const separator = this.itemsToCleanUp.values().next().value;
 | |
|       separator.remove();
 | |
|       this.itemsToCleanUp.clear();
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   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;
 | |
| 
 | |
|       // Keep single checkbox items in the submenu on Linux since
 | |
|       // the extension icon overlaps the checkbox otherwise.
 | |
|       if (AppConstants.platform === "linux" && onlyChild.getAttribute("type") === "checkbox") {
 | |
|         return element;
 | |
|       }
 | |
| 
 | |
|       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.trim();
 | |
|         // 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 codePointsToRemove = 0;
 | |
| 
 | |
|         let selectionArray = Array.from(selection);
 | |
| 
 | |
|         let completeLabelLength = label.length - 2 + selectionArray.length;
 | |
|         if (completeLabelLength > gMaxLabelLength) {
 | |
|           codePointsToRemove = completeLabelLength - gMaxLabelLength;
 | |
|         }
 | |
| 
 | |
|         if (codePointsToRemove) {
 | |
|           let ellipsis = "\u2026";
 | |
|           try {
 | |
|             ellipsis = Services.prefs.getComplexValue("intl.ellipsis",
 | |
|                                                       Ci.nsIPrefLocalizedString).data;
 | |
|           } catch (e) { }
 | |
|           codePointsToRemove += 1;
 | |
|           selection = selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
 | |
|         }
 | |
| 
 | |
|         label = label.replace(/%s/g, selection);
 | |
|       }
 | |
| 
 | |
|       element.setAttribute("label", label);
 | |
|     }
 | |
| 
 | |
|     element.setAttribute("id", item.elementId);
 | |
| 
 | |
|     if (item.icons) {
 | |
|       this.setMenuItemIcon(element, item.extension, contextData, item.icons);
 | |
|     }
 | |
| 
 | |
|     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;
 | |
|       }
 | |
| 
 | |
|       if (!contextData.onBookmark) {
 | |
|         item.tabManager.addActiveTabPermission();
 | |
|       }
 | |
| 
 | |
|       let tab = contextData.tab && item.tabManager.convert(contextData.tab);
 | |
|       let info = item.getClickInfo(contextData, wasChecked);
 | |
| 
 | |
|       const map = {shiftKey: "Shift", altKey: "Alt", metaKey: "Command", ctrlKey: "Ctrl"};
 | |
|       info.modifiers = Object.keys(map).filter(key => event[key]).map(key => map[key]);
 | |
|       if (event.ctrlKey && AppConstants.platform === "macosx") {
 | |
|         info.modifiers.push("MacCtrl");
 | |
|       }
 | |
| 
 | |
|       // Allow menus to open various actions supported in webext prior
 | |
|       // to notifying onclicked.
 | |
|       let actionFor = {
 | |
|         _execute_page_action: global.pageActionFor,
 | |
|         _execute_browser_action: global.browserActionFor,
 | |
|         _execute_sidebar_action: global.sidebarActionFor,
 | |
|       }[item.command];
 | |
|       if (actionFor) {
 | |
|         let win = event.target.ownerGlobal;
 | |
|         actionFor(item.extension).triggerAction(win);
 | |
|       }
 | |
| 
 | |
|       item.extension.emit("webext-menu-menuitem-click", info, tab);
 | |
|     });
 | |
| 
 | |
|     // Don't publish the ID of the root because the root element is
 | |
|     // auto-generated.
 | |
|     if (item.parent) {
 | |
|       gShownMenuItems.get(item.extension).push(item.id);
 | |
|     }
 | |
| 
 | |
|     return element;
 | |
|   },
 | |
| 
 | |
|   setMenuItemIcon(element, extension, contextData, icons) {
 | |
|     let parentWindow = contextData.menu.ownerGlobal;
 | |
| 
 | |
|     let {icon} = IconDetails.getPreferredIcon(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 = extension.baseURI.resolve(icon);
 | |
| 
 | |
|     if (element.localName == "menu") {
 | |
|       element.setAttribute("class", "menu-iconic");
 | |
|     } else if (element.localName == "menuitem") {
 | |
|       element.setAttribute("class", "menuitem-iconic");
 | |
|     }
 | |
| 
 | |
|     element.setAttribute("image", resolvedURL);
 | |
|   },
 | |
| 
 | |
|   rebuildMenu(extension) {
 | |
|     let {contextData} = this;
 | |
|     if (!contextData) {
 | |
|       // This happens if the menu is not visible.
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!gShownMenuItems.has(extension)) {
 | |
|       // The onShown event was not fired for the extension, so the extension
 | |
|       // does not know that a menu is being shown, and therefore they should
 | |
|       // not care whether the extension menu is updated.
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (contextData.onBrowserAction || contextData.onPageAction) {
 | |
|       // The action menu can only have items from one extension, so remove all
 | |
|       // items (including the separator) and rebuild the action menu (if any).
 | |
|       for (let item of this.itemsToCleanUp) {
 | |
|         item.remove();
 | |
|       }
 | |
|       this.itemsToCleanUp.clear();
 | |
|       this.buildActionContextMenu(contextData);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // First find the one and only top-level menu item for the extension.
 | |
|     let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
 | |
|     let oldRoot = null;
 | |
|     for (let item = this.xulMenu.lastElementChild; item !== null; item = item.previousElementSibling) {
 | |
|       if (item.id && item.id.startsWith(elementIdPrefix)) {
 | |
|         oldRoot = item;
 | |
|         this.itemsToCleanUp.delete(oldRoot);
 | |
|         break;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     let root = gRootItems.get(extension);
 | |
|     let newRoot = root && this.createTopLevelElement(root, contextData);
 | |
|     if (newRoot) {
 | |
|       this.itemsToCleanUp.add(newRoot);
 | |
|       if (oldRoot) {
 | |
|         oldRoot.replaceWith(newRoot);
 | |
|       } else {
 | |
|         this.appendTopLevelElement(newRoot);
 | |
|       }
 | |
|     } else if (oldRoot) {
 | |
|       oldRoot.remove();
 | |
|       this.removeSeparatorIfNoTopLevelItems();
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   afterBuildingMenu(contextData) {
 | |
|     if (this.contextData) {
 | |
|       // rebuildMenu can trigger us again, but the logic below should run only
 | |
|       // once per open menu.
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     function dispatchOnShownEvent(extension) {
 | |
|       // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
 | |
|       // extension to be stored in the map even if there are currently no
 | |
|       // shown menu items. This ensures that the onHidden event can be fired
 | |
|       // when the menu is closed.
 | |
|       let menuIds = gShownMenuItems.get(extension);
 | |
|       extension.emit("webext-menu-shown", menuIds, contextData);
 | |
|     }
 | |
| 
 | |
|     if (contextData.onBrowserAction || contextData.onPageAction) {
 | |
|       dispatchOnShownEvent(contextData.extension);
 | |
|     } else {
 | |
|       gOnShownSubscribers.forEach(dispatchOnShownEvent);
 | |
|     }
 | |
| 
 | |
|     this.contextData = contextData;
 | |
|   },
 | |
| 
 | |
|   handleEvent(event) {
 | |
|     if (this.xulMenu != event.target || event.type != "popuphidden") {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     delete this.xulMenu;
 | |
|     delete this.contextData;
 | |
| 
 | |
|     let target = event.target;
 | |
|     target.removeEventListener("popuphidden", this);
 | |
|     for (let item of this.itemsToCleanUp) {
 | |
|       item.remove();
 | |
|     }
 | |
|     this.itemsToCleanUp.clear();
 | |
|     for (let extension of gShownMenuItems.keys()) {
 | |
|       extension.emit("webext-menu-hidden");
 | |
|     }
 | |
|     gShownMenuItems.clear();
 | |
|   },
 | |
| 
 | |
|   itemsToCleanUp: new Set(),
 | |
| };
 | |
| 
 | |
| // Called from pageAction or browserAction popup.
 | |
| global.actionContextMenu = function(contextData) {
 | |
|   contextData.tab = tabTracker.activeTab;
 | |
|   contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
 | |
|   gMenuBuilder.buildActionContextMenu(contextData);
 | |
| };
 | |
| 
 | |
| const contextsMap = {
 | |
|   onAudio: "audio",
 | |
|   onEditable: "editable",
 | |
|   inFrame: "frame",
 | |
|   onImage: "image",
 | |
|   onLink: "link",
 | |
|   onPassword: "password",
 | |
|   isTextSelected: "selection",
 | |
|   onVideo: "video",
 | |
| 
 | |
|   onBookmark: "bookmark",
 | |
|   onBrowserAction: "browser_action",
 | |
|   onPageAction: "page_action",
 | |
|   onTab: "tab",
 | |
|   inToolsMenu: "tools_menu",
 | |
| };
 | |
| 
 | |
| const getMenuContexts = contextData => {
 | |
|   let contexts = new Set();
 | |
| 
 | |
|   for (const [key, value] of Object.entries(contextsMap)) {
 | |
|     if (contextData[key]) {
 | |
|       contexts.add(value);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (contexts.size === 0) {
 | |
|     contexts.add("page");
 | |
|   }
 | |
| 
 | |
|   // New non-content contexts supported in Firefox are not part of "all".
 | |
|   if (!contextData.onBookmark && !contextData.onTab && !contextData.inToolsMenu) {
 | |
|     contexts.add("all");
 | |
|   }
 | |
| 
 | |
|   return contexts;
 | |
| };
 | |
| 
 | |
| function addMenuEventInfo(info, contextData, includeSensitiveData) {
 | |
|   if (contextData.onVideo) {
 | |
|     info.mediaType = "video";
 | |
|   } else if (contextData.onAudio) {
 | |
|     info.mediaType = "audio";
 | |
|   } else if (contextData.onImage) {
 | |
|     info.mediaType = "image";
 | |
|   }
 | |
|   if (contextData.frameId !== undefined) {
 | |
|     info.frameId = contextData.frameId;
 | |
|   }
 | |
|   if (contextData.onBookmark) {
 | |
|     info.bookmarkId = contextData.bookmarkId;
 | |
|   }
 | |
|   info.editable = contextData.onEditable || false;
 | |
|   if (includeSensitiveData) {
 | |
|     if (contextData.onLink) {
 | |
|       info.linkText = contextData.linkText;
 | |
|       info.linkUrl = contextData.linkUrl;
 | |
|     }
 | |
|     if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
 | |
|       info.srcUrl = contextData.srcUrl;
 | |
|     }
 | |
|     if (!contextData.onBookmark) {
 | |
|       info.pageUrl = contextData.pageUrl;
 | |
|     }
 | |
|     if (contextData.inFrame) {
 | |
|       info.frameUrl = contextData.frameUrl;
 | |
|     }
 | |
|     if (contextData.isTextSelected) {
 | |
|       info.selectionText = contextData.selectionText;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function MenuItem(extension, createProperties, isRoot = false) {
 | |
|   this.extension = extension;
 | |
|   this.children = [];
 | |
|   this.parent = null;
 | |
|   this.tabManager = extension.tabManager;
 | |
| 
 | |
|   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 MatchPatternSet(this.documentUrlPatterns);
 | |
|     }
 | |
| 
 | |
|     if (createProperties.targetUrlPatterns != null) {
 | |
|       this.targetUrlMatchPattern = new MatchPatternSet(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 ExtensionError("ID of a MenuItem cannot be changed");
 | |
|     }
 | |
|     let isIdUsed = gMenuMap.get(this.extension).has(id);
 | |
|     if (isIdUsed) {
 | |
|       throw new ExtensionError(`ID already exists: ${id}`);
 | |
|     }
 | |
|     this._id = id;
 | |
|   },
 | |
| 
 | |
|   get id() {
 | |
|     return this._id;
 | |
|   },
 | |
| 
 | |
|   get elementId() {
 | |
|     let id = this.id;
 | |
|     // If the ID is an integer, it is auto-generated and globally unique.
 | |
|     // If the ID is a string, it is only unique within one extension and the
 | |
|     // ID needs to be concatenated with the extension ID.
 | |
|     if (typeof id !== "number") {
 | |
|       // To avoid collisions with numeric IDs, add a prefix to string IDs.
 | |
|       id = `_${id}`;
 | |
|     }
 | |
|     return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
 | |
|   },
 | |
| 
 | |
|   ensureValidParentId(parentId) {
 | |
|     if (parentId === undefined) {
 | |
|       return;
 | |
|     }
 | |
|     let menuMap = gMenuMap.get(this.extension);
 | |
|     if (!menuMap.has(parentId)) {
 | |
|       throw new ExtensionError(`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 = gMenuMap.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 = gMenuMap.get(this.extension);
 | |
|     menuMap.delete(this.id);
 | |
|     if (this.root == this) {
 | |
|       gRootItems.delete(this.extension);
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   getClickInfo(contextData, wasChecked) {
 | |
|     let info = {
 | |
|       menuItemId: this.id,
 | |
|     };
 | |
|     if (this.parent) {
 | |
|       info.parentMenuItemId = this.parentId;
 | |
|     }
 | |
| 
 | |
|     addMenuEventInfo(info, contextData, true);
 | |
| 
 | |
|     if ((this.type === "checkbox") || (this.type === "radio")) {
 | |
|       info.checked = this.checked;
 | |
|       info.wasChecked = wasChecked;
 | |
|     }
 | |
| 
 | |
|     return info;
 | |
|   },
 | |
| 
 | |
|   enabledForContext(contextData) {
 | |
|     let contexts = getMenuContexts(contextData);
 | |
|     if (!this.contexts.some(n => contexts.has(n))) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     if (contextData.onBookmark) {
 | |
|       return this.extension.hasPermission("bookmarks");
 | |
|     }
 | |
| 
 | |
|     let docPattern = this.documentUrlMatchPattern;
 | |
|     let pageURI = Services.io.newURI(contextData[contextData.inFrame ? "frameUrl" : "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(Services.io.newURI(targetUrl)))) {
 | |
|         return false;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
|   },
 | |
| };
 | |
| 
 | |
| // While any extensions are active, this Tracker registers to observe/listen
 | |
| // for menu events from both Tools and context menus, both content and chrome.
 | |
| const menuTracker = {
 | |
|   menuIds: ["placesContext", "menu_ToolsPopup", "tabContextMenu"],
 | |
| 
 | |
|   register() {
 | |
|     Services.obs.addObserver(this, "on-build-contextmenu");
 | |
|     for (const window of windowTracker.browserWindows()) {
 | |
|       this.onWindowOpen(window);
 | |
|     }
 | |
|     windowTracker.addOpenListener(this.onWindowOpen);
 | |
|   },
 | |
| 
 | |
|   unregister() {
 | |
|     Services.obs.removeObserver(this, "on-build-contextmenu");
 | |
|     for (const window of windowTracker.browserWindows()) {
 | |
|       for (const id of this.menuIds) {
 | |
|         const menu = window.document.getElementById(id);
 | |
|         menu.removeEventListener("popupshowing", this);
 | |
|       }
 | |
|     }
 | |
|     windowTracker.removeOpenListener(this.onWindowOpen);
 | |
|   },
 | |
| 
 | |
|   observe(subject, topic, data) {
 | |
|     subject = subject.wrappedJSObject;
 | |
|     gMenuBuilder.build(subject);
 | |
|   },
 | |
| 
 | |
|   onWindowOpen(window) {
 | |
|     for (const id of menuTracker.menuIds) {
 | |
|       const menu = window.document.getElementById(id);
 | |
|       menu.addEventListener("popupshowing", menuTracker);
 | |
|     }
 | |
|   },
 | |
| 
 | |
|   handleEvent(event) {
 | |
|     const menu = event.target;
 | |
|     if (menu.id === "placesContext") {
 | |
|       const trigger = menu.triggerNode;
 | |
|       if (!trigger._placesNode) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       gMenuBuilder.build({
 | |
|         menu,
 | |
|         bookmarkId: trigger._placesNode.bookmarkGuid,
 | |
|         onBookmark: true,
 | |
|       });
 | |
|     }
 | |
|     if (menu.id === "menu_ToolsPopup") {
 | |
|       const tab = tabTracker.activeTab;
 | |
|       const pageUrl = tab.linkedBrowser.currentURI.spec;
 | |
|       gMenuBuilder.build({menu, tab, pageUrl, inToolsMenu: true});
 | |
|     }
 | |
|     if (menu.id === "tabContextMenu") {
 | |
|       const trigger = menu.triggerNode;
 | |
|       const tab = trigger.localName === "tab" ? trigger : tabTracker.activeTab;
 | |
|       const pageUrl = tab.linkedBrowser.currentURI.spec;
 | |
|       gMenuBuilder.build({menu, tab, pageUrl, onTab: true});
 | |
|     }
 | |
|   },
 | |
| };
 | |
| 
 | |
| this.menusInternal = class extends ExtensionAPI {
 | |
|   constructor(extension) {
 | |
|     super(extension);
 | |
| 
 | |
|     if (!gMenuMap.size) {
 | |
|       menuTracker.register();
 | |
|     }
 | |
|     gMenuMap.set(extension, new Map());
 | |
|   }
 | |
| 
 | |
|   onShutdown(reason) {
 | |
|     let {extension} = this;
 | |
| 
 | |
|     if (gMenuMap.has(extension)) {
 | |
|       gMenuMap.delete(extension);
 | |
|       gRootItems.delete(extension);
 | |
|       gShownMenuItems.delete(extension);
 | |
|       gOnShownSubscribers.delete(extension);
 | |
|       if (!gMenuMap.size) {
 | |
|         menuTracker.unregister();
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   getAPI(context) {
 | |
|     let {extension} = context;
 | |
| 
 | |
|     const menus = {
 | |
|       refresh() {
 | |
|         gMenuBuilder.rebuildMenu(extension);
 | |
|       },
 | |
| 
 | |
|       onShown: new EventManager(context, "menus.onShown", fire => {
 | |
|         let listener = (event, menuIds, contextData) => {
 | |
|           let info = {
 | |
|             menuIds,
 | |
|             contexts: Array.from(getMenuContexts(contextData)),
 | |
|           };
 | |
| 
 | |
|           // The menus.onShown event is fired before the user has consciously
 | |
|           // interacted with an extension, so we require permissions before
 | |
|           // exposing sensitive contextual data.
 | |
|           let includeSensitiveData =
 | |
|             extension.tabManager.hasActiveTabPermission(contextData.tab) ||
 | |
|             extension.whiteListedHosts.matches(contextData.inFrame ? contextData.frameUrl : contextData.pageUrl);
 | |
| 
 | |
|           addMenuEventInfo(info, contextData, includeSensitiveData);
 | |
| 
 | |
|           let tab = extension.tabManager.convert(contextData.tab);
 | |
|           fire.sync(info, tab);
 | |
|         };
 | |
|         gOnShownSubscribers.add(extension);
 | |
|         extension.on("webext-menu-shown", listener);
 | |
|         return () => {
 | |
|           gOnShownSubscribers.delete(extension);
 | |
|           extension.off("webext-menu-shown", listener);
 | |
|         };
 | |
|       }).api(),
 | |
|       onHidden: new EventManager(context, "menus.onHidden", fire => {
 | |
|         let listener = () => {
 | |
|           fire.sync();
 | |
|         };
 | |
|         extension.on("webext-menu-hidden", listener);
 | |
|         return () => {
 | |
|           extension.off("webext-menu-hidden", listener);
 | |
|         };
 | |
|       }).api(),
 | |
|     };
 | |
| 
 | |
|     return {
 | |
|       contextMenus: menus,
 | |
|       menus,
 | |
|       menusInternal: {
 | |
|         create: function(createProperties) {
 | |
|           // Note that the id is required by the schema. If the addon did not set
 | |
|           // it, the implementation of menus.create in the child should
 | |
|           // have added it.
 | |
|           let menuItem = new MenuItem(extension, createProperties);
 | |
|           gMenuMap.get(extension).set(menuItem.id, menuItem);
 | |
|         },
 | |
| 
 | |
|         update: function(id, updateProperties) {
 | |
|           let menuItem = gMenuMap.get(extension).get(id);
 | |
|           if (menuItem) {
 | |
|             menuItem.setProps(updateProperties);
 | |
|           }
 | |
|         },
 | |
| 
 | |
|         remove: function(id) {
 | |
|           let menuItem = gMenuMap.get(extension).get(id);
 | |
|           if (menuItem) {
 | |
|             menuItem.remove();
 | |
|           }
 | |
|         },
 | |
| 
 | |
|         removeAll: function() {
 | |
|           let root = gRootItems.get(extension);
 | |
|           if (root) {
 | |
|             root.remove();
 | |
|           }
 | |
|         },
 | |
| 
 | |
|         onClicked: new EventManager(context, "menusInternal.onClicked", fire => {
 | |
|           let listener = (event, info, tab) => {
 | |
|             let {linkedBrowser} = tab || tabTracker.activeTab;
 | |
|             context.withPendingBrowser(linkedBrowser,
 | |
|                                        () => fire.sync(info, tab));
 | |
|           };
 | |
| 
 | |
|           extension.on("webext-menu-menuitem-click", listener);
 | |
|           return () => {
 | |
|             extension.off("webext-menu-menuitem-click", listener);
 | |
|           };
 | |
|         }).api(),
 | |
|       },
 | |
|     };
 | |
|   }
 | |
| };
 |