/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim:set ts=2 sw=2 sts=2 et: */ /* 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/. */ "use strict"; module.metadata = { "stability": "stable" }; const {Ci} = require("chrome"); if (!require("./system/xul-app").is("Firefox")) { throw new Error([ "The context-menu module currently supports only Firefox. In the future ", "we would like it to support other applications, however. Please see ", "https://bugzilla.mozilla.org/show_bug.cgi?id=560716 for more information." ].join("")); } const apiUtils = require("./deprecated/api-utils"); const collection = require("./util/collection"); const { Worker } = require("./content/worker"); const { URL } = require("./url"); const { MatchPattern } = require("./page-mod/match-pattern"); const { EventEmitterTrait: EventEmitter } = require("./deprecated/events"); const observerServ = require("./deprecated/observer-service"); const jpSelf = require("./self"); const { WindowTracker } = require("./deprecated/window-utils"); const { getInnerId, isBrowser } = require("./window/utils"); const { Trait } = require("./deprecated/light-traits"); const { Cortex } = require("./deprecated/cortex"); const timer = require("./timers"); // All user items we add have this class name. const ITEM_CLASS = "jetpack-context-menu-item"; // Items in the top-level context menu also have this class. const TOPLEVEL_ITEM_CLASS = "jetpack-context-menu-item-toplevel"; // Items in the overflow submenu also have this class. const OVERFLOW_ITEM_CLASS = "jetpack-context-menu-item-overflow"; // The ID of the menu separator that separates standard context menu items from // our user items. const SEPARATOR_ID = "jetpack-context-menu-separator"; // If more than this number of items are added to the context menu, all items // overflow into a "Jetpack" submenu. const OVERFLOW_THRESH_DEFAULT = 10; const OVERFLOW_THRESH_PREF = "extensions.addon-sdk.context-menu.overflowThreshold"; // The label of the overflow sub-xul:menu. // // TODO: Localize this. const OVERFLOW_MENU_LABEL = "Add-ons"; // The ID of the overflow sub-xul:menu. const OVERFLOW_MENU_ID = "jetpack-content-menu-overflow-menu"; // The ID of the overflow submenu's xul:menupopup. const OVERFLOW_POPUP_ID = "jetpack-content-menu-overflow-popup"; // These are used by PageContext.isCurrent below. If the popupNode or any of // its ancestors is one of these, Firefox uses a tailored context menu, and so // the page context doesn't apply. const NON_PAGE_CONTEXT_ELTS = [ Ci.nsIDOMHTMLAnchorElement, Ci.nsIDOMHTMLAppletElement, Ci.nsIDOMHTMLAreaElement, Ci.nsIDOMHTMLButtonElement, Ci.nsIDOMHTMLCanvasElement, Ci.nsIDOMHTMLEmbedElement, Ci.nsIDOMHTMLImageElement, Ci.nsIDOMHTMLInputElement, Ci.nsIDOMHTMLMapElement, Ci.nsIDOMHTMLMediaElement, Ci.nsIDOMHTMLMenuElement, Ci.nsIDOMHTMLObjectElement, Ci.nsIDOMHTMLOptionElement, Ci.nsIDOMHTMLSelectElement, Ci.nsIDOMHTMLTextAreaElement, ]; // This is used to access private properties of Item and Menu instances. const PRIVATE_PROPS_KEY = { valueOf: function valueOf() "private properties key" }; // Used as an internal ID for items and as part of a public ID for item DOM // elements. Careful: This number is not necessarily unique to any one instance // of the module. For each module instance, when the first item is created this // number will be 0, when the second is created it will be 1, and so on. let nextItemID = 0; // The number of items that haven't finished initializing yet. See // AIT__finishActiveItemInit(). let numItemsWithUnfinishedInit = 0; exports.Item = Item; exports.Menu = Menu; exports.Separator = Separator; // A word about traits and privates. `this` inside of traits methods is an // object private to the implementation. It should never be publicly leaked. // We use Cortex in the exported menu item constructors to create public // reflections of the private objects that hide private properties -- those // prefixed with an underscore. Public reflections are attached to the private // objects via the `_public` property. // // All item objects passed into the implementation by the client will be public // reflections, not private objects. Likewise, all item objects passed out of // the implementation to the client must be public, not private. Mixing up // public and private is bad and easy to do, so not only are private objects // restricted to the implementation, but as much as possible we try to restrict // them to the Item, Menu, and Separator traits and constructors. Everybody // else in the implementation should expect to be passed public reflections, and // they must specifically request private objects via privateItem(). // Item, Menu, and Separator are composed of this trait. const ItemBaseTrait = Trait({ _initBase: function IBT__initBase(opts, optRules, optsToNotSet) { this._optRules = optRules; for (let optName in optRules) if (optsToNotSet.indexOf(optName) < 0) this[optName] = opts[optName]; optsToNotSet.forEach(function (opt) validateOpt(opts[opt], optRules[opt])); this._isInited = true; this._id = nextItemID++; this._parentMenu = null; // This makes the private properties accessible to anyone with access to // PRIVATE_PROPS_KEY. Barring loader tricks, only this file has has access // to it, so only this file has access to the private properties. const self = this; this.valueOf = function IBT_valueOf(key) { return key === PRIVATE_PROPS_KEY ? self : self._public; }; }, destroy: function IBT_destroy() { if (this._wasDestroyed) return; if (this.parentMenu) this.parentMenu.removeItem(this._public); else if (!(this instanceof Separator) && this._hasFinishedInit) browserManager.removeTopLevelItem(this._public); browserManager.unregisterItem(this._public); this._wasDestroyed = true; }, get parentMenu() { return this._parentMenu; }, set parentMenu(val) { throw new Error("The 'parentMenu' property is not intended to be set. " + "Use menu.addItem(item) instead."); }, set _isTopLevel(val) { if (val) this._workerReg = new WorkerRegistry(this._public); else { this._workerReg.destroy(); delete this._workerReg; } }, get _topLevelItem() { let topLevelItem = this._public; let parentMenu = this.parentMenu; while (parentMenu) { topLevelItem = parentMenu; parentMenu = parentMenu.parentMenu; } return topLevelItem; } }); // Item and Menu are composed of this trait. const ActiveItemTrait = Trait.compose(ItemBaseTrait, EventEmitter, Trait({ _initActiveItem: function AIT__initActiveItem(opts, optRules, optsToNotSet) { this._initBase(opts, optRules, optsToNotSet.concat(["onMessage", "context"])); if ("onMessage" in opts) this.on("message", opts.onMessage); // When a URL context is removed (by calling context.remove(urlContext)), we // may need to create workers for windows containing pages that the item now // matches. Likewise, when a URL context is added, we need to destroy // workers for windows containing pages that the item now does not match. // // collection doesn't provide a way to listen for removals. utils/registry // does, but it doesn't allow its elements to be enumerated. So as a hack, // use a collection for item.context and replace its add and remove methods. collection.addCollectionProperty(this, "context"); if (opts.context) this.context.add(opts.context); const self = this; let add = this.context.add; this.context.add = function itemContextAdd() { let args = Array.slice(arguments); add.apply(self.context, args); if (self._workerReg && args.some(function (a) a instanceof URLContext)) self._workerReg.destroyUnneededWorkers(); }; let remove = this.context.remove; this.context.remove = function itemContextRemove() { let args = Array.slice(arguments); remove.apply(self.context, args); if (self._workerReg && args.some(function (a) a instanceof URLContext)) self._workerReg.createNeededWorkers(); }; }, // Workers are only created for top-level menu items. When a top-level item // is later added to a Menu, its workers are destroyed. Well, all items start // out as top-level because there is, unfortunately, no contextMenu.add(). So // when an item is created and immediately added to a Menu, workers for it are // needlessly created and destroyed. The point of this timeout is to avoid // that. Items that are created and added to Menus in the same turn of the // event loop won't have workers created for them. _finishActiveItemInit: function AIT__finishActiveItemInit() { numItemsWithUnfinishedInit++; const self = this; timer.setTimeout(function AIT__finishActiveItemInitTimeout() { if (!self.parentMenu && !self._wasDestroyed) browserManager.addTopLevelItem(self._public); self._hasFinishedInit = true; numItemsWithUnfinishedInit--; }, 0); }, get label() { return this._label; }, set label(val) { this._label = validateOpt(val, this._optRules.label); if (this._isInited) browserManager.setItemLabel(this, this._label); return this._label; }, get image() { return this._image; }, set image(val) { this._image = validateOpt(val, this._optRules.image); if (this._isInited) browserManager.setItemImage(this, this._image); return this._image; }, get contentScript() { return this._contentScript; }, set contentScript(val) { this._contentScript = validateOpt(val, this._optRules.contentScript); return this._contentScript; }, get contentScriptFile() { return this._contentScriptFile; }, set contentScriptFile(val) { this._contentScriptFile = validateOpt(val, this._optRules.contentScriptFile); return this._contentScriptFile; } })); // Item is composed of this trait. const ItemTrait = Trait.compose(ActiveItemTrait, Trait({ _initItem: function IT__initItem(opts, optRules) { this._initActiveItem(opts, optRules, []); }, get data() { return this._data; }, set data(val) { this._data = validateOpt(val, this._optRules.data); if (this._isInited) browserManager.setItemData(this, this._data); return this._data; }, toString: function IT_toString() { return '[object Item "' + this.label + '"]'; } })); // The exported Item constructor. function Item(options) { let optRules = optionsRules(); optRules.data = { map: function (v) v.toString(), is: ["string", "undefined"] }; let item = ItemTrait.create(Item.prototype); item._initItem(options, optRules); item._public = Cortex(item); browserManager.registerItem(item._public); item._finishActiveItemInit(); return item._public; } // Menu is composed of this trait. const MenuTrait = Trait.compose( ActiveItemTrait.resolve({ destroy: "_destroyThisItem" }), Trait({ _initMenu: function MT__initMenu(opts, optRules, optsToNotSet) { this._items = []; this._initActiveItem(opts, optRules, optsToNotSet); }, destroy: function MT_destroy() { while (this.items.length) this.items[0].destroy(); this._destroyThisItem(); }, get items() { return this._items; }, set items(val) { let newItems = validateOpt(val, this._optRules.items); while (this.items.length) this.items[0].destroy(); newItems.forEach(function (i) this.addItem(i), this); return newItems; }, addItem: function MT_addItem(item) { // First, remove the item from its current parent. let privates = privateItem(item); if (item.parentMenu) item.parentMenu.removeItem(item); else if (!(item instanceof Separator) && privates._hasFinishedInit) browserManager.removeTopLevelItem(item); // Now add the item to this menu. this._items.push(item); privates._parentMenu = this._public; browserManager.addItemToMenu(item, this._public); }, removeItem: function MT_removeItem(item) { let idx = this._items.indexOf(item); if (idx < 0) return; this._items.splice(idx, 1); privateItem(item)._parentMenu = null; browserManager.removeItemFromMenu(item, this._public); }, toString: function MT_toString() { return '[object Menu "' + this.label + '"]'; } })); // The exported Menu constructor. function Menu(options) { let optRules = optionsRules(); optRules.items = { is: ["array"], ok: function (v) { return v.every(function (item) { return (item instanceof Item) || (item instanceof Menu) || (item instanceof Separator); }); }, msg: "items must be an array, and each element in the array must be an " + "Item, Menu, or Separator." }; let menu = MenuTrait.create(Menu.prototype); // We can't rely on _initBase to set the `items` property, because the menu // needs to be registered with and added to the browserManager before any // child items are added to it. menu._initMenu(options, optRules, ["items"]); menu._public = Cortex(menu); browserManager.registerItem(menu._public); menu.items = options.items; menu._finishActiveItemInit(); return menu._public; } // The exported Separator constructor. function Separator() { let sep = ItemBaseTrait.create(Separator.prototype); sep._initBase({}, {}, []); sep._public = Cortex(sep); browserManager.registerItem(sep._public); sep._hasFinishedInit = true; return sep._public; } function Context() {} function PageContext() { this.isCurrent = function PageContext_isCurrent(popupNode) { let win = popupNode.ownerDocument.defaultView; if (win && !win.getSelection().isCollapsed) return false; let cursor = popupNode; while (cursor && !(cursor instanceof Ci.nsIDOMHTMLHtmlElement)) { if (NON_PAGE_CONTEXT_ELTS.some(function (iface) cursor instanceof iface)) return false; cursor = cursor.parentNode; } return true; }; } PageContext.prototype = new Context(); function SelectorContext(selector) { let opts = apiUtils.validateOptions({ selector: selector }, { selector: { is: ["string"], msg: "selector must be a string." } }); this.adjustPopupNode = function SelectorContext_adjustPopupNode(node) { return closestMatchingAncestor(node); }; this.isCurrent = function SelectorContext_isCurrent(popupNode) { return !!closestMatchingAncestor(popupNode); }; // Returns node if it matches selector, or the closest ancestor of node that // matches, or null if node and none of its ancestors matches. function closestMatchingAncestor(node) { let cursor = node; while (cursor) { if (cursor.mozMatchesSelector(selector)) return cursor; if (cursor instanceof Ci.nsIDOMHTMLHtmlElement) break; cursor = cursor.parentNode; } return null; } } SelectorContext.prototype = new Context(); function SelectionContext() { this.isCurrent = function SelectionContext_isCurrent(popupNode) { let win = popupNode.ownerDocument.defaultView; if (!win) return false; let hasSelection = !win.getSelection().isCollapsed; if (!hasSelection) { // window.getSelection doesn't return a selection for text selected in a // form field (see bug 85686), so before returning false we want to check // if the popupNode is a text field. let { selectionStart, selectionEnd } = popupNode; hasSelection = !isNaN(selectionStart) && !isNaN(selectionEnd) && selectionStart !== selectionEnd; } return hasSelection; }; } SelectionContext.prototype = new Context(); function URLContext(patterns) { let opts = apiUtils.validateOptions({ patterns: patterns }, { patterns: { map: function (v) apiUtils.getTypeOf(v) === "array" ? v : [v], ok: function (v) v.every(function (p) typeof(p) === "string"), msg: "patterns must be a string or an array of strings." } }); try { patterns = opts.patterns.map(function (p) new MatchPattern(p)); } catch (err) { console.error("Error creating URLContext match pattern:"); throw err; } const self = this; this.isCurrent = function URLContext_isCurrent(popupNode) { return self.isCurrentForURL(popupNode.ownerDocument.URL); }; this.isCurrentForURL = function URLContext_isCurrentForURL(url) { return patterns.some(function (p) p.test(url)); }; } URLContext.prototype = new Context(); exports.PageContext = apiUtils.publicConstructor(PageContext); exports.SelectorContext = apiUtils.publicConstructor(SelectorContext); exports.SelectionContext = apiUtils.publicConstructor(SelectionContext); exports.URLContext = apiUtils.publicConstructor(URLContext); // Returns a version of opt validated against the given rule. function validateOpt(opt, rule) { return apiUtils.validateOptions({ opt: opt }, { opt: rule }).opt; } // Returns rules for apiUtils.validateOptions() common to Item and Menu. function optionsRules() { return { context: { is: ["undefined", "object", "array"], ok: function (v) { if (!v) return true; let arr = apiUtils.getTypeOf(v) === "array" ? v : [v]; return arr.every(function (o) o instanceof Context); }, msg: "The 'context' option must be a Context object or an array of " + "Context objects." }, label: { map: function (v) v.toString(), is: ["string"], ok: function (v) !!v, msg: "The item must have a non-empty string label." }, image: { map: function (v) v.toString(), is: ["string", "undefined", "null"] }, contentScript: { is: ["string", "array", "undefined"], ok: function (v) { return apiUtils.getTypeOf(v) !== "array" || v.every(function (s) typeof(s) === "string"); } }, contentScriptFile: { is: ["string", "array", "undefined"], ok: function (v) { if (!v) return true; let arr = apiUtils.getTypeOf(v) === "array" ? v : [v]; try { return arr.every(function (s) { return apiUtils.getTypeOf(s) === "string" && URL(s).scheme === 'resource'; }); } catch (err) {} return false; }, msg: "The 'contentScriptFile' option must be a local file URL or " + "an array of local file URLs." }, onMessage: { is: ["function", "undefined"] } }; } // Does a binary search on elts, a NodeList, and returns the DOM element // before which an item with targetLabel should be inserted. null is returned // if the new item should be inserted at the end. function insertionPoint(targetLabel, elts) { let from = 0; let to = elts.length - 1; while (from <= to) { let i = Math.floor((from + to) / 2); let comp = targetLabel.localeCompare(elts[i].getAttribute("label")); if (comp < 0) to = i - 1; else if (comp > 0) from = i + 1; else return elts[i]; } return elts[from] || null; } // Builds an ID suitable for a DOM element from the given item ID. // isInOverflowSubtree should be true if the returned element will be inserted // into the DOM subtree rooted at the overflow menu. function domEltIDFromItemID(itemID, isInOverflowSubtree) { let suffix = isInOverflowSubtree ? "-overflow" : ""; return jpSelf.id + "-context-menu-item-" + itemID + suffix; } // Parses the item ID out of the given DOM element ID and returns it. If the // element's ID is malformed or it indicates that the element was not created by // the instance of the module calling this function, returns -1. function itemIDFromDOMEltID(domEltID) { let match = /^(.+?)-context-menu-item-([0-9]+)[-a-z]*$/.exec(domEltID); return !match || match[1] !== jpSelf.id ? -1 : match[2]; } // Returns the private version of the given public reflection. function privateItem(publicItem) { return publicItem.valueOf(PRIVATE_PROPS_KEY); } // A type of Worker tailored to our uses. const ContextMenuWorker = Worker.compose({ destroy: Worker.required, // Returns true if any context listeners are defined in the worker's port. anyContextListeners: function CMW_anyContextListeners() { return this._contentWorker.hasListenerFor("context"); }, // Returns the first string or truthy value returned by a context listener in // the worker's port. If none return a string or truthy value or if there are // no context listeners, returns false. popupNode is the node that was // context-clicked. isAnyContextCurrent: function CMW_isAnyContextCurrent(popupNode) { let results = this._contentWorker.emitSync("context", popupNode); for (let i = 0; i < results.length; i++) { let val = results[i]; if (typeof val === "string" || val) return val; } return false; }, // Emits a click event in the worker's port. popupNode is the node that was // context-clicked, and clickedItemData is the data of the item that was // clicked. fireClick: function CMW_fireClick(popupNode, clickedItemData) { this._contentWorker.emitSync("click", popupNode, clickedItemData); } }); // This class creates and stores content workers for pairs of menu items and // content windows. Use one instance for each item. Not all pairs need a // worker: if an item has a URL context that does not match a window's page, // then no worker is created for the pair. function WorkerRegistry(item) { this.item = item; // inner window ID => { win, worker } this.winWorkers = {}; // inner window ID => content window this.winsWithoutWorkers = {}; } WorkerRegistry.prototype = { // Registers a content window, creating a worker for it if it needs one. registerContentWin: function WR_registerContentWin(win) { let innerWinID = getInnerId(win); if ((innerWinID in this.winWorkers) || (innerWinID in this.winsWithoutWorkers)) return; if (this._doesURLNeedWorker(win.document.URL)) this.winWorkers[innerWinID] = { win: win, worker: this._makeWorker(win) }; else this.winsWithoutWorkers[innerWinID] = win; }, // Unregisters a content window, destroying its related worker if it has one. unregisterContentWin: function WR_unregisterContentWin(innerWinID) { if (innerWinID in this.winWorkers) { let winWorker = this.winWorkers[innerWinID]; winWorker.worker.destroy(); delete winWorker.worker; delete winWorker.win; delete this.winWorkers[innerWinID]; } else delete this.winsWithoutWorkers[innerWinID]; }, // Creates a worker for each window that needs a worker but doesn't have one. createNeededWorkers: function WR_createNeededWorkers() { for (let innerWinID in this.winsWithoutWorkers) { let win = this.winsWithoutWorkers[innerWinID] delete this.winsWithoutWorkers[innerWinID]; this.registerContentWin(win); } }, // Destroys the worker for each window that has a worker but doesn't need it. destroyUnneededWorkers: function WR_destroyUnneededWorkers() { for (let innerWinID in this.winWorkers) { let winWorker = this.winWorkers[innerWinID]; if (!this._doesURLNeedWorker(winWorker.win.document.URL)) { this.unregisterContentWin(innerWinID); this.winsWithoutWorkers[innerWinID] = winWorker.win; } } }, // Returns the worker for the item-window pair or null if none exists. find: function WR_find(contentWin) { let innerWinID = getInnerId(contentWin); return (innerWinID in this.winWorkers) ? this.winWorkers[innerWinID].worker : null; }, // Unregisters all content windows from the registry, which destroys all // workers. destroy: function WR_destroy() { for (let innerWinID in this.winWorkers) this.unregisterContentWin(innerWinID); for (let innerWinID in this.winsWithoutWorkers) this.unregisterContentWin(innerWinID); }, // Returns false if the item has a URL context that does not match the given // URL. _doesURLNeedWorker: function WR__doesURLNeedWorker(url) { for (let ctxt in this.item.context) if ((ctxt instanceof URLContext) && !ctxt.isCurrentForURL(url)) return false; return true; }, _makeWorker: function WR__makeWorker(win) { let worker = ContextMenuWorker({ window: win, contentScript: this.item.contentScript, contentScriptFile: this.item.contentScriptFile, onError: function (err) console.exception(err) }); let item = this.item; worker.on("message", function workerOnMessage(msg) { try { privateItem(item)._emitOnObject(item, "message", msg); } catch (err) { console.exception(err); } }); return worker; } }; // Mirrors state across all browser windows. Also responsible for detecting // all content window loads and unloads. let browserManager = { topLevelItems: [], browserWins: [], // inner window ID => content window contentWins: {}, // Call this when a new item is created, top-level or not. registerItem: function BM_registerItem(item) { this.browserWins.forEach(function (w) w.registerItem(item)); }, // Call this when an item is destroyed and won't be used again, top-level or // not. unregisterItem: function BM_unregisterItem(item) { this.browserWins.forEach(function (w) w.unregisterItem(item)); }, addTopLevelItem: function BM_addTopLevelItem(item) { this.topLevelItems.push(item); this.browserWins.forEach(function (w) w.addTopLevelItem(item)); // Create the item's worker registry and register all currently loaded // content windows with it. let privates = privateItem(item); privates._isTopLevel = true; for each (let win in this.contentWins) privates._workerReg.registerContentWin(win); }, removeTopLevelItem: function BM_removeTopLevelItem(item) { let idx = this.topLevelItems.indexOf(item); if (idx < 0) throw new Error("Internal error: item not in top-level menu: " + item); this.topLevelItems.splice(idx, 1); this.browserWins.forEach(function (w) w.removeTopLevelItem(item)); privateItem(item)._isTopLevel = false; }, addItemToMenu: function BM_addItemToMenu(item, parentMenu) { this.browserWins.forEach(function (w) w.addItemToMenu(item, parentMenu)); }, removeItemFromMenu: function BM_removeItemFromMenu(item, parentMenu) { this.browserWins.forEach(function (w) w.removeItemFromMenu(item, parentMenu)); }, setItemLabel: function BM_setItemLabel(item, label) { this.browserWins.forEach(function (w) w.setItemLabel(item, label)); }, setItemImage: function BM_setItemImage(item, imageURL) { this.browserWins.forEach(function (w) w.setItemImage(item, imageURL)); }, setItemData: function BM_setItemData(item, data) { this.browserWins.forEach(function (w) w.setItemData(item, data)); }, // Note that calling this method will cause onTrack to be called immediately // for each currently open browser window. init: function BM_init() { require("./system/unload").ensure(this); let windowTracker = WindowTracker(this); // Register content windows on content-document-global-created and // unregister them on inner-window-destroyed. For rationale, see bug 667957 // for the former and bug 642004 for the latter. observerServ.add("content-document-global-created", this._onDocGlobalCreated, this); observerServ.add("inner-window-destroyed", this._onInnerWinDestroyed, this); }, _onDocGlobalCreated: function BM__onDocGlobalCreated(contentWin) { let doc = contentWin.document; if (doc.readyState == "loading") { const self = this; doc.addEventListener("readystatechange", function onReadyStateChange(e) { if (e.target != doc || doc.readyState != "complete") return; doc.removeEventListener("readystatechange", onReadyStateChange, false); self._registerContentWin(contentWin); }, false); } else if (doc.readyState == "complete") this._registerContentWin(contentWin); }, _onInnerWinDestroyed: function BM__onInnerWinDestroyed(subj) { this._unregisterContentWin( subj.QueryInterface(Ci.nsISupportsPRUint64).data); }, // Stores the given content window with the manager and registers it with each // top-level item's worker registry. _registerContentWin: function BM__registerContentWin(win) { let innerID = getInnerId(win); // It's an error to call this method for the same window more than once, but // we allow it in one case: when onTrack races _onDocGlobalCreated. (See // the comment in onTrack.) Make sure the window is registered only once. if (innerID in this.contentWins) return; this.contentWins[innerID] = win; this.topLevelItems.forEach(function (item) { privateItem(item)._workerReg.registerContentWin(win); }); }, // Removes the given content window from the manager and unregisters it from // each top-level item's worker registry. _unregisterContentWin: function BM__unregisterContentWin(innerID) { delete this.contentWins[innerID]; this.topLevelItems.forEach(function (item) { privateItem(item)._workerReg.unregisterContentWin(innerID); }); }, unload: function BM_unload() { // The window tracker is unloaded at the same time this method is called, // which causes onUntrack to be called for each open browser window, so // there's no need to clean up browser windows here. while (this.topLevelItems.length) { let item = this.topLevelItems[0]; this.removeTopLevelItem(item); this.unregisterItem(item); } delete this.contentWins; }, // Registers a browser window with the manager. This is a WindowTracker // callback. Note that this is called in two cases: for each newly opened // chrome window, and for each chrome window that is open when the loader // loads this module. onTrack: function BM_onTrack(window) { if (!isBrowser(window)) return; let browserWin = new BrowserWindow(window); this.browserWins.push(browserWin); // Register all loaded content windows in the browser window. Be sure to // include frames and iframes. If onTrack is called as a result of a new // browser window being opened, as opposed to the module being loaded, then // this will race the content-document-global-created notification. That's // OK, since _registerContentWin will not register the same content window // more than once. window.gBrowser.browsers.forEach(function (browser) { let topContentWin = browser.contentWindow; let allContentWins = Array.slice(topContentWin.frames); allContentWins.push(topContentWin); allContentWins.forEach(function (contentWin) { if (contentWin.document.readyState == "complete") this._registerContentWin(contentWin); }, this); }, this); // Add all top-level items and, recursively, their child items to the new // browser window. function addItemTree(item, parentMenu) { browserWin.registerItem(item); if (parentMenu) browserWin.addItemToMenu(item, parentMenu); else browserWin.addTopLevelItem(item); if (item instanceof Menu) item.items.forEach(function (subitem) addItemTree(subitem, item)); } this.topLevelItems.forEach(function (item) addItemTree(item, null)); }, // Unregisters a browser window from the manager. This is a WindowTracker // callback. Note that this is called in two cases: for each newly closed // chrome window, and for each chrome window that is open when this module is // unloaded. onUntrack: function BM_onUntrack(window) { if (!isBrowser(window)) return; // Remove the window from the window list. let idx = 0; for (; idx < this.browserWins.length; idx++) if (this.browserWins[idx].window == window) break; if (idx == this.browserWins.length) throw new Error("Internal error: browser window not found"); let browserWin = this.browserWins.splice(idx, 1)[0]; // Remove all top-level items from the window. this.topLevelItems.forEach(function (i) browserWin.removeTopLevelItem(i)); browserWin.destroy(); } }; // Responsible for creating and managing context menu item DOM elements for a // browser window. Also responsible for providing a description of the window's // current context and determining whether an item matches the current context. // // TODO: If other apps besides Firefox want to support the context menu in // whatever way is appropriate for them, plugging in a substitute for or an // adapter to this class should be the way to do it. Make it easy for them. // See bug 560716. function BrowserWindow(window) { this.window = window; this.doc = window.document; let popupDOMElt = this.doc.getElementById("contentAreaContextMenu"); if (!popupDOMElt) throw new Error("Internal error: Context menu popup not found."); this.contextMenuPopup = new ContextMenuPopup(popupDOMElt, this); // item ID => { item, domElt, overflowDOMElt, popup, overflowPopup } // item may or may not be top-level. domElt is the item's DOM element // contained in the subtree rooted in the top-level context menu. // overflowDOMElt is the item's DOM element contained in the subtree rooted in // the overflow submenu. popup and overflowPopup are only defined if the item // is a Menu; they're the Popup instances containing the Menu's child items, // with the aforementioned distinction between top-level and overflow // subtrees. this.items = {}; } BrowserWindow.prototype = { // Creates and stores DOM elements for the given item, top-level or not. registerItem: function BW_registerItem(item) { // this.items[id] is referenced by _makeMenu, so it needs to be defined // before _makeDOMElt is called. let props = { item: item }; this.items[privateItem(item)._id] = props; props.domElt = this._makeDOMElt(item, false); props.overflowDOMElt = this._makeDOMElt(item, true); }, // Removes the given item's DOM elements from the store. unregisterItem: function BW_unregisterItem(item) { delete this.items[privateItem(item)._id]; }, addTopLevelItem: function BW_addTopLevelItem(item) { this.contextMenuPopup.addItem(item); }, removeTopLevelItem: function BW_removeTopLevelItem(item) { this.contextMenuPopup.removeItem(item); }, addItemToMenu: function BW_addItemToMenu(item, parentMenu) { let { popup, overflowPopup } = this.items[privateItem(parentMenu)._id]; popup.addItem(item); overflowPopup.addItem(item); }, removeItemFromMenu: function BW_removeItemFromMenu(item, parentMenu) { let { popup, overflowPopup } = this.items[privateItem(parentMenu)._id]; popup.removeItem(item); overflowPopup.removeItem(item); }, setItemLabel: function BW_setItemLabel(item, label) { let privates = privateItem(item); let { domElt, overflowDOMElt } = this.items[privates._id]; this._setDOMEltLabel(domElt, label); this._setDOMEltLabel(overflowDOMElt, label); if (!item.parentMenu && privates._hasFinishedInit) this.contextMenuPopup.itemLabelDidChange(item); }, _setDOMEltLabel: function BW__setDOMEltLabel(domElt, label) { domElt.setAttribute("label", label); }, setItemImage: function BW_setItemImage(item, imageURL) { let { domElt, overflowDOMElt } = this.items[privateItem(item)._id]; let isMenu = item instanceof Menu; this._setDOMEltImage(domElt, imageURL, isMenu); this._setDOMEltImage(overflowDOMElt, imageURL, isMenu); }, _setDOMEltImage: function BW__setDOMEltImage(domElt, imageURL, isMenu) { if (!imageURL) { domElt.removeAttribute("image"); domElt.classList.remove("menu-iconic"); domElt.classList.remove("menuitem-iconic"); } else { domElt.setAttribute("image", imageURL); domElt.classList.add(isMenu ? "menu-iconic" : "menuitem-iconic"); } }, setItemData: function BW_setItemData(item, data) { let { domElt, overflowDOMElt } = this.items[privateItem(item)._id]; this._setDOMEltData(domElt, data); this._setDOMEltData(overflowDOMElt, data); }, _setDOMEltData: function BW__setDOMEltData(domElt, data) { domElt.setAttribute("value", data); }, // The context specified for a top-level item may not match exactly the real // context that triggers it. For example, if the user context-clicks a span // inside an anchor, we want items that specify an anchor context to be // triggered, but the real context will indicate that the span was clicked, // not the anchor. Where the real context and an item's context conflict, // clients should be given the item's context, and this method can be used to // make such adjustments. Returns an adjusted popupNode. adjustPopupNode: function BW_adjustPopupNode(popupNode, topLevelItem) { for (let ctxt in topLevelItem.context) { if (typeof(ctxt.adjustPopupNode) === "function") { let ctxtNode = ctxt.adjustPopupNode(popupNode); if (ctxtNode) { popupNode = ctxtNode; break; } } } return popupNode; }, // Returns true if all of item's contexts are current in the window. areAllContextsCurrent: function BW_areAllContextsCurrent(item, popupNode) { let win = popupNode.ownerDocument.defaultView; let worker = privateItem(item)._workerReg.find(win); // If the worker for the item-window pair doesn't exist (e.g., because the // page hasn't loaded yet), we can't really make a good decision since the // content script may have a context listener. So just don't show the item // at all. if (!worker) return false; // If there are no contexts given at all, the page context applies. let hasContentContext = worker.anyContextListeners(); if (!hasContentContext && !item.context.length) return new PageContext().isCurrent(popupNode); // Otherwise, determine if all given contexts are current. Evaluate the // declarative contexts first and the worker's context listeners last. That // way the listener might be able to avoid some work. let curr = true; for (let ctxt in item.context) { curr = curr && ctxt.isCurrent(popupNode); if (!curr) return false; } return !hasContentContext || worker.isAnyContextCurrent(popupNode); }, // Returns the node the user context-clicked to invoke the context menu. // For Gecko 2.0 and later, triggerNode is this node; // if it's falsey, document.popupNode is used. capturePopupNode: function BW_capturePopupNode(triggerNode) { var popupNode = triggerNode || this.doc.popupNode; if (!popupNode) console.warn("popupNode is null."); return popupNode; }, destroy: function BW_destroy() { this.contextMenuPopup.destroy(); delete this.window; delete this.doc; delete this.items; }, // Emits a click event in the port of the content worker related to given top- // level item and popupNode's content window. Listeners will be passed // popupNode and clickedItemData. fireClick: function BW_fireClick(topLevelItem, popupNode, clickedItemData) { let win = popupNode.ownerDocument.defaultView; let worker = privateItem(topLevelItem)._workerReg.find(win); if (worker) worker.fireClick(popupNode, clickedItemData); }, _makeDOMElt: function BW__makeDOMElt(item, isInOverflowSubtree) { let elt = item instanceof Item ? this._makeMenuitem(item) : item instanceof Menu ? this._makeMenu(item, isInOverflowSubtree) : item instanceof Separator ? this._makeSeparator() : null; if (!elt) throw new Error("Internal error: can't make element, unknown item type"); elt.id = domEltIDFromItemID(privateItem(item)._id, isInOverflowSubtree); elt.classList.add(ITEM_CLASS); return elt; }, // Returns a new xul:menu representing the menu. _makeMenu: function BW__makeMenu(menu, isInOverflowSubtree) { let menuElt = this.doc.createElement("menu"); this._setDOMEltLabel(menuElt, menu.label); if (menu.image) this._setDOMEltImage(menuElt, menu.image, true); let popupElt = this.doc.createElement("menupopup"); menuElt.appendChild(popupElt); let popup = new Popup(popupElt, this, isInOverflowSubtree); let props = this.items[privateItem(menu)._id]; if (isInOverflowSubtree) props.overflowPopup = popup; else props.popup = popup; return menuElt; }, // Returns a new xul:menuitem representing the item. _makeMenuitem: function BW__makeMenuitem(item) { let elt = this.doc.createElement("menuitem"); this._setDOMEltLabel(elt, item.label); if (item.image) this._setDOMEltImage(elt, item.image, false); if (item.data) this._setDOMEltData(elt, item.data); return elt; }, // Returns a new xul:menuseparator. _makeSeparator: function BW__makeSeparator() { return this.doc.createElement("menuseparator"); } }; // Responsible for adding DOM elements to and removing them from poupDOMElt. function Popup(popupDOMElt, browserWin, isInOverflowSubtree) { this.popupDOMElt = popupDOMElt; this.browserWin = browserWin; this.isInOverflowSubtree = isInOverflowSubtree; } Popup.prototype = { addItem: function Popup_addItem(item) { let props = this.browserWin.items[privateItem(item)._id]; let elt = this.isInOverflowSubtree ? props.overflowDOMElt : props.domElt; this.popupDOMElt.appendChild(elt); }, removeItem: function Popup_removeItem(item) { let props = this.browserWin.items[privateItem(item)._id]; let elt = this.isInOverflowSubtree ? props.overflowDOMElt : props.domElt; this.popupDOMElt.removeChild(elt); } }; // Represents a browser window's context menu popup. Responsible for hiding and // showing items according to the browser window's current context and for // handling item clicks. function ContextMenuPopup(popupDOMElt, browserWin) { this.popupDOMElt = popupDOMElt; this.browserWin = browserWin; this.doc = popupDOMElt.ownerDocument; // item ID => item // Calling this variable "topLevelItems" is redundant, since Popup and // ContextMenuPopup are only responsible for their child items, not all their // descendant items. But calling it "items" might encourage one to believe // otherwise, so topLevelItems it is. this.topLevelItems = {}; popupDOMElt.addEventListener("popupshowing", this, false); popupDOMElt.addEventListener("command", this, false); } ContextMenuPopup.prototype = { addItem: function CMP_addItem(item) { this._ensureStaticEltsExist(); let itemID = privateItem(item)._id; this.topLevelItems[itemID] = item; let props = this.browserWin.items[itemID]; props.domElt.classList.add(TOPLEVEL_ITEM_CLASS); props.overflowDOMElt.classList.add(OVERFLOW_ITEM_CLASS); this._insertItemInSortedOrder(item); }, removeItem: function CMP_removeItem(item) { let itemID = privateItem(item)._id; delete this.topLevelItems[itemID]; let { domElt, overflowDOMElt } = this.browserWin.items[itemID]; domElt.classList.remove(TOPLEVEL_ITEM_CLASS); overflowDOMElt.classList.remove(OVERFLOW_ITEM_CLASS); this.popupDOMElt.removeChild(domElt); this._overflowPopup.removeChild(overflowDOMElt); }, // Call this after the item's label changes. This re-inserts the item into // the popup so that it remains in sorted order. itemLabelDidChange: function CMP_itemLabelDidChange(item) { let itemID = privateItem(item)._id; let { domElt, overflowDOMElt } = this.browserWin.items[itemID]; this.popupDOMElt.removeChild(domElt); this._overflowPopup.removeChild(overflowDOMElt); this._insertItemInSortedOrder(item); }, destroy: function CMP_destroy() { // If there are no more items from any instance of the module, remove the // separator and overflow submenu, if they exist. let elts = this._topLevelElts; if (!elts.length) { let submenu = this._overflowMenu; if (submenu) this.popupDOMElt.removeChild(submenu); let sep = this._separator; if (sep) this.popupDOMElt.removeChild(sep); } this.popupDOMElt.removeEventListener("popupshowing", this, false); this.popupDOMElt.removeEventListener("command", this, false); }, handleEvent: function CMP_handleEvent(event) { try { if (event.type === "command") this._handleClick(event.target); else if (event.type === "popupshowing" && event.target === this.popupDOMElt) this._handlePopupShowing(); } catch (err) { console.exception(err); } }, // command events bubble to the context menu's top-level xul:menupopup and are // caught here. _handleClick: function CMP__handleClick(clickedDOMElt) { if (!clickedDOMElt.classList.contains(ITEM_CLASS)) return; let itemID = itemIDFromDOMEltID(clickedDOMElt.id); if (itemID < 0) return; let { item, domElt, overflowDOMElt } = this.browserWin.items[itemID]; // Bail if the DOM element was not created by this module instance. In // real-world add-ons, the itemID < 0 check above is sufficient, but for the // unit test the JID never changes, making this necessary. if (clickedDOMElt != domElt && clickedDOMElt != overflowDOMElt) return; let topLevelItem = privateItem(item)._topLevelItem; let popupNode = this.browserWin.adjustPopupNode(this._getPopupNode(), topLevelItem); this.browserWin.fireClick(topLevelItem, popupNode, item.data); }, _getPopupNode: function CMP__getPopupNode() { // popupDOMElt.triggerNode was added in Gecko 2.0 by bug 383930. The || is // to avoid a Spidermonkey strict warning on earlier versions. let triggerNode = this.popupDOMElt.triggerNode || undefined; return this.browserWin.capturePopupNode(triggerNode); }, // popupshowing is used to show top-level items that match the browser // window's current context and hide items that don't. Each module instance // is responsible for showing and hiding the items it owns. _handlePopupShowing: function CMP__handlePopupShowing() { // If there are items queued up to finish initializing, let them go first. // Otherwise the overflow submenu and menu separator may end up in an // inappropriate state when those items are later added to the menu. if (numItemsWithUnfinishedInit) { const self = this; timer.setTimeout(function popupShowingTryAgain() { self._handlePopupShowing(); }, 0); return; } let popupNode = this._getPopupNode(); // Show and hide items. Set a "jetpackContextCurrent" property on the // DOM elements to signal which of our items match the current context. for (let itemID in this.topLevelItems) { let item = this.topLevelItems[itemID] let areContextsCurr = this.browserWin.areAllContextsCurrent(item, popupNode); // Change the item's label if the return value was a string. if (typeof(areContextsCurr) === "string") { item.label = areContextsCurr; areContextsCurr = true; } let { domElt, overflowDOMElt } = this.browserWin.items[itemID]; domElt.jetpackContextCurrent = areContextsCurr; domElt.hidden = !areContextsCurr; overflowDOMElt.jetpackContextCurrent = areContextsCurr; overflowDOMElt.hidden = !areContextsCurr; } // Get the total number of items that match the current context. It's a // little tricky: There may be other instances of this module loaded, // each hiding and showing their items. So we can't base this number on // only our items, or on the hidden state of items. That's why we set // the jetpackContextCurrent property above. The last instance to run // will leave the menupopup in the correct state. let elts = this._topLevelElts; let numShown = Array.reduce(elts, function (total, elt) { return total + (elt.jetpackContextCurrent ? 1 : 0); }, 0); // If too many items are shown, show the submenu and hide the top-level // items. Otherwise, hide the submenu and show the top-level items. let overflow = numShown > this._overflowThreshold; if (overflow) Array.forEach(elts, function (e) e.hidden = true); let submenu = this._overflowMenu; if (submenu) submenu.hidden = !overflow; // If no items are shown, hide the menu separator. let sep = this._separator; if (sep) sep.hidden = numShown === 0; }, // Adds the menu separator and overflow submenu if they don't exist. _ensureStaticEltsExist: function CMP__ensureStaticEltsExist() { let sep = this._separator; if (!sep) { sep = this._makeSeparator(); this.popupDOMElt.appendChild(sep); } let submenu = this._overflowMenu; if (!submenu) { submenu = this._makeOverflowMenu(); submenu.hidden = true; this.popupDOMElt.insertBefore(submenu, sep.nextSibling); } }, // Inserts the given item's DOM element into the popup in sorted order. _insertItemInSortedOrder: function CMP__insertItemInSortedOrder(item) { let props = this.browserWin.items[privateItem(item)._id]; this.popupDOMElt.insertBefore( props.domElt, insertionPoint(item.label, this._topLevelElts)); this._overflowPopup.insertBefore( props.overflowDOMElt, insertionPoint(item.label, this._overflowElts)); }, // Creates and returns the xul:menu that's shown when too many items are added // to the popup. _makeOverflowMenu: function CMP__makeOverflowMenu() { let submenu = this.doc.createElement("menu"); submenu.id = OVERFLOW_MENU_ID; submenu.setAttribute("label", OVERFLOW_MENU_LABEL); let popup = this.doc.createElement("menupopup"); popup.id = OVERFLOW_POPUP_ID; submenu.appendChild(popup); return submenu; }, // Creates and returns the xul:menuseparator that separates the standard // context menu items from our items. _makeSeparator: function CMP__makeSeparator() { let elt = this.doc.createElement("menuseparator"); elt.id = SEPARATOR_ID; return elt; }, // Returns the item elements contained in the overflow menu, a NodeList. get _overflowElts() { return this._overflowPopup.getElementsByClassName(OVERFLOW_ITEM_CLASS); }, // Returns the overflow xul:menu. get _overflowMenu() { return this.doc.getElementById(OVERFLOW_MENU_ID); }, // Returns the overflow xul:menupopup. get _overflowPopup() { return this.doc.getElementById(OVERFLOW_POPUP_ID); }, // Returns the OVERFLOW_THRESH_PREF pref value if it exists or // OVERFLOW_THRESH_DEFAULT if it doesn't. get _overflowThreshold() { let prefs = require("./preferences/service"); return prefs.get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT); }, // Returns the xul:menuseparator. get _separator() { return this.doc.getElementById(SEPARATOR_ID); }, // Returns the item elements contained in the top-level menu, a NodeList. get _topLevelElts() { return this.popupDOMElt.getElementsByClassName(TOPLEVEL_ITEM_CLASS); } }; // Init the browserManager only after setting prototypes and such above, because // it will cause browserManager.onTrack to be called immediately if there are // open windows. browserManager.init();