browser/devtools/inspector/breadcrumbs.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:a7782d0b84db
1 /* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6
7 const {Cc, Cu, Ci} = require("chrome");
8
9 const PSEUDO_CLASSES = [":hover", ":active", ":focus"];
10 const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms
11
12 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
13 Cu.import("resource:///modules/devtools/DOMHelpers.jsm");
14 Cu.import("resource://gre/modules/Services.jsm");
15 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
16 const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
17 const MAX_LABEL_LENGTH = 40;
18
19 let promise = require("devtools/toolkit/deprecated-sync-thenables");
20
21 const LOW_PRIORITY_ELEMENTS = {
22 "HEAD": true,
23 "BASE": true,
24 "BASEFONT": true,
25 "ISINDEX": true,
26 "LINK": true,
27 "META": true,
28 "SCRIPT": true,
29 "STYLE": true,
30 "TITLE": true,
31 };
32
33 function resolveNextTick(value) {
34 let deferred = promise.defer();
35 Services.tm.mainThread.dispatch(() => {
36 try {
37 deferred.resolve(value);
38 } catch(ex) {
39 console.error(ex);
40 }
41 }, Ci.nsIThread.DISPATCH_NORMAL);
42 return deferred.promise;
43 }
44
45 ///////////////////////////////////////////////////////////////////////////
46 //// HTML Breadcrumbs
47
48 /**
49 * Display the ancestors of the current node and its children.
50 * Only one "branch" of children are displayed (only one line).
51 *
52 * FIXME: Bug 822388 - Use the BreadcrumbsWidget in the Inspector.
53 *
54 * Mechanism:
55 * . If no nodes displayed yet:
56 * then display the ancestor of the selected node and the selected node;
57 * else select the node;
58 * . If the selected node is the last node displayed, append its first (if any).
59 */
60 function HTMLBreadcrumbs(aInspector)
61 {
62 this.inspector = aInspector;
63 this.selection = this.inspector.selection;
64 this.chromeWin = this.inspector.panelWin;
65 this.chromeDoc = this.inspector.panelDoc;
66 this.DOMHelpers = new DOMHelpers(this.chromeWin);
67 this._init();
68 }
69
70 exports.HTMLBreadcrumbs = HTMLBreadcrumbs;
71
72 HTMLBreadcrumbs.prototype = {
73 get walker() this.inspector.walker,
74
75 _init: function BC__init()
76 {
77 this.container = this.chromeDoc.getElementById("inspector-breadcrumbs");
78
79 // These separators are used for CSS purposes only, and are positioned
80 // off screen, but displayed with -moz-element.
81 this.separators = this.chromeDoc.createElement("box");
82 this.separators.className = "breadcrumb-separator-container";
83 this.separators.innerHTML =
84 "<box id='breadcrumb-separator-before'></box>" +
85 "<box id='breadcrumb-separator-after'></box>" +
86 "<box id='breadcrumb-separator-normal'></box>";
87 this.container.parentNode.appendChild(this.separators);
88
89 this.container.addEventListener("mousedown", this, true);
90 this.container.addEventListener("keypress", this, true);
91
92 // We will save a list of already displayed nodes in this array.
93 this.nodeHierarchy = [];
94
95 // Last selected node in nodeHierarchy.
96 this.currentIndex = -1;
97
98 // By default, hide the arrows. We let the <scrollbox> show them
99 // in case of overflow.
100 this.container.removeAttribute("overflows");
101 this.container._scrollButtonUp.collapsed = true;
102 this.container._scrollButtonDown.collapsed = true;
103
104 this.onscrollboxreflow = function() {
105 if (this.container._scrollButtonDown.collapsed)
106 this.container.removeAttribute("overflows");
107 else
108 this.container.setAttribute("overflows", true);
109 }.bind(this);
110
111 this.container.addEventListener("underflow", this.onscrollboxreflow, false);
112 this.container.addEventListener("overflow", this.onscrollboxreflow, false);
113
114 this.update = this.update.bind(this);
115 this.updateSelectors = this.updateSelectors.bind(this);
116 this.selection.on("new-node-front", this.update);
117 this.selection.on("pseudoclass", this.updateSelectors);
118 this.selection.on("attribute-changed", this.updateSelectors);
119 this.inspector.on("markupmutation", this.update);
120 this.update();
121 },
122
123 /**
124 * Include in a promise's then() chain to reject the chain
125 * when the breadcrumbs' selection has changed while the promise
126 * was outstanding.
127 */
128 selectionGuard: function() {
129 let selection = this.selection.nodeFront;
130 return (result) => {
131 if (selection != this.selection.nodeFront) {
132 return promise.reject("selection-changed");
133 }
134 return result;
135 }
136 },
137
138 /**
139 * Print any errors (except selection guard errors).
140 */
141 selectionGuardEnd: function(err) {
142 if (err != "selection-changed") {
143 console.error(err);
144 }
145 promise.reject(err);
146 },
147
148 /**
149 * Build a string that represents the node: tagName#id.class1.class2.
150 *
151 * @param aNode The node to pretty-print
152 * @returns a string
153 */
154 prettyPrintNodeAsText: function BC_prettyPrintNodeText(aNode)
155 {
156 let text = aNode.tagName.toLowerCase();
157 if (aNode.id) {
158 text += "#" + aNode.id;
159 }
160
161 if (aNode.className) {
162 let classList = aNode.className.split(/\s+/);
163 for (let i = 0; i < classList.length; i++) {
164 text += "." + classList[i];
165 }
166 }
167
168 for (let pseudo of aNode.pseudoClassLocks) {
169 text += pseudo;
170 }
171
172 return text;
173 },
174
175
176 /**
177 * Build <label>s that represent the node:
178 * <label class="breadcrumbs-widget-item-tag">tagName</label>
179 * <label class="breadcrumbs-widget-item-id">#id</label>
180 * <label class="breadcrumbs-widget-item-classes">.class1.class2</label>
181 *
182 * @param aNode The node to pretty-print
183 * @returns a document fragment.
184 */
185 prettyPrintNodeAsXUL: function BC_prettyPrintNodeXUL(aNode)
186 {
187 let fragment = this.chromeDoc.createDocumentFragment();
188
189 let tagLabel = this.chromeDoc.createElement("label");
190 tagLabel.className = "breadcrumbs-widget-item-tag plain";
191
192 let idLabel = this.chromeDoc.createElement("label");
193 idLabel.className = "breadcrumbs-widget-item-id plain";
194
195 let classesLabel = this.chromeDoc.createElement("label");
196 classesLabel.className = "breadcrumbs-widget-item-classes plain";
197
198 let pseudosLabel = this.chromeDoc.createElement("label");
199 pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes plain";
200
201 let tagText = aNode.tagName.toLowerCase();
202 let idText = aNode.id ? ("#" + aNode.id) : "";
203 let classesText = "";
204
205 if (aNode.className) {
206 let classList = aNode.className.split(/\s+/);
207 for (let i = 0; i < classList.length; i++) {
208 classesText += "." + classList[i];
209 }
210 }
211
212 // XXX: Until we have pseudoclass lock in the node.
213 for (let pseudo of aNode.pseudoClassLocks) {
214
215 }
216
217 // Figure out which element (if any) needs ellipsing.
218 // Substring for that element, then clear out any extras
219 // (except for pseudo elements).
220 let maxTagLength = MAX_LABEL_LENGTH;
221 let maxIdLength = MAX_LABEL_LENGTH - tagText.length;
222 let maxClassLength = MAX_LABEL_LENGTH - tagText.length - idText.length;
223
224 if (tagText.length > maxTagLength) {
225 tagText = tagText.substr(0, maxTagLength) + ELLIPSIS;
226 idText = classesText = "";
227 } else if (idText.length > maxIdLength) {
228 idText = idText.substr(0, maxIdLength) + ELLIPSIS;
229 classesText = "";
230 } else if (classesText.length > maxClassLength) {
231 classesText = classesText.substr(0, maxClassLength) + ELLIPSIS;
232 }
233
234 tagLabel.textContent = tagText;
235 idLabel.textContent = idText;
236 classesLabel.textContent = classesText;
237 pseudosLabel.textContent = aNode.pseudoClassLocks.join("");
238
239 fragment.appendChild(tagLabel);
240 fragment.appendChild(idLabel);
241 fragment.appendChild(classesLabel);
242 fragment.appendChild(pseudosLabel);
243
244 return fragment;
245 },
246
247 /**
248 * Open the sibling menu.
249 *
250 * @param aButton the button representing the node.
251 * @param aNode the node we want the siblings from.
252 */
253 openSiblingMenu: function BC_openSiblingMenu(aButton, aNode)
254 {
255 // We make sure that the targeted node is selected
256 // because we want to use the nodemenu that only works
257 // for inspector.selection
258 this.selection.setNodeFront(aNode, "breadcrumbs");
259
260 let title = this.chromeDoc.createElement("menuitem");
261 title.setAttribute("label", this.inspector.strings.GetStringFromName("breadcrumbs.siblings"));
262 title.setAttribute("disabled", "true");
263
264 let separator = this.chromeDoc.createElement("menuseparator");
265
266 let items = [title, separator];
267
268 this.walker.siblings(aNode, {
269 whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
270 }).then(siblings => {
271 let nodes = siblings.nodes;
272 for (let i = 0; i < nodes.length; i++) {
273 let item = this.chromeDoc.createElement("menuitem");
274 if (nodes[i] === aNode) {
275 item.setAttribute("disabled", "true");
276 item.setAttribute("checked", "true");
277 }
278
279 item.setAttribute("type", "radio");
280 item.setAttribute("label", this.prettyPrintNodeAsText(nodes[i]));
281
282 let selection = this.selection;
283 item.onmouseup = (function(aNode) {
284 return function() {
285 selection.setNodeFront(aNode, "breadcrumbs");
286 }
287 })(nodes[i]);
288
289 items.push(item);
290 this.inspector.showNodeMenu(aButton, "before_start", items);
291 }
292 });
293 },
294
295 /**
296 * Generic event handler.
297 *
298 * @param nsIDOMEvent event
299 * The DOM event object.
300 */
301 handleEvent: function BC_handleEvent(event)
302 {
303 if (event.type == "mousedown" && event.button == 0) {
304 // on Click and Hold, open the Siblings menu
305
306 let timer;
307 let container = this.container;
308
309 function openMenu(event) {
310 cancelHold();
311 let target = event.originalTarget;
312 if (target.tagName == "button") {
313 target.onBreadcrumbsHold();
314 }
315 }
316
317 function handleClick(event) {
318 cancelHold();
319 let target = event.originalTarget;
320 if (target.tagName == "button") {
321 target.onBreadcrumbsClick();
322 }
323 }
324
325 let window = this.chromeWin;
326 function cancelHold(event) {
327 window.clearTimeout(timer);
328 container.removeEventListener("mouseout", cancelHold, false);
329 container.removeEventListener("mouseup", handleClick, false);
330 }
331
332 container.addEventListener("mouseout", cancelHold, false);
333 container.addEventListener("mouseup", handleClick, false);
334 timer = window.setTimeout(openMenu, 500, event);
335 }
336
337 if (event.type == "keypress" && this.selection.isElementNode()) {
338 let node = null;
339
340
341 this._keyPromise = this._keyPromise || promise.resolve(null);
342
343 this._keyPromise = (this._keyPromise || promise.resolve(null)).then(() => {
344 switch (event.keyCode) {
345 case this.chromeWin.KeyEvent.DOM_VK_LEFT:
346 if (this.currentIndex != 0) {
347 node = promise.resolve(this.nodeHierarchy[this.currentIndex - 1].node);
348 }
349 break;
350 case this.chromeWin.KeyEvent.DOM_VK_RIGHT:
351 if (this.currentIndex < this.nodeHierarchy.length - 1) {
352 node = promise.resolve(this.nodeHierarchy[this.currentIndex + 1].node);
353 }
354 break;
355 case this.chromeWin.KeyEvent.DOM_VK_UP:
356 node = this.walker.previousSibling(this.selection.nodeFront, {
357 whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
358 });
359 break;
360 case this.chromeWin.KeyEvent.DOM_VK_DOWN:
361 node = this.walker.nextSibling(this.selection.nodeFront, {
362 whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
363 });
364 break;
365 }
366
367 return node.then((node) => {
368 if (node) {
369 this.selection.setNodeFront(node, "breadcrumbs");
370 }
371 });
372 });
373 event.preventDefault();
374 event.stopPropagation();
375 }
376 },
377
378 /**
379 * Remove nodes and delete properties.
380 */
381 destroy: function BC_destroy()
382 {
383 this.selection.off("new-node-front", this.update);
384 this.selection.off("pseudoclass", this.updateSelectors);
385 this.selection.off("attribute-changed", this.updateSelectors);
386 this.inspector.off("markupmutation", this.update);
387
388 this.container.removeEventListener("underflow", this.onscrollboxreflow, false);
389 this.container.removeEventListener("overflow", this.onscrollboxreflow, false);
390 this.onscrollboxreflow = null;
391
392 this.empty();
393 this.container.removeEventListener("mousedown", this, true);
394 this.container.removeEventListener("keypress", this, true);
395 this.container = null;
396
397 this.separators.remove();
398 this.separators = null;
399
400 this.nodeHierarchy = null;
401 },
402
403 /**
404 * Empty the breadcrumbs container.
405 */
406 empty: function BC_empty()
407 {
408 while (this.container.hasChildNodes()) {
409 this.container.removeChild(this.container.firstChild);
410 }
411 },
412
413 /**
414 * Set which button represent the selected node.
415 *
416 * @param aIdx Index of the displayed-button to select
417 */
418 setCursor: function BC_setCursor(aIdx)
419 {
420 // Unselect the previously selected button
421 if (this.currentIndex > -1 && this.currentIndex < this.nodeHierarchy.length) {
422 this.nodeHierarchy[this.currentIndex].button.removeAttribute("checked");
423 }
424 if (aIdx > -1) {
425 this.nodeHierarchy[aIdx].button.setAttribute("checked", "true");
426 if (this.hadFocus)
427 this.nodeHierarchy[aIdx].button.focus();
428 }
429 this.currentIndex = aIdx;
430 },
431
432 /**
433 * Get the index of the node in the cache.
434 *
435 * @param aNode
436 * @returns integer the index, -1 if not found
437 */
438 indexOf: function BC_indexOf(aNode)
439 {
440 let i = this.nodeHierarchy.length - 1;
441 for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
442 if (this.nodeHierarchy[i].node === aNode) {
443 return i;
444 }
445 }
446 return -1;
447 },
448
449 /**
450 * Remove all the buttons and their references in the cache
451 * after a given index.
452 *
453 * @param aIdx
454 */
455 cutAfter: function BC_cutAfter(aIdx)
456 {
457 while (this.nodeHierarchy.length > (aIdx + 1)) {
458 let toRemove = this.nodeHierarchy.pop();
459 this.container.removeChild(toRemove.button);
460 }
461 },
462
463 /**
464 * Build a button representing the node.
465 *
466 * @param aNode The node from the page.
467 * @returns aNode The <button>.
468 */
469 buildButton: function BC_buildButton(aNode)
470 {
471 let button = this.chromeDoc.createElement("button");
472 button.appendChild(this.prettyPrintNodeAsXUL(aNode));
473 button.className = "breadcrumbs-widget-item";
474
475 button.setAttribute("tooltiptext", this.prettyPrintNodeAsText(aNode));
476
477 button.onkeypress = function onBreadcrumbsKeypress(e) {
478 if (e.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE ||
479 e.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN)
480 button.click();
481 }
482
483 button.onBreadcrumbsClick = function onBreadcrumbsClick() {
484 this.selection.setNodeFront(aNode, "breadcrumbs");
485 }.bind(this);
486
487 button.onclick = (function _onBreadcrumbsRightClick(event) {
488 button.focus();
489 if (event.button == 2) {
490 this.openSiblingMenu(button, aNode);
491 }
492 }).bind(this);
493
494 button.onBreadcrumbsHold = (function _onBreadcrumbsHold() {
495 this.openSiblingMenu(button, aNode);
496 }).bind(this);
497 return button;
498 },
499
500 /**
501 * Connecting the end of the breadcrumbs to a node.
502 *
503 * @param aNode The node to reach.
504 */
505 expand: function BC_expand(aNode)
506 {
507 let fragment = this.chromeDoc.createDocumentFragment();
508 let toAppend = aNode;
509 let lastButtonInserted = null;
510 let originalLength = this.nodeHierarchy.length;
511 let stopNode = null;
512 if (originalLength > 0) {
513 stopNode = this.nodeHierarchy[originalLength - 1].node;
514 }
515 while (toAppend && toAppend != stopNode) {
516 if (toAppend.tagName) {
517 let button = this.buildButton(toAppend);
518 fragment.insertBefore(button, lastButtonInserted);
519 lastButtonInserted = button;
520 this.nodeHierarchy.splice(originalLength, 0, {node: toAppend, button: button});
521 }
522 toAppend = toAppend.parentNode();
523 }
524 this.container.appendChild(fragment, this.container.firstChild);
525 },
526
527 /**
528 * Get a child of a node that can be displayed in the breadcrumbs
529 * and that is probably visible. See LOW_PRIORITY_ELEMENTS.
530 *
531 * @param aNode The parent node.
532 * @returns nsIDOMNode|null
533 */
534 getInterestingFirstNode: function BC_getInterestingFirstNode(aNode)
535 {
536 let deferred = promise.defer();
537
538 var fallback = null;
539
540 var moreChildren = () => {
541 this.walker.children(aNode, {
542 start: fallback,
543 maxNodes: 10,
544 whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
545 }).then(this.selectionGuard()).then(response => {
546 for (let node of response.nodes) {
547 if (!(node.tagName in LOW_PRIORITY_ELEMENTS)) {
548 deferred.resolve(node);
549 return;
550 }
551 if (!fallback) {
552 fallback = node;
553 }
554 }
555 if (response.hasLast) {
556 deferred.resolve(fallback);
557 return;
558 } else {
559 moreChildren();
560 }
561 }).then(null, this.selectionGuardEnd);
562 }
563 moreChildren();
564 return deferred.promise;
565 },
566
567 /**
568 * Find the "youngest" ancestor of a node which is already in the breadcrumbs.
569 *
570 * @param aNode
571 * @returns Index of the ancestor in the cache
572 */
573 getCommonAncestor: function BC_getCommonAncestor(aNode)
574 {
575 let node = aNode;
576 while (node) {
577 let idx = this.indexOf(node);
578 if (idx > -1) {
579 return idx;
580 } else {
581 node = node.parentNode();
582 }
583 }
584 return -1;
585 },
586
587 /**
588 * Make sure that the latest node in the breadcrumbs is not the selected node
589 * if the selected node still has children.
590 */
591 ensureFirstChild: function BC_ensureFirstChild()
592 {
593 // If the last displayed node is the selected node
594 if (this.currentIndex == this.nodeHierarchy.length - 1) {
595 let node = this.nodeHierarchy[this.currentIndex].node;
596 return this.getInterestingFirstNode(node).then(child => {
597 // If the node has a child
598 if (child) {
599 // Show this child
600 this.expand(child);
601 }
602 });
603 }
604
605 return resolveNextTick(true);
606 },
607
608 /**
609 * Ensure the selected node is visible.
610 */
611 scroll: function BC_scroll()
612 {
613 // FIXME bug 684352: make sure its immediate neighbors are visible too.
614
615 let scrollbox = this.container;
616 let element = this.nodeHierarchy[this.currentIndex].button;
617
618 // Repeated calls to ensureElementIsVisible would interfere with each other
619 // and may sometimes result in incorrect scroll positions.
620 this.chromeWin.clearTimeout(this._ensureVisibleTimeout);
621 this._ensureVisibleTimeout = this.chromeWin.setTimeout(function() {
622 scrollbox.ensureElementIsVisible(element);
623 }, ENSURE_SELECTION_VISIBLE_DELAY);
624 },
625
626 updateSelectors: function BC_updateSelectors()
627 {
628 for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
629 let crumb = this.nodeHierarchy[i];
630 let button = crumb.button;
631
632 while(button.hasChildNodes()) {
633 button.removeChild(button.firstChild);
634 }
635 button.appendChild(this.prettyPrintNodeAsXUL(crumb.node));
636 button.setAttribute("tooltiptext", this.prettyPrintNodeAsText(crumb.node));
637 }
638 },
639
640 /**
641 * Update the breadcrumbs display when a new node is selected.
642 */
643 update: function BC_update(reason)
644 {
645 if (reason !== "markupmutation") {
646 this.inspector.hideNodeMenu();
647 }
648
649 let cmdDispatcher = this.chromeDoc.commandDispatcher;
650 this.hadFocus = (cmdDispatcher.focusedElement &&
651 cmdDispatcher.focusedElement.parentNode == this.container);
652
653 if (!this.selection.isConnected()) {
654 this.cutAfter(-1); // remove all the crumbs
655 return;
656 }
657
658 if (!this.selection.isElementNode()) {
659 this.setCursor(-1); // no selection
660 return;
661 }
662
663 let idx = this.indexOf(this.selection.nodeFront);
664
665 // Is the node already displayed in the breadcrumbs?
666 // (and there are no mutations that need re-display of the crumbs)
667 if (idx > -1 && reason !== "markupmutation") {
668 // Yes. We select it.
669 this.setCursor(idx);
670 } else {
671 // No. Is the breadcrumbs display empty?
672 if (this.nodeHierarchy.length > 0) {
673 // No. We drop all the element that are not direct ancestors
674 // of the selection
675 let parent = this.selection.nodeFront.parentNode();
676 let idx = this.getCommonAncestor(parent);
677 this.cutAfter(idx);
678 }
679 // we append the missing button between the end of the breadcrumbs display
680 // and the current node.
681 this.expand(this.selection.nodeFront);
682
683 // we select the current node button
684 idx = this.indexOf(this.selection.nodeFront);
685 this.setCursor(idx);
686 }
687
688 let doneUpdating = this.inspector.updating("breadcrumbs");
689 // Add the first child of the very last node of the breadcrumbs if possible.
690 this.ensureFirstChild().then(this.selectionGuard()).then(() => {
691 this.updateSelectors();
692
693 // Make sure the selected node and its neighbours are visible.
694 this.scroll();
695 return resolveNextTick().then(() => {
696 this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
697 doneUpdating();
698 });
699 }).then(null, err => {
700 doneUpdating(this.selection.nodeFront);
701 this.selectionGuardEnd(err);
702 });
703 }
704 };
705
706 XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () {
707 return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
708 });

mercurial