summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/context-menu.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/jetpack/sdk/context-menu.js')
-rw-r--r--toolkit/jetpack/sdk/context-menu.js1188
1 files changed, 1188 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/context-menu.js b/toolkit/jetpack/sdk/context-menu.js
new file mode 100644
index 000000000..004c642d4
--- /dev/null
+++ b/toolkit/jetpack/sdk/context-menu.js
@@ -0,0 +1,1188 @@
+/* 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",
+ "engines": {
+ // TODO Fennec support Bug 788334
+ "Firefox": "*",
+ "SeaMonkey": "*"
+ }
+};
+
+const { Class, mix } = require("./core/heritage");
+const { addCollectionProperty } = require("./util/collection");
+const { ns } = require("./core/namespace");
+const { validateOptions, getTypeOf } = require("./deprecated/api-utils");
+const { URL, isValidURI } = require("./url");
+const { WindowTracker, browserWindowIterator } = require("./deprecated/window-utils");
+const { isBrowser, getInnerId } = require("./window/utils");
+const { MatchPattern } = require("./util/match-pattern");
+const { EventTarget } = require("./event/target");
+const { emit } = require('./event/core');
+const { when } = require('./system/unload');
+const { contract: loaderContract } = require('./content/loader');
+const { omit } = require('./util/object');
+const self = require('./self')
+const { remoteRequire, processes } = require('./remote/parent');
+remoteRequire('sdk/content/context-menu');
+
+// All user items we add have this class.
+const ITEM_CLASS = "addon-context-menu-item";
+
+// Items in the top-level context menu also have this class.
+const TOPLEVEL_ITEM_CLASS = "addon-context-menu-item-toplevel";
+
+// Items in the overflow submenu also have this class.
+const OVERFLOW_ITEM_CLASS = "addon-context-menu-item-overflow";
+
+// The class of the menu separator that separates standard context menu items
+// from our user items.
+const SEPARATOR_CLASS = "addon-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 these.
+const OVERFLOW_MENU_LABEL = "Add-ons";
+const OVERFLOW_MENU_ACCESSKEY = "A";
+
+// The class of the overflow sub-xul:menu.
+const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu";
+
+// The class of the overflow submenu's xul:menupopup.
+const OVERFLOW_POPUP_CLASS = "addon-content-menu-overflow-popup";
+
+// Holds private properties for API objects
+var internal = ns();
+
+// A little hacky but this is the last process ID that last opened the context
+// menu
+var lastContextProcessId = null;
+
+var uuidModule = require('./util/uuid');
+function uuid() {
+ return uuidModule.uuid().toString();
+}
+
+function getScheme(spec) {
+ try {
+ return URL(spec).scheme;
+ }
+ catch(e) {
+ return null;
+ }
+}
+
+var Context = Class({
+ initialize: function() {
+ internal(this).id = uuid();
+ },
+
+ // Returns the node that made this context current
+ adjustPopupNode: function adjustPopupNode(popupNode) {
+ return popupNode;
+ },
+
+ // Returns whether this context is current for the current node
+ isCurrent: function isCurrent(state) {
+ return state;
+ }
+});
+
+// Matches when the context-clicked node doesn't have any of
+// NON_PAGE_CONTEXT_ELTS in its ancestors
+var PageContext = Class({
+ extends: Context,
+
+ serialize: function() {
+ return {
+ id: internal(this).id,
+ type: "PageContext",
+ args: []
+ }
+ }
+});
+exports.PageContext = PageContext;
+
+// Matches when there is an active selection in the window
+var SelectionContext = Class({
+ extends: Context,
+
+ serialize: function() {
+ return {
+ id: internal(this).id,
+ type: "SelectionContext",
+ args: []
+ }
+ }
+});
+exports.SelectionContext = SelectionContext;
+
+// Matches when the context-clicked node or any of its ancestors matches the
+// selector given
+var SelectorContext = Class({
+ extends: Context,
+
+ initialize: function initialize(selector) {
+ Context.prototype.initialize.call(this);
+ let options = validateOptions({ selector: selector }, {
+ selector: {
+ is: ["string"],
+ msg: "selector must be a string."
+ }
+ });
+ internal(this).selector = options.selector;
+ },
+
+ serialize: function() {
+ return {
+ id: internal(this).id,
+ type: "SelectorContext",
+ args: [internal(this).selector]
+ }
+ }
+});
+exports.SelectorContext = SelectorContext;
+
+// Matches when the page url matches any of the patterns given
+var URLContext = Class({
+ extends: Context,
+
+ initialize: function initialize(patterns) {
+ Context.prototype.initialize.call(this);
+ patterns = Array.isArray(patterns) ? patterns : [patterns];
+
+ try {
+ internal(this).patterns = patterns.map(p => new MatchPattern(p));
+ }
+ catch (err) {
+ throw new Error("Patterns must be a string, regexp or an array of " +
+ "strings or regexps: " + err);
+ }
+ },
+
+ isCurrent: function isCurrent(url) {
+ return internal(this).patterns.some(p => p.test(url));
+ },
+
+ serialize: function() {
+ return {
+ id: internal(this).id,
+ type: "URLContext",
+ args: []
+ }
+ }
+});
+exports.URLContext = URLContext;
+
+// Matches when the user-supplied predicate returns true
+var PredicateContext = Class({
+ extends: Context,
+
+ initialize: function initialize(predicate) {
+ Context.prototype.initialize.call(this);
+ let options = validateOptions({ predicate: predicate }, {
+ predicate: {
+ is: ["function"],
+ msg: "predicate must be a function."
+ }
+ });
+ internal(this).predicate = options.predicate;
+ },
+
+ isCurrent: function isCurrent(state) {
+ return internal(this).predicate(state);
+ },
+
+ serialize: function() {
+ return {
+ id: internal(this).id,
+ type: "PredicateContext",
+ args: []
+ }
+ }
+});
+exports.PredicateContext = PredicateContext;
+
+function removeItemFromArray(array, item) {
+ return array.filter(i => i !== item);
+}
+
+// Converts anything that isn't false, null or undefined into a string
+function stringOrNull(val) {
+ return val ? String(val) : val;
+}
+
+// Shared option validation rules for Item, Menu, and Separator
+var baseItemRules = {
+ parentMenu: {
+ is: ["object", "undefined"],
+ ok: function (v) {
+ if (!v)
+ return true;
+ return (v instanceof ItemContainer) || (v instanceof Menu);
+ },
+ msg: "parentMenu must be a Menu or not specified."
+ },
+ context: {
+ is: ["undefined", "object", "array"],
+ ok: function (v) {
+ if (!v)
+ return true;
+ let arr = Array.isArray(v) ? v : [v];
+ return arr.every(o => o instanceof Context);
+ },
+ msg: "The 'context' option must be a Context object or an array of " +
+ "Context objects."
+ },
+ onMessage: {
+ is: ["function", "undefined"]
+ },
+ contentScript: loaderContract.rules.contentScript,
+ contentScriptFile: loaderContract.rules.contentScriptFile
+};
+
+var labelledItemRules = mix(baseItemRules, {
+ label: {
+ map: stringOrNull,
+ is: ["string"],
+ ok: v => !!v,
+ msg: "The item must have a non-empty string label."
+ },
+ accesskey: {
+ map: stringOrNull,
+ is: ["string", "undefined", "null"],
+ ok: (v) => {
+ if (!v) {
+ return true;
+ }
+ return typeof v == "string" && v.length === 1;
+ },
+ msg: "The item must have a single character accesskey, or no accesskey."
+ },
+ image: {
+ map: stringOrNull,
+ is: ["string", "undefined", "null"],
+ ok: function (url) {
+ if (!url)
+ return true;
+ return isValidURI(url);
+ },
+ msg: "Image URL validation failed"
+ }
+});
+
+// Additional validation rules for Item
+var itemRules = mix(labelledItemRules, {
+ data: {
+ map: stringOrNull,
+ is: ["string", "undefined", "null"]
+ }
+});
+
+// Additional validation rules for Menu
+var menuRules = mix(labelledItemRules, {
+ items: {
+ is: ["array", "undefined"],
+ ok: function (v) {
+ if (!v)
+ return true;
+ return v.every(function (item) {
+ return item instanceof BaseItem;
+ });
+ },
+ msg: "items must be an array, and each element in the array must be an " +
+ "Item, Menu, or Separator."
+ }
+});
+
+// Returns true if any contexts match. If there are no contexts then a
+// PageContext is tested instead
+function hasMatchingContext(contexts, addonInfo) {
+ for (let context of contexts) {
+ if (!(internal(context).id in addonInfo.contextStates)) {
+ console.error("Missing state for context " + internal(context).id + " this is an error in the SDK modules.");
+ return false;
+ }
+ if (!context.isCurrent(addonInfo.contextStates[internal(context).id]))
+ return false;
+ }
+
+ return true;
+}
+
+// Tests whether an item should be visible or not based on its contexts and
+// content scripts
+function isItemVisible(item, addonInfo, usePageWorker) {
+ if (!item.context.length) {
+ if (!addonInfo.hasWorker)
+ return usePageWorker ? addonInfo.pageContext : true;
+ }
+
+ if (!hasMatchingContext(item.context, addonInfo))
+ return false;
+
+ let context = addonInfo.workerContext;
+ if (typeof(context) === "string" && context != "")
+ item.label = context;
+
+ return !!context;
+}
+
+// Called when an item is clicked to send out click events to the content
+// scripts
+function itemActivated(item, clickedNode) {
+ let items = [internal(item).id];
+ let data = item.data;
+
+ while (item.parentMenu) {
+ item = item.parentMenu;
+ items.push(internal(item).id);
+ }
+
+ let process = processes.getById(lastContextProcessId);
+ if (process)
+ process.port.emit('sdk/contextmenu/activateitems', items, data);
+}
+
+function serializeItem(item) {
+ return {
+ id: internal(item).id,
+ contexts: item.context.map(c => c.serialize()),
+ contentScript: item.contentScript,
+ contentScriptFile: item.contentScriptFile,
+ };
+}
+
+// All things that appear in the context menu extend this
+var BaseItem = Class({
+ initialize: function initialize() {
+ internal(this).id = uuid();
+
+ internal(this).contexts = [];
+ if ("context" in internal(this).options && internal(this).options.context) {
+ let contexts = internal(this).options.context;
+ if (Array.isArray(contexts)) {
+ for (let context of contexts)
+ internal(this).contexts.push(context);
+ }
+ else {
+ internal(this).contexts.push(contexts);
+ }
+ }
+
+ let parentMenu = internal(this).options.parentMenu;
+ if (!parentMenu)
+ parentMenu = contentContextMenu;
+
+ parentMenu.addItem(this);
+
+ Object.defineProperty(this, "contentScript", {
+ enumerable: true,
+ value: internal(this).options.contentScript
+ });
+
+ // Resolve URIs here as tests may have overriden self
+ let files = internal(this).options.contentScriptFile;
+ if (files) {
+ if (!Array.isArray(files))
+ files = [files];
+ files = files.map(self.data.url);
+ }
+ internal(this).options.contentScriptFile = files;
+ Object.defineProperty(this, "contentScriptFile", {
+ enumerable: true,
+ value: internal(this).options.contentScriptFile
+ });
+
+ // Notify all frames of this new item
+ sendItems([serializeItem(this)]);
+ },
+
+ destroy: function destroy() {
+ if (internal(this).destroyed)
+ return;
+
+ // Tell all existing frames that this item has been destroyed
+ processes.port.emit("sdk/contextmenu/destroyitems", [internal(this).id]);
+
+ if (this.parentMenu)
+ this.parentMenu.removeItem(this);
+
+ internal(this).destroyed = true;
+ },
+
+ get context() {
+ let contexts = internal(this).contexts.slice(0);
+ contexts.add = (context) => {
+ internal(this).contexts.push(context);
+ // Notify all frames that this item has changed
+ sendItems([serializeItem(this)]);
+ };
+ contexts.remove = (context) => {
+ internal(this).contexts = internal(this).contexts.filter(c => {
+ return c != context;
+ });
+ // Notify all frames that this item has changed
+ sendItems([serializeItem(this)]);
+ };
+ return contexts;
+ },
+
+ set context(val) {
+ internal(this).contexts = val.slice(0);
+ // Notify all frames that this item has changed
+ sendItems([serializeItem(this)]);
+ },
+
+ get parentMenu() {
+ return internal(this).parentMenu;
+ },
+});
+
+function workerMessageReceived(process, id, args) {
+ if (internal(this).id != id)
+ return;
+
+ emit(this, ...JSON.parse(args));
+}
+
+// All things that have a label on the context menu extend this
+var LabelledItem = Class({
+ extends: BaseItem,
+ implements: [ EventTarget ],
+
+ initialize: function initialize(options) {
+ BaseItem.prototype.initialize.call(this);
+ EventTarget.prototype.initialize.call(this, options);
+
+ internal(this).messageListener = workerMessageReceived.bind(this);
+ processes.port.on('sdk/worker/event', internal(this).messageListener);
+ },
+
+ destroy: function destroy() {
+ if (internal(this).destroyed)
+ return;
+
+ processes.port.off('sdk/worker/event', internal(this).messageListener);
+
+ BaseItem.prototype.destroy.call(this);
+ },
+
+ get label() {
+ return internal(this).options.label;
+ },
+
+ set label(val) {
+ internal(this).options.label = val;
+
+ MenuManager.updateItem(this);
+ },
+
+ get accesskey() {
+ return internal(this).options.accesskey;
+ },
+
+ set accesskey(val) {
+ internal(this).options.accesskey = val;
+
+ MenuManager.updateItem(this);
+ },
+
+ get image() {
+ return internal(this).options.image;
+ },
+
+ set image(val) {
+ internal(this).options.image = val;
+
+ MenuManager.updateItem(this);
+ },
+
+ get data() {
+ return internal(this).options.data;
+ },
+
+ set data(val) {
+ internal(this).options.data = val;
+ }
+});
+
+var Item = Class({
+ extends: LabelledItem,
+
+ initialize: function initialize(options) {
+ internal(this).options = validateOptions(options, itemRules);
+
+ LabelledItem.prototype.initialize.call(this, options);
+ },
+
+ toString: function toString() {
+ return "[object Item \"" + this.label + "\"]";
+ },
+
+ get data() {
+ return internal(this).options.data;
+ },
+
+ set data(val) {
+ internal(this).options.data = val;
+
+ MenuManager.updateItem(this);
+ },
+});
+exports.Item = Item;
+
+var ItemContainer = Class({
+ initialize: function initialize() {
+ internal(this).children = [];
+ },
+
+ destroy: function destroy() {
+ // Destroys the entire hierarchy
+ for (let item of internal(this).children)
+ item.destroy();
+ },
+
+ addItem: function addItem(item) {
+ let oldParent = item.parentMenu;
+
+ // Don't just call removeItem here as that would remove the corresponding
+ // UI element which is more costly than just moving it to the right place
+ if (oldParent)
+ internal(oldParent).children = removeItemFromArray(internal(oldParent).children, item);
+
+ let after = null;
+ let children = internal(this).children;
+ if (children.length > 0)
+ after = children[children.length - 1];
+
+ children.push(item);
+ internal(item).parentMenu = this;
+
+ // If there was an old parent then we just have to move the item, otherwise
+ // it needs to be created
+ if (oldParent)
+ MenuManager.moveItem(item, after);
+ else
+ MenuManager.createItem(item, after);
+ },
+
+ removeItem: function removeItem(item) {
+ // If the item isn't a child of this menu then ignore this call
+ if (item.parentMenu !== this)
+ return;
+
+ MenuManager.removeItem(item);
+
+ internal(this).children = removeItemFromArray(internal(this).children, item);
+ internal(item).parentMenu = null;
+ },
+
+ get items() {
+ return internal(this).children.slice(0);
+ },
+
+ set items(val) {
+ // Validate the arguments before making any changes
+ if (!Array.isArray(val))
+ throw new Error(menuOptionRules.items.msg);
+
+ for (let item of val) {
+ if (!(item instanceof BaseItem))
+ throw new Error(menuOptionRules.items.msg);
+ }
+
+ // Remove the old items and add the new ones
+ for (let item of internal(this).children)
+ this.removeItem(item);
+
+ for (let item of val)
+ this.addItem(item);
+ },
+});
+
+var Menu = Class({
+ extends: LabelledItem,
+ implements: [ItemContainer],
+
+ initialize: function initialize(options) {
+ internal(this).options = validateOptions(options, menuRules);
+
+ LabelledItem.prototype.initialize.call(this, options);
+ ItemContainer.prototype.initialize.call(this);
+
+ if (internal(this).options.items) {
+ for (let item of internal(this).options.items)
+ this.addItem(item);
+ }
+ },
+
+ destroy: function destroy() {
+ ItemContainer.prototype.destroy.call(this);
+ LabelledItem.prototype.destroy.call(this);
+ },
+
+ toString: function toString() {
+ return "[object Menu \"" + this.label + "\"]";
+ },
+});
+exports.Menu = Menu;
+
+var Separator = Class({
+ extends: BaseItem,
+
+ initialize: function initialize(options) {
+ internal(this).options = validateOptions(options, baseItemRules);
+
+ BaseItem.prototype.initialize.call(this);
+ },
+
+ toString: function toString() {
+ return "[object Separator]";
+ }
+});
+exports.Separator = Separator;
+
+// Holds items for the content area context menu
+var contentContextMenu = ItemContainer();
+exports.contentContextMenu = contentContextMenu;
+
+function getContainerItems(container) {
+ let items = [];
+ for (let item of internal(container).children) {
+ items.push(serializeItem(item));
+ if (item instanceof Menu)
+ items = items.concat(getContainerItems(item));
+ }
+ return items;
+}
+
+// Notify all frames of these new or changed items
+function sendItems(items) {
+ processes.port.emit("sdk/contextmenu/createitems", items);
+}
+
+// Called when a new process is created and needs to get the current list of items
+function remoteItemRequest(process) {
+ let items = getContainerItems(contentContextMenu);
+ if (items.length == 0)
+ return;
+
+ process.port.emit("sdk/contextmenu/createitems", items);
+}
+processes.forEvery(remoteItemRequest);
+
+when(function() {
+ contentContextMenu.destroy();
+});
+
+// App specific UI code lives here, it should handle populating the context
+// menu and passing clicks etc. through to the items.
+
+function countVisibleItems(nodes) {
+ return Array.reduce(nodes, function(sum, node) {
+ return node.hidden ? sum : sum + 1;
+ }, 0);
+}
+
+var MenuWrapper = Class({
+ initialize: function initialize(winWrapper, items, contextMenu) {
+ this.winWrapper = winWrapper;
+ this.window = winWrapper.window;
+ this.items = items;
+ this.contextMenu = contextMenu;
+ this.populated = false;
+ this.menuMap = new Map();
+
+ // updateItemVisibilities will run first, updateOverflowState will run after
+ // all other instances of this module have run updateItemVisibilities
+ this._updateItemVisibilities = this.updateItemVisibilities.bind(this);
+ this.contextMenu.addEventListener("popupshowing", this._updateItemVisibilities, true);
+ this._updateOverflowState = this.updateOverflowState.bind(this);
+ this.contextMenu.addEventListener("popupshowing", this._updateOverflowState, false);
+ },
+
+ destroy: function destroy() {
+ this.contextMenu.removeEventListener("popupshowing", this._updateOverflowState, false);
+ this.contextMenu.removeEventListener("popupshowing", this._updateItemVisibilities, true);
+
+ if (!this.populated)
+ return;
+
+ // If we're getting unloaded at runtime then we must remove all the
+ // generated XUL nodes
+ let oldParent = null;
+ for (let item of internal(this.items).children) {
+ let xulNode = this.getXULNodeForItem(item);
+ oldParent = xulNode.parentNode;
+ oldParent.removeChild(xulNode);
+ }
+
+ if (oldParent)
+ this.onXULRemoved(oldParent);
+ },
+
+ get separator() {
+ return this.contextMenu.querySelector("." + SEPARATOR_CLASS);
+ },
+
+ get overflowMenu() {
+ return this.contextMenu.querySelector("." + OVERFLOW_MENU_CLASS);
+ },
+
+ get overflowPopup() {
+ return this.contextMenu.querySelector("." + OVERFLOW_POPUP_CLASS);
+ },
+
+ get topLevelItems() {
+ return this.contextMenu.querySelectorAll("." + TOPLEVEL_ITEM_CLASS);
+ },
+
+ get overflowItems() {
+ return this.contextMenu.querySelectorAll("." + OVERFLOW_ITEM_CLASS);
+ },
+
+ getXULNodeForItem: function getXULNodeForItem(item) {
+ return this.menuMap.get(item);
+ },
+
+ // Recurses through the item hierarchy creating XUL nodes for everything
+ populate: function populate(menu) {
+ for (let i = 0; i < internal(menu).children.length; i++) {
+ let item = internal(menu).children[i];
+ let after = i === 0 ? null : internal(menu).children[i - 1];
+ this.createItem(item, after);
+
+ if (item instanceof Menu)
+ this.populate(item);
+ }
+ },
+
+ // Recurses through the menu setting the visibility of items. Returns true
+ // if any of the items in this menu were visible
+ setVisibility: function setVisibility(menu, addonInfo, usePageWorker) {
+ let anyVisible = false;
+
+ for (let item of internal(menu).children) {
+ let visible = isItemVisible(item, addonInfo[internal(item).id], usePageWorker);
+
+ // Recurse through Menus, if none of the sub-items were visible then the
+ // menu is hidden too.
+ if (visible && (item instanceof Menu))
+ visible = this.setVisibility(item, addonInfo, false);
+
+ let xulNode = this.getXULNodeForItem(item);
+ xulNode.hidden = !visible;
+
+ anyVisible = anyVisible || visible;
+ }
+
+ return anyVisible;
+ },
+
+ // Works out where to insert a XUL node for an item in a browser window
+ insertIntoXUL: function insertIntoXUL(item, node, after) {
+ let menupopup = null;
+ let before = null;
+
+ let menu = item.parentMenu;
+ if (menu === this.items) {
+ // Insert into the overflow popup if it exists, otherwise the normal
+ // context menu
+ menupopup = this.overflowPopup;
+ if (!menupopup)
+ menupopup = this.contextMenu;
+ }
+ else {
+ let xulNode = this.getXULNodeForItem(menu);
+ menupopup = xulNode.firstChild;
+ }
+
+ if (after) {
+ let afterNode = this.getXULNodeForItem(after);
+ before = afterNode.nextSibling;
+ }
+ else if (menupopup === this.contextMenu) {
+ let topLevel = this.topLevelItems;
+ if (topLevel.length > 0)
+ before = topLevel[topLevel.length - 1].nextSibling;
+ else
+ before = this.separator.nextSibling;
+ }
+
+ menupopup.insertBefore(node, before);
+ },
+
+ // Sets the right class for XUL nodes
+ updateXULClass: function updateXULClass(xulNode) {
+ if (xulNode.parentNode == this.contextMenu)
+ xulNode.classList.add(TOPLEVEL_ITEM_CLASS);
+ else
+ xulNode.classList.remove(TOPLEVEL_ITEM_CLASS);
+
+ if (xulNode.parentNode == this.overflowPopup)
+ xulNode.classList.add(OVERFLOW_ITEM_CLASS);
+ else
+ xulNode.classList.remove(OVERFLOW_ITEM_CLASS);
+ },
+
+ // Creates a XUL node for an item
+ createItem: function createItem(item, after) {
+ if (!this.populated)
+ return;
+
+ // Create the separator if it doesn't already exist
+ if (!this.separator) {
+ let separator = this.window.document.createElement("menuseparator");
+ separator.setAttribute("class", SEPARATOR_CLASS);
+
+ // Insert before the separator created by the old context-menu if it
+ // exists to avoid bug 832401
+ let oldSeparator = this.window.document.getElementById("jetpack-context-menu-separator");
+ if (oldSeparator && oldSeparator.parentNode != this.contextMenu)
+ oldSeparator = null;
+ this.contextMenu.insertBefore(separator, oldSeparator);
+ }
+
+ let type = "menuitem";
+ if (item instanceof Menu)
+ type = "menu";
+ else if (item instanceof Separator)
+ type = "menuseparator";
+
+ let xulNode = this.window.document.createElement(type);
+ xulNode.setAttribute("class", ITEM_CLASS);
+ if (item instanceof LabelledItem) {
+ xulNode.setAttribute("label", item.label);
+ if (item.accesskey)
+ xulNode.setAttribute("accesskey", item.accesskey);
+ if (item.image) {
+ xulNode.setAttribute("image", item.image);
+ if (item instanceof Menu)
+ xulNode.classList.add("menu-iconic");
+ else
+ xulNode.classList.add("menuitem-iconic");
+ }
+ if (item.data)
+ xulNode.setAttribute("value", item.data);
+
+ let self = this;
+ xulNode.addEventListener("command", function(event) {
+ // Only care about clicks directly on this item
+ if (event.target !== xulNode)
+ return;
+
+ itemActivated(item, xulNode);
+ }, false);
+ }
+
+ this.insertIntoXUL(item, xulNode, after);
+ this.updateXULClass(xulNode);
+ xulNode.data = item.data;
+
+ if (item instanceof Menu) {
+ let menupopup = this.window.document.createElement("menupopup");
+ xulNode.appendChild(menupopup);
+ }
+
+ this.menuMap.set(item, xulNode);
+ },
+
+ // Updates the XUL node for an item in this window
+ updateItem: function updateItem(item) {
+ if (!this.populated)
+ return;
+
+ let xulNode = this.getXULNodeForItem(item);
+
+ // TODO figure out why this requires setAttribute
+ xulNode.setAttribute("label", item.label);
+ xulNode.setAttribute("accesskey", item.accesskey || "");
+
+ if (item.image) {
+ xulNode.setAttribute("image", item.image);
+ if (item instanceof Menu)
+ xulNode.classList.add("menu-iconic");
+ else
+ xulNode.classList.add("menuitem-iconic");
+ }
+ else {
+ xulNode.removeAttribute("image");
+ xulNode.classList.remove("menu-iconic");
+ xulNode.classList.remove("menuitem-iconic");
+ }
+
+ if (item.data)
+ xulNode.setAttribute("value", item.data);
+ else
+ xulNode.removeAttribute("value");
+ },
+
+ // Moves the XUL node for an item in this window to its new place in the
+ // hierarchy
+ moveItem: function moveItem(item, after) {
+ if (!this.populated)
+ return;
+
+ let xulNode = this.getXULNodeForItem(item);
+ let oldParent = xulNode.parentNode;
+
+ this.insertIntoXUL(item, xulNode, after);
+ this.updateXULClass(xulNode);
+ this.onXULRemoved(oldParent);
+ },
+
+ // Removes the XUL nodes for an item in every window we've ever populated.
+ removeItem: function removeItem(item) {
+ if (!this.populated)
+ return;
+
+ let xulItem = this.getXULNodeForItem(item);
+
+ let oldParent = xulItem.parentNode;
+
+ oldParent.removeChild(xulItem);
+ this.menuMap.delete(item);
+
+ this.onXULRemoved(oldParent);
+ },
+
+ // Called when any XUL nodes have been removed from a menupopup. This handles
+ // making sure the separator and overflow are correct
+ onXULRemoved: function onXULRemoved(parent) {
+ if (parent == this.contextMenu) {
+ let toplevel = this.topLevelItems;
+
+ // If there are no more items then remove the separator
+ if (toplevel.length == 0) {
+ let separator = this.separator;
+ if (separator)
+ separator.parentNode.removeChild(separator);
+ }
+ }
+ else if (parent == this.overflowPopup) {
+ // If there are no more items then remove the overflow menu and separator
+ if (parent.childNodes.length == 0) {
+ let separator = this.separator;
+ separator.parentNode.removeChild(separator);
+ this.contextMenu.removeChild(parent.parentNode);
+ }
+ }
+ },
+
+ // Recurses through all the items owned by this module and sets their hidden
+ // state
+ updateItemVisibilities: function updateItemVisibilities(event) {
+ try {
+ if (event.type != "popupshowing")
+ return;
+ if (event.target != this.contextMenu)
+ return;
+
+ if (internal(this.items).children.length == 0)
+ return;
+
+ if (!this.populated) {
+ this.populated = true;
+ this.populate(this.items);
+ }
+
+ let mainWindow = event.target.ownerDocument.defaultView;
+ this.contextMenuContentData = mainWindow.gContextMenuContentData
+ if (!(self.id in this.contextMenuContentData.addonInfo)) {
+ console.warn("No context menu state data was provided.");
+ return;
+ }
+ let addonInfo = this.contextMenuContentData.addonInfo[self.id];
+ lastContextProcessId = addonInfo.processID;
+ this.setVisibility(this.items, addonInfo.items, true);
+ }
+ catch (e) {
+ console.exception(e);
+ }
+ },
+
+ // Counts the number of visible items across all modules and makes sure they
+ // are in the right place between the top level context menu and the overflow
+ // menu
+ updateOverflowState: function updateOverflowState(event) {
+ try {
+ if (event.type != "popupshowing")
+ return;
+ if (event.target != this.contextMenu)
+ return;
+
+ // The main items will be in either the top level context menu or the
+ // overflow menu at this point. Count the visible ones and if they are in
+ // the wrong place move them
+ let toplevel = this.topLevelItems;
+ let overflow = this.overflowItems;
+ let visibleCount = countVisibleItems(toplevel) +
+ countVisibleItems(overflow);
+
+ if (visibleCount == 0) {
+ let separator = this.separator;
+ if (separator)
+ separator.hidden = true;
+ let overflowMenu = this.overflowMenu;
+ if (overflowMenu)
+ overflowMenu.hidden = true;
+ }
+ else if (visibleCount > MenuManager.overflowThreshold) {
+ this.separator.hidden = false;
+ let overflowPopup = this.overflowPopup;
+ if (overflowPopup)
+ overflowPopup.parentNode.hidden = false;
+
+ if (toplevel.length > 0) {
+ // The overflow menu shouldn't exist here but let's play it safe
+ if (!overflowPopup) {
+ let overflowMenu = this.window.document.createElement("menu");
+ overflowMenu.setAttribute("class", OVERFLOW_MENU_CLASS);
+ overflowMenu.setAttribute("label", OVERFLOW_MENU_LABEL);
+ overflowMenu.setAttribute("accesskey", OVERFLOW_MENU_ACCESSKEY);
+ this.contextMenu.insertBefore(overflowMenu, this.separator.nextSibling);
+
+ overflowPopup = this.window.document.createElement("menupopup");
+ overflowPopup.setAttribute("class", OVERFLOW_POPUP_CLASS);
+ overflowMenu.appendChild(overflowPopup);
+ }
+
+ for (let xulNode of toplevel) {
+ overflowPopup.appendChild(xulNode);
+ this.updateXULClass(xulNode);
+ }
+ }
+ }
+ else {
+ this.separator.hidden = false;
+
+ if (overflow.length > 0) {
+ // Move all the overflow nodes out of the overflow menu and position
+ // them immediately before it
+ for (let xulNode of overflow) {
+ this.contextMenu.insertBefore(xulNode, xulNode.parentNode.parentNode);
+ this.updateXULClass(xulNode);
+ }
+ this.contextMenu.removeChild(this.overflowMenu);
+ }
+ }
+ }
+ catch (e) {
+ console.exception(e);
+ }
+ }
+});
+
+// This wraps every window that we've seen
+var WindowWrapper = Class({
+ initialize: function initialize(window) {
+ this.window = window;
+ this.menus = [
+ new MenuWrapper(this, contentContextMenu, window.document.getElementById("contentAreaContextMenu")),
+ ];
+ },
+
+ destroy: function destroy() {
+ for (let menuWrapper of this.menus)
+ menuWrapper.destroy();
+ },
+
+ getMenuWrapperForItem: function getMenuWrapperForItem(item) {
+ let root = item.parentMenu;
+ while (root.parentMenu)
+ root = root.parentMenu;
+
+ for (let wrapper of this.menus) {
+ if (wrapper.items === root)
+ return wrapper;
+ }
+
+ return null;
+ }
+});
+
+var MenuManager = {
+ windowMap: new Map(),
+
+ get overflowThreshold() {
+ let prefs = require("./preferences/service");
+ return prefs.get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT);
+ },
+
+ // When a new window is added start watching it for context menu shows
+ onTrack: function onTrack(window) {
+ if (!isBrowser(window))
+ return;
+
+ // Generally shouldn't happen, but just in case
+ if (this.windowMap.has(window)) {
+ console.warn("Already seen this window");
+ return;
+ }
+
+ let winWrapper = WindowWrapper(window);
+ this.windowMap.set(window, winWrapper);
+ },
+
+ onUntrack: function onUntrack(window) {
+ if (!isBrowser(window))
+ return;
+
+ let winWrapper = this.windowMap.get(window);
+ // This shouldn't happen but protect against it anyway
+ if (!winWrapper)
+ return;
+ winWrapper.destroy();
+
+ this.windowMap.delete(window);
+ },
+
+ // Creates a XUL node for an item in every window we've already populated
+ createItem: function createItem(item, after) {
+ for (let [window, winWrapper] of this.windowMap) {
+ let menuWrapper = winWrapper.getMenuWrapperForItem(item);
+ if (menuWrapper)
+ menuWrapper.createItem(item, after);
+ }
+ },
+
+ // Updates the XUL node for an item in every window we've already populated
+ updateItem: function updateItem(item) {
+ for (let [window, winWrapper] of this.windowMap) {
+ let menuWrapper = winWrapper.getMenuWrapperForItem(item);
+ if (menuWrapper)
+ menuWrapper.updateItem(item);
+ }
+ },
+
+ // Moves the XUL node for an item in every window we've ever populated to its
+ // new place in the hierarchy
+ moveItem: function moveItem(item, after) {
+ for (let [window, winWrapper] of this.windowMap) {
+ let menuWrapper = winWrapper.getMenuWrapperForItem(item);
+ if (menuWrapper)
+ menuWrapper.moveItem(item, after);
+ }
+ },
+
+ // Removes the XUL nodes for an item in every window we've ever populated.
+ removeItem: function removeItem(item) {
+ for (let [window, winWrapper] of this.windowMap) {
+ let menuWrapper = winWrapper.getMenuWrapperForItem(item);
+ if (menuWrapper)
+ menuWrapper.removeItem(item);
+ }
+ }
+};
+
+WindowTracker(MenuManager);