| // 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') |
| }; |