blob: cc180be19227eb763b00cdeecdce67b5a967e3ce [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {decorateNodeLabel} from './DOMLinkifier.js';
/**
* @unrestricted
*/
export class ElementsBreadcrumbs extends UI.HBox {
constructor() {
super(true);
this.registerRequiredCSS('elements/breadcrumbs.css');
this.crumbsElement = this.contentElement.createChild('div', 'crumbs');
this.crumbsElement.addEventListener('mousemove', this._mouseMovedInCrumbs.bind(this), false);
this.crumbsElement.addEventListener('mouseleave', this._mouseMovedOutOfCrumbs.bind(this), false);
this._nodeSymbol = Symbol('node');
UI.ARIAUtils.markAsHidden(this.element);
}
/**
* @override
*/
wasShown() {
this.update();
}
/**
* @param {!Array.<!SDK.DOMNode>} nodes
*/
updateNodes(nodes) {
if (!nodes.length) {
return;
}
const crumbs = this.crumbsElement;
for (let crumb = crumbs.firstChild; crumb; crumb = crumb.nextSibling) {
if (nodes.indexOf(crumb[this._nodeSymbol]) !== -1) {
this.update(true);
return;
}
}
}
/**
* @param {?SDK.DOMNode} node
*/
setSelectedNode(node) {
this._currentDOMNode = node;
this.crumbsElement.window().requestAnimationFrame(() => this.update());
}
_mouseMovedInCrumbs(event) {
const nodeUnderMouse = event.target;
const crumbElement = nodeUnderMouse.enclosingNodeOrSelfWithClass('crumb');
const node = /** @type {?SDK.DOMNode} */ (crumbElement ? crumbElement[this._nodeSymbol] : null);
if (node) {
node.highlight();
}
}
_mouseMovedOutOfCrumbs(event) {
if (this._currentDOMNode) {
SDK.OverlayModel.hideDOMNodeHighlight();
}
}
/**
* @param {!Event} event
* @this {ElementsBreadcrumbs}
*/
_onClickCrumb(event) {
event.preventDefault();
let crumb = /** @type {!Element} */ (event.currentTarget);
if (!crumb.classList.contains('collapsed')) {
this.dispatchEventToListeners(Events.NodeSelected, crumb[this._nodeSymbol]);
return;
}
// Clicking a collapsed crumb will expose the hidden crumbs.
if (crumb === this.crumbsElement.firstChild) {
// If the clicked crumb is the first child, pick the farthest crumb
// that is still hidden. This allows the user to expose every crumb.
let currentCrumb = crumb;
while (currentCrumb) {
const hidden = currentCrumb.classList.contains('hidden');
const collapsed = currentCrumb.classList.contains('collapsed');
if (!hidden && !collapsed) {
break;
}
crumb = currentCrumb;
currentCrumb = currentCrumb.nextSiblingElement;
}
}
this.updateSizes(crumb);
}
/**
* @param {!SDK.DOMNode} domNode
* @return {?string}
*/
_determineElementTitle(domNode) {
switch (domNode.nodeType()) {
case Node.ELEMENT_NODE:
if (domNode.pseudoType()) {
return '::' + domNode.pseudoType();
}
return null;
case Node.TEXT_NODE:
return Common.UIString('(text)');
case Node.COMMENT_NODE:
return '<!-->';
case Node.DOCUMENT_TYPE_NODE:
return '<!doctype>';
case Node.DOCUMENT_FRAGMENT_NODE:
return domNode.shadowRootType() ? '#shadow-root' : domNode.nodeNameInCorrectCase();
default:
return domNode.nodeNameInCorrectCase();
}
}
/**
* @param {boolean=} force
*/
update(force) {
if (!this.isShowing()) {
return;
}
const currentDOMNode = this._currentDOMNode;
const crumbs = this.crumbsElement;
let handled = false;
let crumb = crumbs.firstChild;
while (crumb) {
if (crumb[this._nodeSymbol] === currentDOMNode) {
crumb.classList.add('selected');
handled = true;
} else {
crumb.classList.remove('selected');
}
crumb = crumb.nextSibling;
}
if (handled && !force) {
// We don't need to rebuild the crumbs, but we need to adjust sizes
// to reflect the new focused or root node.
this.updateSizes();
return;
}
crumbs.removeChildren();
for (let current = currentDOMNode; current; current = current.parentNode) {
if (current.nodeType() === Node.DOCUMENT_NODE) {
continue;
}
crumb = createElementWithClass('span', 'crumb');
crumb[this._nodeSymbol] = current;
crumb.addEventListener('mousedown', this._onClickCrumb.bind(this), false);
const crumbTitle = this._determineElementTitle(current);
if (crumbTitle) {
const nameElement = createElement('span');
nameElement.textContent = crumbTitle;
crumb.appendChild(nameElement);
crumb.title = crumbTitle;
} else {
decorateNodeLabel(current, crumb);
}
if (current === currentDOMNode) {
crumb.classList.add('selected');
}
crumbs.insertBefore(crumb, crumbs.firstChild);
}
this.updateSizes();
}
/**
* @param {!Element=} focusedCrumb
* @return {{selectedIndex: number, focusedIndex: number, selectedCrumb: ?Element}}
*/
_resetCrumbStylesAndFindSelections(focusedCrumb) {
const crumbs = this.crumbsElement;
let selectedIndex = 0;
let focusedIndex = 0;
let selectedCrumb = null;
// Reset crumb styles.
for (let i = 0; i < crumbs.childNodes.length; ++i) {
const crumb = crumbs.children[i];
// Find the selected crumb and index.
if (!selectedCrumb && crumb.classList.contains('selected')) {
selectedCrumb = crumb;
selectedIndex = i;
}
// Find the focused crumb index.
if (crumb === focusedCrumb) {
focusedIndex = i;
}
crumb.classList.remove('compact', 'collapsed', 'hidden');
}
return {selectedIndex: selectedIndex, focusedIndex: focusedIndex, selectedCrumb: selectedCrumb};
}
/**
* @return {{normal: !Array.<number>, compact: !Array.<number>, collapsed: number, available: number}}
*/
_measureElementSizes() {
const crumbs = this.crumbsElement;
// Layout 1: Measure total and normal crumb sizes at the same time as a
// dummy element for the collapsed size.
const collapsedElement = createElementWithClass('span', 'crumb collapsed');
crumbs.insertBefore(collapsedElement, crumbs.firstChild);
const available = crumbs.offsetWidth;
const collapsed = collapsedElement.offsetWidth;
const normalSizes = [];
for (let i = 1; i < crumbs.childNodes.length; ++i) {
const crumb = crumbs.childNodes[i];
normalSizes[i - 1] = crumb.offsetWidth;
}
crumbs.removeChild(collapsedElement);
// Layout 2: Measure collapsed crumb sizes
const compactSizes = [];
for (let i = 0; i < crumbs.childNodes.length; ++i) {
const crumb = crumbs.childNodes[i];
crumb.classList.add('compact');
}
for (let i = 0; i < crumbs.childNodes.length; ++i) {
const crumb = crumbs.childNodes[i];
compactSizes[i] = crumb.offsetWidth;
}
// Clean up.
for (let i = 0; i < crumbs.childNodes.length; ++i) {
const crumb = crumbs.childNodes[i];
crumb.classList.remove('compact', 'collapsed');
}
return {normal: normalSizes, compact: compactSizes, collapsed: collapsed, available: available};
}
/**
* @param {!Element=} focusedCrumb
*/
updateSizes(focusedCrumb) {
if (!this.isShowing()) {
return;
}
const crumbs = this.crumbsElement;
if (!crumbs.firstChild) {
return;
}
const selections = this._resetCrumbStylesAndFindSelections(focusedCrumb);
const sizes = this._measureElementSizes();
const selectedIndex = selections.selectedIndex;
const focusedIndex = selections.focusedIndex;
const selectedCrumb = selections.selectedCrumb;
function crumbsAreSmallerThanContainer() {
let totalSize = 0;
for (let i = 0; i < crumbs.childNodes.length; ++i) {
const crumb = crumbs.childNodes[i];
if (crumb.classList.contains('hidden')) {
continue;
}
if (crumb.classList.contains('collapsed')) {
totalSize += sizes.collapsed;
continue;
}
totalSize += crumb.classList.contains('compact') ? sizes.compact[i] : sizes.normal[i];
}
const rightPadding = 10;
return totalSize + rightPadding < sizes.available;
}
if (crumbsAreSmallerThanContainer()) {
return;
} // No need to compact the crumbs, they all fit at full size.
const BothSides = 0;
const AncestorSide = -1;
const ChildSide = 1;
/**
* @param {function(!Element)} shrinkingFunction
* @param {number} direction
*/
function makeCrumbsSmaller(shrinkingFunction, direction) {
const significantCrumb = focusedCrumb || selectedCrumb;
const significantIndex = significantCrumb === selectedCrumb ? selectedIndex : focusedIndex;
function shrinkCrumbAtIndex(index) {
const shrinkCrumb = crumbs.children[index];
if (shrinkCrumb && shrinkCrumb !== significantCrumb) {
shrinkingFunction(shrinkCrumb);
}
if (crumbsAreSmallerThanContainer()) {
return true;
} // No need to compact the crumbs more.
return false;
}
// Shrink crumbs one at a time by applying the shrinkingFunction until the crumbs
// fit in the container or we run out of crumbs to shrink.
if (direction) {
// Crumbs are shrunk on only one side (based on direction) of the signifcant crumb.
let index = (direction > 0 ? 0 : crumbs.childNodes.length - 1);
while (index !== significantIndex) {
if (shrinkCrumbAtIndex(index)) {
return true;
}
index += (direction > 0 ? 1 : -1);
}
} else {
// Crumbs are shrunk in order of descending distance from the signifcant crumb,
// with a tie going to child crumbs.
let startIndex = 0;
let endIndex = crumbs.childNodes.length - 1;
while (startIndex !== significantIndex || endIndex !== significantIndex) {
const startDistance = significantIndex - startIndex;
const endDistance = endIndex - significantIndex;
let index;
if (startDistance >= endDistance) {
index = startIndex++;
} else {
index = endIndex--;
}
if (shrinkCrumbAtIndex(index)) {
return true;
}
}
}
// We are not small enough yet, return false so the caller knows.
return false;
}
function coalesceCollapsedCrumbs() {
let crumb = crumbs.firstChild;
let collapsedRun = false;
let newStartNeeded = false;
let newEndNeeded = false;
while (crumb) {
const hidden = crumb.classList.contains('hidden');
if (!hidden) {
const collapsed = crumb.classList.contains('collapsed');
if (collapsedRun && collapsed) {
crumb.classList.add('hidden');
crumb.classList.remove('compact');
crumb.classList.remove('collapsed');
if (crumb.classList.contains('start')) {
crumb.classList.remove('start');
newStartNeeded = true;
}
if (crumb.classList.contains('end')) {
crumb.classList.remove('end');
newEndNeeded = true;
}
continue;
}
collapsedRun = collapsed;
if (newEndNeeded) {
newEndNeeded = false;
crumb.classList.add('end');
}
} else {
collapsedRun = true;
}
crumb = crumb.nextSibling;
}
if (newStartNeeded) {
crumb = crumbs.lastChild;
while (crumb) {
if (!crumb.classList.contains('hidden')) {
crumb.classList.add('start');
break;
}
crumb = crumb.previousSibling;
}
}
}
/**
* @param {!Element} crumb
*/
function compact(crumb) {
if (crumb.classList.contains('hidden')) {
return;
}
crumb.classList.add('compact');
}
/**
* @param {!Element} crumb
* @param {boolean=} dontCoalesce
*/
function collapse(crumb, dontCoalesce) {
if (crumb.classList.contains('hidden')) {
return;
}
crumb.classList.add('collapsed');
crumb.classList.remove('compact');
if (!dontCoalesce) {
coalesceCollapsedCrumbs();
}
}
if (!focusedCrumb) {
// When not focused on a crumb we can be biased and collapse less important
// crumbs that the user might not care much about.
// Compact child crumbs.
if (makeCrumbsSmaller(compact, ChildSide)) {
return;
}
// Collapse child crumbs.
if (makeCrumbsSmaller(collapse, ChildSide)) {
return;
}
}
// Compact ancestor crumbs, or from both sides if focused.
if (makeCrumbsSmaller(compact, focusedCrumb ? BothSides : AncestorSide)) {
return;
}
// Collapse ancestor crumbs, or from both sides if focused.
if (makeCrumbsSmaller(collapse, focusedCrumb ? BothSides : AncestorSide)) {
return;
}
if (!selectedCrumb) {
return;
}
// Compact the selected crumb.
compact(selectedCrumb);
if (crumbsAreSmallerThanContainer()) {
return;
}
// Collapse the selected crumb as a last resort. Pass true to prevent coalescing.
collapse(selectedCrumb, true);
}
}
/** @enum {symbol} */
export const Events = {
NodeSelected: Symbol('NodeSelected')
};