1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
|
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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";
const Ci = Components.interfaces;
const Cu = Components.utils;
const SHARED_STRINGS_URI = "devtools/client/locales/shared.properties";
const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
const EventEmitter = require("devtools/shared/event-emitter");
const { LocalizationHelper } = require("devtools/shared/l10n");
const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
this.EXPORTED_SYMBOLS = ["SideMenuWidget"];
/**
* Localization convenience methods.
*/
var L10N = new LocalizationHelper(SHARED_STRINGS_URI);
/**
* A simple side menu, with the ability of grouping menu items.
*
* Note: this widget should be used in tandem with the WidgetMethods in
* view-helpers.js.
*
* @param nsIDOMNode aNode
* The element associated with the widget.
* @param Object aOptions
* - contextMenu: optional element or element ID that serves as a context menu.
* - showArrows: specifies if items should display horizontal arrows.
* - showItemCheckboxes: specifies if items should display checkboxes.
* - showGroupCheckboxes: specifies if groups should display checkboxes.
*/
this.SideMenuWidget = function SideMenuWidget(aNode, aOptions = {}) {
this.document = aNode.ownerDocument;
this.window = this.document.defaultView;
this._parent = aNode;
let { contextMenu, showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions;
this._contextMenu = contextMenu || null;
this._showArrows = showArrows || false;
this._showItemCheckboxes = showItemCheckboxes || false;
this._showGroupCheckboxes = showGroupCheckboxes || false;
// Create an internal scrollbox container.
this._list = this.document.createElement("scrollbox");
this._list.className = "side-menu-widget-container theme-sidebar";
this._list.setAttribute("flex", "1");
this._list.setAttribute("orient", "vertical");
this._list.setAttribute("with-arrows", this._showArrows);
this._list.setAttribute("with-item-checkboxes", this._showItemCheckboxes);
this._list.setAttribute("with-group-checkboxes", this._showGroupCheckboxes);
this._list.setAttribute("tabindex", "0");
this._list.addEventListener("contextmenu", e => this._showContextMenu(e), false);
this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
this._parent.appendChild(this._list);
// Menu items can optionally be grouped.
this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings.
this._orderedGroupElementsArray = [];
this._orderedMenuElementsArray = [];
this._itemsByElement = new Map();
// This widget emits events that can be handled in a MenuContainer.
EventEmitter.decorate(this);
// Delegate some of the associated node's methods to satisfy the interface
// required by MenuContainer instances.
ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
ViewHelpers.delegateWidgetEventMethods(this, aNode);
};
SideMenuWidget.prototype = {
/**
* Specifies if groups in this container should be sorted.
*/
sortedGroups: true,
/**
* The comparator used to sort groups.
*/
groupSortPredicate: (a, b) => a.localeCompare(b),
/**
* Inserts an item in this container at the specified index, optionally
* grouping by name.
*
* @param number aIndex
* The position in the container intended for this item.
* @param nsIDOMNode aContents
* The node displayed in the container.
* @param object aAttachment [optional]
* Some attached primitive/object. Custom options supported:
* - group: a string specifying the group to place this item into
* - checkboxState: the checked state of the checkbox, if shown
* - checkboxTooltip: the tooltip text for the checkbox, if shown
* @return nsIDOMNode
* The element associated with the displayed item.
*/
insertItemAt: function (aIndex, aContents, aAttachment = {}) {
let group = this._getMenuGroupForName(aAttachment.group);
let item = this._getMenuItemForGroup(group, aContents, aAttachment);
let element = item.insertSelfAt(aIndex);
return element;
},
/**
* Checks to see if the list is scrolled all the way to the bottom.
* Uses getBoundsWithoutFlushing to limit the performance impact
* of this function.
*
* @return bool
*/
isScrolledToBottom: function () {
if (this._list.lastElementChild) {
let utils = this.window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
let childRect = utils.getBoundsWithoutFlushing(this._list.lastElementChild);
let listRect = utils.getBoundsWithoutFlushing(this._list);
// Cheap way to check if it's scrolled all the way to the bottom.
return (childRect.height + childRect.top) <= listRect.bottom;
}
return false;
},
/**
* Scroll the list to the bottom after a timeout.
* If the user scrolls in the meantime, cancel this operation.
*/
scrollToBottom: function () {
this._list.scrollTop = this._list.scrollHeight;
this.emit("scroll-to-bottom");
},
/**
* Returns the child node in this container situated at the specified index.
*
* @param number aIndex
* The position in the container intended for this item.
* @return nsIDOMNode
* The element associated with the displayed item.
*/
getItemAtIndex: function (aIndex) {
return this._orderedMenuElementsArray[aIndex];
},
/**
* Removes the specified child node from this container.
*
* @param nsIDOMNode aChild
* The element associated with the displayed item.
*/
removeChild: function (aChild) {
this._getNodeForContents(aChild).remove();
this._orderedMenuElementsArray.splice(
this._orderedMenuElementsArray.indexOf(aChild), 1);
this._itemsByElement.delete(aChild);
if (this._selectedItem == aChild) {
this._selectedItem = null;
}
},
/**
* Removes all of the child nodes from this container.
*/
removeAllItems: function () {
let parent = this._parent;
let list = this._list;
while (list.hasChildNodes()) {
list.firstChild.remove();
}
this._selectedItem = null;
this._groupsByName.clear();
this._orderedGroupElementsArray.length = 0;
this._orderedMenuElementsArray.length = 0;
this._itemsByElement.clear();
},
/**
* Gets the currently selected child node in this container.
* @return nsIDOMNode
*/
get selectedItem() {
return this._selectedItem;
},
/**
* Sets the currently selected child node in this container.
* @param nsIDOMNode aChild
*/
set selectedItem(aChild) {
let menuArray = this._orderedMenuElementsArray;
if (!aChild) {
this._selectedItem = null;
}
for (let node of menuArray) {
if (node == aChild) {
this._getNodeForContents(node).classList.add("selected");
this._selectedItem = node;
} else {
this._getNodeForContents(node).classList.remove("selected");
}
}
},
/**
* Ensures the specified element is visible.
*
* @param nsIDOMNode aElement
* The element to make visible.
*/
ensureElementIsVisible: function (aElement) {
if (!aElement) {
return;
}
// Ensure the element is visible but not scrolled horizontally.
let boxObject = this._list.boxObject;
boxObject.ensureElementIsVisible(aElement);
boxObject.scrollBy(-this._list.clientWidth, 0);
},
/**
* Shows all the groups, even the ones with no visible children.
*/
showEmptyGroups: function () {
for (let group of this._orderedGroupElementsArray) {
group.hidden = false;
}
},
/**
* Hides all the groups which have no visible children.
*/
hideEmptyGroups: function () {
let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])";
for (let group of this._orderedGroupElementsArray) {
group.hidden = group.querySelectorAll(visibleChildNodes).length == 0;
}
for (let menuItem of this._orderedMenuElementsArray) {
menuItem.parentNode.hidden = menuItem.hidden;
}
},
/**
* Adds a new attribute or changes an existing attribute on this container.
*
* @param string aName
* The name of the attribute.
* @param string aValue
* The desired attribute value.
*/
setAttribute: function (aName, aValue) {
this._parent.setAttribute(aName, aValue);
if (aName == "emptyText") {
this._textWhenEmpty = aValue;
}
},
/**
* Removes an attribute on this container.
*
* @param string aName
* The name of the attribute.
*/
removeAttribute: function (aName) {
this._parent.removeAttribute(aName);
if (aName == "emptyText") {
this._removeEmptyText();
}
},
/**
* Set the checkbox state for the item associated with the given node.
*
* @param nsIDOMNode aNode
* The dom node for an item we want to check.
* @param boolean aCheckState
* True to check, false to uncheck.
*/
checkItem: function (aNode, aCheckState) {
const widgetItem = this._itemsByElement.get(aNode);
if (!widgetItem) {
throw new Error("No item for " + aNode);
}
widgetItem.check(aCheckState);
},
/**
* Sets the text displayed in this container when empty.
* @param string aValue
*/
set _textWhenEmpty(aValue) {
if (this._emptyTextNode) {
this._emptyTextNode.setAttribute("value", aValue);
}
this._emptyTextValue = aValue;
this._showEmptyText();
},
/**
* Creates and appends a label signaling that this container is empty.
*/
_showEmptyText: function () {
if (this._emptyTextNode || !this._emptyTextValue) {
return;
}
let label = this.document.createElement("label");
label.className = "plain side-menu-widget-empty-text";
label.setAttribute("value", this._emptyTextValue);
this._parent.insertBefore(label, this._list);
this._emptyTextNode = label;
},
/**
* Removes the label representing a notice in this container.
*/
_removeEmptyText: function () {
if (!this._emptyTextNode) {
return;
}
this._parent.removeChild(this._emptyTextNode);
this._emptyTextNode = null;
},
/**
* Gets a container representing a group for menu items. If the container
* is not available yet, it is immediately created.
*
* @param string aName
* The required group name.
* @return SideMenuGroup
* The newly created group.
*/
_getMenuGroupForName: function (aName) {
let cachedGroup = this._groupsByName.get(aName);
if (cachedGroup) {
return cachedGroup;
}
let group = new SideMenuGroup(this, aName, {
showCheckbox: this._showGroupCheckboxes
});
this._groupsByName.set(aName, group);
group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf(this.groupSortPredicate) : -1);
return group;
},
/**
* Gets a menu item to be displayed inside a group.
* @see SideMenuWidget.prototype._getMenuGroupForName
*
* @param SideMenuGroup aGroup
* The group to contain the menu item.
* @param nsIDOMNode aContents
* The node displayed in the container.
* @param object aAttachment [optional]
* Some attached primitive/object.
*/
_getMenuItemForGroup: function (aGroup, aContents, aAttachment) {
return new SideMenuItem(aGroup, aContents, aAttachment, {
showArrow: this._showArrows,
showCheckbox: this._showItemCheckboxes
});
},
/**
* Returns the .side-menu-widget-item node corresponding to a SideMenuItem.
* To optimize the markup, some redundant elemenst are skipped when creating
* these child items, in which case we need to be careful on which nodes
* .selected class names are added, or which nodes are removed.
*
* @param nsIDOMNode aChild
* An element which is the target node of a SideMenuItem.
* @return nsIDOMNode
* The wrapper node if there is one, or the same child otherwise.
*/
_getNodeForContents: function (aChild) {
if (aChild.hasAttribute("merged-item-contents")) {
return aChild;
} else {
return aChild.parentNode;
}
},
/**
* Shows the contextMenu element.
*/
_showContextMenu: function (e) {
if (!this._contextMenu) {
return;
}
// Don't show the menu if a descendant node is going to be visible also.
let node = e.originalTarget;
while (node && node !== this._list) {
if (node.hasAttribute("contextmenu")) {
return;
}
node = node.parentNode;
}
this._contextMenu.openPopupAtScreen(e.screenX, e.screenY, true);
},
window: null,
document: null,
_showArrows: false,
_showItemCheckboxes: false,
_showGroupCheckboxes: false,
_parent: null,
_list: null,
_selectedItem: null,
_groupsByName: null,
_orderedGroupElementsArray: null,
_orderedMenuElementsArray: null,
_itemsByElement: null,
_emptyTextNode: null,
_emptyTextValue: ""
};
/**
* A SideMenuGroup constructor for the BreadcrumbsWidget.
* Represents a group which should contain SideMenuItems.
*
* @param SideMenuWidget aWidget
* The widget to contain this menu item.
* @param string aName
* The string displayed in the container.
* @param object aOptions [optional]
* An object containing the following properties:
* - showCheckbox: specifies if a checkbox should be displayed.
*/
function SideMenuGroup(aWidget, aName, aOptions = {}) {
this.document = aWidget.document;
this.window = aWidget.window;
this.ownerView = aWidget;
this.identifier = aName;
// Create an internal title and list container.
if (aName) {
let target = this._target = this.document.createElement("vbox");
target.className = "side-menu-widget-group";
target.setAttribute("name", aName);
let list = this._list = this.document.createElement("vbox");
list.className = "side-menu-widget-group-list";
let title = this._title = this.document.createElement("hbox");
title.className = "side-menu-widget-group-title";
let name = this._name = this.document.createElement("label");
name.className = "plain name";
name.setAttribute("value", aName);
name.setAttribute("crop", "end");
name.setAttribute("flex", "1");
// Show a checkbox before the content.
if (aOptions.showCheckbox) {
let checkbox = this._checkbox = makeCheckbox(title, {
description: aName,
checkboxTooltip: L10N.getStr("sideMenu.groupCheckbox.tooltip")
});
checkbox.className = "side-menu-widget-group-checkbox";
}
title.appendChild(name);
target.appendChild(title);
target.appendChild(list);
}
// Skip a few redundant nodes when no title is shown.
else {
let target = this._target = this._list = this.document.createElement("vbox");
target.className = "side-menu-widget-group side-menu-widget-group-list";
target.setAttribute("merged-group-contents", "");
}
}
SideMenuGroup.prototype = {
get _orderedGroupElementsArray() {
return this.ownerView._orderedGroupElementsArray;
},
get _orderedMenuElementsArray() {
return this.ownerView._orderedMenuElementsArray;
},
get _itemsByElement() { return this.ownerView._itemsByElement; },
/**
* Inserts this group in the parent container at the specified index.
*
* @param number aIndex
* The position in the container intended for this group.
*/
insertSelfAt: function (aIndex) {
let ownerList = this.ownerView._list;
let groupsArray = this._orderedGroupElementsArray;
if (aIndex >= 0) {
ownerList.insertBefore(this._target, groupsArray[aIndex]);
groupsArray.splice(aIndex, 0, this._target);
} else {
ownerList.appendChild(this._target);
groupsArray.push(this._target);
}
},
/**
* Finds the expected index of this group based on its name.
*
* @return number
* The expected index.
*/
findExpectedIndexForSelf: function (sortPredicate) {
let identifier = this.identifier;
let groupsArray = this._orderedGroupElementsArray;
for (let group of groupsArray) {
let name = group.getAttribute("name");
if (sortPredicate(name, identifier) > 0 && // Insertion sort at its best :)
!name.includes(identifier)) { // Least significant group should be last.
return groupsArray.indexOf(group);
}
}
return -1;
},
window: null,
document: null,
ownerView: null,
identifier: "",
_target: null,
_checkbox: null,
_title: null,
_name: null,
_list: null
};
/**
* A SideMenuItem constructor for the BreadcrumbsWidget.
*
* @param SideMenuGroup aGroup
* The group to contain this menu item.
* @param nsIDOMNode aContents
* The node displayed in the container.
* @param object aAttachment [optional]
* The attachment object.
* @param object aOptions [optional]
* An object containing the following properties:
* - showArrow: specifies if a horizontal arrow should be displayed.
* - showCheckbox: specifies if a checkbox should be displayed.
*/
function SideMenuItem(aGroup, aContents, aAttachment = {}, aOptions = {}) {
this.document = aGroup.document;
this.window = aGroup.window;
this.ownerView = aGroup;
if (aOptions.showArrow || aOptions.showCheckbox) {
let container = this._container = this.document.createElement("hbox");
container.className = "side-menu-widget-item";
let target = this._target = this.document.createElement("vbox");
target.className = "side-menu-widget-item-contents";
// Show a checkbox before the content.
if (aOptions.showCheckbox) {
let checkbox = this._checkbox = makeCheckbox(container, aAttachment);
checkbox.className = "side-menu-widget-item-checkbox";
}
container.appendChild(target);
// Show a horizontal arrow towards the content.
if (aOptions.showArrow) {
let arrow = this._arrow = this.document.createElement("hbox");
arrow.className = "side-menu-widget-item-arrow";
container.appendChild(arrow);
}
}
// Skip a few redundant nodes when no horizontal arrow or checkbox is shown.
else {
let target = this._target = this._container = this.document.createElement("hbox");
target.className = "side-menu-widget-item side-menu-widget-item-contents";
target.setAttribute("merged-item-contents", "");
}
this._target.setAttribute("flex", "1");
this.contents = aContents;
}
SideMenuItem.prototype = {
get _orderedGroupElementsArray() {
return this.ownerView._orderedGroupElementsArray;
},
get _orderedMenuElementsArray() {
return this.ownerView._orderedMenuElementsArray;
},
get _itemsByElement() { return this.ownerView._itemsByElement; },
/**
* Inserts this item in the parent group at the specified index.
*
* @param number aIndex
* The position in the container intended for this item.
* @return nsIDOMNode
* The element associated with the displayed item.
*/
insertSelfAt: function (aIndex) {
let ownerList = this.ownerView._list;
let menuArray = this._orderedMenuElementsArray;
if (aIndex >= 0) {
ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]);
menuArray.splice(aIndex, 0, this._target);
} else {
ownerList.appendChild(this._container);
menuArray.push(this._target);
}
this._itemsByElement.set(this._target, this);
return this._target;
},
/**
* Check or uncheck the checkbox associated with this item.
*
* @param boolean aCheckState
* True to check, false to uncheck.
*/
check: function (aCheckState) {
if (!this._checkbox) {
throw new Error("Cannot check items that do not have checkboxes.");
}
// Don't set or remove the "checked" attribute, assign the property instead.
// Otherwise, the "CheckboxStateChange" event will not be fired. XUL!!
this._checkbox.checked = !!aCheckState;
},
/**
* Sets the contents displayed in this item's view.
*
* @param string | nsIDOMNode aContents
* The string or node displayed in the container.
*/
set contents(aContents) {
// If there are already some contents displayed, replace them.
if (this._target.hasChildNodes()) {
this._target.replaceChild(aContents, this._target.firstChild);
return;
}
// These are the first contents ever displayed.
this._target.appendChild(aContents);
},
window: null,
document: null,
ownerView: null,
_target: null,
_container: null,
_checkbox: null,
_arrow: null
};
/**
* Creates a checkbox to a specified parent node. Emits a "check" event
* whenever the checkbox is checked or unchecked by the user.
*
* @param nsIDOMNode aParentNode
* The parent node to contain this checkbox.
* @param object aOptions
* An object containing some or all of the following properties:
* - description: defaults to "item" if unspecified
* - checkboxState: true for checked, false for unchecked
* - checkboxTooltip: the tooltip text of the checkbox
*/
function makeCheckbox(aParentNode, aOptions) {
let checkbox = aParentNode.ownerDocument.createElement("checkbox");
checkbox.setAttribute("tooltiptext", aOptions.checkboxTooltip || "");
if (aOptions.checkboxState) {
checkbox.setAttribute("checked", true);
} else {
checkbox.removeAttribute("checked");
}
// Stop the toggling of the checkbox from selecting the list item.
checkbox.addEventListener("mousedown", e => {
e.stopPropagation();
}, false);
// Emit an event from the checkbox when it is toggled. Don't listen for the
// "command" event! It won't fire for programmatic changes. XUL!!
checkbox.addEventListener("CheckboxStateChange", e => {
ViewHelpers.dispatchEvent(checkbox, "check", {
description: aOptions.description || "item",
checked: checkbox.checked
});
}, false);
aParentNode.appendChild(checkbox);
return checkbox;
}
|