1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/addon-sdk/source/lib/sdk/context-menu.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,1202 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 +"use strict"; 1.8 + 1.9 +module.metadata = { 1.10 + "stability": "stable", 1.11 + "engines": { 1.12 + // TODO Fennec support Bug 788334 1.13 + "Firefox": "*" 1.14 + } 1.15 +}; 1.16 + 1.17 +const { Class, mix } = require("./core/heritage"); 1.18 +const { addCollectionProperty } = require("./util/collection"); 1.19 +const { ns } = require("./core/namespace"); 1.20 +const { validateOptions, getTypeOf } = require("./deprecated/api-utils"); 1.21 +const { URL, isValidURI } = require("./url"); 1.22 +const { WindowTracker, browserWindowIterator } = require("./deprecated/window-utils"); 1.23 +const { isBrowser, getInnerId } = require("./window/utils"); 1.24 +const { Ci } = require("chrome"); 1.25 +const { MatchPattern } = require("./util/match-pattern"); 1.26 +const { Worker } = require("./content/worker"); 1.27 +const { EventTarget } = require("./event/target"); 1.28 +const { emit } = require('./event/core'); 1.29 +const { when } = require('./system/unload'); 1.30 +const selection = require('./selection'); 1.31 + 1.32 +// All user items we add have this class. 1.33 +const ITEM_CLASS = "addon-context-menu-item"; 1.34 + 1.35 +// Items in the top-level context menu also have this class. 1.36 +const TOPLEVEL_ITEM_CLASS = "addon-context-menu-item-toplevel"; 1.37 + 1.38 +// Items in the overflow submenu also have this class. 1.39 +const OVERFLOW_ITEM_CLASS = "addon-context-menu-item-overflow"; 1.40 + 1.41 +// The class of the menu separator that separates standard context menu items 1.42 +// from our user items. 1.43 +const SEPARATOR_CLASS = "addon-context-menu-separator"; 1.44 + 1.45 +// If more than this number of items are added to the context menu, all items 1.46 +// overflow into a "Jetpack" submenu. 1.47 +const OVERFLOW_THRESH_DEFAULT = 10; 1.48 +const OVERFLOW_THRESH_PREF = 1.49 + "extensions.addon-sdk.context-menu.overflowThreshold"; 1.50 + 1.51 +// The label of the overflow sub-xul:menu. 1.52 +// 1.53 +// TODO: Localize this. 1.54 +const OVERFLOW_MENU_LABEL = "Add-ons"; 1.55 + 1.56 +// The class of the overflow sub-xul:menu. 1.57 +const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu"; 1.58 + 1.59 +// The class of the overflow submenu's xul:menupopup. 1.60 +const OVERFLOW_POPUP_CLASS = "addon-content-menu-overflow-popup"; 1.61 + 1.62 +//These are used by PageContext.isCurrent below. If the popupNode or any of 1.63 +//its ancestors is one of these, Firefox uses a tailored context menu, and so 1.64 +//the page context doesn't apply. 1.65 +const NON_PAGE_CONTEXT_ELTS = [ 1.66 + Ci.nsIDOMHTMLAnchorElement, 1.67 + Ci.nsIDOMHTMLAppletElement, 1.68 + Ci.nsIDOMHTMLAreaElement, 1.69 + Ci.nsIDOMHTMLButtonElement, 1.70 + Ci.nsIDOMHTMLCanvasElement, 1.71 + Ci.nsIDOMHTMLEmbedElement, 1.72 + Ci.nsIDOMHTMLImageElement, 1.73 + Ci.nsIDOMHTMLInputElement, 1.74 + Ci.nsIDOMHTMLMapElement, 1.75 + Ci.nsIDOMHTMLMediaElement, 1.76 + Ci.nsIDOMHTMLMenuElement, 1.77 + Ci.nsIDOMHTMLObjectElement, 1.78 + Ci.nsIDOMHTMLOptionElement, 1.79 + Ci.nsIDOMHTMLSelectElement, 1.80 + Ci.nsIDOMHTMLTextAreaElement, 1.81 +]; 1.82 + 1.83 +// Holds private properties for API objects 1.84 +let internal = ns(); 1.85 + 1.86 +function getScheme(spec) { 1.87 + try { 1.88 + return URL(spec).scheme; 1.89 + } 1.90 + catch(e) { 1.91 + return null; 1.92 + } 1.93 +} 1.94 + 1.95 +let Context = Class({ 1.96 + // Returns the node that made this context current 1.97 + adjustPopupNode: function adjustPopupNode(popupNode) { 1.98 + return popupNode; 1.99 + }, 1.100 + 1.101 + // Returns whether this context is current for the current node 1.102 + isCurrent: function isCurrent(popupNode) { 1.103 + return false; 1.104 + } 1.105 +}); 1.106 + 1.107 +// Matches when the context-clicked node doesn't have any of 1.108 +// NON_PAGE_CONTEXT_ELTS in its ancestors 1.109 +let PageContext = Class({ 1.110 + extends: Context, 1.111 + 1.112 + isCurrent: function isCurrent(popupNode) { 1.113 + // If there is a selection in the window then this context does not match 1.114 + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) 1.115 + return false; 1.116 + 1.117 + // If the clicked node or any of its ancestors is one of the blacklisted 1.118 + // NON_PAGE_CONTEXT_ELTS then this context does not match 1.119 + while (!(popupNode instanceof Ci.nsIDOMDocument)) { 1.120 + if (NON_PAGE_CONTEXT_ELTS.some(function(type) popupNode instanceof type)) 1.121 + return false; 1.122 + 1.123 + popupNode = popupNode.parentNode; 1.124 + } 1.125 + 1.126 + return true; 1.127 + } 1.128 +}); 1.129 +exports.PageContext = PageContext; 1.130 + 1.131 +// Matches when there is an active selection in the window 1.132 +let SelectionContext = Class({ 1.133 + extends: Context, 1.134 + 1.135 + isCurrent: function isCurrent(popupNode) { 1.136 + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) 1.137 + return true; 1.138 + 1.139 + try { 1.140 + // The node may be a text box which has selectionStart and selectionEnd 1.141 + // properties. If not this will throw. 1.142 + let { selectionStart, selectionEnd } = popupNode; 1.143 + return !isNaN(selectionStart) && !isNaN(selectionEnd) && 1.144 + selectionStart !== selectionEnd; 1.145 + } 1.146 + catch (e) { 1.147 + return false; 1.148 + } 1.149 + } 1.150 +}); 1.151 +exports.SelectionContext = SelectionContext; 1.152 + 1.153 +// Matches when the context-clicked node or any of its ancestors matches the 1.154 +// selector given 1.155 +let SelectorContext = Class({ 1.156 + extends: Context, 1.157 + 1.158 + initialize: function initialize(selector) { 1.159 + let options = validateOptions({ selector: selector }, { 1.160 + selector: { 1.161 + is: ["string"], 1.162 + msg: "selector must be a string." 1.163 + } 1.164 + }); 1.165 + internal(this).selector = options.selector; 1.166 + }, 1.167 + 1.168 + adjustPopupNode: function adjustPopupNode(popupNode) { 1.169 + let selector = internal(this).selector; 1.170 + 1.171 + while (!(popupNode instanceof Ci.nsIDOMDocument)) { 1.172 + if (popupNode.mozMatchesSelector(selector)) 1.173 + return popupNode; 1.174 + 1.175 + popupNode = popupNode.parentNode; 1.176 + } 1.177 + 1.178 + return null; 1.179 + }, 1.180 + 1.181 + isCurrent: function isCurrent(popupNode) { 1.182 + return !!this.adjustPopupNode(popupNode); 1.183 + } 1.184 +}); 1.185 +exports.SelectorContext = SelectorContext; 1.186 + 1.187 +// Matches when the page url matches any of the patterns given 1.188 +let URLContext = Class({ 1.189 + extends: Context, 1.190 + 1.191 + initialize: function initialize(patterns) { 1.192 + patterns = Array.isArray(patterns) ? patterns : [patterns]; 1.193 + 1.194 + try { 1.195 + internal(this).patterns = patterns.map(function (p) new MatchPattern(p)); 1.196 + } 1.197 + catch (err) { 1.198 + throw new Error("Patterns must be a string, regexp or an array of " + 1.199 + "strings or regexps: " + err); 1.200 + } 1.201 + 1.202 + }, 1.203 + 1.204 + isCurrent: function isCurrent(popupNode) { 1.205 + let url = popupNode.ownerDocument.URL; 1.206 + return internal(this).patterns.some(function (p) p.test(url)); 1.207 + } 1.208 +}); 1.209 +exports.URLContext = URLContext; 1.210 + 1.211 +// Matches when the user-supplied predicate returns true 1.212 +let PredicateContext = Class({ 1.213 + extends: Context, 1.214 + 1.215 + initialize: function initialize(predicate) { 1.216 + let options = validateOptions({ predicate: predicate }, { 1.217 + predicate: { 1.218 + is: ["function"], 1.219 + msg: "predicate must be a function." 1.220 + } 1.221 + }); 1.222 + internal(this).predicate = options.predicate; 1.223 + }, 1.224 + 1.225 + isCurrent: function isCurrent(popupNode) { 1.226 + return internal(this).predicate(populateCallbackNodeData(popupNode)); 1.227 + } 1.228 +}); 1.229 +exports.PredicateContext = PredicateContext; 1.230 + 1.231 +// List all editable types of inputs. Or is it better to have a list 1.232 +// of non-editable inputs? 1.233 +let editableInputs = { 1.234 + email: true, 1.235 + number: true, 1.236 + password: true, 1.237 + search: true, 1.238 + tel: true, 1.239 + text: true, 1.240 + textarea: true, 1.241 + url: true 1.242 +}; 1.243 + 1.244 +function populateCallbackNodeData(node) { 1.245 + let window = node.ownerDocument.defaultView; 1.246 + let data = {}; 1.247 + 1.248 + data.documentType = node.ownerDocument.contentType; 1.249 + 1.250 + data.documentURL = node.ownerDocument.location.href; 1.251 + data.targetName = node.nodeName.toLowerCase(); 1.252 + data.targetID = node.id || null ; 1.253 + 1.254 + if ((data.targetName === 'input' && editableInputs[node.type]) || 1.255 + data.targetName === 'textarea') { 1.256 + data.isEditable = !node.readOnly && !node.disabled; 1.257 + } 1.258 + else { 1.259 + data.isEditable = node.isContentEditable; 1.260 + } 1.261 + 1.262 + data.selectionText = selection.text; 1.263 + 1.264 + data.srcURL = node.src || null; 1.265 + data.linkURL = node.href || null; 1.266 + data.value = node.value || null; 1.267 + 1.268 + return data; 1.269 +} 1.270 + 1.271 +function removeItemFromArray(array, item) { 1.272 + return array.filter(function(i) i !== item); 1.273 +} 1.274 + 1.275 +// Converts anything that isn't false, null or undefined into a string 1.276 +function stringOrNull(val) val ? String(val) : val; 1.277 + 1.278 +// Shared option validation rules for Item and Menu 1.279 +let baseItemRules = { 1.280 + parentMenu: { 1.281 + is: ["object", "undefined"], 1.282 + ok: function (v) { 1.283 + if (!v) 1.284 + return true; 1.285 + return (v instanceof ItemContainer) || (v instanceof Menu); 1.286 + }, 1.287 + msg: "parentMenu must be a Menu or not specified." 1.288 + }, 1.289 + context: { 1.290 + is: ["undefined", "object", "array"], 1.291 + ok: function (v) { 1.292 + if (!v) 1.293 + return true; 1.294 + let arr = Array.isArray(v) ? v : [v]; 1.295 + return arr.every(function (o) o instanceof Context); 1.296 + }, 1.297 + msg: "The 'context' option must be a Context object or an array of " + 1.298 + "Context objects." 1.299 + }, 1.300 + contentScript: { 1.301 + is: ["string", "array", "undefined"], 1.302 + ok: function (v) { 1.303 + return !Array.isArray(v) || 1.304 + v.every(function (s) typeof(s) === "string"); 1.305 + } 1.306 + }, 1.307 + contentScriptFile: { 1.308 + is: ["string", "array", "undefined"], 1.309 + ok: function (v) { 1.310 + if (!v) 1.311 + return true; 1.312 + let arr = Array.isArray(v) ? v : [v]; 1.313 + return arr.every(function (s) { 1.314 + return getTypeOf(s) === "string" && 1.315 + getScheme(s) === 'resource'; 1.316 + }); 1.317 + }, 1.318 + msg: "The 'contentScriptFile' option must be a local file URL or " + 1.319 + "an array of local file URLs." 1.320 + }, 1.321 + onMessage: { 1.322 + is: ["function", "undefined"] 1.323 + } 1.324 +}; 1.325 + 1.326 +let labelledItemRules = mix(baseItemRules, { 1.327 + label: { 1.328 + map: stringOrNull, 1.329 + is: ["string"], 1.330 + ok: function (v) !!v, 1.331 + msg: "The item must have a non-empty string label." 1.332 + }, 1.333 + image: { 1.334 + map: stringOrNull, 1.335 + is: ["string", "undefined", "null"], 1.336 + ok: function (url) { 1.337 + if (!url) 1.338 + return true; 1.339 + return isValidURI(url); 1.340 + }, 1.341 + msg: "Image URL validation failed" 1.342 + } 1.343 +}); 1.344 + 1.345 +// Additional validation rules for Item 1.346 +let itemRules = mix(labelledItemRules, { 1.347 + data: { 1.348 + map: stringOrNull, 1.349 + is: ["string", "undefined", "null"] 1.350 + } 1.351 +}); 1.352 + 1.353 +// Additional validation rules for Menu 1.354 +let menuRules = mix(labelledItemRules, { 1.355 + items: { 1.356 + is: ["array", "undefined"], 1.357 + ok: function (v) { 1.358 + if (!v) 1.359 + return true; 1.360 + return v.every(function (item) { 1.361 + return item instanceof BaseItem; 1.362 + }); 1.363 + }, 1.364 + msg: "items must be an array, and each element in the array must be an " + 1.365 + "Item, Menu, or Separator." 1.366 + } 1.367 +}); 1.368 + 1.369 +let ContextWorker = Class({ 1.370 + implements: [ Worker ], 1.371 + 1.372 + //Returns true if any context listeners are defined in the worker's port. 1.373 + anyContextListeners: function anyContextListeners() { 1.374 + return this.getSandbox().hasListenerFor("context"); 1.375 + }, 1.376 + 1.377 + // Calls the context workers context listeners and returns the first result 1.378 + // that is either a string or a value that evaluates to true. If all of the 1.379 + // listeners returned false then returns false. If there are no listeners 1.380 + // then returns null. 1.381 + getMatchedContext: function getCurrentContexts(popupNode) { 1.382 + let results = this.getSandbox().emitSync("context", popupNode); 1.383 + return results.reduce(function(val, result) val || result, null); 1.384 + }, 1.385 + 1.386 + // Emits a click event in the worker's port. popupNode is the node that was 1.387 + // context-clicked, and clickedItemData is the data of the item that was 1.388 + // clicked. 1.389 + fireClick: function fireClick(popupNode, clickedItemData) { 1.390 + this.getSandbox().emitSync("click", popupNode, clickedItemData); 1.391 + } 1.392 +}); 1.393 + 1.394 +// Returns true if any contexts match. If there are no contexts then a 1.395 +// PageContext is tested instead 1.396 +function hasMatchingContext(contexts, popupNode) { 1.397 + for (let context in contexts) { 1.398 + if (!context.isCurrent(popupNode)) 1.399 + return false; 1.400 + } 1.401 + 1.402 + return true; 1.403 +} 1.404 + 1.405 +// Gets the matched context from any worker for this item. If there is no worker 1.406 +// or no matched context then returns false. 1.407 +function getCurrentWorkerContext(item, popupNode) { 1.408 + let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); 1.409 + if (!worker || !worker.anyContextListeners()) 1.410 + return true; 1.411 + return worker.getMatchedContext(popupNode); 1.412 +} 1.413 + 1.414 +// Tests whether an item should be visible or not based on its contexts and 1.415 +// content scripts 1.416 +function isItemVisible(item, popupNode, defaultVisibility) { 1.417 + if (!item.context.length) { 1.418 + let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); 1.419 + if (!worker || !worker.anyContextListeners()) 1.420 + return defaultVisibility; 1.421 + } 1.422 + 1.423 + if (!hasMatchingContext(item.context, popupNode)) 1.424 + return false; 1.425 + 1.426 + let context = getCurrentWorkerContext(item, popupNode); 1.427 + if (typeof(context) === "string" && context != "") 1.428 + item.label = context; 1.429 + 1.430 + return !!context; 1.431 +} 1.432 + 1.433 +// Gets the item's content script worker for a window, creating one if necessary 1.434 +// Once created it will be automatically destroyed when the window unloads. 1.435 +// If there is not content scripts for the item then null will be returned. 1.436 +function getItemWorkerForWindow(item, window) { 1.437 + if (!item.contentScript && !item.contentScriptFile) 1.438 + return null; 1.439 + 1.440 + let id = getInnerId(window); 1.441 + let worker = internal(item).workerMap.get(id); 1.442 + 1.443 + if (worker) 1.444 + return worker; 1.445 + 1.446 + worker = ContextWorker({ 1.447 + window: window, 1.448 + contentScript: item.contentScript, 1.449 + contentScriptFile: item.contentScriptFile, 1.450 + onMessage: function(msg) { 1.451 + emit(item, "message", msg); 1.452 + }, 1.453 + onDetach: function() { 1.454 + internal(item).workerMap.delete(id); 1.455 + } 1.456 + }); 1.457 + 1.458 + internal(item).workerMap.set(id, worker); 1.459 + 1.460 + return worker; 1.461 +} 1.462 + 1.463 +// Called when an item is clicked to send out click events to the content 1.464 +// scripts 1.465 +function itemClicked(item, clickedItem, popupNode) { 1.466 + let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); 1.467 + 1.468 + if (worker) { 1.469 + let adjustedNode = popupNode; 1.470 + for (let context in item.context) 1.471 + adjustedNode = context.adjustPopupNode(adjustedNode); 1.472 + worker.fireClick(adjustedNode, clickedItem.data); 1.473 + } 1.474 + 1.475 + if (item.parentMenu) 1.476 + itemClicked(item.parentMenu, clickedItem, popupNode); 1.477 +} 1.478 + 1.479 +// All things that appear in the context menu extend this 1.480 +let BaseItem = Class({ 1.481 + initialize: function initialize() { 1.482 + addCollectionProperty(this, "context"); 1.483 + 1.484 + // Used to cache content script workers and the windows they have been 1.485 + // created for 1.486 + internal(this).workerMap = new Map(); 1.487 + 1.488 + if ("context" in internal(this).options && internal(this).options.context) { 1.489 + let contexts = internal(this).options.context; 1.490 + if (Array.isArray(contexts)) { 1.491 + for (let context of contexts) 1.492 + this.context.add(context); 1.493 + } 1.494 + else { 1.495 + this.context.add(contexts); 1.496 + } 1.497 + } 1.498 + 1.499 + let parentMenu = internal(this).options.parentMenu; 1.500 + if (!parentMenu) 1.501 + parentMenu = contentContextMenu; 1.502 + 1.503 + parentMenu.addItem(this); 1.504 + 1.505 + Object.defineProperty(this, "contentScript", { 1.506 + enumerable: true, 1.507 + value: internal(this).options.contentScript 1.508 + }); 1.509 + 1.510 + Object.defineProperty(this, "contentScriptFile", { 1.511 + enumerable: true, 1.512 + value: internal(this).options.contentScriptFile 1.513 + }); 1.514 + }, 1.515 + 1.516 + destroy: function destroy() { 1.517 + if (this.parentMenu) 1.518 + this.parentMenu.removeItem(this); 1.519 + }, 1.520 + 1.521 + get parentMenu() { 1.522 + return internal(this).parentMenu; 1.523 + }, 1.524 +}); 1.525 + 1.526 +// All things that have a label on the context menu extend this 1.527 +let LabelledItem = Class({ 1.528 + extends: BaseItem, 1.529 + implements: [ EventTarget ], 1.530 + 1.531 + initialize: function initialize(options) { 1.532 + BaseItem.prototype.initialize.call(this); 1.533 + EventTarget.prototype.initialize.call(this, options); 1.534 + }, 1.535 + 1.536 + destroy: function destroy() { 1.537 + for (let [,worker] of internal(this).workerMap) 1.538 + worker.destroy(); 1.539 + 1.540 + BaseItem.prototype.destroy.call(this); 1.541 + }, 1.542 + 1.543 + get label() { 1.544 + return internal(this).options.label; 1.545 + }, 1.546 + 1.547 + set label(val) { 1.548 + internal(this).options.label = val; 1.549 + 1.550 + MenuManager.updateItem(this); 1.551 + }, 1.552 + 1.553 + get image() { 1.554 + return internal(this).options.image; 1.555 + }, 1.556 + 1.557 + set image(val) { 1.558 + internal(this).options.image = val; 1.559 + 1.560 + MenuManager.updateItem(this); 1.561 + }, 1.562 + 1.563 + get data() { 1.564 + return internal(this).options.data; 1.565 + }, 1.566 + 1.567 + set data(val) { 1.568 + internal(this).options.data = val; 1.569 + } 1.570 +}); 1.571 + 1.572 +let Item = Class({ 1.573 + extends: LabelledItem, 1.574 + 1.575 + initialize: function initialize(options) { 1.576 + internal(this).options = validateOptions(options, itemRules); 1.577 + 1.578 + LabelledItem.prototype.initialize.call(this, options); 1.579 + }, 1.580 + 1.581 + toString: function toString() { 1.582 + return "[object Item \"" + this.label + "\"]"; 1.583 + }, 1.584 + 1.585 + get data() { 1.586 + return internal(this).options.data; 1.587 + }, 1.588 + 1.589 + set data(val) { 1.590 + internal(this).options.data = val; 1.591 + 1.592 + MenuManager.updateItem(this); 1.593 + }, 1.594 +}); 1.595 +exports.Item = Item; 1.596 + 1.597 +let ItemContainer = Class({ 1.598 + initialize: function initialize() { 1.599 + internal(this).children = []; 1.600 + }, 1.601 + 1.602 + destroy: function destroy() { 1.603 + // Destroys the entire hierarchy 1.604 + for (let item of internal(this).children) 1.605 + item.destroy(); 1.606 + }, 1.607 + 1.608 + addItem: function addItem(item) { 1.609 + let oldParent = item.parentMenu; 1.610 + 1.611 + // Don't just call removeItem here as that would remove the corresponding 1.612 + // UI element which is more costly than just moving it to the right place 1.613 + if (oldParent) 1.614 + internal(oldParent).children = removeItemFromArray(internal(oldParent).children, item); 1.615 + 1.616 + let after = null; 1.617 + let children = internal(this).children; 1.618 + if (children.length > 0) 1.619 + after = children[children.length - 1]; 1.620 + 1.621 + children.push(item); 1.622 + internal(item).parentMenu = this; 1.623 + 1.624 + // If there was an old parent then we just have to move the item, otherwise 1.625 + // it needs to be created 1.626 + if (oldParent) 1.627 + MenuManager.moveItem(item, after); 1.628 + else 1.629 + MenuManager.createItem(item, after); 1.630 + }, 1.631 + 1.632 + removeItem: function removeItem(item) { 1.633 + // If the item isn't a child of this menu then ignore this call 1.634 + if (item.parentMenu !== this) 1.635 + return; 1.636 + 1.637 + MenuManager.removeItem(item); 1.638 + 1.639 + internal(this).children = removeItemFromArray(internal(this).children, item); 1.640 + internal(item).parentMenu = null; 1.641 + }, 1.642 + 1.643 + get items() { 1.644 + return internal(this).children.slice(0); 1.645 + }, 1.646 + 1.647 + set items(val) { 1.648 + // Validate the arguments before making any changes 1.649 + if (!Array.isArray(val)) 1.650 + throw new Error(menuOptionRules.items.msg); 1.651 + 1.652 + for (let item of val) { 1.653 + if (!(item instanceof BaseItem)) 1.654 + throw new Error(menuOptionRules.items.msg); 1.655 + } 1.656 + 1.657 + // Remove the old items and add the new ones 1.658 + for (let item of internal(this).children) 1.659 + this.removeItem(item); 1.660 + 1.661 + for (let item of val) 1.662 + this.addItem(item); 1.663 + }, 1.664 +}); 1.665 + 1.666 +let Menu = Class({ 1.667 + extends: LabelledItem, 1.668 + implements: [ItemContainer], 1.669 + 1.670 + initialize: function initialize(options) { 1.671 + internal(this).options = validateOptions(options, menuRules); 1.672 + 1.673 + LabelledItem.prototype.initialize.call(this, options); 1.674 + ItemContainer.prototype.initialize.call(this); 1.675 + 1.676 + if (internal(this).options.items) { 1.677 + for (let item of internal(this).options.items) 1.678 + this.addItem(item); 1.679 + } 1.680 + }, 1.681 + 1.682 + destroy: function destroy() { 1.683 + ItemContainer.prototype.destroy.call(this); 1.684 + LabelledItem.prototype.destroy.call(this); 1.685 + }, 1.686 + 1.687 + toString: function toString() { 1.688 + return "[object Menu \"" + this.label + "\"]"; 1.689 + }, 1.690 +}); 1.691 +exports.Menu = Menu; 1.692 + 1.693 +let Separator = Class({ 1.694 + extends: BaseItem, 1.695 + 1.696 + initialize: function initialize(options) { 1.697 + internal(this).options = validateOptions(options, baseItemRules); 1.698 + 1.699 + BaseItem.prototype.initialize.call(this); 1.700 + }, 1.701 + 1.702 + toString: function toString() { 1.703 + return "[object Separator]"; 1.704 + } 1.705 +}); 1.706 +exports.Separator = Separator; 1.707 + 1.708 +// Holds items for the content area context menu 1.709 +let contentContextMenu = ItemContainer(); 1.710 +exports.contentContextMenu = contentContextMenu; 1.711 + 1.712 +when(function() { 1.713 + contentContextMenu.destroy(); 1.714 +}); 1.715 + 1.716 +// App specific UI code lives here, it should handle populating the context 1.717 +// menu and passing clicks etc. through to the items. 1.718 + 1.719 +function countVisibleItems(nodes) { 1.720 + return Array.reduce(nodes, function(sum, node) { 1.721 + return node.hidden ? sum : sum + 1; 1.722 + }, 0); 1.723 +} 1.724 + 1.725 +let MenuWrapper = Class({ 1.726 + initialize: function initialize(winWrapper, items, contextMenu) { 1.727 + this.winWrapper = winWrapper; 1.728 + this.window = winWrapper.window; 1.729 + this.items = items; 1.730 + this.contextMenu = contextMenu; 1.731 + this.populated = false; 1.732 + this.menuMap = new Map(); 1.733 + 1.734 + // updateItemVisibilities will run first, updateOverflowState will run after 1.735 + // all other instances of this module have run updateItemVisibilities 1.736 + this._updateItemVisibilities = this.updateItemVisibilities.bind(this); 1.737 + this.contextMenu.addEventListener("popupshowing", this._updateItemVisibilities, true); 1.738 + this._updateOverflowState = this.updateOverflowState.bind(this); 1.739 + this.contextMenu.addEventListener("popupshowing", this._updateOverflowState, false); 1.740 + }, 1.741 + 1.742 + destroy: function destroy() { 1.743 + this.contextMenu.removeEventListener("popupshowing", this._updateOverflowState, false); 1.744 + this.contextMenu.removeEventListener("popupshowing", this._updateItemVisibilities, true); 1.745 + 1.746 + if (!this.populated) 1.747 + return; 1.748 + 1.749 + // If we're getting unloaded at runtime then we must remove all the 1.750 + // generated XUL nodes 1.751 + let oldParent = null; 1.752 + for (let item of internal(this.items).children) { 1.753 + let xulNode = this.getXULNodeForItem(item); 1.754 + oldParent = xulNode.parentNode; 1.755 + oldParent.removeChild(xulNode); 1.756 + } 1.757 + 1.758 + if (oldParent) 1.759 + this.onXULRemoved(oldParent); 1.760 + }, 1.761 + 1.762 + get separator() { 1.763 + return this.contextMenu.querySelector("." + SEPARATOR_CLASS); 1.764 + }, 1.765 + 1.766 + get overflowMenu() { 1.767 + return this.contextMenu.querySelector("." + OVERFLOW_MENU_CLASS); 1.768 + }, 1.769 + 1.770 + get overflowPopup() { 1.771 + return this.contextMenu.querySelector("." + OVERFLOW_POPUP_CLASS); 1.772 + }, 1.773 + 1.774 + get topLevelItems() { 1.775 + return this.contextMenu.querySelectorAll("." + TOPLEVEL_ITEM_CLASS); 1.776 + }, 1.777 + 1.778 + get overflowItems() { 1.779 + return this.contextMenu.querySelectorAll("." + OVERFLOW_ITEM_CLASS); 1.780 + }, 1.781 + 1.782 + getXULNodeForItem: function getXULNodeForItem(item) { 1.783 + return this.menuMap.get(item); 1.784 + }, 1.785 + 1.786 + // Recurses through the item hierarchy creating XUL nodes for everything 1.787 + populate: function populate(menu) { 1.788 + for (let i = 0; i < internal(menu).children.length; i++) { 1.789 + let item = internal(menu).children[i]; 1.790 + let after = i === 0 ? null : internal(menu).children[i - 1]; 1.791 + this.createItem(item, after); 1.792 + 1.793 + if (item instanceof Menu) 1.794 + this.populate(item); 1.795 + } 1.796 + }, 1.797 + 1.798 + // Recurses through the menu setting the visibility of items. Returns true 1.799 + // if any of the items in this menu were visible 1.800 + setVisibility: function setVisibility(menu, popupNode, defaultVisibility) { 1.801 + let anyVisible = false; 1.802 + 1.803 + for (let item of internal(menu).children) { 1.804 + let visible = isItemVisible(item, popupNode, defaultVisibility); 1.805 + 1.806 + // Recurse through Menus, if none of the sub-items were visible then the 1.807 + // menu is hidden too. 1.808 + if (visible && (item instanceof Menu)) 1.809 + visible = this.setVisibility(item, popupNode, true); 1.810 + 1.811 + let xulNode = this.getXULNodeForItem(item); 1.812 + xulNode.hidden = !visible; 1.813 + 1.814 + anyVisible = anyVisible || visible; 1.815 + } 1.816 + 1.817 + return anyVisible; 1.818 + }, 1.819 + 1.820 + // Works out where to insert a XUL node for an item in a browser window 1.821 + insertIntoXUL: function insertIntoXUL(item, node, after) { 1.822 + let menupopup = null; 1.823 + let before = null; 1.824 + 1.825 + let menu = item.parentMenu; 1.826 + if (menu === this.items) { 1.827 + // Insert into the overflow popup if it exists, otherwise the normal 1.828 + // context menu 1.829 + menupopup = this.overflowPopup; 1.830 + if (!menupopup) 1.831 + menupopup = this.contextMenu; 1.832 + } 1.833 + else { 1.834 + let xulNode = this.getXULNodeForItem(menu); 1.835 + menupopup = xulNode.firstChild; 1.836 + } 1.837 + 1.838 + if (after) { 1.839 + let afterNode = this.getXULNodeForItem(after); 1.840 + before = afterNode.nextSibling; 1.841 + } 1.842 + else if (menupopup === this.contextMenu) { 1.843 + let topLevel = this.topLevelItems; 1.844 + if (topLevel.length > 0) 1.845 + before = topLevel[topLevel.length - 1].nextSibling; 1.846 + else 1.847 + before = this.separator.nextSibling; 1.848 + } 1.849 + 1.850 + menupopup.insertBefore(node, before); 1.851 + }, 1.852 + 1.853 + // Sets the right class for XUL nodes 1.854 + updateXULClass: function updateXULClass(xulNode) { 1.855 + if (xulNode.parentNode == this.contextMenu) 1.856 + xulNode.classList.add(TOPLEVEL_ITEM_CLASS); 1.857 + else 1.858 + xulNode.classList.remove(TOPLEVEL_ITEM_CLASS); 1.859 + 1.860 + if (xulNode.parentNode == this.overflowPopup) 1.861 + xulNode.classList.add(OVERFLOW_ITEM_CLASS); 1.862 + else 1.863 + xulNode.classList.remove(OVERFLOW_ITEM_CLASS); 1.864 + }, 1.865 + 1.866 + // Creates a XUL node for an item 1.867 + createItem: function createItem(item, after) { 1.868 + if (!this.populated) 1.869 + return; 1.870 + 1.871 + // Create the separator if it doesn't already exist 1.872 + if (!this.separator) { 1.873 + let separator = this.window.document.createElement("menuseparator"); 1.874 + separator.setAttribute("class", SEPARATOR_CLASS); 1.875 + 1.876 + // Insert before the separator created by the old context-menu if it 1.877 + // exists to avoid bug 832401 1.878 + let oldSeparator = this.window.document.getElementById("jetpack-context-menu-separator"); 1.879 + if (oldSeparator && oldSeparator.parentNode != this.contextMenu) 1.880 + oldSeparator = null; 1.881 + this.contextMenu.insertBefore(separator, oldSeparator); 1.882 + } 1.883 + 1.884 + let type = "menuitem"; 1.885 + if (item instanceof Menu) 1.886 + type = "menu"; 1.887 + else if (item instanceof Separator) 1.888 + type = "menuseparator"; 1.889 + 1.890 + let xulNode = this.window.document.createElement(type); 1.891 + xulNode.setAttribute("class", ITEM_CLASS); 1.892 + if (item instanceof LabelledItem) { 1.893 + xulNode.setAttribute("label", item.label); 1.894 + if (item.image) { 1.895 + xulNode.setAttribute("image", item.image); 1.896 + if (item instanceof Menu) 1.897 + xulNode.classList.add("menu-iconic"); 1.898 + else 1.899 + xulNode.classList.add("menuitem-iconic"); 1.900 + } 1.901 + if (item.data) 1.902 + xulNode.setAttribute("value", item.data); 1.903 + 1.904 + let self = this; 1.905 + xulNode.addEventListener("command", function(event) { 1.906 + // Only care about clicks directly on this item 1.907 + if (event.target !== xulNode) 1.908 + return; 1.909 + 1.910 + itemClicked(item, item, self.contextMenu.triggerNode); 1.911 + }, false); 1.912 + } 1.913 + 1.914 + this.insertIntoXUL(item, xulNode, after); 1.915 + this.updateXULClass(xulNode); 1.916 + xulNode.data = item.data; 1.917 + 1.918 + if (item instanceof Menu) { 1.919 + let menupopup = this.window.document.createElement("menupopup"); 1.920 + xulNode.appendChild(menupopup); 1.921 + } 1.922 + 1.923 + this.menuMap.set(item, xulNode); 1.924 + }, 1.925 + 1.926 + // Updates the XUL node for an item in this window 1.927 + updateItem: function updateItem(item) { 1.928 + if (!this.populated) 1.929 + return; 1.930 + 1.931 + let xulNode = this.getXULNodeForItem(item); 1.932 + 1.933 + // TODO figure out why this requires setAttribute 1.934 + xulNode.setAttribute("label", item.label); 1.935 + 1.936 + if (item.image) { 1.937 + xulNode.setAttribute("image", item.image); 1.938 + if (item instanceof Menu) 1.939 + xulNode.classList.add("menu-iconic"); 1.940 + else 1.941 + xulNode.classList.add("menuitem-iconic"); 1.942 + } 1.943 + else { 1.944 + xulNode.removeAttribute("image"); 1.945 + xulNode.classList.remove("menu-iconic"); 1.946 + xulNode.classList.remove("menuitem-iconic"); 1.947 + } 1.948 + 1.949 + if (item.data) 1.950 + xulNode.setAttribute("value", item.data); 1.951 + else 1.952 + xulNode.removeAttribute("value"); 1.953 + }, 1.954 + 1.955 + // Moves the XUL node for an item in this window to its new place in the 1.956 + // hierarchy 1.957 + moveItem: function moveItem(item, after) { 1.958 + if (!this.populated) 1.959 + return; 1.960 + 1.961 + let xulNode = this.getXULNodeForItem(item); 1.962 + let oldParent = xulNode.parentNode; 1.963 + 1.964 + this.insertIntoXUL(item, xulNode, after); 1.965 + this.updateXULClass(xulNode); 1.966 + this.onXULRemoved(oldParent); 1.967 + }, 1.968 + 1.969 + // Removes the XUL nodes for an item in every window we've ever populated. 1.970 + removeItem: function removeItem(item) { 1.971 + if (!this.populated) 1.972 + return; 1.973 + 1.974 + let xulItem = this.getXULNodeForItem(item); 1.975 + 1.976 + let oldParent = xulItem.parentNode; 1.977 + 1.978 + oldParent.removeChild(xulItem); 1.979 + this.menuMap.delete(item); 1.980 + 1.981 + this.onXULRemoved(oldParent); 1.982 + }, 1.983 + 1.984 + // Called when any XUL nodes have been removed from a menupopup. This handles 1.985 + // making sure the separator and overflow are correct 1.986 + onXULRemoved: function onXULRemoved(parent) { 1.987 + if (parent == this.contextMenu) { 1.988 + let toplevel = this.topLevelItems; 1.989 + 1.990 + // If there are no more items then remove the separator 1.991 + if (toplevel.length == 0) { 1.992 + let separator = this.separator; 1.993 + if (separator) 1.994 + separator.parentNode.removeChild(separator); 1.995 + } 1.996 + } 1.997 + else if (parent == this.overflowPopup) { 1.998 + // If there are no more items then remove the overflow menu and separator 1.999 + if (parent.childNodes.length == 0) { 1.1000 + let separator = this.separator; 1.1001 + separator.parentNode.removeChild(separator); 1.1002 + this.contextMenu.removeChild(parent.parentNode); 1.1003 + } 1.1004 + } 1.1005 + }, 1.1006 + 1.1007 + // Recurses through all the items owned by this module and sets their hidden 1.1008 + // state 1.1009 + updateItemVisibilities: function updateItemVisibilities(event) { 1.1010 + try { 1.1011 + if (event.type != "popupshowing") 1.1012 + return; 1.1013 + if (event.target != this.contextMenu) 1.1014 + return; 1.1015 + 1.1016 + if (internal(this.items).children.length == 0) 1.1017 + return; 1.1018 + 1.1019 + if (!this.populated) { 1.1020 + this.populated = true; 1.1021 + this.populate(this.items); 1.1022 + } 1.1023 + 1.1024 + let popupNode = event.target.triggerNode; 1.1025 + this.setVisibility(this.items, popupNode, PageContext().isCurrent(popupNode)); 1.1026 + } 1.1027 + catch (e) { 1.1028 + console.exception(e); 1.1029 + } 1.1030 + }, 1.1031 + 1.1032 + // Counts the number of visible items across all modules and makes sure they 1.1033 + // are in the right place between the top level context menu and the overflow 1.1034 + // menu 1.1035 + updateOverflowState: function updateOverflowState(event) { 1.1036 + try { 1.1037 + if (event.type != "popupshowing") 1.1038 + return; 1.1039 + if (event.target != this.contextMenu) 1.1040 + return; 1.1041 + 1.1042 + // The main items will be in either the top level context menu or the 1.1043 + // overflow menu at this point. Count the visible ones and if they are in 1.1044 + // the wrong place move them 1.1045 + let toplevel = this.topLevelItems; 1.1046 + let overflow = this.overflowItems; 1.1047 + let visibleCount = countVisibleItems(toplevel) + 1.1048 + countVisibleItems(overflow); 1.1049 + 1.1050 + if (visibleCount == 0) { 1.1051 + let separator = this.separator; 1.1052 + if (separator) 1.1053 + separator.hidden = true; 1.1054 + let overflowMenu = this.overflowMenu; 1.1055 + if (overflowMenu) 1.1056 + overflowMenu.hidden = true; 1.1057 + } 1.1058 + else if (visibleCount > MenuManager.overflowThreshold) { 1.1059 + this.separator.hidden = false; 1.1060 + let overflowPopup = this.overflowPopup; 1.1061 + if (overflowPopup) 1.1062 + overflowPopup.parentNode.hidden = false; 1.1063 + 1.1064 + if (toplevel.length > 0) { 1.1065 + // The overflow menu shouldn't exist here but let's play it safe 1.1066 + if (!overflowPopup) { 1.1067 + let overflowMenu = this.window.document.createElement("menu"); 1.1068 + overflowMenu.setAttribute("class", OVERFLOW_MENU_CLASS); 1.1069 + overflowMenu.setAttribute("label", OVERFLOW_MENU_LABEL); 1.1070 + this.contextMenu.insertBefore(overflowMenu, this.separator.nextSibling); 1.1071 + 1.1072 + overflowPopup = this.window.document.createElement("menupopup"); 1.1073 + overflowPopup.setAttribute("class", OVERFLOW_POPUP_CLASS); 1.1074 + overflowMenu.appendChild(overflowPopup); 1.1075 + } 1.1076 + 1.1077 + for (let xulNode of toplevel) { 1.1078 + overflowPopup.appendChild(xulNode); 1.1079 + this.updateXULClass(xulNode); 1.1080 + } 1.1081 + } 1.1082 + } 1.1083 + else { 1.1084 + this.separator.hidden = false; 1.1085 + 1.1086 + if (overflow.length > 0) { 1.1087 + // Move all the overflow nodes out of the overflow menu and position 1.1088 + // them immediately before it 1.1089 + for (let xulNode of overflow) { 1.1090 + this.contextMenu.insertBefore(xulNode, xulNode.parentNode.parentNode); 1.1091 + this.updateXULClass(xulNode); 1.1092 + } 1.1093 + this.contextMenu.removeChild(this.overflowMenu); 1.1094 + } 1.1095 + } 1.1096 + } 1.1097 + catch (e) { 1.1098 + console.exception(e); 1.1099 + } 1.1100 + } 1.1101 +}); 1.1102 + 1.1103 +// This wraps every window that we've seen 1.1104 +let WindowWrapper = Class({ 1.1105 + initialize: function initialize(window) { 1.1106 + this.window = window; 1.1107 + this.menus = [ 1.1108 + new MenuWrapper(this, contentContextMenu, window.document.getElementById("contentAreaContextMenu")), 1.1109 + ]; 1.1110 + }, 1.1111 + 1.1112 + destroy: function destroy() { 1.1113 + for (let menuWrapper of this.menus) 1.1114 + menuWrapper.destroy(); 1.1115 + }, 1.1116 + 1.1117 + getMenuWrapperForItem: function getMenuWrapperForItem(item) { 1.1118 + let root = item.parentMenu; 1.1119 + while (root.parentMenu) 1.1120 + root = root.parentMenu; 1.1121 + 1.1122 + for (let wrapper of this.menus) { 1.1123 + if (wrapper.items === root) 1.1124 + return wrapper; 1.1125 + } 1.1126 + 1.1127 + return null; 1.1128 + } 1.1129 +}); 1.1130 + 1.1131 +let MenuManager = { 1.1132 + windowMap: new Map(), 1.1133 + 1.1134 + get overflowThreshold() { 1.1135 + let prefs = require("./preferences/service"); 1.1136 + return prefs.get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT); 1.1137 + }, 1.1138 + 1.1139 + // When a new window is added start watching it for context menu shows 1.1140 + onTrack: function onTrack(window) { 1.1141 + if (!isBrowser(window)) 1.1142 + return; 1.1143 + 1.1144 + // Generally shouldn't happen, but just in case 1.1145 + if (this.windowMap.has(window)) { 1.1146 + console.warn("Already seen this window"); 1.1147 + return; 1.1148 + } 1.1149 + 1.1150 + let winWrapper = WindowWrapper(window); 1.1151 + this.windowMap.set(window, winWrapper); 1.1152 + }, 1.1153 + 1.1154 + onUntrack: function onUntrack(window) { 1.1155 + if (!isBrowser(window)) 1.1156 + return; 1.1157 + 1.1158 + let winWrapper = this.windowMap.get(window); 1.1159 + // This shouldn't happen but protect against it anyway 1.1160 + if (!winWrapper) 1.1161 + return; 1.1162 + winWrapper.destroy(); 1.1163 + 1.1164 + this.windowMap.delete(window); 1.1165 + }, 1.1166 + 1.1167 + // Creates a XUL node for an item in every window we've already populated 1.1168 + createItem: function createItem(item, after) { 1.1169 + for (let [window, winWrapper] of this.windowMap) { 1.1170 + let menuWrapper = winWrapper.getMenuWrapperForItem(item); 1.1171 + if (menuWrapper) 1.1172 + menuWrapper.createItem(item, after); 1.1173 + } 1.1174 + }, 1.1175 + 1.1176 + // Updates the XUL node for an item in every window we've already populated 1.1177 + updateItem: function updateItem(item) { 1.1178 + for (let [window, winWrapper] of this.windowMap) { 1.1179 + let menuWrapper = winWrapper.getMenuWrapperForItem(item); 1.1180 + if (menuWrapper) 1.1181 + menuWrapper.updateItem(item); 1.1182 + } 1.1183 + }, 1.1184 + 1.1185 + // Moves the XUL node for an item in every window we've ever populated to its 1.1186 + // new place in the hierarchy 1.1187 + moveItem: function moveItem(item, after) { 1.1188 + for (let [window, winWrapper] of this.windowMap) { 1.1189 + let menuWrapper = winWrapper.getMenuWrapperForItem(item); 1.1190 + if (menuWrapper) 1.1191 + menuWrapper.moveItem(item, after); 1.1192 + } 1.1193 + }, 1.1194 + 1.1195 + // Removes the XUL nodes for an item in every window we've ever populated. 1.1196 + removeItem: function removeItem(item) { 1.1197 + for (let [window, winWrapper] of this.windowMap) { 1.1198 + let menuWrapper = winWrapper.getMenuWrapperForItem(item); 1.1199 + if (menuWrapper) 1.1200 + menuWrapper.removeItem(item); 1.1201 + } 1.1202 + } 1.1203 +}; 1.1204 + 1.1205 +WindowTracker(MenuManager);