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, contextMenuItems, runSafe } = ExtensionUtils; // Map[Extension -> Map[ID -> MenuItem]] // Note: we want to enumerate all the menu items so // this cannot be a weak map. var contextMenuMap = new Map(); // Not really used yet, will be used for event pages. var onClickedCallbacksMap = new WeakMap(); // If id is not specified for an item we use an integer. var nextID = 0; function contextMenuObserver(subject, topic, data) { subject = subject.wrappedJSObject; menuBuilder.build(subject); } // 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. Since most of the info we need is already // calculated in nsContextMenu.jsm we simple reuse its flags here. // For remote processes there is a gContextMenuContentData where all // the important info is stored from the child process. We get // this data in |contentData|. var menuBuilder = { build: function(contextData) { // TODO: icons should be set for items let xulMenu = contextData.menu; xulMenu.addEventListener("popuphidden", this); let doc = xulMenu.ownerDocument; for (let [ext, menuItemMap] of contextMenuMap) { let parentMap = new Map(); let topLevelItems = new Set(); for (let entry of menuItemMap) { // We need a closure over |item|, and we don't currently get a // fresh binding per loop if we declare it in the loop head. let [id, item] = entry; if (item.enabledForContext(contextData)) { let element; if (item.isMenu) { element = doc.createElement("menu"); // Menu elements need to have a menupopup child for // its menu items. let menupopup = doc.createElement("menupopup"); element.appendChild(menupopup); // Storing the menupopup in a map, so we can find it for its // menu item children later. parentMap.set(id, menupopup); } else { element = (item.type == "separator") ? doc.createElement("menuseparator") : doc.createElement("menuitem"); } // FIXME: handle %s in the title element.setAttribute("label", item.title); if (!item.enabled) { element.setAttribute("disabled", true); } let parentId = item.parentId; if (parentId && parentMap.has(parentId)) { // If parentId is set we have to look up its parent // and appened to its menupopup. let parentElement = parentMap.get(parentId); parentElement.appendChild(element); } else { topLevelItems.add(element); } element.addEventListener("command", event => { item.tabManager.addActiveTabPermission(); if (item.onclick) { let clickData = item.getClickData(contextData, event); runSafe(item.extContext, item.onclick, clickData); } }); } } if (topLevelItems.size > 1) { // If more than one top level items are visible, callopse them. let top = doc.createElement("menu"); top.setAttribute("label", ext.name); top.setAttribute("ext-type", "top-level-menu"); let menupopup = doc.createElement("menupopup"); top.appendChild(menupopup); for (i of topLevelItems) { menupopup.appendChild(i); } xulMenu.appendChild(top); this._itemsToCleanUp.add(top); } else if (topLevelItems.size == 1) { // If there is only one visible item, we can just append it. let singleItem = topLevelItems.values().next().value; xulMenu.appendChild(singleItem); this._itemsToCleanUp.add(singleItem); } } }, handleEvent: function(event) { let target = event.target; target.removeEventListener("popuphidden", this); for (let item of this._itemsToCleanUp) { target.removeChild(item); } // Let's detach the top level items. this._itemsToCleanUp.clear(); }, _itemsToCleanUp: new Set(), } function getContexts(contextData) { let contexts = new Set(["all"]); contexts.add("page"); 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.onImage) { contexts.add("image") } if (contextData.onVideo) { contexts.add("video") } if (contextData.onAudio) { contexts.add("audio") } return contexts; } function MenuItem(extension, extContext, createProperties) { this.extension = extension; this.extContext = extContext; this.tabManager = TabManager.for(extension); this.init(createProperties); } MenuItem.prototype = { // init is called from the MenuItem ctor and from update. The |update| // flag is for the later case. init(createProperties, update=false) { let item = this; // return true if the prop was set on |this| function parseProp(propName, defaultValue = null) { if (propName in createProperties) { item[propName] = createProperties[propName]; return true; } else if (!update && defaultValue !== null) { item[propName] = defaultValue; return true; } return false; } if (update && "id" in createProperties) { throw new Error("Id of a MenuItem cannot be changed"); } else if (!update) { let isIdUsed = contextMenuMap.get(this.extension).has(createProperties.id); if (createProperties.id && isIdUsed) { throw new Error("Id already exists"); } this.id = createProperties.id ? createProperties.id : nextID++; } parseProp("type", "normal"); parseProp("title"); parseProp("checked", false); parseProp("contexts", ["all"]); // It's a bit wacky... but we shouldn't be too scared to use wrappedJSObject here. // Later on we will do proper argument validation anyway. if ("onclick" in createProperties.wrappedJSObject) { this.onclick = createProperties.wrappedJSObject.onclick; } if (parseProp("parentId")) { let found = false; let menuMap = contextMenuMap.get(this.extension); if (menuMap.has(this.parentId)) { found = true; menuMap.get(this.parentId).isMenu = true; } if (!found) { throw new Error("MenuItem with this parentId not found"); } } else { this.parentId = undefined; } if (parseProp("documentUrlPatterns")) { this.documentUrlMatchPattern = new MatchPattern(this.documentUrlPatterns); } if (parseProp("targetUrlPatterns")) { this.targetUrlPatterns = new MatchPattern(this.targetUrlPatterns); } parseProp("enabled", true); }, remove() { let menuMap = contextMenuMap.get(this.extension); // We want to remove all the items that has |this| in its parent chain. // The |checked| map is only an optimisation to avoid checking any item // twice in the algorithm. let checked = new Map(); function hasAncestorWithId(item, id) { if (checked.has(item)) { return checked.get(item); } if (item.parentId === undefined) { checked.set(item, false); return false; } let parent = menuMap.get(item.parentId); if (!parent) { checked.set(item, false); return false; } if (parent.id === id) { checked.set(item, true); return true; } let rv = hasAncestorWithId(parent, id); checked.set(item, rv); return rv; } let toRemove = new Set(); toRemove.add(this.id); for (let [id, item] of menuMap) { if (hasAncestorWithId(item, this.id)) { toRemove.add(item.id); } } for (let id of toRemove) { menuMap.delete(id); } }, getClickData(contextData, event) { let mediaType; if (contextData.onVideo) { mediaType = "video"; } if (contextData.onAudio) { mediaType = "audio"; } if (contextData.onImage) { mediaType = "image"; } let clickData = { menuItemId: this.id }; function setIfDefined(argName, value) { if (value) { clickData[argName] = value; } } let tab = contextData.tab ? TabManager.convert(this.extension, contextData.tab) : undefined; 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); setIfDefined("editable", contextData.onEditableArea); setIfDefined("tab", tab); return clickData; }, enabledForContext(contextData) { let enabled = false; let contexts = getContexts(contextData); for (let c of this.contexts) { if (contexts.has(c)) { enabled = true; break } } if (!enabled) { return false; } if (this.documentUrlMatchPattern && !this.documentUrlMatchPattern.matches(contentData.documentURIObject)) { return false; } if (this.targetUrlPatterns && (contextData.onImage || contextData.onAudio || contextData.onVideo) && !this.targetUrlPatterns.matches(contentData.mediaURL)) { // TODO: double check if mediaURL is always set when we need it return false; } return true; }, }; var extCount = 0; extensions.on("startup", (type, extension) => { contextMenuMap.set(extension, new Map()); if (++extCount == 1) { Services.obs.addObserver(contextMenuObserver, "on-build-contextmenu", false); } }); extensions.on("shutdown", (type, extension) => { contextMenuMap.delete(extension); if (--extCount == 0) { Services.obs.removeObserver(contextMenuObserver, "on-build-contextmenu"); } }); extensions.registerPrivilegedAPI("contextMenus", (extension, context) => { return { contextMenus: { create: function(createProperties, callback) { let menuItem = new MenuItem(extension, context, createProperties); contextMenuMap.get(extension).set(menuItem.id, menuItem); if (callback) { runSafe(context, callback); } return menuItem.id; }, update: function(id, updateProperties, callback) { let menuItem = contextMenuMap.get(extension).get(id); if (menuItem) { menuItem.init(updateProperties, true); } if (callback) { runSafe(context, callback); } }, remove: function(id, callback) { let menuItem = contextMenuMap.get(extension).get(id); if (menuItem) { menuItem.remove(); } if (callback) { runSafe(context, callback); } }, removeAll: function(callback) { for (let [id, menuItem] of contextMenuMap.get(extension)) { menuItem.remove(); } if (callback) { runSafe(context, callback); } }, // TODO: implement this once event pages are ready. onClicked: new EventManager(context, "contextMenus.onClicked", fire => { let callback = menuItem => { fire(menuItem.data); }; onClickedCallbacksMap.set(extension, callback); return () => { onClickedCallbacksMap.delete(extension); }; }).api(), }, }; });