diff -r 000000000000 -r 6474c204b198 addon-sdk/source/lib/sdk/context-menu.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/addon-sdk/source/lib/sdk/context-menu.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1202 @@ +/* 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": "*" + } +}; + +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 { Ci } = require("chrome"); +const { MatchPattern } = require("./util/match-pattern"); +const { Worker } = require("./content/worker"); +const { EventTarget } = require("./event/target"); +const { emit } = require('./event/core'); +const { when } = require('./system/unload'); +const selection = require('./selection'); + +// 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 this. +const OVERFLOW_MENU_LABEL = "Add-ons"; + +// 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"; + +//These are used by PageContext.isCurrent below. If the popupNode or any of +//its ancestors is one of these, Firefox uses a tailored context menu, and so +//the page context doesn't apply. +const NON_PAGE_CONTEXT_ELTS = [ + Ci.nsIDOMHTMLAnchorElement, + Ci.nsIDOMHTMLAppletElement, + Ci.nsIDOMHTMLAreaElement, + Ci.nsIDOMHTMLButtonElement, + Ci.nsIDOMHTMLCanvasElement, + Ci.nsIDOMHTMLEmbedElement, + Ci.nsIDOMHTMLImageElement, + Ci.nsIDOMHTMLInputElement, + Ci.nsIDOMHTMLMapElement, + Ci.nsIDOMHTMLMediaElement, + Ci.nsIDOMHTMLMenuElement, + Ci.nsIDOMHTMLObjectElement, + Ci.nsIDOMHTMLOptionElement, + Ci.nsIDOMHTMLSelectElement, + Ci.nsIDOMHTMLTextAreaElement, +]; + +// Holds private properties for API objects +let internal = ns(); + +function getScheme(spec) { + try { + return URL(spec).scheme; + } + catch(e) { + return null; + } +} + +let Context = Class({ + // 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(popupNode) { + return false; + } +}); + +// Matches when the context-clicked node doesn't have any of +// NON_PAGE_CONTEXT_ELTS in its ancestors +let PageContext = Class({ + extends: Context, + + isCurrent: function isCurrent(popupNode) { + // If there is a selection in the window then this context does not match + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) + return false; + + // If the clicked node or any of its ancestors is one of the blacklisted + // NON_PAGE_CONTEXT_ELTS then this context does not match + while (!(popupNode instanceof Ci.nsIDOMDocument)) { + if (NON_PAGE_CONTEXT_ELTS.some(function(type) popupNode instanceof type)) + return false; + + popupNode = popupNode.parentNode; + } + + return true; + } +}); +exports.PageContext = PageContext; + +// Matches when there is an active selection in the window +let SelectionContext = Class({ + extends: Context, + + isCurrent: function isCurrent(popupNode) { + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) + return true; + + try { + // The node may be a text box which has selectionStart and selectionEnd + // properties. If not this will throw. + let { selectionStart, selectionEnd } = popupNode; + return !isNaN(selectionStart) && !isNaN(selectionEnd) && + selectionStart !== selectionEnd; + } + catch (e) { + return false; + } + } +}); +exports.SelectionContext = SelectionContext; + +// Matches when the context-clicked node or any of its ancestors matches the +// selector given +let SelectorContext = Class({ + extends: Context, + + initialize: function initialize(selector) { + let options = validateOptions({ selector: selector }, { + selector: { + is: ["string"], + msg: "selector must be a string." + } + }); + internal(this).selector = options.selector; + }, + + adjustPopupNode: function adjustPopupNode(popupNode) { + let selector = internal(this).selector; + + while (!(popupNode instanceof Ci.nsIDOMDocument)) { + if (popupNode.mozMatchesSelector(selector)) + return popupNode; + + popupNode = popupNode.parentNode; + } + + return null; + }, + + isCurrent: function isCurrent(popupNode) { + return !!this.adjustPopupNode(popupNode); + } +}); +exports.SelectorContext = SelectorContext; + +// Matches when the page url matches any of the patterns given +let URLContext = Class({ + extends: Context, + + initialize: function initialize(patterns) { + patterns = Array.isArray(patterns) ? patterns : [patterns]; + + try { + internal(this).patterns = patterns.map(function (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(popupNode) { + let url = popupNode.ownerDocument.URL; + return internal(this).patterns.some(function (p) p.test(url)); + } +}); +exports.URLContext = URLContext; + +// Matches when the user-supplied predicate returns true +let PredicateContext = Class({ + extends: Context, + + initialize: function initialize(predicate) { + let options = validateOptions({ predicate: predicate }, { + predicate: { + is: ["function"], + msg: "predicate must be a function." + } + }); + internal(this).predicate = options.predicate; + }, + + isCurrent: function isCurrent(popupNode) { + return internal(this).predicate(populateCallbackNodeData(popupNode)); + } +}); +exports.PredicateContext = PredicateContext; + +// List all editable types of inputs. Or is it better to have a list +// of non-editable inputs? +let editableInputs = { + email: true, + number: true, + password: true, + search: true, + tel: true, + text: true, + textarea: true, + url: true +}; + +function populateCallbackNodeData(node) { + let window = node.ownerDocument.defaultView; + let data = {}; + + data.documentType = node.ownerDocument.contentType; + + data.documentURL = node.ownerDocument.location.href; + data.targetName = node.nodeName.toLowerCase(); + data.targetID = node.id || null ; + + if ((data.targetName === 'input' && editableInputs[node.type]) || + data.targetName === 'textarea') { + data.isEditable = !node.readOnly && !node.disabled; + } + else { + data.isEditable = node.isContentEditable; + } + + data.selectionText = selection.text; + + data.srcURL = node.src || null; + data.linkURL = node.href || null; + data.value = node.value || null; + + return data; +} + +function removeItemFromArray(array, item) { + return array.filter(function(i) i !== item); +} + +// Converts anything that isn't false, null or undefined into a string +function stringOrNull(val) val ? String(val) : val; + +// Shared option validation rules for Item and Menu +let 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(function (o) o instanceof Context); + }, + msg: "The 'context' option must be a Context object or an array of " + + "Context objects." + }, + contentScript: { + is: ["string", "array", "undefined"], + ok: function (v) { + return !Array.isArray(v) || + v.every(function (s) typeof(s) === "string"); + } + }, + contentScriptFile: { + is: ["string", "array", "undefined"], + ok: function (v) { + if (!v) + return true; + let arr = Array.isArray(v) ? v : [v]; + return arr.every(function (s) { + return getTypeOf(s) === "string" && + getScheme(s) === 'resource'; + }); + }, + msg: "The 'contentScriptFile' option must be a local file URL or " + + "an array of local file URLs." + }, + onMessage: { + is: ["function", "undefined"] + } +}; + +let labelledItemRules = mix(baseItemRules, { + label: { + map: stringOrNull, + is: ["string"], + ok: function (v) !!v, + msg: "The item must have a non-empty string label." + }, + 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 +let itemRules = mix(labelledItemRules, { + data: { + map: stringOrNull, + is: ["string", "undefined", "null"] + } +}); + +// Additional validation rules for Menu +let 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." + } +}); + +let ContextWorker = Class({ + implements: [ Worker ], + + //Returns true if any context listeners are defined in the worker's port. + anyContextListeners: function anyContextListeners() { + return this.getSandbox().hasListenerFor("context"); + }, + + // Calls the context workers context listeners and returns the first result + // that is either a string or a value that evaluates to true. If all of the + // listeners returned false then returns false. If there are no listeners + // then returns null. + getMatchedContext: function getCurrentContexts(popupNode) { + let results = this.getSandbox().emitSync("context", popupNode); + return results.reduce(function(val, result) val || result, null); + }, + + // Emits a click event in the worker's port. popupNode is the node that was + // context-clicked, and clickedItemData is the data of the item that was + // clicked. + fireClick: function fireClick(popupNode, clickedItemData) { + this.getSandbox().emitSync("click", popupNode, clickedItemData); + } +}); + +// Returns true if any contexts match. If there are no contexts then a +// PageContext is tested instead +function hasMatchingContext(contexts, popupNode) { + for (let context in contexts) { + if (!context.isCurrent(popupNode)) + return false; + } + + return true; +} + +// Gets the matched context from any worker for this item. If there is no worker +// or no matched context then returns false. +function getCurrentWorkerContext(item, popupNode) { + let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); + if (!worker || !worker.anyContextListeners()) + return true; + return worker.getMatchedContext(popupNode); +} + +// Tests whether an item should be visible or not based on its contexts and +// content scripts +function isItemVisible(item, popupNode, defaultVisibility) { + if (!item.context.length) { + let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); + if (!worker || !worker.anyContextListeners()) + return defaultVisibility; + } + + if (!hasMatchingContext(item.context, popupNode)) + return false; + + let context = getCurrentWorkerContext(item, popupNode); + if (typeof(context) === "string" && context != "") + item.label = context; + + return !!context; +} + +// Gets the item's content script worker for a window, creating one if necessary +// Once created it will be automatically destroyed when the window unloads. +// If there is not content scripts for the item then null will be returned. +function getItemWorkerForWindow(item, window) { + if (!item.contentScript && !item.contentScriptFile) + return null; + + let id = getInnerId(window); + let worker = internal(item).workerMap.get(id); + + if (worker) + return worker; + + worker = ContextWorker({ + window: window, + contentScript: item.contentScript, + contentScriptFile: item.contentScriptFile, + onMessage: function(msg) { + emit(item, "message", msg); + }, + onDetach: function() { + internal(item).workerMap.delete(id); + } + }); + + internal(item).workerMap.set(id, worker); + + return worker; +} + +// Called when an item is clicked to send out click events to the content +// scripts +function itemClicked(item, clickedItem, popupNode) { + let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); + + if (worker) { + let adjustedNode = popupNode; + for (let context in item.context) + adjustedNode = context.adjustPopupNode(adjustedNode); + worker.fireClick(adjustedNode, clickedItem.data); + } + + if (item.parentMenu) + itemClicked(item.parentMenu, clickedItem, popupNode); +} + +// All things that appear in the context menu extend this +let BaseItem = Class({ + initialize: function initialize() { + addCollectionProperty(this, "context"); + + // Used to cache content script workers and the windows they have been + // created for + internal(this).workerMap = new Map(); + + 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) + this.context.add(context); + } + else { + this.context.add(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 + }); + + Object.defineProperty(this, "contentScriptFile", { + enumerable: true, + value: internal(this).options.contentScriptFile + }); + }, + + destroy: function destroy() { + if (this.parentMenu) + this.parentMenu.removeItem(this); + }, + + get parentMenu() { + return internal(this).parentMenu; + }, +}); + +// All things that have a label on the context menu extend this +let LabelledItem = Class({ + extends: BaseItem, + implements: [ EventTarget ], + + initialize: function initialize(options) { + BaseItem.prototype.initialize.call(this); + EventTarget.prototype.initialize.call(this, options); + }, + + destroy: function destroy() { + for (let [,worker] of internal(this).workerMap) + worker.destroy(); + + BaseItem.prototype.destroy.call(this); + }, + + get label() { + return internal(this).options.label; + }, + + set label(val) { + internal(this).options.label = 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; + } +}); + +let 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; + +let 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); + }, +}); + +let 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; + +let 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 +let contentContextMenu = ItemContainer(); +exports.contentContextMenu = contentContextMenu; + +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); +} + +let 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, popupNode, defaultVisibility) { + let anyVisible = false; + + for (let item of internal(menu).children) { + let visible = isItemVisible(item, popupNode, defaultVisibility); + + // 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, popupNode, true); + + 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.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; + + itemClicked(item, item, self.contextMenu.triggerNode); + }, 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); + + 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 popupNode = event.target.triggerNode; + this.setVisibility(this.items, popupNode, PageContext().isCurrent(popupNode)); + } + 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); + 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 +let 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; + } +}); + +let 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);