gecko-dev/browser/devtools/inspector/breadcrumbs.js
Jim Blandy 7e20285e70 Bug 914753: Make Emacs file variable header lines correct, or at least consistent. DONTBUILD r=ehsan
The -*- file variable lines -*- establish per-file settings that Emacs will
pick up. This patch makes the following changes to those lines (and touches
nothing else):

 - Never set the buffer's mode.

   Years ago, Emacs did not have a good JavaScript mode, so it made sense
   to use Java or C++ mode in .js files. However, Emacs has had js-mode for
   years now; it's perfectly serviceable, and is available and enabled by
   default in all major Emacs packagings.

   Selecting a mode in the -*- file variable line -*- is almost always the
   wrong thing to do anyway. It overrides Emacs's default choice, which is
   (now) reasonable; and even worse, it overrides settings the user might
   have made in their '.emacs' file for that file extension. It's only
   useful when there's something specific about that particular file that
   makes a particular mode appropriate.

 - Correctly propagate settings that establish the correct indentation
   level for this file: c-basic-offset and js2-basic-offset should be
   js-indent-level. Whatever value they're given should be preserved;
   different parts of our tree use different indentation styles.

 - We don't use tabs in Mozilla JS code. Always set indent-tabs-mode: nil.
   Remove tab-width: settings, at least in files that don't contain tab
   characters.

 - Remove js2-mode settings that belong in the user's .emacs file, like
   js2-skip-preprocessor-directives.
2014-06-24 22:12:07 -07:00

709 lines
21 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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/. */
const {Cc, Cu, Ci} = require("chrome");
const PSEUDO_CLASSES = [":hover", ":active", ":focus"];
const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/devtools/DOMHelpers.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
const MAX_LABEL_LENGTH = 40;
let promise = require("devtools/toolkit/deprecated-sync-thenables");
const LOW_PRIORITY_ELEMENTS = {
"HEAD": true,
"BASE": true,
"BASEFONT": true,
"ISINDEX": true,
"LINK": true,
"META": true,
"SCRIPT": true,
"STYLE": true,
"TITLE": true,
};
function resolveNextTick(value) {
let deferred = promise.defer();
Services.tm.mainThread.dispatch(() => {
try {
deferred.resolve(value);
} catch(ex) {
console.error(ex);
}
}, Ci.nsIThread.DISPATCH_NORMAL);
return deferred.promise;
}
///////////////////////////////////////////////////////////////////////////
//// HTML Breadcrumbs
/**
* Display the ancestors of the current node and its children.
* Only one "branch" of children are displayed (only one line).
*
* FIXME: Bug 822388 - Use the BreadcrumbsWidget in the Inspector.
*
* Mechanism:
* . If no nodes displayed yet:
* then display the ancestor of the selected node and the selected node;
* else select the node;
* . If the selected node is the last node displayed, append its first (if any).
*/
function HTMLBreadcrumbs(aInspector)
{
this.inspector = aInspector;
this.selection = this.inspector.selection;
this.chromeWin = this.inspector.panelWin;
this.chromeDoc = this.inspector.panelDoc;
this.DOMHelpers = new DOMHelpers(this.chromeWin);
this._init();
}
exports.HTMLBreadcrumbs = HTMLBreadcrumbs;
HTMLBreadcrumbs.prototype = {
get walker() this.inspector.walker,
_init: function BC__init()
{
this.container = this.chromeDoc.getElementById("inspector-breadcrumbs");
// These separators are used for CSS purposes only, and are positioned
// off screen, but displayed with -moz-element.
this.separators = this.chromeDoc.createElement("box");
this.separators.className = "breadcrumb-separator-container";
this.separators.innerHTML =
"<box id='breadcrumb-separator-before'></box>" +
"<box id='breadcrumb-separator-after'></box>" +
"<box id='breadcrumb-separator-normal'></box>";
this.container.parentNode.appendChild(this.separators);
this.container.addEventListener("mousedown", this, true);
this.container.addEventListener("keypress", this, true);
// We will save a list of already displayed nodes in this array.
this.nodeHierarchy = [];
// Last selected node in nodeHierarchy.
this.currentIndex = -1;
// By default, hide the arrows. We let the <scrollbox> show them
// in case of overflow.
this.container.removeAttribute("overflows");
this.container._scrollButtonUp.collapsed = true;
this.container._scrollButtonDown.collapsed = true;
this.onscrollboxreflow = () => {
if (this.container._scrollButtonDown.collapsed)
this.container.removeAttribute("overflows");
else
this.container.setAttribute("overflows", true);
};
this.container.addEventListener("underflow", this.onscrollboxreflow, false);
this.container.addEventListener("overflow", this.onscrollboxreflow, false);
this.update = this.update.bind(this);
this.updateSelectors = this.updateSelectors.bind(this);
this.selection.on("new-node-front", this.update);
this.selection.on("pseudoclass", this.updateSelectors);
this.selection.on("attribute-changed", this.updateSelectors);
this.inspector.on("markupmutation", this.update);
this.update();
},
/**
* Include in a promise's then() chain to reject the chain
* when the breadcrumbs' selection has changed while the promise
* was outstanding.
*/
selectionGuard: function() {
let selection = this.selection.nodeFront;
return (result) => {
if (selection != this.selection.nodeFront) {
return promise.reject("selection-changed");
}
return result;
}
},
/**
* Print any errors (except selection guard errors).
*/
selectionGuardEnd: function(err) {
if (err != "selection-changed") {
console.error(err);
}
promise.reject(err);
},
/**
* Build a string that represents the node: tagName#id.class1.class2.
*
* @param aNode The node to pretty-print
* @returns a string
*/
prettyPrintNodeAsText: function BC_prettyPrintNodeText(aNode)
{
let text = aNode.tagName.toLowerCase();
if (aNode.id) {
text += "#" + aNode.id;
}
if (aNode.className) {
let classList = aNode.className.split(/\s+/);
for (let i = 0; i < classList.length; i++) {
text += "." + classList[i];
}
}
for (let pseudo of aNode.pseudoClassLocks) {
text += pseudo;
}
return text;
},
/**
* Build <label>s that represent the node:
* <label class="breadcrumbs-widget-item-tag">tagName</label>
* <label class="breadcrumbs-widget-item-id">#id</label>
* <label class="breadcrumbs-widget-item-classes">.class1.class2</label>
*
* @param aNode The node to pretty-print
* @returns a document fragment.
*/
prettyPrintNodeAsXUL: function BC_prettyPrintNodeXUL(aNode)
{
let fragment = this.chromeDoc.createDocumentFragment();
let tagLabel = this.chromeDoc.createElement("label");
tagLabel.className = "breadcrumbs-widget-item-tag plain";
let idLabel = this.chromeDoc.createElement("label");
idLabel.className = "breadcrumbs-widget-item-id plain";
let classesLabel = this.chromeDoc.createElement("label");
classesLabel.className = "breadcrumbs-widget-item-classes plain";
let pseudosLabel = this.chromeDoc.createElement("label");
pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes plain";
let tagText = aNode.tagName.toLowerCase();
let idText = aNode.id ? ("#" + aNode.id) : "";
let classesText = "";
if (aNode.className) {
let classList = aNode.className.split(/\s+/);
for (let i = 0; i < classList.length; i++) {
classesText += "." + classList[i];
}
}
// XXX: Until we have pseudoclass lock in the node.
for (let pseudo of aNode.pseudoClassLocks) {
}
// Figure out which element (if any) needs ellipsing.
// Substring for that element, then clear out any extras
// (except for pseudo elements).
let maxTagLength = MAX_LABEL_LENGTH;
let maxIdLength = MAX_LABEL_LENGTH - tagText.length;
let maxClassLength = MAX_LABEL_LENGTH - tagText.length - idText.length;
if (tagText.length > maxTagLength) {
tagText = tagText.substr(0, maxTagLength) + ELLIPSIS;
idText = classesText = "";
} else if (idText.length > maxIdLength) {
idText = idText.substr(0, maxIdLength) + ELLIPSIS;
classesText = "";
} else if (classesText.length > maxClassLength) {
classesText = classesText.substr(0, maxClassLength) + ELLIPSIS;
}
tagLabel.textContent = tagText;
idLabel.textContent = idText;
classesLabel.textContent = classesText;
pseudosLabel.textContent = aNode.pseudoClassLocks.join("");
fragment.appendChild(tagLabel);
fragment.appendChild(idLabel);
fragment.appendChild(classesLabel);
fragment.appendChild(pseudosLabel);
return fragment;
},
/**
* Open the sibling menu.
*
* @param aButton the button representing the node.
* @param aNode the node we want the siblings from.
*/
openSiblingMenu: function BC_openSiblingMenu(aButton, aNode)
{
// We make sure that the targeted node is selected
// because we want to use the nodemenu that only works
// for inspector.selection
this.selection.setNodeFront(aNode, "breadcrumbs");
let title = this.chromeDoc.createElement("menuitem");
title.setAttribute("label", this.inspector.strings.GetStringFromName("breadcrumbs.siblings"));
title.setAttribute("disabled", "true");
let separator = this.chromeDoc.createElement("menuseparator");
let items = [title, separator];
this.walker.siblings(aNode, {
whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
}).then(siblings => {
let nodes = siblings.nodes;
for (let i = 0; i < nodes.length; i++) {
let item = this.chromeDoc.createElement("menuitem");
if (nodes[i] === aNode) {
item.setAttribute("disabled", "true");
item.setAttribute("checked", "true");
}
item.setAttribute("type", "radio");
item.setAttribute("label", this.prettyPrintNodeAsText(nodes[i]));
let selection = this.selection;
item.onmouseup = (function(aNode) {
return function() {
selection.setNodeFront(aNode, "breadcrumbs");
}
})(nodes[i]);
items.push(item);
this.inspector.showNodeMenu(aButton, "before_start", items);
}
});
},
/**
* Generic event handler.
*
* @param nsIDOMEvent event
* The DOM event object.
*/
handleEvent: function BC_handleEvent(event)
{
if (event.type == "mousedown" && event.button == 0) {
// on Click and Hold, open the Siblings menu
let timer;
let container = this.container;
function openMenu(event) {
cancelHold();
let target = event.originalTarget;
if (target.tagName == "button") {
target.onBreadcrumbsHold();
}
}
function handleClick(event) {
cancelHold();
let target = event.originalTarget;
if (target.tagName == "button") {
target.onBreadcrumbsClick();
}
}
let window = this.chromeWin;
function cancelHold(event) {
window.clearTimeout(timer);
container.removeEventListener("mouseout", cancelHold, false);
container.removeEventListener("mouseup", handleClick, false);
}
container.addEventListener("mouseout", cancelHold, false);
container.addEventListener("mouseup", handleClick, false);
timer = window.setTimeout(openMenu, 500, event);
}
if (event.type == "keypress" && this.selection.isElementNode()) {
let node = null;
this._keyPromise = this._keyPromise || promise.resolve(null);
this._keyPromise = (this._keyPromise || promise.resolve(null)).then(() => {
switch (event.keyCode) {
case this.chromeWin.KeyEvent.DOM_VK_LEFT:
if (this.currentIndex != 0) {
node = promise.resolve(this.nodeHierarchy[this.currentIndex - 1].node);
}
break;
case this.chromeWin.KeyEvent.DOM_VK_RIGHT:
if (this.currentIndex < this.nodeHierarchy.length - 1) {
node = promise.resolve(this.nodeHierarchy[this.currentIndex + 1].node);
}
break;
case this.chromeWin.KeyEvent.DOM_VK_UP:
node = this.walker.previousSibling(this.selection.nodeFront, {
whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
});
break;
case this.chromeWin.KeyEvent.DOM_VK_DOWN:
node = this.walker.nextSibling(this.selection.nodeFront, {
whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
});
break;
}
return node.then((node) => {
if (node) {
this.selection.setNodeFront(node, "breadcrumbs");
}
});
});
event.preventDefault();
event.stopPropagation();
}
},
/**
* Remove nodes and delete properties.
*/
destroy: function BC_destroy()
{
this.selection.off("new-node-front", this.update);
this.selection.off("pseudoclass", this.updateSelectors);
this.selection.off("attribute-changed", this.updateSelectors);
this.inspector.off("markupmutation", this.update);
this.container.removeEventListener("underflow", this.onscrollboxreflow, false);
this.container.removeEventListener("overflow", this.onscrollboxreflow, false);
this.onscrollboxreflow = null;
this.empty();
this.container.removeEventListener("mousedown", this, true);
this.container.removeEventListener("keypress", this, true);
this.container = null;
this.separators.remove();
this.separators = null;
this.nodeHierarchy = null;
},
/**
* Empty the breadcrumbs container.
*/
empty: function BC_empty()
{
while (this.container.hasChildNodes()) {
this.container.removeChild(this.container.firstChild);
}
},
/**
* Set which button represent the selected node.
*
* @param aIdx Index of the displayed-button to select
*/
setCursor: function BC_setCursor(aIdx)
{
// Unselect the previously selected button
if (this.currentIndex > -1 && this.currentIndex < this.nodeHierarchy.length) {
this.nodeHierarchy[this.currentIndex].button.removeAttribute("checked");
}
if (aIdx > -1) {
this.nodeHierarchy[aIdx].button.setAttribute("checked", "true");
if (this.hadFocus)
this.nodeHierarchy[aIdx].button.focus();
}
this.currentIndex = aIdx;
},
/**
* Get the index of the node in the cache.
*
* @param aNode
* @returns integer the index, -1 if not found
*/
indexOf: function BC_indexOf(aNode)
{
let i = this.nodeHierarchy.length - 1;
for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
if (this.nodeHierarchy[i].node === aNode) {
return i;
}
}
return -1;
},
/**
* Remove all the buttons and their references in the cache
* after a given index.
*
* @param aIdx
*/
cutAfter: function BC_cutAfter(aIdx)
{
while (this.nodeHierarchy.length > (aIdx + 1)) {
let toRemove = this.nodeHierarchy.pop();
this.container.removeChild(toRemove.button);
}
},
/**
* Build a button representing the node.
*
* @param aNode The node from the page.
* @returns aNode The <button>.
*/
buildButton: function BC_buildButton(aNode)
{
let button = this.chromeDoc.createElement("button");
button.appendChild(this.prettyPrintNodeAsXUL(aNode));
button.className = "breadcrumbs-widget-item";
button.setAttribute("tooltiptext", this.prettyPrintNodeAsText(aNode));
button.onkeypress = function onBreadcrumbsKeypress(e) {
if (e.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE ||
e.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN)
button.click();
}
button.onBreadcrumbsClick = () => {
this.selection.setNodeFront(aNode, "breadcrumbs");
};
button.onclick = (function _onBreadcrumbsRightClick(event) {
button.focus();
if (event.button == 2) {
this.openSiblingMenu(button, aNode);
}
}).bind(this);
button.onBreadcrumbsHold = (function _onBreadcrumbsHold() {
this.openSiblingMenu(button, aNode);
}).bind(this);
return button;
},
/**
* Connecting the end of the breadcrumbs to a node.
*
* @param aNode The node to reach.
*/
expand: function BC_expand(aNode)
{
let fragment = this.chromeDoc.createDocumentFragment();
let toAppend = aNode;
let lastButtonInserted = null;
let originalLength = this.nodeHierarchy.length;
let stopNode = null;
if (originalLength > 0) {
stopNode = this.nodeHierarchy[originalLength - 1].node;
}
while (toAppend && toAppend != stopNode) {
if (toAppend.tagName) {
let button = this.buildButton(toAppend);
fragment.insertBefore(button, lastButtonInserted);
lastButtonInserted = button;
this.nodeHierarchy.splice(originalLength, 0, {node: toAppend, button: button});
}
toAppend = toAppend.parentNode();
}
this.container.appendChild(fragment, this.container.firstChild);
},
/**
* Get a child of a node that can be displayed in the breadcrumbs
* and that is probably visible. See LOW_PRIORITY_ELEMENTS.
*
* @param aNode The parent node.
* @returns nsIDOMNode|null
*/
getInterestingFirstNode: function BC_getInterestingFirstNode(aNode)
{
let deferred = promise.defer();
var fallback = null;
var moreChildren = () => {
this.walker.children(aNode, {
start: fallback,
maxNodes: 10,
whatToShow: Ci.nsIDOMNodeFilter.SHOW_ELEMENT
}).then(this.selectionGuard()).then(response => {
for (let node of response.nodes) {
if (!(node.tagName in LOW_PRIORITY_ELEMENTS)) {
deferred.resolve(node);
return;
}
if (!fallback) {
fallback = node;
}
}
if (response.hasLast) {
deferred.resolve(fallback);
return;
} else {
moreChildren();
}
}).then(null, this.selectionGuardEnd);
}
moreChildren();
return deferred.promise;
},
/**
* Find the "youngest" ancestor of a node which is already in the breadcrumbs.
*
* @param aNode
* @returns Index of the ancestor in the cache
*/
getCommonAncestor: function BC_getCommonAncestor(aNode)
{
let node = aNode;
while (node) {
let idx = this.indexOf(node);
if (idx > -1) {
return idx;
} else {
node = node.parentNode();
}
}
return -1;
},
/**
* Make sure that the latest node in the breadcrumbs is not the selected node
* if the selected node still has children.
*/
ensureFirstChild: function BC_ensureFirstChild()
{
// If the last displayed node is the selected node
if (this.currentIndex == this.nodeHierarchy.length - 1) {
let node = this.nodeHierarchy[this.currentIndex].node;
return this.getInterestingFirstNode(node).then(child => {
// If the node has a child
if (child) {
// Show this child
this.expand(child);
}
});
}
return resolveNextTick(true);
},
/**
* Ensure the selected node is visible.
*/
scroll: function BC_scroll()
{
// FIXME bug 684352: make sure its immediate neighbors are visible too.
let scrollbox = this.container;
let element = this.nodeHierarchy[this.currentIndex].button;
// Repeated calls to ensureElementIsVisible would interfere with each other
// and may sometimes result in incorrect scroll positions.
this.chromeWin.clearTimeout(this._ensureVisibleTimeout);
this._ensureVisibleTimeout = this.chromeWin.setTimeout(function() {
scrollbox.ensureElementIsVisible(element);
}, ENSURE_SELECTION_VISIBLE_DELAY);
},
updateSelectors: function BC_updateSelectors()
{
for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
let crumb = this.nodeHierarchy[i];
let button = crumb.button;
while(button.hasChildNodes()) {
button.removeChild(button.firstChild);
}
button.appendChild(this.prettyPrintNodeAsXUL(crumb.node));
button.setAttribute("tooltiptext", this.prettyPrintNodeAsText(crumb.node));
}
},
/**
* Update the breadcrumbs display when a new node is selected.
*/
update: function BC_update(reason)
{
if (reason !== "markupmutation") {
this.inspector.hideNodeMenu();
}
let cmdDispatcher = this.chromeDoc.commandDispatcher;
this.hadFocus = (cmdDispatcher.focusedElement &&
cmdDispatcher.focusedElement.parentNode == this.container);
if (!this.selection.isConnected()) {
this.cutAfter(-1); // remove all the crumbs
return;
}
if (!this.selection.isElementNode()) {
this.setCursor(-1); // no selection
return;
}
let idx = this.indexOf(this.selection.nodeFront);
// Is the node already displayed in the breadcrumbs?
// (and there are no mutations that need re-display of the crumbs)
if (idx > -1 && reason !== "markupmutation") {
// Yes. We select it.
this.setCursor(idx);
} else {
// No. Is the breadcrumbs display empty?
if (this.nodeHierarchy.length > 0) {
// No. We drop all the element that are not direct ancestors
// of the selection
let parent = this.selection.nodeFront.parentNode();
let idx = this.getCommonAncestor(parent);
this.cutAfter(idx);
}
// we append the missing button between the end of the breadcrumbs display
// and the current node.
this.expand(this.selection.nodeFront);
// we select the current node button
idx = this.indexOf(this.selection.nodeFront);
this.setCursor(idx);
}
let doneUpdating = this.inspector.updating("breadcrumbs");
// Add the first child of the very last node of the breadcrumbs if possible.
this.ensureFirstChild().then(this.selectionGuard()).then(() => {
this.updateSelectors();
// Make sure the selected node and its neighbours are visible.
this.scroll();
return resolveNextTick().then(() => {
this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
doneUpdating();
});
}).then(null, err => {
doneUpdating(this.selection.nodeFront);
this.selectionGuardEnd(err);
});
}
};
XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () {
return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});