michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: "use strict"; michael@0: michael@0: module.metadata = { michael@0: "stability": "stable", michael@0: "engines": { michael@0: // TODO Fennec support Bug 788334 michael@0: "Firefox": "*" michael@0: } michael@0: }; michael@0: michael@0: const { Class, mix } = require("./core/heritage"); michael@0: const { addCollectionProperty } = require("./util/collection"); michael@0: const { ns } = require("./core/namespace"); michael@0: const { validateOptions, getTypeOf } = require("./deprecated/api-utils"); michael@0: const { URL, isValidURI } = require("./url"); michael@0: const { WindowTracker, browserWindowIterator } = require("./deprecated/window-utils"); michael@0: const { isBrowser, getInnerId } = require("./window/utils"); michael@0: const { Ci } = require("chrome"); michael@0: const { MatchPattern } = require("./util/match-pattern"); michael@0: const { Worker } = require("./content/worker"); michael@0: const { EventTarget } = require("./event/target"); michael@0: const { emit } = require('./event/core'); michael@0: const { when } = require('./system/unload'); michael@0: const selection = require('./selection'); michael@0: michael@0: // All user items we add have this class. michael@0: const ITEM_CLASS = "addon-context-menu-item"; michael@0: michael@0: // Items in the top-level context menu also have this class. michael@0: const TOPLEVEL_ITEM_CLASS = "addon-context-menu-item-toplevel"; michael@0: michael@0: // Items in the overflow submenu also have this class. michael@0: const OVERFLOW_ITEM_CLASS = "addon-context-menu-item-overflow"; michael@0: michael@0: // The class of the menu separator that separates standard context menu items michael@0: // from our user items. michael@0: const SEPARATOR_CLASS = "addon-context-menu-separator"; michael@0: michael@0: // If more than this number of items are added to the context menu, all items michael@0: // overflow into a "Jetpack" submenu. michael@0: const OVERFLOW_THRESH_DEFAULT = 10; michael@0: const OVERFLOW_THRESH_PREF = michael@0: "extensions.addon-sdk.context-menu.overflowThreshold"; michael@0: michael@0: // The label of the overflow sub-xul:menu. michael@0: // michael@0: // TODO: Localize this. michael@0: const OVERFLOW_MENU_LABEL = "Add-ons"; michael@0: michael@0: // The class of the overflow sub-xul:menu. michael@0: const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu"; michael@0: michael@0: // The class of the overflow submenu's xul:menupopup. michael@0: const OVERFLOW_POPUP_CLASS = "addon-content-menu-overflow-popup"; michael@0: michael@0: //These are used by PageContext.isCurrent below. If the popupNode or any of michael@0: //its ancestors is one of these, Firefox uses a tailored context menu, and so michael@0: //the page context doesn't apply. michael@0: const NON_PAGE_CONTEXT_ELTS = [ michael@0: Ci.nsIDOMHTMLAnchorElement, michael@0: Ci.nsIDOMHTMLAppletElement, michael@0: Ci.nsIDOMHTMLAreaElement, michael@0: Ci.nsIDOMHTMLButtonElement, michael@0: Ci.nsIDOMHTMLCanvasElement, michael@0: Ci.nsIDOMHTMLEmbedElement, michael@0: Ci.nsIDOMHTMLImageElement, michael@0: Ci.nsIDOMHTMLInputElement, michael@0: Ci.nsIDOMHTMLMapElement, michael@0: Ci.nsIDOMHTMLMediaElement, michael@0: Ci.nsIDOMHTMLMenuElement, michael@0: Ci.nsIDOMHTMLObjectElement, michael@0: Ci.nsIDOMHTMLOptionElement, michael@0: Ci.nsIDOMHTMLSelectElement, michael@0: Ci.nsIDOMHTMLTextAreaElement, michael@0: ]; michael@0: michael@0: // Holds private properties for API objects michael@0: let internal = ns(); michael@0: michael@0: function getScheme(spec) { michael@0: try { michael@0: return URL(spec).scheme; michael@0: } michael@0: catch(e) { michael@0: return null; michael@0: } michael@0: } michael@0: michael@0: let Context = Class({ michael@0: // Returns the node that made this context current michael@0: adjustPopupNode: function adjustPopupNode(popupNode) { michael@0: return popupNode; michael@0: }, michael@0: michael@0: // Returns whether this context is current for the current node michael@0: isCurrent: function isCurrent(popupNode) { michael@0: return false; michael@0: } michael@0: }); michael@0: michael@0: // Matches when the context-clicked node doesn't have any of michael@0: // NON_PAGE_CONTEXT_ELTS in its ancestors michael@0: let PageContext = Class({ michael@0: extends: Context, michael@0: michael@0: isCurrent: function isCurrent(popupNode) { michael@0: // If there is a selection in the window then this context does not match michael@0: if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) michael@0: return false; michael@0: michael@0: // If the clicked node or any of its ancestors is one of the blacklisted michael@0: // NON_PAGE_CONTEXT_ELTS then this context does not match michael@0: while (!(popupNode instanceof Ci.nsIDOMDocument)) { michael@0: if (NON_PAGE_CONTEXT_ELTS.some(function(type) popupNode instanceof type)) michael@0: return false; michael@0: michael@0: popupNode = popupNode.parentNode; michael@0: } michael@0: michael@0: return true; michael@0: } michael@0: }); michael@0: exports.PageContext = PageContext; michael@0: michael@0: // Matches when there is an active selection in the window michael@0: let SelectionContext = Class({ michael@0: extends: Context, michael@0: michael@0: isCurrent: function isCurrent(popupNode) { michael@0: if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) michael@0: return true; michael@0: michael@0: try { michael@0: // The node may be a text box which has selectionStart and selectionEnd michael@0: // properties. If not this will throw. michael@0: let { selectionStart, selectionEnd } = popupNode; michael@0: return !isNaN(selectionStart) && !isNaN(selectionEnd) && michael@0: selectionStart !== selectionEnd; michael@0: } michael@0: catch (e) { michael@0: return false; michael@0: } michael@0: } michael@0: }); michael@0: exports.SelectionContext = SelectionContext; michael@0: michael@0: // Matches when the context-clicked node or any of its ancestors matches the michael@0: // selector given michael@0: let SelectorContext = Class({ michael@0: extends: Context, michael@0: michael@0: initialize: function initialize(selector) { michael@0: let options = validateOptions({ selector: selector }, { michael@0: selector: { michael@0: is: ["string"], michael@0: msg: "selector must be a string." michael@0: } michael@0: }); michael@0: internal(this).selector = options.selector; michael@0: }, michael@0: michael@0: adjustPopupNode: function adjustPopupNode(popupNode) { michael@0: let selector = internal(this).selector; michael@0: michael@0: while (!(popupNode instanceof Ci.nsIDOMDocument)) { michael@0: if (popupNode.mozMatchesSelector(selector)) michael@0: return popupNode; michael@0: michael@0: popupNode = popupNode.parentNode; michael@0: } michael@0: michael@0: return null; michael@0: }, michael@0: michael@0: isCurrent: function isCurrent(popupNode) { michael@0: return !!this.adjustPopupNode(popupNode); michael@0: } michael@0: }); michael@0: exports.SelectorContext = SelectorContext; michael@0: michael@0: // Matches when the page url matches any of the patterns given michael@0: let URLContext = Class({ michael@0: extends: Context, michael@0: michael@0: initialize: function initialize(patterns) { michael@0: patterns = Array.isArray(patterns) ? patterns : [patterns]; michael@0: michael@0: try { michael@0: internal(this).patterns = patterns.map(function (p) new MatchPattern(p)); michael@0: } michael@0: catch (err) { michael@0: throw new Error("Patterns must be a string, regexp or an array of " + michael@0: "strings or regexps: " + err); michael@0: } michael@0: michael@0: }, michael@0: michael@0: isCurrent: function isCurrent(popupNode) { michael@0: let url = popupNode.ownerDocument.URL; michael@0: return internal(this).patterns.some(function (p) p.test(url)); michael@0: } michael@0: }); michael@0: exports.URLContext = URLContext; michael@0: michael@0: // Matches when the user-supplied predicate returns true michael@0: let PredicateContext = Class({ michael@0: extends: Context, michael@0: michael@0: initialize: function initialize(predicate) { michael@0: let options = validateOptions({ predicate: predicate }, { michael@0: predicate: { michael@0: is: ["function"], michael@0: msg: "predicate must be a function." michael@0: } michael@0: }); michael@0: internal(this).predicate = options.predicate; michael@0: }, michael@0: michael@0: isCurrent: function isCurrent(popupNode) { michael@0: return internal(this).predicate(populateCallbackNodeData(popupNode)); michael@0: } michael@0: }); michael@0: exports.PredicateContext = PredicateContext; michael@0: michael@0: // List all editable types of inputs. Or is it better to have a list michael@0: // of non-editable inputs? michael@0: let editableInputs = { michael@0: email: true, michael@0: number: true, michael@0: password: true, michael@0: search: true, michael@0: tel: true, michael@0: text: true, michael@0: textarea: true, michael@0: url: true michael@0: }; michael@0: michael@0: function populateCallbackNodeData(node) { michael@0: let window = node.ownerDocument.defaultView; michael@0: let data = {}; michael@0: michael@0: data.documentType = node.ownerDocument.contentType; michael@0: michael@0: data.documentURL = node.ownerDocument.location.href; michael@0: data.targetName = node.nodeName.toLowerCase(); michael@0: data.targetID = node.id || null ; michael@0: michael@0: if ((data.targetName === 'input' && editableInputs[node.type]) || michael@0: data.targetName === 'textarea') { michael@0: data.isEditable = !node.readOnly && !node.disabled; michael@0: } michael@0: else { michael@0: data.isEditable = node.isContentEditable; michael@0: } michael@0: michael@0: data.selectionText = selection.text; michael@0: michael@0: data.srcURL = node.src || null; michael@0: data.linkURL = node.href || null; michael@0: data.value = node.value || null; michael@0: michael@0: return data; michael@0: } michael@0: michael@0: function removeItemFromArray(array, item) { michael@0: return array.filter(function(i) i !== item); michael@0: } michael@0: michael@0: // Converts anything that isn't false, null or undefined into a string michael@0: function stringOrNull(val) val ? String(val) : val; michael@0: michael@0: // Shared option validation rules for Item and Menu michael@0: let baseItemRules = { michael@0: parentMenu: { michael@0: is: ["object", "undefined"], michael@0: ok: function (v) { michael@0: if (!v) michael@0: return true; michael@0: return (v instanceof ItemContainer) || (v instanceof Menu); michael@0: }, michael@0: msg: "parentMenu must be a Menu or not specified." michael@0: }, michael@0: context: { michael@0: is: ["undefined", "object", "array"], michael@0: ok: function (v) { michael@0: if (!v) michael@0: return true; michael@0: let arr = Array.isArray(v) ? v : [v]; michael@0: return arr.every(function (o) o instanceof Context); michael@0: }, michael@0: msg: "The 'context' option must be a Context object or an array of " + michael@0: "Context objects." michael@0: }, michael@0: contentScript: { michael@0: is: ["string", "array", "undefined"], michael@0: ok: function (v) { michael@0: return !Array.isArray(v) || michael@0: v.every(function (s) typeof(s) === "string"); michael@0: } michael@0: }, michael@0: contentScriptFile: { michael@0: is: ["string", "array", "undefined"], michael@0: ok: function (v) { michael@0: if (!v) michael@0: return true; michael@0: let arr = Array.isArray(v) ? v : [v]; michael@0: return arr.every(function (s) { michael@0: return getTypeOf(s) === "string" && michael@0: getScheme(s) === 'resource'; michael@0: }); michael@0: }, michael@0: msg: "The 'contentScriptFile' option must be a local file URL or " + michael@0: "an array of local file URLs." michael@0: }, michael@0: onMessage: { michael@0: is: ["function", "undefined"] michael@0: } michael@0: }; michael@0: michael@0: let labelledItemRules = mix(baseItemRules, { michael@0: label: { michael@0: map: stringOrNull, michael@0: is: ["string"], michael@0: ok: function (v) !!v, michael@0: msg: "The item must have a non-empty string label." michael@0: }, michael@0: image: { michael@0: map: stringOrNull, michael@0: is: ["string", "undefined", "null"], michael@0: ok: function (url) { michael@0: if (!url) michael@0: return true; michael@0: return isValidURI(url); michael@0: }, michael@0: msg: "Image URL validation failed" michael@0: } michael@0: }); michael@0: michael@0: // Additional validation rules for Item michael@0: let itemRules = mix(labelledItemRules, { michael@0: data: { michael@0: map: stringOrNull, michael@0: is: ["string", "undefined", "null"] michael@0: } michael@0: }); michael@0: michael@0: // Additional validation rules for Menu michael@0: let menuRules = mix(labelledItemRules, { michael@0: items: { michael@0: is: ["array", "undefined"], michael@0: ok: function (v) { michael@0: if (!v) michael@0: return true; michael@0: return v.every(function (item) { michael@0: return item instanceof BaseItem; michael@0: }); michael@0: }, michael@0: msg: "items must be an array, and each element in the array must be an " + michael@0: "Item, Menu, or Separator." michael@0: } michael@0: }); michael@0: michael@0: let ContextWorker = Class({ michael@0: implements: [ Worker ], michael@0: michael@0: //Returns true if any context listeners are defined in the worker's port. michael@0: anyContextListeners: function anyContextListeners() { michael@0: return this.getSandbox().hasListenerFor("context"); michael@0: }, michael@0: michael@0: // Calls the context workers context listeners and returns the first result michael@0: // that is either a string or a value that evaluates to true. If all of the michael@0: // listeners returned false then returns false. If there are no listeners michael@0: // then returns null. michael@0: getMatchedContext: function getCurrentContexts(popupNode) { michael@0: let results = this.getSandbox().emitSync("context", popupNode); michael@0: return results.reduce(function(val, result) val || result, null); michael@0: }, michael@0: michael@0: // Emits a click event in the worker's port. popupNode is the node that was michael@0: // context-clicked, and clickedItemData is the data of the item that was michael@0: // clicked. michael@0: fireClick: function fireClick(popupNode, clickedItemData) { michael@0: this.getSandbox().emitSync("click", popupNode, clickedItemData); michael@0: } michael@0: }); michael@0: michael@0: // Returns true if any contexts match. If there are no contexts then a michael@0: // PageContext is tested instead michael@0: function hasMatchingContext(contexts, popupNode) { michael@0: for (let context in contexts) { michael@0: if (!context.isCurrent(popupNode)) michael@0: return false; michael@0: } michael@0: michael@0: return true; michael@0: } michael@0: michael@0: // Gets the matched context from any worker for this item. If there is no worker michael@0: // or no matched context then returns false. michael@0: function getCurrentWorkerContext(item, popupNode) { michael@0: let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); michael@0: if (!worker || !worker.anyContextListeners()) michael@0: return true; michael@0: return worker.getMatchedContext(popupNode); michael@0: } michael@0: michael@0: // Tests whether an item should be visible or not based on its contexts and michael@0: // content scripts michael@0: function isItemVisible(item, popupNode, defaultVisibility) { michael@0: if (!item.context.length) { michael@0: let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); michael@0: if (!worker || !worker.anyContextListeners()) michael@0: return defaultVisibility; michael@0: } michael@0: michael@0: if (!hasMatchingContext(item.context, popupNode)) michael@0: return false; michael@0: michael@0: let context = getCurrentWorkerContext(item, popupNode); michael@0: if (typeof(context) === "string" && context != "") michael@0: item.label = context; michael@0: michael@0: return !!context; michael@0: } michael@0: michael@0: // Gets the item's content script worker for a window, creating one if necessary michael@0: // Once created it will be automatically destroyed when the window unloads. michael@0: // If there is not content scripts for the item then null will be returned. michael@0: function getItemWorkerForWindow(item, window) { michael@0: if (!item.contentScript && !item.contentScriptFile) michael@0: return null; michael@0: michael@0: let id = getInnerId(window); michael@0: let worker = internal(item).workerMap.get(id); michael@0: michael@0: if (worker) michael@0: return worker; michael@0: michael@0: worker = ContextWorker({ michael@0: window: window, michael@0: contentScript: item.contentScript, michael@0: contentScriptFile: item.contentScriptFile, michael@0: onMessage: function(msg) { michael@0: emit(item, "message", msg); michael@0: }, michael@0: onDetach: function() { michael@0: internal(item).workerMap.delete(id); michael@0: } michael@0: }); michael@0: michael@0: internal(item).workerMap.set(id, worker); michael@0: michael@0: return worker; michael@0: } michael@0: michael@0: // Called when an item is clicked to send out click events to the content michael@0: // scripts michael@0: function itemClicked(item, clickedItem, popupNode) { michael@0: let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); michael@0: michael@0: if (worker) { michael@0: let adjustedNode = popupNode; michael@0: for (let context in item.context) michael@0: adjustedNode = context.adjustPopupNode(adjustedNode); michael@0: worker.fireClick(adjustedNode, clickedItem.data); michael@0: } michael@0: michael@0: if (item.parentMenu) michael@0: itemClicked(item.parentMenu, clickedItem, popupNode); michael@0: } michael@0: michael@0: // All things that appear in the context menu extend this michael@0: let BaseItem = Class({ michael@0: initialize: function initialize() { michael@0: addCollectionProperty(this, "context"); michael@0: michael@0: // Used to cache content script workers and the windows they have been michael@0: // created for michael@0: internal(this).workerMap = new Map(); michael@0: michael@0: if ("context" in internal(this).options && internal(this).options.context) { michael@0: let contexts = internal(this).options.context; michael@0: if (Array.isArray(contexts)) { michael@0: for (let context of contexts) michael@0: this.context.add(context); michael@0: } michael@0: else { michael@0: this.context.add(contexts); michael@0: } michael@0: } michael@0: michael@0: let parentMenu = internal(this).options.parentMenu; michael@0: if (!parentMenu) michael@0: parentMenu = contentContextMenu; michael@0: michael@0: parentMenu.addItem(this); michael@0: michael@0: Object.defineProperty(this, "contentScript", { michael@0: enumerable: true, michael@0: value: internal(this).options.contentScript michael@0: }); michael@0: michael@0: Object.defineProperty(this, "contentScriptFile", { michael@0: enumerable: true, michael@0: value: internal(this).options.contentScriptFile michael@0: }); michael@0: }, michael@0: michael@0: destroy: function destroy() { michael@0: if (this.parentMenu) michael@0: this.parentMenu.removeItem(this); michael@0: }, michael@0: michael@0: get parentMenu() { michael@0: return internal(this).parentMenu; michael@0: }, michael@0: }); michael@0: michael@0: // All things that have a label on the context menu extend this michael@0: let LabelledItem = Class({ michael@0: extends: BaseItem, michael@0: implements: [ EventTarget ], michael@0: michael@0: initialize: function initialize(options) { michael@0: BaseItem.prototype.initialize.call(this); michael@0: EventTarget.prototype.initialize.call(this, options); michael@0: }, michael@0: michael@0: destroy: function destroy() { michael@0: for (let [,worker] of internal(this).workerMap) michael@0: worker.destroy(); michael@0: michael@0: BaseItem.prototype.destroy.call(this); michael@0: }, michael@0: michael@0: get label() { michael@0: return internal(this).options.label; michael@0: }, michael@0: michael@0: set label(val) { michael@0: internal(this).options.label = val; michael@0: michael@0: MenuManager.updateItem(this); michael@0: }, michael@0: michael@0: get image() { michael@0: return internal(this).options.image; michael@0: }, michael@0: michael@0: set image(val) { michael@0: internal(this).options.image = val; michael@0: michael@0: MenuManager.updateItem(this); michael@0: }, michael@0: michael@0: get data() { michael@0: return internal(this).options.data; michael@0: }, michael@0: michael@0: set data(val) { michael@0: internal(this).options.data = val; michael@0: } michael@0: }); michael@0: michael@0: let Item = Class({ michael@0: extends: LabelledItem, michael@0: michael@0: initialize: function initialize(options) { michael@0: internal(this).options = validateOptions(options, itemRules); michael@0: michael@0: LabelledItem.prototype.initialize.call(this, options); michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: return "[object Item \"" + this.label + "\"]"; michael@0: }, michael@0: michael@0: get data() { michael@0: return internal(this).options.data; michael@0: }, michael@0: michael@0: set data(val) { michael@0: internal(this).options.data = val; michael@0: michael@0: MenuManager.updateItem(this); michael@0: }, michael@0: }); michael@0: exports.Item = Item; michael@0: michael@0: let ItemContainer = Class({ michael@0: initialize: function initialize() { michael@0: internal(this).children = []; michael@0: }, michael@0: michael@0: destroy: function destroy() { michael@0: // Destroys the entire hierarchy michael@0: for (let item of internal(this).children) michael@0: item.destroy(); michael@0: }, michael@0: michael@0: addItem: function addItem(item) { michael@0: let oldParent = item.parentMenu; michael@0: michael@0: // Don't just call removeItem here as that would remove the corresponding michael@0: // UI element which is more costly than just moving it to the right place michael@0: if (oldParent) michael@0: internal(oldParent).children = removeItemFromArray(internal(oldParent).children, item); michael@0: michael@0: let after = null; michael@0: let children = internal(this).children; michael@0: if (children.length > 0) michael@0: after = children[children.length - 1]; michael@0: michael@0: children.push(item); michael@0: internal(item).parentMenu = this; michael@0: michael@0: // If there was an old parent then we just have to move the item, otherwise michael@0: // it needs to be created michael@0: if (oldParent) michael@0: MenuManager.moveItem(item, after); michael@0: else michael@0: MenuManager.createItem(item, after); michael@0: }, michael@0: michael@0: removeItem: function removeItem(item) { michael@0: // If the item isn't a child of this menu then ignore this call michael@0: if (item.parentMenu !== this) michael@0: return; michael@0: michael@0: MenuManager.removeItem(item); michael@0: michael@0: internal(this).children = removeItemFromArray(internal(this).children, item); michael@0: internal(item).parentMenu = null; michael@0: }, michael@0: michael@0: get items() { michael@0: return internal(this).children.slice(0); michael@0: }, michael@0: michael@0: set items(val) { michael@0: // Validate the arguments before making any changes michael@0: if (!Array.isArray(val)) michael@0: throw new Error(menuOptionRules.items.msg); michael@0: michael@0: for (let item of val) { michael@0: if (!(item instanceof BaseItem)) michael@0: throw new Error(menuOptionRules.items.msg); michael@0: } michael@0: michael@0: // Remove the old items and add the new ones michael@0: for (let item of internal(this).children) michael@0: this.removeItem(item); michael@0: michael@0: for (let item of val) michael@0: this.addItem(item); michael@0: }, michael@0: }); michael@0: michael@0: let Menu = Class({ michael@0: extends: LabelledItem, michael@0: implements: [ItemContainer], michael@0: michael@0: initialize: function initialize(options) { michael@0: internal(this).options = validateOptions(options, menuRules); michael@0: michael@0: LabelledItem.prototype.initialize.call(this, options); michael@0: ItemContainer.prototype.initialize.call(this); michael@0: michael@0: if (internal(this).options.items) { michael@0: for (let item of internal(this).options.items) michael@0: this.addItem(item); michael@0: } michael@0: }, michael@0: michael@0: destroy: function destroy() { michael@0: ItemContainer.prototype.destroy.call(this); michael@0: LabelledItem.prototype.destroy.call(this); michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: return "[object Menu \"" + this.label + "\"]"; michael@0: }, michael@0: }); michael@0: exports.Menu = Menu; michael@0: michael@0: let Separator = Class({ michael@0: extends: BaseItem, michael@0: michael@0: initialize: function initialize(options) { michael@0: internal(this).options = validateOptions(options, baseItemRules); michael@0: michael@0: BaseItem.prototype.initialize.call(this); michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: return "[object Separator]"; michael@0: } michael@0: }); michael@0: exports.Separator = Separator; michael@0: michael@0: // Holds items for the content area context menu michael@0: let contentContextMenu = ItemContainer(); michael@0: exports.contentContextMenu = contentContextMenu; michael@0: michael@0: when(function() { michael@0: contentContextMenu.destroy(); michael@0: }); michael@0: michael@0: // App specific UI code lives here, it should handle populating the context michael@0: // menu and passing clicks etc. through to the items. michael@0: michael@0: function countVisibleItems(nodes) { michael@0: return Array.reduce(nodes, function(sum, node) { michael@0: return node.hidden ? sum : sum + 1; michael@0: }, 0); michael@0: } michael@0: michael@0: let MenuWrapper = Class({ michael@0: initialize: function initialize(winWrapper, items, contextMenu) { michael@0: this.winWrapper = winWrapper; michael@0: this.window = winWrapper.window; michael@0: this.items = items; michael@0: this.contextMenu = contextMenu; michael@0: this.populated = false; michael@0: this.menuMap = new Map(); michael@0: michael@0: // updateItemVisibilities will run first, updateOverflowState will run after michael@0: // all other instances of this module have run updateItemVisibilities michael@0: this._updateItemVisibilities = this.updateItemVisibilities.bind(this); michael@0: this.contextMenu.addEventListener("popupshowing", this._updateItemVisibilities, true); michael@0: this._updateOverflowState = this.updateOverflowState.bind(this); michael@0: this.contextMenu.addEventListener("popupshowing", this._updateOverflowState, false); michael@0: }, michael@0: michael@0: destroy: function destroy() { michael@0: this.contextMenu.removeEventListener("popupshowing", this._updateOverflowState, false); michael@0: this.contextMenu.removeEventListener("popupshowing", this._updateItemVisibilities, true); michael@0: michael@0: if (!this.populated) michael@0: return; michael@0: michael@0: // If we're getting unloaded at runtime then we must remove all the michael@0: // generated XUL nodes michael@0: let oldParent = null; michael@0: for (let item of internal(this.items).children) { michael@0: let xulNode = this.getXULNodeForItem(item); michael@0: oldParent = xulNode.parentNode; michael@0: oldParent.removeChild(xulNode); michael@0: } michael@0: michael@0: if (oldParent) michael@0: this.onXULRemoved(oldParent); michael@0: }, michael@0: michael@0: get separator() { michael@0: return this.contextMenu.querySelector("." + SEPARATOR_CLASS); michael@0: }, michael@0: michael@0: get overflowMenu() { michael@0: return this.contextMenu.querySelector("." + OVERFLOW_MENU_CLASS); michael@0: }, michael@0: michael@0: get overflowPopup() { michael@0: return this.contextMenu.querySelector("." + OVERFLOW_POPUP_CLASS); michael@0: }, michael@0: michael@0: get topLevelItems() { michael@0: return this.contextMenu.querySelectorAll("." + TOPLEVEL_ITEM_CLASS); michael@0: }, michael@0: michael@0: get overflowItems() { michael@0: return this.contextMenu.querySelectorAll("." + OVERFLOW_ITEM_CLASS); michael@0: }, michael@0: michael@0: getXULNodeForItem: function getXULNodeForItem(item) { michael@0: return this.menuMap.get(item); michael@0: }, michael@0: michael@0: // Recurses through the item hierarchy creating XUL nodes for everything michael@0: populate: function populate(menu) { michael@0: for (let i = 0; i < internal(menu).children.length; i++) { michael@0: let item = internal(menu).children[i]; michael@0: let after = i === 0 ? null : internal(menu).children[i - 1]; michael@0: this.createItem(item, after); michael@0: michael@0: if (item instanceof Menu) michael@0: this.populate(item); michael@0: } michael@0: }, michael@0: michael@0: // Recurses through the menu setting the visibility of items. Returns true michael@0: // if any of the items in this menu were visible michael@0: setVisibility: function setVisibility(menu, popupNode, defaultVisibility) { michael@0: let anyVisible = false; michael@0: michael@0: for (let item of internal(menu).children) { michael@0: let visible = isItemVisible(item, popupNode, defaultVisibility); michael@0: michael@0: // Recurse through Menus, if none of the sub-items were visible then the michael@0: // menu is hidden too. michael@0: if (visible && (item instanceof Menu)) michael@0: visible = this.setVisibility(item, popupNode, true); michael@0: michael@0: let xulNode = this.getXULNodeForItem(item); michael@0: xulNode.hidden = !visible; michael@0: michael@0: anyVisible = anyVisible || visible; michael@0: } michael@0: michael@0: return anyVisible; michael@0: }, michael@0: michael@0: // Works out where to insert a XUL node for an item in a browser window michael@0: insertIntoXUL: function insertIntoXUL(item, node, after) { michael@0: let menupopup = null; michael@0: let before = null; michael@0: michael@0: let menu = item.parentMenu; michael@0: if (menu === this.items) { michael@0: // Insert into the overflow popup if it exists, otherwise the normal michael@0: // context menu michael@0: menupopup = this.overflowPopup; michael@0: if (!menupopup) michael@0: menupopup = this.contextMenu; michael@0: } michael@0: else { michael@0: let xulNode = this.getXULNodeForItem(menu); michael@0: menupopup = xulNode.firstChild; michael@0: } michael@0: michael@0: if (after) { michael@0: let afterNode = this.getXULNodeForItem(after); michael@0: before = afterNode.nextSibling; michael@0: } michael@0: else if (menupopup === this.contextMenu) { michael@0: let topLevel = this.topLevelItems; michael@0: if (topLevel.length > 0) michael@0: before = topLevel[topLevel.length - 1].nextSibling; michael@0: else michael@0: before = this.separator.nextSibling; michael@0: } michael@0: michael@0: menupopup.insertBefore(node, before); michael@0: }, michael@0: michael@0: // Sets the right class for XUL nodes michael@0: updateXULClass: function updateXULClass(xulNode) { michael@0: if (xulNode.parentNode == this.contextMenu) michael@0: xulNode.classList.add(TOPLEVEL_ITEM_CLASS); michael@0: else michael@0: xulNode.classList.remove(TOPLEVEL_ITEM_CLASS); michael@0: michael@0: if (xulNode.parentNode == this.overflowPopup) michael@0: xulNode.classList.add(OVERFLOW_ITEM_CLASS); michael@0: else michael@0: xulNode.classList.remove(OVERFLOW_ITEM_CLASS); michael@0: }, michael@0: michael@0: // Creates a XUL node for an item michael@0: createItem: function createItem(item, after) { michael@0: if (!this.populated) michael@0: return; michael@0: michael@0: // Create the separator if it doesn't already exist michael@0: if (!this.separator) { michael@0: let separator = this.window.document.createElement("menuseparator"); michael@0: separator.setAttribute("class", SEPARATOR_CLASS); michael@0: michael@0: // Insert before the separator created by the old context-menu if it michael@0: // exists to avoid bug 832401 michael@0: let oldSeparator = this.window.document.getElementById("jetpack-context-menu-separator"); michael@0: if (oldSeparator && oldSeparator.parentNode != this.contextMenu) michael@0: oldSeparator = null; michael@0: this.contextMenu.insertBefore(separator, oldSeparator); michael@0: } michael@0: michael@0: let type = "menuitem"; michael@0: if (item instanceof Menu) michael@0: type = "menu"; michael@0: else if (item instanceof Separator) michael@0: type = "menuseparator"; michael@0: michael@0: let xulNode = this.window.document.createElement(type); michael@0: xulNode.setAttribute("class", ITEM_CLASS); michael@0: if (item instanceof LabelledItem) { michael@0: xulNode.setAttribute("label", item.label); michael@0: if (item.image) { michael@0: xulNode.setAttribute("image", item.image); michael@0: if (item instanceof Menu) michael@0: xulNode.classList.add("menu-iconic"); michael@0: else michael@0: xulNode.classList.add("menuitem-iconic"); michael@0: } michael@0: if (item.data) michael@0: xulNode.setAttribute("value", item.data); michael@0: michael@0: let self = this; michael@0: xulNode.addEventListener("command", function(event) { michael@0: // Only care about clicks directly on this item michael@0: if (event.target !== xulNode) michael@0: return; michael@0: michael@0: itemClicked(item, item, self.contextMenu.triggerNode); michael@0: }, false); michael@0: } michael@0: michael@0: this.insertIntoXUL(item, xulNode, after); michael@0: this.updateXULClass(xulNode); michael@0: xulNode.data = item.data; michael@0: michael@0: if (item instanceof Menu) { michael@0: let menupopup = this.window.document.createElement("menupopup"); michael@0: xulNode.appendChild(menupopup); michael@0: } michael@0: michael@0: this.menuMap.set(item, xulNode); michael@0: }, michael@0: michael@0: // Updates the XUL node for an item in this window michael@0: updateItem: function updateItem(item) { michael@0: if (!this.populated) michael@0: return; michael@0: michael@0: let xulNode = this.getXULNodeForItem(item); michael@0: michael@0: // TODO figure out why this requires setAttribute michael@0: xulNode.setAttribute("label", item.label); michael@0: michael@0: if (item.image) { michael@0: xulNode.setAttribute("image", item.image); michael@0: if (item instanceof Menu) michael@0: xulNode.classList.add("menu-iconic"); michael@0: else michael@0: xulNode.classList.add("menuitem-iconic"); michael@0: } michael@0: else { michael@0: xulNode.removeAttribute("image"); michael@0: xulNode.classList.remove("menu-iconic"); michael@0: xulNode.classList.remove("menuitem-iconic"); michael@0: } michael@0: michael@0: if (item.data) michael@0: xulNode.setAttribute("value", item.data); michael@0: else michael@0: xulNode.removeAttribute("value"); michael@0: }, michael@0: michael@0: // Moves the XUL node for an item in this window to its new place in the michael@0: // hierarchy michael@0: moveItem: function moveItem(item, after) { michael@0: if (!this.populated) michael@0: return; michael@0: michael@0: let xulNode = this.getXULNodeForItem(item); michael@0: let oldParent = xulNode.parentNode; michael@0: michael@0: this.insertIntoXUL(item, xulNode, after); michael@0: this.updateXULClass(xulNode); michael@0: this.onXULRemoved(oldParent); michael@0: }, michael@0: michael@0: // Removes the XUL nodes for an item in every window we've ever populated. michael@0: removeItem: function removeItem(item) { michael@0: if (!this.populated) michael@0: return; michael@0: michael@0: let xulItem = this.getXULNodeForItem(item); michael@0: michael@0: let oldParent = xulItem.parentNode; michael@0: michael@0: oldParent.removeChild(xulItem); michael@0: this.menuMap.delete(item); michael@0: michael@0: this.onXULRemoved(oldParent); michael@0: }, michael@0: michael@0: // Called when any XUL nodes have been removed from a menupopup. This handles michael@0: // making sure the separator and overflow are correct michael@0: onXULRemoved: function onXULRemoved(parent) { michael@0: if (parent == this.contextMenu) { michael@0: let toplevel = this.topLevelItems; michael@0: michael@0: // If there are no more items then remove the separator michael@0: if (toplevel.length == 0) { michael@0: let separator = this.separator; michael@0: if (separator) michael@0: separator.parentNode.removeChild(separator); michael@0: } michael@0: } michael@0: else if (parent == this.overflowPopup) { michael@0: // If there are no more items then remove the overflow menu and separator michael@0: if (parent.childNodes.length == 0) { michael@0: let separator = this.separator; michael@0: separator.parentNode.removeChild(separator); michael@0: this.contextMenu.removeChild(parent.parentNode); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: // Recurses through all the items owned by this module and sets their hidden michael@0: // state michael@0: updateItemVisibilities: function updateItemVisibilities(event) { michael@0: try { michael@0: if (event.type != "popupshowing") michael@0: return; michael@0: if (event.target != this.contextMenu) michael@0: return; michael@0: michael@0: if (internal(this.items).children.length == 0) michael@0: return; michael@0: michael@0: if (!this.populated) { michael@0: this.populated = true; michael@0: this.populate(this.items); michael@0: } michael@0: michael@0: let popupNode = event.target.triggerNode; michael@0: this.setVisibility(this.items, popupNode, PageContext().isCurrent(popupNode)); michael@0: } michael@0: catch (e) { michael@0: console.exception(e); michael@0: } michael@0: }, michael@0: michael@0: // Counts the number of visible items across all modules and makes sure they michael@0: // are in the right place between the top level context menu and the overflow michael@0: // menu michael@0: updateOverflowState: function updateOverflowState(event) { michael@0: try { michael@0: if (event.type != "popupshowing") michael@0: return; michael@0: if (event.target != this.contextMenu) michael@0: return; michael@0: michael@0: // The main items will be in either the top level context menu or the michael@0: // overflow menu at this point. Count the visible ones and if they are in michael@0: // the wrong place move them michael@0: let toplevel = this.topLevelItems; michael@0: let overflow = this.overflowItems; michael@0: let visibleCount = countVisibleItems(toplevel) + michael@0: countVisibleItems(overflow); michael@0: michael@0: if (visibleCount == 0) { michael@0: let separator = this.separator; michael@0: if (separator) michael@0: separator.hidden = true; michael@0: let overflowMenu = this.overflowMenu; michael@0: if (overflowMenu) michael@0: overflowMenu.hidden = true; michael@0: } michael@0: else if (visibleCount > MenuManager.overflowThreshold) { michael@0: this.separator.hidden = false; michael@0: let overflowPopup = this.overflowPopup; michael@0: if (overflowPopup) michael@0: overflowPopup.parentNode.hidden = false; michael@0: michael@0: if (toplevel.length > 0) { michael@0: // The overflow menu shouldn't exist here but let's play it safe michael@0: if (!overflowPopup) { michael@0: let overflowMenu = this.window.document.createElement("menu"); michael@0: overflowMenu.setAttribute("class", OVERFLOW_MENU_CLASS); michael@0: overflowMenu.setAttribute("label", OVERFLOW_MENU_LABEL); michael@0: this.contextMenu.insertBefore(overflowMenu, this.separator.nextSibling); michael@0: michael@0: overflowPopup = this.window.document.createElement("menupopup"); michael@0: overflowPopup.setAttribute("class", OVERFLOW_POPUP_CLASS); michael@0: overflowMenu.appendChild(overflowPopup); michael@0: } michael@0: michael@0: for (let xulNode of toplevel) { michael@0: overflowPopup.appendChild(xulNode); michael@0: this.updateXULClass(xulNode); michael@0: } michael@0: } michael@0: } michael@0: else { michael@0: this.separator.hidden = false; michael@0: michael@0: if (overflow.length > 0) { michael@0: // Move all the overflow nodes out of the overflow menu and position michael@0: // them immediately before it michael@0: for (let xulNode of overflow) { michael@0: this.contextMenu.insertBefore(xulNode, xulNode.parentNode.parentNode); michael@0: this.updateXULClass(xulNode); michael@0: } michael@0: this.contextMenu.removeChild(this.overflowMenu); michael@0: } michael@0: } michael@0: } michael@0: catch (e) { michael@0: console.exception(e); michael@0: } michael@0: } michael@0: }); michael@0: michael@0: // This wraps every window that we've seen michael@0: let WindowWrapper = Class({ michael@0: initialize: function initialize(window) { michael@0: this.window = window; michael@0: this.menus = [ michael@0: new MenuWrapper(this, contentContextMenu, window.document.getElementById("contentAreaContextMenu")), michael@0: ]; michael@0: }, michael@0: michael@0: destroy: function destroy() { michael@0: for (let menuWrapper of this.menus) michael@0: menuWrapper.destroy(); michael@0: }, michael@0: michael@0: getMenuWrapperForItem: function getMenuWrapperForItem(item) { michael@0: let root = item.parentMenu; michael@0: while (root.parentMenu) michael@0: root = root.parentMenu; michael@0: michael@0: for (let wrapper of this.menus) { michael@0: if (wrapper.items === root) michael@0: return wrapper; michael@0: } michael@0: michael@0: return null; michael@0: } michael@0: }); michael@0: michael@0: let MenuManager = { michael@0: windowMap: new Map(), michael@0: michael@0: get overflowThreshold() { michael@0: let prefs = require("./preferences/service"); michael@0: return prefs.get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT); michael@0: }, michael@0: michael@0: // When a new window is added start watching it for context menu shows michael@0: onTrack: function onTrack(window) { michael@0: if (!isBrowser(window)) michael@0: return; michael@0: michael@0: // Generally shouldn't happen, but just in case michael@0: if (this.windowMap.has(window)) { michael@0: console.warn("Already seen this window"); michael@0: return; michael@0: } michael@0: michael@0: let winWrapper = WindowWrapper(window); michael@0: this.windowMap.set(window, winWrapper); michael@0: }, michael@0: michael@0: onUntrack: function onUntrack(window) { michael@0: if (!isBrowser(window)) michael@0: return; michael@0: michael@0: let winWrapper = this.windowMap.get(window); michael@0: // This shouldn't happen but protect against it anyway michael@0: if (!winWrapper) michael@0: return; michael@0: winWrapper.destroy(); michael@0: michael@0: this.windowMap.delete(window); michael@0: }, michael@0: michael@0: // Creates a XUL node for an item in every window we've already populated michael@0: createItem: function createItem(item, after) { michael@0: for (let [window, winWrapper] of this.windowMap) { michael@0: let menuWrapper = winWrapper.getMenuWrapperForItem(item); michael@0: if (menuWrapper) michael@0: menuWrapper.createItem(item, after); michael@0: } michael@0: }, michael@0: michael@0: // Updates the XUL node for an item in every window we've already populated michael@0: updateItem: function updateItem(item) { michael@0: for (let [window, winWrapper] of this.windowMap) { michael@0: let menuWrapper = winWrapper.getMenuWrapperForItem(item); michael@0: if (menuWrapper) michael@0: menuWrapper.updateItem(item); michael@0: } michael@0: }, michael@0: michael@0: // Moves the XUL node for an item in every window we've ever populated to its michael@0: // new place in the hierarchy michael@0: moveItem: function moveItem(item, after) { michael@0: for (let [window, winWrapper] of this.windowMap) { michael@0: let menuWrapper = winWrapper.getMenuWrapperForItem(item); michael@0: if (menuWrapper) michael@0: menuWrapper.moveItem(item, after); michael@0: } michael@0: }, michael@0: michael@0: // Removes the XUL nodes for an item in every window we've ever populated. michael@0: removeItem: function removeItem(item) { michael@0: for (let [window, winWrapper] of this.windowMap) { michael@0: let menuWrapper = winWrapper.getMenuWrapperForItem(item); michael@0: if (menuWrapper) michael@0: menuWrapper.removeItem(item); michael@0: } michael@0: } michael@0: }; michael@0: michael@0: WindowTracker(MenuManager);