diff options
author | Matt A. Tobin <email@mattatobin.com> | 2018-02-10 02:51:36 -0500 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2018-02-10 02:51:36 -0500 |
commit | 37d5300335d81cecbecc99812747a657588c63eb (patch) | |
tree | 765efa3b6a56bb715d9813a8697473e120436278 /toolkit/jetpack/sdk/context-menu.js | |
parent | b2bdac20c02b12f2057b9ef70b0a946113a00e00 (diff) | |
parent | 4fb11cd5966461bccc3ed1599b808237be6b0de9 (diff) | |
download | UXP-37d5300335d81cecbecc99812747a657588c63eb.tar UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.gz UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.lz UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.xz UXP-37d5300335d81cecbecc99812747a657588c63eb.zip |
Merge branch 'ext-work'
Diffstat (limited to 'toolkit/jetpack/sdk/context-menu.js')
-rw-r--r-- | toolkit/jetpack/sdk/context-menu.js | 1188 |
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); |