breadcrumbs.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921
  1. /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
  2. /* This Source Code Form is subject to the terms of the Mozilla Public
  3. * License, v. 2.0. If a copy of the MPL was not distributed with this
  4. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  5. "use strict";
  6. const promise = require("promise");
  7. const {ELLIPSIS} = require("devtools/shared/l10n");
  8. const MAX_LABEL_LENGTH = 40;
  9. const NS_XHTML = "http://www.w3.org/1999/xhtml";
  10. const SCROLL_REPEAT_MS = 100;
  11. const EventEmitter = require("devtools/shared/event-emitter");
  12. const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
  13. // Some margin may be required for visible element detection.
  14. const SCROLL_MARGIN = 1;
  15. /**
  16. * Component to replicate functionality of XUL arrowscrollbox
  17. * for breadcrumbs
  18. *
  19. * @param {Window} win The window containing the breadcrumbs
  20. * @parem {DOMNode} container The element in which to put the scroll box
  21. */
  22. function ArrowScrollBox(win, container) {
  23. this.win = win;
  24. this.doc = win.document;
  25. this.container = container;
  26. EventEmitter.decorate(this);
  27. this.init();
  28. }
  29. ArrowScrollBox.prototype = {
  30. // Scroll behavior, exposed for testing
  31. scrollBehavior: "smooth",
  32. /**
  33. * Build the HTML, add to the DOM and start listening to
  34. * events
  35. */
  36. init: function () {
  37. this.constructHtml();
  38. this.onUnderflow();
  39. this.onScroll = this.onScroll.bind(this);
  40. this.onStartBtnClick = this.onStartBtnClick.bind(this);
  41. this.onEndBtnClick = this.onEndBtnClick.bind(this);
  42. this.onStartBtnDblClick = this.onStartBtnDblClick.bind(this);
  43. this.onEndBtnDblClick = this.onEndBtnDblClick.bind(this);
  44. this.onUnderflow = this.onUnderflow.bind(this);
  45. this.onOverflow = this.onOverflow.bind(this);
  46. this.inner.addEventListener("scroll", this.onScroll, false);
  47. this.startBtn.addEventListener("mousedown", this.onStartBtnClick, false);
  48. this.endBtn.addEventListener("mousedown", this.onEndBtnClick, false);
  49. this.startBtn.addEventListener("dblclick", this.onStartBtnDblClick, false);
  50. this.endBtn.addEventListener("dblclick", this.onEndBtnDblClick, false);
  51. // Overflow and underflow are moz specific events
  52. this.inner.addEventListener("underflow", this.onUnderflow, false);
  53. this.inner.addEventListener("overflow", this.onOverflow, false);
  54. },
  55. /**
  56. * Determine whether the current text directionality is RTL
  57. */
  58. isRtl: function () {
  59. return this.win.getComputedStyle(this.container).direction === "rtl";
  60. },
  61. /**
  62. * Scroll to the specified element using the current scroll behavior
  63. * @param {Element} element element to scroll
  64. * @param {String} block desired alignment of element after scrolling
  65. */
  66. scrollToElement: function (element, block) {
  67. element.scrollIntoView({ block: block, behavior: this.scrollBehavior });
  68. },
  69. /**
  70. * Call the given function once; then continuously
  71. * while the mouse button is held
  72. * @param {Function} repeatFn the function to repeat while the button is held
  73. */
  74. clickOrHold: function (repeatFn) {
  75. let timer;
  76. let container = this.container;
  77. function handleClick() {
  78. cancelHold();
  79. repeatFn();
  80. }
  81. let window = this.win;
  82. function cancelHold() {
  83. window.clearTimeout(timer);
  84. container.removeEventListener("mouseout", cancelHold, false);
  85. container.removeEventListener("mouseup", handleClick, false);
  86. }
  87. function repeated() {
  88. repeatFn();
  89. timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
  90. }
  91. container.addEventListener("mouseout", cancelHold, false);
  92. container.addEventListener("mouseup", handleClick, false);
  93. timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
  94. },
  95. /**
  96. * When start button is dbl clicked scroll to first element
  97. */
  98. onStartBtnDblClick: function () {
  99. let children = this.inner.childNodes;
  100. if (children.length < 1) {
  101. return;
  102. }
  103. let element = this.inner.childNodes[0];
  104. this.scrollToElement(element, "start");
  105. },
  106. /**
  107. * When end button is dbl clicked scroll to last element
  108. */
  109. onEndBtnDblClick: function () {
  110. let children = this.inner.childNodes;
  111. if (children.length < 1) {
  112. return;
  113. }
  114. let element = children[children.length - 1];
  115. this.scrollToElement(element, "start");
  116. },
  117. /**
  118. * When start arrow button is clicked scroll towards first element
  119. */
  120. onStartBtnClick: function () {
  121. let scrollToStart = () => {
  122. let element = this.getFirstInvisibleElement();
  123. if (!element) {
  124. return;
  125. }
  126. let block = this.isRtl() ? "end" : "start";
  127. this.scrollToElement(element, block);
  128. };
  129. this.clickOrHold(scrollToStart);
  130. },
  131. /**
  132. * When end arrow button is clicked scroll towards last element
  133. */
  134. onEndBtnClick: function () {
  135. let scrollToEnd = () => {
  136. let element = this.getLastInvisibleElement();
  137. if (!element) {
  138. return;
  139. }
  140. let block = this.isRtl() ? "start" : "end";
  141. this.scrollToElement(element, block);
  142. };
  143. this.clickOrHold(scrollToEnd);
  144. },
  145. /**
  146. * Event handler for scrolling, update the
  147. * enabled/disabled status of the arrow buttons
  148. */
  149. onScroll: function () {
  150. let first = this.getFirstInvisibleElement();
  151. if (!first) {
  152. this.startBtn.setAttribute("disabled", "true");
  153. } else {
  154. this.startBtn.removeAttribute("disabled");
  155. }
  156. let last = this.getLastInvisibleElement();
  157. if (!last) {
  158. this.endBtn.setAttribute("disabled", "true");
  159. } else {
  160. this.endBtn.removeAttribute("disabled");
  161. }
  162. },
  163. /**
  164. * On underflow, make the arrow buttons invisible
  165. */
  166. onUnderflow: function () {
  167. this.startBtn.style.visibility = "collapse";
  168. this.endBtn.style.visibility = "collapse";
  169. this.emit("underflow");
  170. },
  171. /**
  172. * On overflow, show the arrow buttons
  173. */
  174. onOverflow: function () {
  175. this.startBtn.style.visibility = "visible";
  176. this.endBtn.style.visibility = "visible";
  177. this.emit("overflow");
  178. },
  179. /**
  180. * Check whether the element is to the left of its container but does
  181. * not also span the entire container.
  182. * @param {Number} left the left scroll point of the container
  183. * @param {Number} right the right edge of the container
  184. * @param {Number} elementLeft the left edge of the element
  185. * @param {Number} elementRight the right edge of the element
  186. */
  187. elementLeftOfContainer: function (left, right, elementLeft, elementRight) {
  188. return elementLeft < (left - SCROLL_MARGIN)
  189. && elementRight < (right - SCROLL_MARGIN);
  190. },
  191. /**
  192. * Check whether the element is to the right of its container but does
  193. * not also span the entire container.
  194. * @param {Number} left the left scroll point of the container
  195. * @param {Number} right the right edge of the container
  196. * @param {Number} elementLeft the left edge of the element
  197. * @param {Number} elementRight the right edge of the element
  198. */
  199. elementRightOfContainer: function (left, right, elementLeft, elementRight) {
  200. return elementLeft > (left + SCROLL_MARGIN)
  201. && elementRight > (right + SCROLL_MARGIN);
  202. },
  203. /**
  204. * Get the first (i.e. furthest left for LTR)
  205. * non or partly visible element in the scroll box
  206. */
  207. getFirstInvisibleElement: function () {
  208. let elementsList = Array.from(this.inner.childNodes).reverse();
  209. let predicate = this.isRtl() ?
  210. this.elementRightOfContainer : this.elementLeftOfContainer;
  211. return this.findFirstWithBounds(elementsList, predicate);
  212. },
  213. /**
  214. * Get the last (i.e. furthest right for LTR)
  215. * non or partly visible element in the scroll box
  216. */
  217. getLastInvisibleElement: function () {
  218. let predicate = this.isRtl() ?
  219. this.elementLeftOfContainer : this.elementRightOfContainer;
  220. return this.findFirstWithBounds(this.inner.childNodes, predicate);
  221. },
  222. /**
  223. * Find the first element that matches the given predicate, called with bounds
  224. * information
  225. * @param {Array} elements an ordered list of elements
  226. * @param {Function} predicate a function to be called with bounds
  227. * information
  228. */
  229. findFirstWithBounds: function (elements, predicate) {
  230. let left = this.inner.scrollLeft;
  231. let right = left + this.inner.clientWidth;
  232. for (let element of elements) {
  233. let elementLeft = element.offsetLeft - element.parentElement.offsetLeft;
  234. let elementRight = elementLeft + element.offsetWidth;
  235. // Check that the starting edge of the element is out of the visible area
  236. // and that the ending edge does not span the whole container
  237. if (predicate(left, right, elementLeft, elementRight)) {
  238. return element;
  239. }
  240. }
  241. return null;
  242. },
  243. /**
  244. * Build the HTML for the scroll box and insert it into the DOM
  245. */
  246. constructHtml: function () {
  247. this.startBtn = this.createElement("div", "scrollbutton-up",
  248. this.container);
  249. this.createElement("div", "toolbarbutton-icon", this.startBtn);
  250. this.createElement("div", "arrowscrollbox-overflow-start-indicator",
  251. this.container);
  252. this.inner = this.createElement("div", "html-arrowscrollbox-inner",
  253. this.container);
  254. this.createElement("div", "arrowscrollbox-overflow-end-indicator",
  255. this.container);
  256. this.endBtn = this.createElement("div", "scrollbutton-down",
  257. this.container);
  258. this.createElement("div", "toolbarbutton-icon", this.endBtn);
  259. },
  260. /**
  261. * Create an XHTML element with the given class name, and append it to the
  262. * parent.
  263. * @param {String} tagName name of the tag to create
  264. * @param {String} className class of the element
  265. * @param {DOMNode} parent the parent node to which it should be appended
  266. * @return {DOMNode} The new element
  267. */
  268. createElement: function (tagName, className, parent) {
  269. let el = this.doc.createElementNS(NS_XHTML, tagName);
  270. el.className = className;
  271. if (parent) {
  272. parent.appendChild(el);
  273. }
  274. return el;
  275. },
  276. /**
  277. * Remove event handlers and clean up
  278. */
  279. destroy: function () {
  280. this.inner.removeEventListener("scroll", this.onScroll, false);
  281. this.startBtn.removeEventListener("mousedown",
  282. this.onStartBtnClick, false);
  283. this.endBtn.removeEventListener("mousedown", this.onEndBtnClick, false);
  284. this.startBtn.removeEventListener("dblclick",
  285. this.onStartBtnDblClick, false);
  286. this.endBtn.removeEventListener("dblclick",
  287. this.onRightBtnDblClick, false);
  288. // Overflow and underflow are moz specific events
  289. this.inner.removeEventListener("underflow", this.onUnderflow, false);
  290. this.inner.removeEventListener("overflow", this.onOverflow, false);
  291. },
  292. };
  293. /**
  294. * Display the ancestors of the current node and its children.
  295. * Only one "branch" of children are displayed (only one line).
  296. *
  297. * Mechanism:
  298. * - If no nodes displayed yet:
  299. * then display the ancestor of the selected node and the selected node;
  300. * else select the node;
  301. * - If the selected node is the last node displayed, append its first (if any).
  302. *
  303. * @param {InspectorPanel} inspector The inspector hosting this widget.
  304. */
  305. function HTMLBreadcrumbs(inspector) {
  306. this.inspector = inspector;
  307. this.selection = this.inspector.selection;
  308. this.win = this.inspector.panelWin;
  309. this.doc = this.inspector.panelDoc;
  310. this._init();
  311. }
  312. exports.HTMLBreadcrumbs = HTMLBreadcrumbs;
  313. HTMLBreadcrumbs.prototype = {
  314. get walker() {
  315. return this.inspector.walker;
  316. },
  317. _init: function () {
  318. this.outer = this.doc.getElementById("inspector-breadcrumbs");
  319. this.arrowScrollBox = new ArrowScrollBox(
  320. this.win,
  321. this.outer);
  322. this.container = this.arrowScrollBox.inner;
  323. this.scroll = this.scroll.bind(this);
  324. this.arrowScrollBox.on("overflow", this.scroll);
  325. this.outer.addEventListener("click", this, true);
  326. this.outer.addEventListener("mouseover", this, true);
  327. this.outer.addEventListener("mouseout", this, true);
  328. this.outer.addEventListener("focus", this, true);
  329. this.shortcuts = new KeyShortcuts({ window: this.win, target: this.outer });
  330. this.handleShortcut = this.handleShortcut.bind(this);
  331. this.shortcuts.on("Right", this.handleShortcut);
  332. this.shortcuts.on("Left", this.handleShortcut);
  333. // We will save a list of already displayed nodes in this array.
  334. this.nodeHierarchy = [];
  335. // Last selected node in nodeHierarchy.
  336. this.currentIndex = -1;
  337. // Used to build a unique breadcrumb button Id.
  338. this.breadcrumbsWidgetItemId = 0;
  339. this.update = this.update.bind(this);
  340. this.updateSelectors = this.updateSelectors.bind(this);
  341. this.selection.on("new-node-front", this.update);
  342. this.selection.on("pseudoclass", this.updateSelectors);
  343. this.selection.on("attribute-changed", this.updateSelectors);
  344. this.inspector.on("markupmutation", this.update);
  345. this.update();
  346. },
  347. /**
  348. * Build a string that represents the node: tagName#id.class1.class2.
  349. * @param {NodeFront} node The node to pretty-print
  350. * @return {String}
  351. */
  352. prettyPrintNodeAsText: function (node) {
  353. let text = node.displayName;
  354. if (node.isPseudoElement) {
  355. text = node.isBeforePseudoElement ? "::before" : "::after";
  356. }
  357. if (node.id) {
  358. text += "#" + node.id;
  359. }
  360. if (node.className) {
  361. let classList = node.className.split(/\s+/);
  362. for (let i = 0; i < classList.length; i++) {
  363. text += "." + classList[i];
  364. }
  365. }
  366. for (let pseudo of node.pseudoClassLocks) {
  367. text += pseudo;
  368. }
  369. return text;
  370. },
  371. /**
  372. * Build <span>s that represent the node:
  373. * <span class="breadcrumbs-widget-item-tag">tagName</span>
  374. * <span class="breadcrumbs-widget-item-id">#id</span>
  375. * <span class="breadcrumbs-widget-item-classes">.class1.class2</span>
  376. * @param {NodeFront} node The node to pretty-print
  377. * @returns {DocumentFragment}
  378. */
  379. prettyPrintNodeAsXHTML: function (node) {
  380. let tagLabel = this.doc.createElementNS(NS_XHTML, "span");
  381. tagLabel.className = "breadcrumbs-widget-item-tag plain";
  382. let idLabel = this.doc.createElementNS(NS_XHTML, "span");
  383. idLabel.className = "breadcrumbs-widget-item-id plain";
  384. let classesLabel = this.doc.createElementNS(NS_XHTML, "span");
  385. classesLabel.className = "breadcrumbs-widget-item-classes plain";
  386. let pseudosLabel = this.doc.createElementNS(NS_XHTML, "span");
  387. pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes plain";
  388. let tagText = node.displayName;
  389. if (node.isPseudoElement) {
  390. tagText = node.isBeforePseudoElement ? "::before" : "::after";
  391. }
  392. let idText = node.id ? ("#" + node.id) : "";
  393. let classesText = "";
  394. if (node.className) {
  395. let classList = node.className.split(/\s+/);
  396. for (let i = 0; i < classList.length; i++) {
  397. classesText += "." + classList[i];
  398. }
  399. }
  400. // Figure out which element (if any) needs ellipsing.
  401. // Substring for that element, then clear out any extras
  402. // (except for pseudo elements).
  403. let maxTagLength = MAX_LABEL_LENGTH;
  404. let maxIdLength = MAX_LABEL_LENGTH - tagText.length;
  405. let maxClassLength = MAX_LABEL_LENGTH - tagText.length - idText.length;
  406. if (tagText.length > maxTagLength) {
  407. tagText = tagText.substr(0, maxTagLength) + ELLIPSIS;
  408. idText = classesText = "";
  409. } else if (idText.length > maxIdLength) {
  410. idText = idText.substr(0, maxIdLength) + ELLIPSIS;
  411. classesText = "";
  412. } else if (classesText.length > maxClassLength) {
  413. classesText = classesText.substr(0, maxClassLength) + ELLIPSIS;
  414. }
  415. tagLabel.textContent = tagText;
  416. idLabel.textContent = idText;
  417. classesLabel.textContent = classesText;
  418. pseudosLabel.textContent = node.pseudoClassLocks.join("");
  419. let fragment = this.doc.createDocumentFragment();
  420. fragment.appendChild(tagLabel);
  421. fragment.appendChild(idLabel);
  422. fragment.appendChild(classesLabel);
  423. fragment.appendChild(pseudosLabel);
  424. return fragment;
  425. },
  426. /**
  427. * Generic event handler.
  428. * @param {DOMEvent} event.
  429. */
  430. handleEvent: function (event) {
  431. if (event.type == "click" && event.button == 0) {
  432. this.handleClick(event);
  433. } else if (event.type == "mouseover") {
  434. this.handleMouseOver(event);
  435. } else if (event.type == "mouseout") {
  436. this.handleMouseOut(event);
  437. } else if (event.type == "focus") {
  438. this.handleFocus(event);
  439. }
  440. },
  441. /**
  442. * Focus event handler. When breadcrumbs container gets focus,
  443. * aria-activedescendant needs to be updated to currently selected
  444. * breadcrumb. Ensures that the focus stays on the container at all times.
  445. * @param {DOMEvent} event.
  446. */
  447. handleFocus: function (event) {
  448. event.stopPropagation();
  449. let node = this.nodeHierarchy[this.currentIndex];
  450. if (node) {
  451. this.outer.setAttribute("aria-activedescendant", node.button.id);
  452. } else {
  453. this.outer.removeAttribute("aria-activedescendant");
  454. }
  455. this.outer.focus();
  456. },
  457. /**
  458. * On click navigate to the correct node.
  459. * @param {DOMEvent} event.
  460. */
  461. handleClick: function (event) {
  462. let target = event.originalTarget;
  463. if (target.tagName == "button") {
  464. target.onBreadcrumbsClick();
  465. }
  466. },
  467. /**
  468. * On mouse over, highlight the corresponding content DOM Node.
  469. * @param {DOMEvent} event.
  470. */
  471. handleMouseOver: function (event) {
  472. let target = event.originalTarget;
  473. if (target.tagName == "button") {
  474. target.onBreadcrumbsHover();
  475. }
  476. },
  477. /**
  478. * On mouse out, make sure to unhighlight.
  479. * @param {DOMEvent} event.
  480. */
  481. handleMouseOut: function (event) {
  482. this.inspector.toolbox.highlighterUtils.unhighlight();
  483. },
  484. /**
  485. * Handle a keyboard shortcut supported by the breadcrumbs widget.
  486. *
  487. * @param {String} name
  488. * Name of the keyboard shortcut received.
  489. * @param {DOMEvent} event
  490. * Original event that triggered the shortcut.
  491. */
  492. handleShortcut: function (name, event) {
  493. if (!this.selection.isElementNode()) {
  494. return;
  495. }
  496. event.preventDefault();
  497. event.stopPropagation();
  498. this.keyPromise = (this.keyPromise || promise.resolve(null)).then(() => {
  499. let currentnode;
  500. if (name === "Left" && this.currentIndex != 0) {
  501. currentnode = this.nodeHierarchy[this.currentIndex - 1];
  502. } else if (name === "Right" && this.currentIndex < this.nodeHierarchy.length - 1) {
  503. currentnode = this.nodeHierarchy[this.currentIndex + 1];
  504. } else {
  505. return null;
  506. }
  507. this.outer.setAttribute("aria-activedescendant", currentnode.button.id);
  508. return this.selection.setNodeFront(currentnode.node, "breadcrumbs");
  509. });
  510. },
  511. /**
  512. * Remove nodes and clean up.
  513. */
  514. destroy: function () {
  515. this.selection.off("new-node-front", this.update);
  516. this.selection.off("pseudoclass", this.updateSelectors);
  517. this.selection.off("attribute-changed", this.updateSelectors);
  518. this.inspector.off("markupmutation", this.update);
  519. this.container.removeEventListener("click", this, true);
  520. this.container.removeEventListener("mouseover", this, true);
  521. this.container.removeEventListener("mouseout", this, true);
  522. this.container.removeEventListener("focus", this, true);
  523. this.shortcuts.destroy();
  524. this.empty();
  525. this.arrowScrollBox.off("overflow", this.scroll);
  526. this.arrowScrollBox.destroy();
  527. this.arrowScrollBox = null;
  528. this.outer = null;
  529. this.container = null;
  530. this.nodeHierarchy = null;
  531. this.isDestroyed = true;
  532. },
  533. /**
  534. * Empty the breadcrumbs container.
  535. */
  536. empty: function () {
  537. while (this.container.hasChildNodes()) {
  538. this.container.firstChild.remove();
  539. }
  540. },
  541. /**
  542. * Set which button represent the selected node.
  543. * @param {Number} index Index of the displayed-button to select.
  544. */
  545. setCursor: function (index) {
  546. // Unselect the previously selected button
  547. if (this.currentIndex > -1
  548. && this.currentIndex < this.nodeHierarchy.length) {
  549. this.nodeHierarchy[this.currentIndex].button.removeAttribute("checked");
  550. }
  551. if (index > -1) {
  552. this.nodeHierarchy[index].button.setAttribute("checked", "true");
  553. } else {
  554. // Unset active active descendant when all buttons are unselected.
  555. this.outer.removeAttribute("aria-activedescendant");
  556. }
  557. this.currentIndex = index;
  558. },
  559. /**
  560. * Get the index of the node in the cache.
  561. * @param {NodeFront} node.
  562. * @returns {Number} The index for this node or -1 if not found.
  563. */
  564. indexOf: function (node) {
  565. for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
  566. if (this.nodeHierarchy[i].node === node) {
  567. return i;
  568. }
  569. }
  570. return -1;
  571. },
  572. /**
  573. * Remove all the buttons and their references in the cache after a given
  574. * index.
  575. * @param {Number} index.
  576. */
  577. cutAfter: function (index) {
  578. while (this.nodeHierarchy.length > (index + 1)) {
  579. let toRemove = this.nodeHierarchy.pop();
  580. this.container.removeChild(toRemove.button);
  581. }
  582. },
  583. /**
  584. * Build a button representing the node.
  585. * @param {NodeFront} node The node from the page.
  586. * @return {DOMNode} The <button> for this node.
  587. */
  588. buildButton: function (node) {
  589. let button = this.doc.createElementNS(NS_XHTML, "button");
  590. button.appendChild(this.prettyPrintNodeAsXHTML(node));
  591. button.className = "breadcrumbs-widget-item";
  592. button.id = "breadcrumbs-widget-item-" + this.breadcrumbsWidgetItemId++;
  593. button.setAttribute("tabindex", "-1");
  594. button.setAttribute("title", this.prettyPrintNodeAsText(node));
  595. button.onclick = () => {
  596. button.focus();
  597. };
  598. button.onBreadcrumbsClick = () => {
  599. this.selection.setNodeFront(node, "breadcrumbs");
  600. };
  601. button.onBreadcrumbsHover = () => {
  602. this.inspector.toolbox.highlighterUtils.highlightNodeFront(node);
  603. };
  604. return button;
  605. },
  606. /**
  607. * Connecting the end of the breadcrumbs to a node.
  608. * @param {NodeFront} node The node to reach.
  609. */
  610. expand: function (node) {
  611. let fragment = this.doc.createDocumentFragment();
  612. let lastButtonInserted = null;
  613. let originalLength = this.nodeHierarchy.length;
  614. let stopNode = null;
  615. if (originalLength > 0) {
  616. stopNode = this.nodeHierarchy[originalLength - 1].node;
  617. }
  618. while (node && node != stopNode) {
  619. if (node.tagName) {
  620. let button = this.buildButton(node);
  621. fragment.insertBefore(button, lastButtonInserted);
  622. lastButtonInserted = button;
  623. this.nodeHierarchy.splice(originalLength, 0, {
  624. node,
  625. button,
  626. currentPrettyPrintText: this.prettyPrintNodeAsText(node)
  627. });
  628. }
  629. node = node.parentNode();
  630. }
  631. this.container.appendChild(fragment, this.container.firstChild);
  632. },
  633. /**
  634. * Find the "youngest" ancestor of a node which is already in the breadcrumbs.
  635. * @param {NodeFront} node.
  636. * @return {Number} Index of the ancestor in the cache, or -1 if not found.
  637. */
  638. getCommonAncestor: function (node) {
  639. while (node) {
  640. let idx = this.indexOf(node);
  641. if (idx > -1) {
  642. return idx;
  643. }
  644. node = node.parentNode();
  645. }
  646. return -1;
  647. },
  648. /**
  649. * Ensure the selected node is visible.
  650. */
  651. scroll: function () {
  652. // FIXME bug 684352: make sure its immediate neighbors are visible too.
  653. if (!this.isDestroyed) {
  654. let element = this.nodeHierarchy[this.currentIndex].button;
  655. this.arrowScrollBox.scrollToElement(element, "end");
  656. }
  657. },
  658. /**
  659. * Update all button outputs.
  660. */
  661. updateSelectors: function () {
  662. if (this.isDestroyed) {
  663. return;
  664. }
  665. for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
  666. let {node, button, currentPrettyPrintText} = this.nodeHierarchy[i];
  667. // If the output of the node doesn't change, skip the update.
  668. let textOutput = this.prettyPrintNodeAsText(node);
  669. if (currentPrettyPrintText === textOutput) {
  670. continue;
  671. }
  672. // Otherwise, update the whole markup for the button.
  673. while (button.hasChildNodes()) {
  674. button.firstChild.remove();
  675. }
  676. button.appendChild(this.prettyPrintNodeAsXHTML(node));
  677. button.setAttribute("title", textOutput);
  678. this.nodeHierarchy[i].currentPrettyPrintText = textOutput;
  679. }
  680. },
  681. /**
  682. * Given a list of mutation changes (passed by the markupmutation event),
  683. * decide whether or not they are "interesting" to the current state of the
  684. * breadcrumbs widget, i.e. at least one of them should cause part of the
  685. * widget to be updated.
  686. * @param {Array} mutations The mutations array.
  687. * @return {Boolean}
  688. */
  689. _hasInterestingMutations: function (mutations) {
  690. if (!mutations || !mutations.length) {
  691. return false;
  692. }
  693. for (let {type, added, removed, target, attributeName} of mutations) {
  694. if (type === "childList") {
  695. // Only interested in childList mutations if the added or removed
  696. // nodes are currently displayed.
  697. return added.some(node => this.indexOf(node) > -1) ||
  698. removed.some(node => this.indexOf(node) > -1);
  699. } else if (type === "attributes" && this.indexOf(target) > -1) {
  700. // Only interested in attributes mutations if the target is
  701. // currently displayed, and the attribute is either id or class.
  702. return attributeName === "class" || attributeName === "id";
  703. }
  704. }
  705. // Catch all return in case the mutations array was empty, or in case none
  706. // of the changes iterated above were interesting.
  707. return false;
  708. },
  709. /**
  710. * Update the breadcrumbs display when a new node is selected.
  711. * @param {String} reason The reason for the update, if any.
  712. * @param {Array} mutations An array of mutations in case this was called as
  713. * the "markupmutation" event listener.
  714. */
  715. update: function (reason, mutations) {
  716. if (this.isDestroyed) {
  717. return;
  718. }
  719. let hasInterestingMutations = this._hasInterestingMutations(mutations);
  720. if (reason === "markupmutation" && !hasInterestingMutations) {
  721. return;
  722. }
  723. if (!this.selection.isConnected()) {
  724. // remove all the crumbs
  725. this.cutAfter(-1);
  726. return;
  727. }
  728. // If this was an interesting deletion; then trim the breadcrumb trail
  729. let trimmed = false;
  730. if (reason === "markupmutation") {
  731. for (let {type, removed} of mutations) {
  732. if (type !== "childList") {
  733. continue;
  734. }
  735. for (let node of removed) {
  736. let removedIndex = this.indexOf(node);
  737. if (removedIndex > -1) {
  738. this.cutAfter(removedIndex - 1);
  739. trimmed = true;
  740. }
  741. }
  742. }
  743. }
  744. if (!this.selection.isElementNode()) {
  745. // no selection
  746. this.setCursor(-1);
  747. if (trimmed) {
  748. // Since something changed, notify the interested parties.
  749. this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
  750. }
  751. return;
  752. }
  753. let idx = this.indexOf(this.selection.nodeFront);
  754. // Is the node already displayed in the breadcrumbs?
  755. // (and there are no mutations that need re-display of the crumbs)
  756. if (idx > -1 && !hasInterestingMutations) {
  757. // Yes. We select it.
  758. this.setCursor(idx);
  759. } else {
  760. // No. Is the breadcrumbs display empty?
  761. if (this.nodeHierarchy.length > 0) {
  762. // No. We drop all the element that are not direct ancestors
  763. // of the selection
  764. let parent = this.selection.nodeFront.parentNode();
  765. let ancestorIdx = this.getCommonAncestor(parent);
  766. this.cutAfter(ancestorIdx);
  767. }
  768. // we append the missing button between the end of the breadcrumbs display
  769. // and the current node.
  770. this.expand(this.selection.nodeFront);
  771. // we select the current node button
  772. idx = this.indexOf(this.selection.nodeFront);
  773. this.setCursor(idx);
  774. }
  775. let doneUpdating = this.inspector.updating("breadcrumbs");
  776. this.updateSelectors();
  777. // Make sure the selected node and its neighbours are visible.
  778. setTimeout(() => {
  779. try {
  780. this.scroll();
  781. this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
  782. doneUpdating();
  783. } catch (e) {
  784. // Only log this as an error if we haven't been destroyed in the meantime.
  785. if (!this.isDestroyed) {
  786. console.error(e);
  787. }
  788. }
  789. }, 0);
  790. }
  791. };