|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 "use strict"; |
|
5 |
|
6 module.metadata = { |
|
7 "stability": "stable", |
|
8 "engines": { |
|
9 // TODO Fennec support Bug 788334 |
|
10 "Firefox": "*" |
|
11 } |
|
12 }; |
|
13 |
|
14 const { Class, mix } = require("./core/heritage"); |
|
15 const { addCollectionProperty } = require("./util/collection"); |
|
16 const { ns } = require("./core/namespace"); |
|
17 const { validateOptions, getTypeOf } = require("./deprecated/api-utils"); |
|
18 const { URL, isValidURI } = require("./url"); |
|
19 const { WindowTracker, browserWindowIterator } = require("./deprecated/window-utils"); |
|
20 const { isBrowser, getInnerId } = require("./window/utils"); |
|
21 const { Ci } = require("chrome"); |
|
22 const { MatchPattern } = require("./util/match-pattern"); |
|
23 const { Worker } = require("./content/worker"); |
|
24 const { EventTarget } = require("./event/target"); |
|
25 const { emit } = require('./event/core'); |
|
26 const { when } = require('./system/unload'); |
|
27 const selection = require('./selection'); |
|
28 |
|
29 // All user items we add have this class. |
|
30 const ITEM_CLASS = "addon-context-menu-item"; |
|
31 |
|
32 // Items in the top-level context menu also have this class. |
|
33 const TOPLEVEL_ITEM_CLASS = "addon-context-menu-item-toplevel"; |
|
34 |
|
35 // Items in the overflow submenu also have this class. |
|
36 const OVERFLOW_ITEM_CLASS = "addon-context-menu-item-overflow"; |
|
37 |
|
38 // The class of the menu separator that separates standard context menu items |
|
39 // from our user items. |
|
40 const SEPARATOR_CLASS = "addon-context-menu-separator"; |
|
41 |
|
42 // If more than this number of items are added to the context menu, all items |
|
43 // overflow into a "Jetpack" submenu. |
|
44 const OVERFLOW_THRESH_DEFAULT = 10; |
|
45 const OVERFLOW_THRESH_PREF = |
|
46 "extensions.addon-sdk.context-menu.overflowThreshold"; |
|
47 |
|
48 // The label of the overflow sub-xul:menu. |
|
49 // |
|
50 // TODO: Localize this. |
|
51 const OVERFLOW_MENU_LABEL = "Add-ons"; |
|
52 |
|
53 // The class of the overflow sub-xul:menu. |
|
54 const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu"; |
|
55 |
|
56 // The class of the overflow submenu's xul:menupopup. |
|
57 const OVERFLOW_POPUP_CLASS = "addon-content-menu-overflow-popup"; |
|
58 |
|
59 //These are used by PageContext.isCurrent below. If the popupNode or any of |
|
60 //its ancestors is one of these, Firefox uses a tailored context menu, and so |
|
61 //the page context doesn't apply. |
|
62 const NON_PAGE_CONTEXT_ELTS = [ |
|
63 Ci.nsIDOMHTMLAnchorElement, |
|
64 Ci.nsIDOMHTMLAppletElement, |
|
65 Ci.nsIDOMHTMLAreaElement, |
|
66 Ci.nsIDOMHTMLButtonElement, |
|
67 Ci.nsIDOMHTMLCanvasElement, |
|
68 Ci.nsIDOMHTMLEmbedElement, |
|
69 Ci.nsIDOMHTMLImageElement, |
|
70 Ci.nsIDOMHTMLInputElement, |
|
71 Ci.nsIDOMHTMLMapElement, |
|
72 Ci.nsIDOMHTMLMediaElement, |
|
73 Ci.nsIDOMHTMLMenuElement, |
|
74 Ci.nsIDOMHTMLObjectElement, |
|
75 Ci.nsIDOMHTMLOptionElement, |
|
76 Ci.nsIDOMHTMLSelectElement, |
|
77 Ci.nsIDOMHTMLTextAreaElement, |
|
78 ]; |
|
79 |
|
80 // Holds private properties for API objects |
|
81 let internal = ns(); |
|
82 |
|
83 function getScheme(spec) { |
|
84 try { |
|
85 return URL(spec).scheme; |
|
86 } |
|
87 catch(e) { |
|
88 return null; |
|
89 } |
|
90 } |
|
91 |
|
92 let Context = Class({ |
|
93 // Returns the node that made this context current |
|
94 adjustPopupNode: function adjustPopupNode(popupNode) { |
|
95 return popupNode; |
|
96 }, |
|
97 |
|
98 // Returns whether this context is current for the current node |
|
99 isCurrent: function isCurrent(popupNode) { |
|
100 return false; |
|
101 } |
|
102 }); |
|
103 |
|
104 // Matches when the context-clicked node doesn't have any of |
|
105 // NON_PAGE_CONTEXT_ELTS in its ancestors |
|
106 let PageContext = Class({ |
|
107 extends: Context, |
|
108 |
|
109 isCurrent: function isCurrent(popupNode) { |
|
110 // If there is a selection in the window then this context does not match |
|
111 if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) |
|
112 return false; |
|
113 |
|
114 // If the clicked node or any of its ancestors is one of the blacklisted |
|
115 // NON_PAGE_CONTEXT_ELTS then this context does not match |
|
116 while (!(popupNode instanceof Ci.nsIDOMDocument)) { |
|
117 if (NON_PAGE_CONTEXT_ELTS.some(function(type) popupNode instanceof type)) |
|
118 return false; |
|
119 |
|
120 popupNode = popupNode.parentNode; |
|
121 } |
|
122 |
|
123 return true; |
|
124 } |
|
125 }); |
|
126 exports.PageContext = PageContext; |
|
127 |
|
128 // Matches when there is an active selection in the window |
|
129 let SelectionContext = Class({ |
|
130 extends: Context, |
|
131 |
|
132 isCurrent: function isCurrent(popupNode) { |
|
133 if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) |
|
134 return true; |
|
135 |
|
136 try { |
|
137 // The node may be a text box which has selectionStart and selectionEnd |
|
138 // properties. If not this will throw. |
|
139 let { selectionStart, selectionEnd } = popupNode; |
|
140 return !isNaN(selectionStart) && !isNaN(selectionEnd) && |
|
141 selectionStart !== selectionEnd; |
|
142 } |
|
143 catch (e) { |
|
144 return false; |
|
145 } |
|
146 } |
|
147 }); |
|
148 exports.SelectionContext = SelectionContext; |
|
149 |
|
150 // Matches when the context-clicked node or any of its ancestors matches the |
|
151 // selector given |
|
152 let SelectorContext = Class({ |
|
153 extends: Context, |
|
154 |
|
155 initialize: function initialize(selector) { |
|
156 let options = validateOptions({ selector: selector }, { |
|
157 selector: { |
|
158 is: ["string"], |
|
159 msg: "selector must be a string." |
|
160 } |
|
161 }); |
|
162 internal(this).selector = options.selector; |
|
163 }, |
|
164 |
|
165 adjustPopupNode: function adjustPopupNode(popupNode) { |
|
166 let selector = internal(this).selector; |
|
167 |
|
168 while (!(popupNode instanceof Ci.nsIDOMDocument)) { |
|
169 if (popupNode.mozMatchesSelector(selector)) |
|
170 return popupNode; |
|
171 |
|
172 popupNode = popupNode.parentNode; |
|
173 } |
|
174 |
|
175 return null; |
|
176 }, |
|
177 |
|
178 isCurrent: function isCurrent(popupNode) { |
|
179 return !!this.adjustPopupNode(popupNode); |
|
180 } |
|
181 }); |
|
182 exports.SelectorContext = SelectorContext; |
|
183 |
|
184 // Matches when the page url matches any of the patterns given |
|
185 let URLContext = Class({ |
|
186 extends: Context, |
|
187 |
|
188 initialize: function initialize(patterns) { |
|
189 patterns = Array.isArray(patterns) ? patterns : [patterns]; |
|
190 |
|
191 try { |
|
192 internal(this).patterns = patterns.map(function (p) new MatchPattern(p)); |
|
193 } |
|
194 catch (err) { |
|
195 throw new Error("Patterns must be a string, regexp or an array of " + |
|
196 "strings or regexps: " + err); |
|
197 } |
|
198 |
|
199 }, |
|
200 |
|
201 isCurrent: function isCurrent(popupNode) { |
|
202 let url = popupNode.ownerDocument.URL; |
|
203 return internal(this).patterns.some(function (p) p.test(url)); |
|
204 } |
|
205 }); |
|
206 exports.URLContext = URLContext; |
|
207 |
|
208 // Matches when the user-supplied predicate returns true |
|
209 let PredicateContext = Class({ |
|
210 extends: Context, |
|
211 |
|
212 initialize: function initialize(predicate) { |
|
213 let options = validateOptions({ predicate: predicate }, { |
|
214 predicate: { |
|
215 is: ["function"], |
|
216 msg: "predicate must be a function." |
|
217 } |
|
218 }); |
|
219 internal(this).predicate = options.predicate; |
|
220 }, |
|
221 |
|
222 isCurrent: function isCurrent(popupNode) { |
|
223 return internal(this).predicate(populateCallbackNodeData(popupNode)); |
|
224 } |
|
225 }); |
|
226 exports.PredicateContext = PredicateContext; |
|
227 |
|
228 // List all editable types of inputs. Or is it better to have a list |
|
229 // of non-editable inputs? |
|
230 let editableInputs = { |
|
231 email: true, |
|
232 number: true, |
|
233 password: true, |
|
234 search: true, |
|
235 tel: true, |
|
236 text: true, |
|
237 textarea: true, |
|
238 url: true |
|
239 }; |
|
240 |
|
241 function populateCallbackNodeData(node) { |
|
242 let window = node.ownerDocument.defaultView; |
|
243 let data = {}; |
|
244 |
|
245 data.documentType = node.ownerDocument.contentType; |
|
246 |
|
247 data.documentURL = node.ownerDocument.location.href; |
|
248 data.targetName = node.nodeName.toLowerCase(); |
|
249 data.targetID = node.id || null ; |
|
250 |
|
251 if ((data.targetName === 'input' && editableInputs[node.type]) || |
|
252 data.targetName === 'textarea') { |
|
253 data.isEditable = !node.readOnly && !node.disabled; |
|
254 } |
|
255 else { |
|
256 data.isEditable = node.isContentEditable; |
|
257 } |
|
258 |
|
259 data.selectionText = selection.text; |
|
260 |
|
261 data.srcURL = node.src || null; |
|
262 data.linkURL = node.href || null; |
|
263 data.value = node.value || null; |
|
264 |
|
265 return data; |
|
266 } |
|
267 |
|
268 function removeItemFromArray(array, item) { |
|
269 return array.filter(function(i) i !== item); |
|
270 } |
|
271 |
|
272 // Converts anything that isn't false, null or undefined into a string |
|
273 function stringOrNull(val) val ? String(val) : val; |
|
274 |
|
275 // Shared option validation rules for Item and Menu |
|
276 let baseItemRules = { |
|
277 parentMenu: { |
|
278 is: ["object", "undefined"], |
|
279 ok: function (v) { |
|
280 if (!v) |
|
281 return true; |
|
282 return (v instanceof ItemContainer) || (v instanceof Menu); |
|
283 }, |
|
284 msg: "parentMenu must be a Menu or not specified." |
|
285 }, |
|
286 context: { |
|
287 is: ["undefined", "object", "array"], |
|
288 ok: function (v) { |
|
289 if (!v) |
|
290 return true; |
|
291 let arr = Array.isArray(v) ? v : [v]; |
|
292 return arr.every(function (o) o instanceof Context); |
|
293 }, |
|
294 msg: "The 'context' option must be a Context object or an array of " + |
|
295 "Context objects." |
|
296 }, |
|
297 contentScript: { |
|
298 is: ["string", "array", "undefined"], |
|
299 ok: function (v) { |
|
300 return !Array.isArray(v) || |
|
301 v.every(function (s) typeof(s) === "string"); |
|
302 } |
|
303 }, |
|
304 contentScriptFile: { |
|
305 is: ["string", "array", "undefined"], |
|
306 ok: function (v) { |
|
307 if (!v) |
|
308 return true; |
|
309 let arr = Array.isArray(v) ? v : [v]; |
|
310 return arr.every(function (s) { |
|
311 return getTypeOf(s) === "string" && |
|
312 getScheme(s) === 'resource'; |
|
313 }); |
|
314 }, |
|
315 msg: "The 'contentScriptFile' option must be a local file URL or " + |
|
316 "an array of local file URLs." |
|
317 }, |
|
318 onMessage: { |
|
319 is: ["function", "undefined"] |
|
320 } |
|
321 }; |
|
322 |
|
323 let labelledItemRules = mix(baseItemRules, { |
|
324 label: { |
|
325 map: stringOrNull, |
|
326 is: ["string"], |
|
327 ok: function (v) !!v, |
|
328 msg: "The item must have a non-empty string label." |
|
329 }, |
|
330 image: { |
|
331 map: stringOrNull, |
|
332 is: ["string", "undefined", "null"], |
|
333 ok: function (url) { |
|
334 if (!url) |
|
335 return true; |
|
336 return isValidURI(url); |
|
337 }, |
|
338 msg: "Image URL validation failed" |
|
339 } |
|
340 }); |
|
341 |
|
342 // Additional validation rules for Item |
|
343 let itemRules = mix(labelledItemRules, { |
|
344 data: { |
|
345 map: stringOrNull, |
|
346 is: ["string", "undefined", "null"] |
|
347 } |
|
348 }); |
|
349 |
|
350 // Additional validation rules for Menu |
|
351 let menuRules = mix(labelledItemRules, { |
|
352 items: { |
|
353 is: ["array", "undefined"], |
|
354 ok: function (v) { |
|
355 if (!v) |
|
356 return true; |
|
357 return v.every(function (item) { |
|
358 return item instanceof BaseItem; |
|
359 }); |
|
360 }, |
|
361 msg: "items must be an array, and each element in the array must be an " + |
|
362 "Item, Menu, or Separator." |
|
363 } |
|
364 }); |
|
365 |
|
366 let ContextWorker = Class({ |
|
367 implements: [ Worker ], |
|
368 |
|
369 //Returns true if any context listeners are defined in the worker's port. |
|
370 anyContextListeners: function anyContextListeners() { |
|
371 return this.getSandbox().hasListenerFor("context"); |
|
372 }, |
|
373 |
|
374 // Calls the context workers context listeners and returns the first result |
|
375 // that is either a string or a value that evaluates to true. If all of the |
|
376 // listeners returned false then returns false. If there are no listeners |
|
377 // then returns null. |
|
378 getMatchedContext: function getCurrentContexts(popupNode) { |
|
379 let results = this.getSandbox().emitSync("context", popupNode); |
|
380 return results.reduce(function(val, result) val || result, null); |
|
381 }, |
|
382 |
|
383 // Emits a click event in the worker's port. popupNode is the node that was |
|
384 // context-clicked, and clickedItemData is the data of the item that was |
|
385 // clicked. |
|
386 fireClick: function fireClick(popupNode, clickedItemData) { |
|
387 this.getSandbox().emitSync("click", popupNode, clickedItemData); |
|
388 } |
|
389 }); |
|
390 |
|
391 // Returns true if any contexts match. If there are no contexts then a |
|
392 // PageContext is tested instead |
|
393 function hasMatchingContext(contexts, popupNode) { |
|
394 for (let context in contexts) { |
|
395 if (!context.isCurrent(popupNode)) |
|
396 return false; |
|
397 } |
|
398 |
|
399 return true; |
|
400 } |
|
401 |
|
402 // Gets the matched context from any worker for this item. If there is no worker |
|
403 // or no matched context then returns false. |
|
404 function getCurrentWorkerContext(item, popupNode) { |
|
405 let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); |
|
406 if (!worker || !worker.anyContextListeners()) |
|
407 return true; |
|
408 return worker.getMatchedContext(popupNode); |
|
409 } |
|
410 |
|
411 // Tests whether an item should be visible or not based on its contexts and |
|
412 // content scripts |
|
413 function isItemVisible(item, popupNode, defaultVisibility) { |
|
414 if (!item.context.length) { |
|
415 let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); |
|
416 if (!worker || !worker.anyContextListeners()) |
|
417 return defaultVisibility; |
|
418 } |
|
419 |
|
420 if (!hasMatchingContext(item.context, popupNode)) |
|
421 return false; |
|
422 |
|
423 let context = getCurrentWorkerContext(item, popupNode); |
|
424 if (typeof(context) === "string" && context != "") |
|
425 item.label = context; |
|
426 |
|
427 return !!context; |
|
428 } |
|
429 |
|
430 // Gets the item's content script worker for a window, creating one if necessary |
|
431 // Once created it will be automatically destroyed when the window unloads. |
|
432 // If there is not content scripts for the item then null will be returned. |
|
433 function getItemWorkerForWindow(item, window) { |
|
434 if (!item.contentScript && !item.contentScriptFile) |
|
435 return null; |
|
436 |
|
437 let id = getInnerId(window); |
|
438 let worker = internal(item).workerMap.get(id); |
|
439 |
|
440 if (worker) |
|
441 return worker; |
|
442 |
|
443 worker = ContextWorker({ |
|
444 window: window, |
|
445 contentScript: item.contentScript, |
|
446 contentScriptFile: item.contentScriptFile, |
|
447 onMessage: function(msg) { |
|
448 emit(item, "message", msg); |
|
449 }, |
|
450 onDetach: function() { |
|
451 internal(item).workerMap.delete(id); |
|
452 } |
|
453 }); |
|
454 |
|
455 internal(item).workerMap.set(id, worker); |
|
456 |
|
457 return worker; |
|
458 } |
|
459 |
|
460 // Called when an item is clicked to send out click events to the content |
|
461 // scripts |
|
462 function itemClicked(item, clickedItem, popupNode) { |
|
463 let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView); |
|
464 |
|
465 if (worker) { |
|
466 let adjustedNode = popupNode; |
|
467 for (let context in item.context) |
|
468 adjustedNode = context.adjustPopupNode(adjustedNode); |
|
469 worker.fireClick(adjustedNode, clickedItem.data); |
|
470 } |
|
471 |
|
472 if (item.parentMenu) |
|
473 itemClicked(item.parentMenu, clickedItem, popupNode); |
|
474 } |
|
475 |
|
476 // All things that appear in the context menu extend this |
|
477 let BaseItem = Class({ |
|
478 initialize: function initialize() { |
|
479 addCollectionProperty(this, "context"); |
|
480 |
|
481 // Used to cache content script workers and the windows they have been |
|
482 // created for |
|
483 internal(this).workerMap = new Map(); |
|
484 |
|
485 if ("context" in internal(this).options && internal(this).options.context) { |
|
486 let contexts = internal(this).options.context; |
|
487 if (Array.isArray(contexts)) { |
|
488 for (let context of contexts) |
|
489 this.context.add(context); |
|
490 } |
|
491 else { |
|
492 this.context.add(contexts); |
|
493 } |
|
494 } |
|
495 |
|
496 let parentMenu = internal(this).options.parentMenu; |
|
497 if (!parentMenu) |
|
498 parentMenu = contentContextMenu; |
|
499 |
|
500 parentMenu.addItem(this); |
|
501 |
|
502 Object.defineProperty(this, "contentScript", { |
|
503 enumerable: true, |
|
504 value: internal(this).options.contentScript |
|
505 }); |
|
506 |
|
507 Object.defineProperty(this, "contentScriptFile", { |
|
508 enumerable: true, |
|
509 value: internal(this).options.contentScriptFile |
|
510 }); |
|
511 }, |
|
512 |
|
513 destroy: function destroy() { |
|
514 if (this.parentMenu) |
|
515 this.parentMenu.removeItem(this); |
|
516 }, |
|
517 |
|
518 get parentMenu() { |
|
519 return internal(this).parentMenu; |
|
520 }, |
|
521 }); |
|
522 |
|
523 // All things that have a label on the context menu extend this |
|
524 let LabelledItem = Class({ |
|
525 extends: BaseItem, |
|
526 implements: [ EventTarget ], |
|
527 |
|
528 initialize: function initialize(options) { |
|
529 BaseItem.prototype.initialize.call(this); |
|
530 EventTarget.prototype.initialize.call(this, options); |
|
531 }, |
|
532 |
|
533 destroy: function destroy() { |
|
534 for (let [,worker] of internal(this).workerMap) |
|
535 worker.destroy(); |
|
536 |
|
537 BaseItem.prototype.destroy.call(this); |
|
538 }, |
|
539 |
|
540 get label() { |
|
541 return internal(this).options.label; |
|
542 }, |
|
543 |
|
544 set label(val) { |
|
545 internal(this).options.label = val; |
|
546 |
|
547 MenuManager.updateItem(this); |
|
548 }, |
|
549 |
|
550 get image() { |
|
551 return internal(this).options.image; |
|
552 }, |
|
553 |
|
554 set image(val) { |
|
555 internal(this).options.image = val; |
|
556 |
|
557 MenuManager.updateItem(this); |
|
558 }, |
|
559 |
|
560 get data() { |
|
561 return internal(this).options.data; |
|
562 }, |
|
563 |
|
564 set data(val) { |
|
565 internal(this).options.data = val; |
|
566 } |
|
567 }); |
|
568 |
|
569 let Item = Class({ |
|
570 extends: LabelledItem, |
|
571 |
|
572 initialize: function initialize(options) { |
|
573 internal(this).options = validateOptions(options, itemRules); |
|
574 |
|
575 LabelledItem.prototype.initialize.call(this, options); |
|
576 }, |
|
577 |
|
578 toString: function toString() { |
|
579 return "[object Item \"" + this.label + "\"]"; |
|
580 }, |
|
581 |
|
582 get data() { |
|
583 return internal(this).options.data; |
|
584 }, |
|
585 |
|
586 set data(val) { |
|
587 internal(this).options.data = val; |
|
588 |
|
589 MenuManager.updateItem(this); |
|
590 }, |
|
591 }); |
|
592 exports.Item = Item; |
|
593 |
|
594 let ItemContainer = Class({ |
|
595 initialize: function initialize() { |
|
596 internal(this).children = []; |
|
597 }, |
|
598 |
|
599 destroy: function destroy() { |
|
600 // Destroys the entire hierarchy |
|
601 for (let item of internal(this).children) |
|
602 item.destroy(); |
|
603 }, |
|
604 |
|
605 addItem: function addItem(item) { |
|
606 let oldParent = item.parentMenu; |
|
607 |
|
608 // Don't just call removeItem here as that would remove the corresponding |
|
609 // UI element which is more costly than just moving it to the right place |
|
610 if (oldParent) |
|
611 internal(oldParent).children = removeItemFromArray(internal(oldParent).children, item); |
|
612 |
|
613 let after = null; |
|
614 let children = internal(this).children; |
|
615 if (children.length > 0) |
|
616 after = children[children.length - 1]; |
|
617 |
|
618 children.push(item); |
|
619 internal(item).parentMenu = this; |
|
620 |
|
621 // If there was an old parent then we just have to move the item, otherwise |
|
622 // it needs to be created |
|
623 if (oldParent) |
|
624 MenuManager.moveItem(item, after); |
|
625 else |
|
626 MenuManager.createItem(item, after); |
|
627 }, |
|
628 |
|
629 removeItem: function removeItem(item) { |
|
630 // If the item isn't a child of this menu then ignore this call |
|
631 if (item.parentMenu !== this) |
|
632 return; |
|
633 |
|
634 MenuManager.removeItem(item); |
|
635 |
|
636 internal(this).children = removeItemFromArray(internal(this).children, item); |
|
637 internal(item).parentMenu = null; |
|
638 }, |
|
639 |
|
640 get items() { |
|
641 return internal(this).children.slice(0); |
|
642 }, |
|
643 |
|
644 set items(val) { |
|
645 // Validate the arguments before making any changes |
|
646 if (!Array.isArray(val)) |
|
647 throw new Error(menuOptionRules.items.msg); |
|
648 |
|
649 for (let item of val) { |
|
650 if (!(item instanceof BaseItem)) |
|
651 throw new Error(menuOptionRules.items.msg); |
|
652 } |
|
653 |
|
654 // Remove the old items and add the new ones |
|
655 for (let item of internal(this).children) |
|
656 this.removeItem(item); |
|
657 |
|
658 for (let item of val) |
|
659 this.addItem(item); |
|
660 }, |
|
661 }); |
|
662 |
|
663 let Menu = Class({ |
|
664 extends: LabelledItem, |
|
665 implements: [ItemContainer], |
|
666 |
|
667 initialize: function initialize(options) { |
|
668 internal(this).options = validateOptions(options, menuRules); |
|
669 |
|
670 LabelledItem.prototype.initialize.call(this, options); |
|
671 ItemContainer.prototype.initialize.call(this); |
|
672 |
|
673 if (internal(this).options.items) { |
|
674 for (let item of internal(this).options.items) |
|
675 this.addItem(item); |
|
676 } |
|
677 }, |
|
678 |
|
679 destroy: function destroy() { |
|
680 ItemContainer.prototype.destroy.call(this); |
|
681 LabelledItem.prototype.destroy.call(this); |
|
682 }, |
|
683 |
|
684 toString: function toString() { |
|
685 return "[object Menu \"" + this.label + "\"]"; |
|
686 }, |
|
687 }); |
|
688 exports.Menu = Menu; |
|
689 |
|
690 let Separator = Class({ |
|
691 extends: BaseItem, |
|
692 |
|
693 initialize: function initialize(options) { |
|
694 internal(this).options = validateOptions(options, baseItemRules); |
|
695 |
|
696 BaseItem.prototype.initialize.call(this); |
|
697 }, |
|
698 |
|
699 toString: function toString() { |
|
700 return "[object Separator]"; |
|
701 } |
|
702 }); |
|
703 exports.Separator = Separator; |
|
704 |
|
705 // Holds items for the content area context menu |
|
706 let contentContextMenu = ItemContainer(); |
|
707 exports.contentContextMenu = contentContextMenu; |
|
708 |
|
709 when(function() { |
|
710 contentContextMenu.destroy(); |
|
711 }); |
|
712 |
|
713 // App specific UI code lives here, it should handle populating the context |
|
714 // menu and passing clicks etc. through to the items. |
|
715 |
|
716 function countVisibleItems(nodes) { |
|
717 return Array.reduce(nodes, function(sum, node) { |
|
718 return node.hidden ? sum : sum + 1; |
|
719 }, 0); |
|
720 } |
|
721 |
|
722 let MenuWrapper = Class({ |
|
723 initialize: function initialize(winWrapper, items, contextMenu) { |
|
724 this.winWrapper = winWrapper; |
|
725 this.window = winWrapper.window; |
|
726 this.items = items; |
|
727 this.contextMenu = contextMenu; |
|
728 this.populated = false; |
|
729 this.menuMap = new Map(); |
|
730 |
|
731 // updateItemVisibilities will run first, updateOverflowState will run after |
|
732 // all other instances of this module have run updateItemVisibilities |
|
733 this._updateItemVisibilities = this.updateItemVisibilities.bind(this); |
|
734 this.contextMenu.addEventListener("popupshowing", this._updateItemVisibilities, true); |
|
735 this._updateOverflowState = this.updateOverflowState.bind(this); |
|
736 this.contextMenu.addEventListener("popupshowing", this._updateOverflowState, false); |
|
737 }, |
|
738 |
|
739 destroy: function destroy() { |
|
740 this.contextMenu.removeEventListener("popupshowing", this._updateOverflowState, false); |
|
741 this.contextMenu.removeEventListener("popupshowing", this._updateItemVisibilities, true); |
|
742 |
|
743 if (!this.populated) |
|
744 return; |
|
745 |
|
746 // If we're getting unloaded at runtime then we must remove all the |
|
747 // generated XUL nodes |
|
748 let oldParent = null; |
|
749 for (let item of internal(this.items).children) { |
|
750 let xulNode = this.getXULNodeForItem(item); |
|
751 oldParent = xulNode.parentNode; |
|
752 oldParent.removeChild(xulNode); |
|
753 } |
|
754 |
|
755 if (oldParent) |
|
756 this.onXULRemoved(oldParent); |
|
757 }, |
|
758 |
|
759 get separator() { |
|
760 return this.contextMenu.querySelector("." + SEPARATOR_CLASS); |
|
761 }, |
|
762 |
|
763 get overflowMenu() { |
|
764 return this.contextMenu.querySelector("." + OVERFLOW_MENU_CLASS); |
|
765 }, |
|
766 |
|
767 get overflowPopup() { |
|
768 return this.contextMenu.querySelector("." + OVERFLOW_POPUP_CLASS); |
|
769 }, |
|
770 |
|
771 get topLevelItems() { |
|
772 return this.contextMenu.querySelectorAll("." + TOPLEVEL_ITEM_CLASS); |
|
773 }, |
|
774 |
|
775 get overflowItems() { |
|
776 return this.contextMenu.querySelectorAll("." + OVERFLOW_ITEM_CLASS); |
|
777 }, |
|
778 |
|
779 getXULNodeForItem: function getXULNodeForItem(item) { |
|
780 return this.menuMap.get(item); |
|
781 }, |
|
782 |
|
783 // Recurses through the item hierarchy creating XUL nodes for everything |
|
784 populate: function populate(menu) { |
|
785 for (let i = 0; i < internal(menu).children.length; i++) { |
|
786 let item = internal(menu).children[i]; |
|
787 let after = i === 0 ? null : internal(menu).children[i - 1]; |
|
788 this.createItem(item, after); |
|
789 |
|
790 if (item instanceof Menu) |
|
791 this.populate(item); |
|
792 } |
|
793 }, |
|
794 |
|
795 // Recurses through the menu setting the visibility of items. Returns true |
|
796 // if any of the items in this menu were visible |
|
797 setVisibility: function setVisibility(menu, popupNode, defaultVisibility) { |
|
798 let anyVisible = false; |
|
799 |
|
800 for (let item of internal(menu).children) { |
|
801 let visible = isItemVisible(item, popupNode, defaultVisibility); |
|
802 |
|
803 // Recurse through Menus, if none of the sub-items were visible then the |
|
804 // menu is hidden too. |
|
805 if (visible && (item instanceof Menu)) |
|
806 visible = this.setVisibility(item, popupNode, true); |
|
807 |
|
808 let xulNode = this.getXULNodeForItem(item); |
|
809 xulNode.hidden = !visible; |
|
810 |
|
811 anyVisible = anyVisible || visible; |
|
812 } |
|
813 |
|
814 return anyVisible; |
|
815 }, |
|
816 |
|
817 // Works out where to insert a XUL node for an item in a browser window |
|
818 insertIntoXUL: function insertIntoXUL(item, node, after) { |
|
819 let menupopup = null; |
|
820 let before = null; |
|
821 |
|
822 let menu = item.parentMenu; |
|
823 if (menu === this.items) { |
|
824 // Insert into the overflow popup if it exists, otherwise the normal |
|
825 // context menu |
|
826 menupopup = this.overflowPopup; |
|
827 if (!menupopup) |
|
828 menupopup = this.contextMenu; |
|
829 } |
|
830 else { |
|
831 let xulNode = this.getXULNodeForItem(menu); |
|
832 menupopup = xulNode.firstChild; |
|
833 } |
|
834 |
|
835 if (after) { |
|
836 let afterNode = this.getXULNodeForItem(after); |
|
837 before = afterNode.nextSibling; |
|
838 } |
|
839 else if (menupopup === this.contextMenu) { |
|
840 let topLevel = this.topLevelItems; |
|
841 if (topLevel.length > 0) |
|
842 before = topLevel[topLevel.length - 1].nextSibling; |
|
843 else |
|
844 before = this.separator.nextSibling; |
|
845 } |
|
846 |
|
847 menupopup.insertBefore(node, before); |
|
848 }, |
|
849 |
|
850 // Sets the right class for XUL nodes |
|
851 updateXULClass: function updateXULClass(xulNode) { |
|
852 if (xulNode.parentNode == this.contextMenu) |
|
853 xulNode.classList.add(TOPLEVEL_ITEM_CLASS); |
|
854 else |
|
855 xulNode.classList.remove(TOPLEVEL_ITEM_CLASS); |
|
856 |
|
857 if (xulNode.parentNode == this.overflowPopup) |
|
858 xulNode.classList.add(OVERFLOW_ITEM_CLASS); |
|
859 else |
|
860 xulNode.classList.remove(OVERFLOW_ITEM_CLASS); |
|
861 }, |
|
862 |
|
863 // Creates a XUL node for an item |
|
864 createItem: function createItem(item, after) { |
|
865 if (!this.populated) |
|
866 return; |
|
867 |
|
868 // Create the separator if it doesn't already exist |
|
869 if (!this.separator) { |
|
870 let separator = this.window.document.createElement("menuseparator"); |
|
871 separator.setAttribute("class", SEPARATOR_CLASS); |
|
872 |
|
873 // Insert before the separator created by the old context-menu if it |
|
874 // exists to avoid bug 832401 |
|
875 let oldSeparator = this.window.document.getElementById("jetpack-context-menu-separator"); |
|
876 if (oldSeparator && oldSeparator.parentNode != this.contextMenu) |
|
877 oldSeparator = null; |
|
878 this.contextMenu.insertBefore(separator, oldSeparator); |
|
879 } |
|
880 |
|
881 let type = "menuitem"; |
|
882 if (item instanceof Menu) |
|
883 type = "menu"; |
|
884 else if (item instanceof Separator) |
|
885 type = "menuseparator"; |
|
886 |
|
887 let xulNode = this.window.document.createElement(type); |
|
888 xulNode.setAttribute("class", ITEM_CLASS); |
|
889 if (item instanceof LabelledItem) { |
|
890 xulNode.setAttribute("label", item.label); |
|
891 if (item.image) { |
|
892 xulNode.setAttribute("image", item.image); |
|
893 if (item instanceof Menu) |
|
894 xulNode.classList.add("menu-iconic"); |
|
895 else |
|
896 xulNode.classList.add("menuitem-iconic"); |
|
897 } |
|
898 if (item.data) |
|
899 xulNode.setAttribute("value", item.data); |
|
900 |
|
901 let self = this; |
|
902 xulNode.addEventListener("command", function(event) { |
|
903 // Only care about clicks directly on this item |
|
904 if (event.target !== xulNode) |
|
905 return; |
|
906 |
|
907 itemClicked(item, item, self.contextMenu.triggerNode); |
|
908 }, false); |
|
909 } |
|
910 |
|
911 this.insertIntoXUL(item, xulNode, after); |
|
912 this.updateXULClass(xulNode); |
|
913 xulNode.data = item.data; |
|
914 |
|
915 if (item instanceof Menu) { |
|
916 let menupopup = this.window.document.createElement("menupopup"); |
|
917 xulNode.appendChild(menupopup); |
|
918 } |
|
919 |
|
920 this.menuMap.set(item, xulNode); |
|
921 }, |
|
922 |
|
923 // Updates the XUL node for an item in this window |
|
924 updateItem: function updateItem(item) { |
|
925 if (!this.populated) |
|
926 return; |
|
927 |
|
928 let xulNode = this.getXULNodeForItem(item); |
|
929 |
|
930 // TODO figure out why this requires setAttribute |
|
931 xulNode.setAttribute("label", item.label); |
|
932 |
|
933 if (item.image) { |
|
934 xulNode.setAttribute("image", item.image); |
|
935 if (item instanceof Menu) |
|
936 xulNode.classList.add("menu-iconic"); |
|
937 else |
|
938 xulNode.classList.add("menuitem-iconic"); |
|
939 } |
|
940 else { |
|
941 xulNode.removeAttribute("image"); |
|
942 xulNode.classList.remove("menu-iconic"); |
|
943 xulNode.classList.remove("menuitem-iconic"); |
|
944 } |
|
945 |
|
946 if (item.data) |
|
947 xulNode.setAttribute("value", item.data); |
|
948 else |
|
949 xulNode.removeAttribute("value"); |
|
950 }, |
|
951 |
|
952 // Moves the XUL node for an item in this window to its new place in the |
|
953 // hierarchy |
|
954 moveItem: function moveItem(item, after) { |
|
955 if (!this.populated) |
|
956 return; |
|
957 |
|
958 let xulNode = this.getXULNodeForItem(item); |
|
959 let oldParent = xulNode.parentNode; |
|
960 |
|
961 this.insertIntoXUL(item, xulNode, after); |
|
962 this.updateXULClass(xulNode); |
|
963 this.onXULRemoved(oldParent); |
|
964 }, |
|
965 |
|
966 // Removes the XUL nodes for an item in every window we've ever populated. |
|
967 removeItem: function removeItem(item) { |
|
968 if (!this.populated) |
|
969 return; |
|
970 |
|
971 let xulItem = this.getXULNodeForItem(item); |
|
972 |
|
973 let oldParent = xulItem.parentNode; |
|
974 |
|
975 oldParent.removeChild(xulItem); |
|
976 this.menuMap.delete(item); |
|
977 |
|
978 this.onXULRemoved(oldParent); |
|
979 }, |
|
980 |
|
981 // Called when any XUL nodes have been removed from a menupopup. This handles |
|
982 // making sure the separator and overflow are correct |
|
983 onXULRemoved: function onXULRemoved(parent) { |
|
984 if (parent == this.contextMenu) { |
|
985 let toplevel = this.topLevelItems; |
|
986 |
|
987 // If there are no more items then remove the separator |
|
988 if (toplevel.length == 0) { |
|
989 let separator = this.separator; |
|
990 if (separator) |
|
991 separator.parentNode.removeChild(separator); |
|
992 } |
|
993 } |
|
994 else if (parent == this.overflowPopup) { |
|
995 // If there are no more items then remove the overflow menu and separator |
|
996 if (parent.childNodes.length == 0) { |
|
997 let separator = this.separator; |
|
998 separator.parentNode.removeChild(separator); |
|
999 this.contextMenu.removeChild(parent.parentNode); |
|
1000 } |
|
1001 } |
|
1002 }, |
|
1003 |
|
1004 // Recurses through all the items owned by this module and sets their hidden |
|
1005 // state |
|
1006 updateItemVisibilities: function updateItemVisibilities(event) { |
|
1007 try { |
|
1008 if (event.type != "popupshowing") |
|
1009 return; |
|
1010 if (event.target != this.contextMenu) |
|
1011 return; |
|
1012 |
|
1013 if (internal(this.items).children.length == 0) |
|
1014 return; |
|
1015 |
|
1016 if (!this.populated) { |
|
1017 this.populated = true; |
|
1018 this.populate(this.items); |
|
1019 } |
|
1020 |
|
1021 let popupNode = event.target.triggerNode; |
|
1022 this.setVisibility(this.items, popupNode, PageContext().isCurrent(popupNode)); |
|
1023 } |
|
1024 catch (e) { |
|
1025 console.exception(e); |
|
1026 } |
|
1027 }, |
|
1028 |
|
1029 // Counts the number of visible items across all modules and makes sure they |
|
1030 // are in the right place between the top level context menu and the overflow |
|
1031 // menu |
|
1032 updateOverflowState: function updateOverflowState(event) { |
|
1033 try { |
|
1034 if (event.type != "popupshowing") |
|
1035 return; |
|
1036 if (event.target != this.contextMenu) |
|
1037 return; |
|
1038 |
|
1039 // The main items will be in either the top level context menu or the |
|
1040 // overflow menu at this point. Count the visible ones and if they are in |
|
1041 // the wrong place move them |
|
1042 let toplevel = this.topLevelItems; |
|
1043 let overflow = this.overflowItems; |
|
1044 let visibleCount = countVisibleItems(toplevel) + |
|
1045 countVisibleItems(overflow); |
|
1046 |
|
1047 if (visibleCount == 0) { |
|
1048 let separator = this.separator; |
|
1049 if (separator) |
|
1050 separator.hidden = true; |
|
1051 let overflowMenu = this.overflowMenu; |
|
1052 if (overflowMenu) |
|
1053 overflowMenu.hidden = true; |
|
1054 } |
|
1055 else if (visibleCount > MenuManager.overflowThreshold) { |
|
1056 this.separator.hidden = false; |
|
1057 let overflowPopup = this.overflowPopup; |
|
1058 if (overflowPopup) |
|
1059 overflowPopup.parentNode.hidden = false; |
|
1060 |
|
1061 if (toplevel.length > 0) { |
|
1062 // The overflow menu shouldn't exist here but let's play it safe |
|
1063 if (!overflowPopup) { |
|
1064 let overflowMenu = this.window.document.createElement("menu"); |
|
1065 overflowMenu.setAttribute("class", OVERFLOW_MENU_CLASS); |
|
1066 overflowMenu.setAttribute("label", OVERFLOW_MENU_LABEL); |
|
1067 this.contextMenu.insertBefore(overflowMenu, this.separator.nextSibling); |
|
1068 |
|
1069 overflowPopup = this.window.document.createElement("menupopup"); |
|
1070 overflowPopup.setAttribute("class", OVERFLOW_POPUP_CLASS); |
|
1071 overflowMenu.appendChild(overflowPopup); |
|
1072 } |
|
1073 |
|
1074 for (let xulNode of toplevel) { |
|
1075 overflowPopup.appendChild(xulNode); |
|
1076 this.updateXULClass(xulNode); |
|
1077 } |
|
1078 } |
|
1079 } |
|
1080 else { |
|
1081 this.separator.hidden = false; |
|
1082 |
|
1083 if (overflow.length > 0) { |
|
1084 // Move all the overflow nodes out of the overflow menu and position |
|
1085 // them immediately before it |
|
1086 for (let xulNode of overflow) { |
|
1087 this.contextMenu.insertBefore(xulNode, xulNode.parentNode.parentNode); |
|
1088 this.updateXULClass(xulNode); |
|
1089 } |
|
1090 this.contextMenu.removeChild(this.overflowMenu); |
|
1091 } |
|
1092 } |
|
1093 } |
|
1094 catch (e) { |
|
1095 console.exception(e); |
|
1096 } |
|
1097 } |
|
1098 }); |
|
1099 |
|
1100 // This wraps every window that we've seen |
|
1101 let WindowWrapper = Class({ |
|
1102 initialize: function initialize(window) { |
|
1103 this.window = window; |
|
1104 this.menus = [ |
|
1105 new MenuWrapper(this, contentContextMenu, window.document.getElementById("contentAreaContextMenu")), |
|
1106 ]; |
|
1107 }, |
|
1108 |
|
1109 destroy: function destroy() { |
|
1110 for (let menuWrapper of this.menus) |
|
1111 menuWrapper.destroy(); |
|
1112 }, |
|
1113 |
|
1114 getMenuWrapperForItem: function getMenuWrapperForItem(item) { |
|
1115 let root = item.parentMenu; |
|
1116 while (root.parentMenu) |
|
1117 root = root.parentMenu; |
|
1118 |
|
1119 for (let wrapper of this.menus) { |
|
1120 if (wrapper.items === root) |
|
1121 return wrapper; |
|
1122 } |
|
1123 |
|
1124 return null; |
|
1125 } |
|
1126 }); |
|
1127 |
|
1128 let MenuManager = { |
|
1129 windowMap: new Map(), |
|
1130 |
|
1131 get overflowThreshold() { |
|
1132 let prefs = require("./preferences/service"); |
|
1133 return prefs.get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT); |
|
1134 }, |
|
1135 |
|
1136 // When a new window is added start watching it for context menu shows |
|
1137 onTrack: function onTrack(window) { |
|
1138 if (!isBrowser(window)) |
|
1139 return; |
|
1140 |
|
1141 // Generally shouldn't happen, but just in case |
|
1142 if (this.windowMap.has(window)) { |
|
1143 console.warn("Already seen this window"); |
|
1144 return; |
|
1145 } |
|
1146 |
|
1147 let winWrapper = WindowWrapper(window); |
|
1148 this.windowMap.set(window, winWrapper); |
|
1149 }, |
|
1150 |
|
1151 onUntrack: function onUntrack(window) { |
|
1152 if (!isBrowser(window)) |
|
1153 return; |
|
1154 |
|
1155 let winWrapper = this.windowMap.get(window); |
|
1156 // This shouldn't happen but protect against it anyway |
|
1157 if (!winWrapper) |
|
1158 return; |
|
1159 winWrapper.destroy(); |
|
1160 |
|
1161 this.windowMap.delete(window); |
|
1162 }, |
|
1163 |
|
1164 // Creates a XUL node for an item in every window we've already populated |
|
1165 createItem: function createItem(item, after) { |
|
1166 for (let [window, winWrapper] of this.windowMap) { |
|
1167 let menuWrapper = winWrapper.getMenuWrapperForItem(item); |
|
1168 if (menuWrapper) |
|
1169 menuWrapper.createItem(item, after); |
|
1170 } |
|
1171 }, |
|
1172 |
|
1173 // Updates the XUL node for an item in every window we've already populated |
|
1174 updateItem: function updateItem(item) { |
|
1175 for (let [window, winWrapper] of this.windowMap) { |
|
1176 let menuWrapper = winWrapper.getMenuWrapperForItem(item); |
|
1177 if (menuWrapper) |
|
1178 menuWrapper.updateItem(item); |
|
1179 } |
|
1180 }, |
|
1181 |
|
1182 // Moves the XUL node for an item in every window we've ever populated to its |
|
1183 // new place in the hierarchy |
|
1184 moveItem: function moveItem(item, after) { |
|
1185 for (let [window, winWrapper] of this.windowMap) { |
|
1186 let menuWrapper = winWrapper.getMenuWrapperForItem(item); |
|
1187 if (menuWrapper) |
|
1188 menuWrapper.moveItem(item, after); |
|
1189 } |
|
1190 }, |
|
1191 |
|
1192 // Removes the XUL nodes for an item in every window we've ever populated. |
|
1193 removeItem: function removeItem(item) { |
|
1194 for (let [window, winWrapper] of this.windowMap) { |
|
1195 let menuWrapper = winWrapper.getMenuWrapperForItem(item); |
|
1196 if (menuWrapper) |
|
1197 menuWrapper.removeItem(item); |
|
1198 } |
|
1199 } |
|
1200 }; |
|
1201 |
|
1202 WindowTracker(MenuManager); |