/* 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 { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react"); const { ViewHelpers } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm"); const AUTO_EXPAND_DEPTH = 3; // depth /** * An arrow that displays whether its node is expanded (▼) or collapsed * (▶). When its node has no children, it is hidden. */ const ArrowExpander = createFactory(createClass({ displayName: "ArrowExpander", shouldComponentUpdate(nextProps, nextState) { return this.props.item !== nextProps.item || this.props.visible != nextProps.visible || this.props.expanded !== nextProps.expanded; }, render() { const attrs = { className: "arrow theme-twisty", onClick: this.props.expanded ? () => this.props.onCollapse(this.props.item) : e => this.props.onExpand(this.props.item, e.altKey) }; if (this.props.expanded) { attrs.className += " open"; } if (!this.props.visible) { attrs.style = { visibility: "hidden" }; } return dom.div(attrs); } })); const TreeNode = createFactory(createClass({ componentDidUpdate() { if (this.props.focused) { this.refs.button.focus(); } }, render() { const arrow = ArrowExpander({ item: this.props.item, expanded: this.props.expanded, visible: this.props.hasChildren, onExpand: this.props.onExpand, onCollapse: this.props.onCollapse }); return dom.div( { className: "tree-node div", onFocus: this.props.onFocus, onClick: this.props.onFocus, onBlur: this.props.onBlur, style: { padding: 0, margin: 0 } }, this.props.renderItem(this.props.item, this.props.depth, this.props.focused, arrow), // XXX: OSX won't focus/blur regular elements even if you set tabindex // unless there is an input/button child. dom.button(this._buttonAttrs) ); }, _buttonAttrs: { ref: "button", style: { opacity: 0, width: "0 !important", height: "0 !important", padding: "0 !important", outline: "none", MozAppearance: "none", // XXX: Despite resetting all of the above properties (and margin), the // button still ends up with ~79px width, so we set a large negative // margin to completely hide it. MozMarginStart: "-1000px !important", } } })); /** * A generic tree component. See propTypes for the public API. * * @see `devtools/client/memory/components/test/mochitest/head.js` for usage * @see `devtools/client/memory/components/heap.js` for usage */ const Tree = module.exports = createClass({ displayName: "Tree", propTypes: { // Required props // A function to get an item's parent, or null if it is a root. getParent: PropTypes.func.isRequired, // A function to get an item's children. getChildren: PropTypes.func.isRequired, // A function which takes an item and ArrowExpander and returns a // component. renderItem: PropTypes.func.isRequired, // A function which returns the roots of the tree (forest). getRoots: PropTypes.func.isRequired, // A function to get a unique key for the given item. getKey: PropTypes.func.isRequired, // The height of an item in the tree including margin and padding, in // pixels. itemHeight: PropTypes.number.isRequired, // Optional props // A predicate function to filter out unwanted items from the tree. filter: PropTypes.func, // The depth to which we should automatically expand new items. autoExpandDepth: PropTypes.number, // A predicate that returns true if the last DFS traversal that was cached // can be reused, false otherwise. The predicate function is passed the // cached traversal as an array of nodes. reuseCachedTraversal: PropTypes.func, }, getDefaultProps() { return { filter: item => true, expanded: new Set(), seen: new Set(), focused: undefined, autoExpandDepth: AUTO_EXPAND_DEPTH, reuseCachedTraversal: null, }; }, getInitialState() { return { scroll: 0, height: window.innerHeight, expanded: new Set(), seen: new Set(), focused: undefined, cachedTraversal: undefined, }; }, componentDidMount() { window.addEventListener("resize", this._updateHeight); this._updateHeight(); }, componentWillUnmount() { window.removeEventListener("resize", this._updateHeight); }, componentWillReceiveProps(nextProps) { // Automatically expand the first autoExpandDepth levels for new items. for (let { item } of this._dfsFromRoots(this.props.autoExpandDepth)) { if (!this.state.seen.has(item)) { this.state.expanded.add(item); this.state.seen.add(item); } } }, render() { const traversal = this._dfsFromRoots(); // Remove 1 from `begin` and add 2 to `end` so that the top and bottom of // the page are filled with the previous and next items respectively, // rather than whitespace if the item is not in full view. const begin = Math.max(((this.state.scroll / this.props.itemHeight) | 0) - 1, 0); const end = begin + 2 + ((this.state.height / this.props.itemHeight) | 0); const toRender = traversal.slice(begin, end); const nodes = [ dom.div({ key: "top-spacer", style: { padding: 0, margin: 0, height: begin * this.props.itemHeight + "px" } }) ]; for (let i = 0; i < toRender.length; i++) { let { item, depth } = toRender[i]; nodes.push(TreeNode({ key: this.props.getKey(item), item: item, depth: depth, renderItem: this.props.renderItem, focused: this.state.focused === item, expanded: this.state.expanded.has(item), hasChildren: !!this.props.getChildren(item).length, onExpand: this._onExpand, onCollapse: this._onCollapse, onFocus: () => this._onFocus(item) })); } nodes.push(dom.div({ key: "bottom-spacer", style: { padding: 0, margin: 0, height: (traversal.length - 1 - end) * this.props.itemHeight + "px" } })); return dom.div( { className: "tree", ref: "tree", onKeyDown: this._onKeyDown, onScroll: this._onScroll, style: { padding: 0, margin: 0 } }, nodes ); }, /** * Updates the state's height based on clientHeight. */ _updateHeight() { this.setState({ height: this.refs.tree.clientHeight }); }, /** * Perform a pre-order depth-first search from item. */ _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) { if (!this.props.filter(item)) { return traversal; } traversal.push({ item, depth: _depth }); if (!this.state.expanded.has(item)) { return traversal; } const nextDepth = _depth + 1; if (nextDepth > maxDepth) { return traversal; } for (let child of this.props.getChildren(item)) { this._dfs(child, maxDepth, traversal, nextDepth); } return traversal; }, /** * Perform a pre-order depth-first search over the whole forest. */ _dfsFromRoots(maxDepth = Infinity) { const cached = this.state.cachedTraversal; if (cached && maxDepth === Infinity && this.props.reuseCachedTraversal && this.props.reuseCachedTraversal(cached)) { return cached; } const traversal = []; for (let root of this.props.getRoots()) { this._dfs(root, maxDepth, traversal); } if (this.props.reuseCachedTraversal) { this.state.cachedTraversal = traversal; } return traversal; }, /** * Expands current row. * * @param {Object} item * @param {Boolean} expandAllChildren */ _onExpand(item, expandAllChildren) { this.state.expanded.add(item); if (expandAllChildren) { for (let { item: child } of this._dfs(item)) { this.state.expanded.add(child); } } this.setState({ expanded: this.state.expanded, cachedTraversal: null, }); }, /** * Collapses current row. * * @param {Object} item */ _onCollapse(item) { this.state.expanded.delete(item); this.setState({ expanded: this.state.expanded, cachedTraversal: null, }); }, /** * Sets the passed in item to be the focused item. * * @param {Object} item */ _onFocus(item) { this.setState({ focused: item }); }, /** * Sets the state to have no focused item. */ _onBlur() { this.setState({ focused: undefined }); }, /** * Fired on a scroll within the tree's container, updates * the stored position of the view port to handle virtual view rendering. * * @param {Event} e */ _onScroll(e) { this.setState({ scroll: Math.max(this.refs.tree.scrollTop, 0), height: this.refs.tree.clientHeight }); }, /** * Handles key down events in the tree's container. * * @param {Event} e */ _onKeyDown(e) { if (this.state.focused == null) { return; } // Prevent scrolling when pressing navigation keys. Guard against mocked // events received when testing. if (e.nativeEvent && e.nativeEvent.preventDefault) { ViewHelpers.preventScrolling(e.nativeEvent); } switch (e.key) { case "ArrowUp": this._focusPrevNode(); return false; case "ArrowDown": this._focusNextNode(); return false; case "ArrowLeft": if (this.state.expanded.has(this.state.focused) && this.props.getChildren(this.state.focused).length) { this._onCollapse(this.state.focused); } else { this._focusParentNode(); } return false; case "ArrowRight": if (!this.state.expanded.has(this.state.focused)) { this._onExpand(this.state.focused); } else { this._focusNextNode(); } return false; } }, /** * Sets the previous node relative to the currently focused item, to focused. */ _focusPrevNode() { // Start a depth first search and keep going until we reach the currently // focused node. Focus the previous node in the DFS, if it exists. If it // doesn't exist, we're at the first node already. let prev; for (let { item } of this._dfsFromRoots()) { if (item === this.state.focused) { break; } prev = item; } if (prev === undefined) { return; } this.setState({ focused: prev }); }, /** * Handles the down arrow key which will focus either the next child * or sibling row. */ _focusNextNode() { // Start a depth first search and keep going until we reach the currently // focused node. Focus the next node in the DFS, if it exists. If it // doesn't exist, we're at the last node already. const traversal = this._dfsFromRoots(); let i = 0; for (let { item } of traversal) { if (item === this.state.focused) { break; } i++; } if (i + 1 < traversal.length) { this.setState({ focused: traversal[i + 1].item }); } }, /** * Handles the left arrow key, going back up to the current rows' * parent row. */ _focusParentNode() { const parent = this.props.getParent(this.state.focused); if (parent) { this.setState({ focused: parent }); } } });