123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774 |
- /* 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/. */
- /* eslint-env browser */
- "use strict";
- const { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react");
- const AUTO_EXPAND_DEPTH = 0;
- const NUMBER_OF_OFFSCREEN_ITEMS = 1;
- /**
- * A fast, generic, expandable and collapsible tree component.
- *
- * This tree component is fast: it can handle trees with *many* items. It only
- * renders the subset of those items which are visible in the viewport. It's
- * been battle tested on huge trees in the memory panel. We've optimized tree
- * traversal and rendering, even in the presence of cross-compartment wrappers.
- *
- * This tree component doesn't make any assumptions about the structure of your
- * tree data. Whether children are computed on demand, or stored in an array in
- * the parent's `_children` property, it doesn't matter. We only require the
- * implementation of `getChildren`, `getRoots`, `getParent`, and `isExpanded`
- * functions.
- *
- * This tree component is well tested and reliable. See
- * devtools/client/shared/components/test/mochitest/test_tree_* and its usage in
- * the performance and memory panels.
- *
- * This tree component doesn't make any assumptions about how to render items in
- * the tree. You provide a `renderItem` function, and this component will ensure
- * that only those items whose parents are expanded and which are visible in the
- * viewport are rendered. The `renderItem` function could render the items as a
- * "traditional" tree or as rows in a table or anything else. It doesn't
- * restrict you to only one certain kind of tree.
- *
- * The only requirement is that every item in the tree render as the same
- * height. This is required in order to compute which items are visible in the
- * viewport in constant time.
- *
- * ### Example Usage
- *
- * Suppose we have some tree data where each item has this form:
- *
- * {
- * id: Number,
- * label: String,
- * parent: Item or null,
- * children: Array of child items,
- * expanded: bool,
- * }
- *
- * Here is how we could render that data with this component:
- *
- * const MyTree = createClass({
- * displayName: "MyTree",
- *
- * propTypes: {
- * // The root item of the tree, with the form described above.
- * root: PropTypes.object.isRequired
- * },
- *
- * render() {
- * return Tree({
- * itemHeight: 20, // px
- *
- * getRoots: () => [this.props.root],
- *
- * getParent: item => item.parent,
- * getChildren: item => item.children,
- * getKey: item => item.id,
- * isExpanded: item => item.expanded,
- *
- * renderItem: (item, depth, isFocused, arrow, isExpanded) => {
- * let className = "my-tree-item";
- * if (isFocused) {
- * className += " focused";
- * }
- * return dom.div(
- * {
- * className,
- * // Apply 10px nesting per expansion depth.
- * style: { marginLeft: depth * 10 + "px" }
- * },
- * // Here is the expando arrow so users can toggle expansion and
- * // collapse state.
- * arrow,
- * // And here is the label for this item.
- * dom.span({ className: "my-tree-item-label" }, item.label)
- * );
- * },
- *
- * onExpand: item => dispatchExpandActionToRedux(item),
- * onCollapse: item => dispatchCollapseActionToRedux(item),
- * });
- * }
- * });
- */
- module.exports = createClass({
- displayName: "Tree",
- propTypes: {
- // Required props
- // A function to get an item's parent, or null if it is a root.
- //
- // Type: getParent(item: Item) -> Maybe<Item>
- //
- // Example:
- //
- // // The parent of this item is stored in its `parent` property.
- // getParent: item => item.parent
- getParent: PropTypes.func.isRequired,
- // A function to get an item's children.
- //
- // Type: getChildren(item: Item) -> [Item]
- //
- // Example:
- //
- // // This item's children are stored in its `children` property.
- // getChildren: item => item.children
- getChildren: PropTypes.func.isRequired,
- // A function which takes an item and ArrowExpander component instance and
- // returns a component, or text, or anything else that React considers
- // renderable.
- //
- // Type: renderItem(item: Item,
- // depth: Number,
- // isFocused: Boolean,
- // arrow: ReactComponent,
- // isExpanded: Boolean) -> ReactRenderable
- //
- // Example:
- //
- // renderItem: (item, depth, isFocused, arrow, isExpanded) => {
- // let className = "my-tree-item";
- // if (isFocused) {
- // className += " focused";
- // }
- // return dom.div(
- // {
- // className,
- // style: { marginLeft: depth * 10 + "px" }
- // },
- // arrow,
- // dom.span({ className: "my-tree-item-label" }, item.label)
- // );
- // },
- renderItem: PropTypes.func.isRequired,
- // A function which returns the roots of the tree (forest).
- //
- // Type: getRoots() -> [Item]
- //
- // Example:
- //
- // // In this case, we only have one top level, root item. You could
- // // return multiple items if you have many top level items in your
- // // tree.
- // getRoots: () => [this.props.rootOfMyTree]
- getRoots: PropTypes.func.isRequired,
- // A function to get a unique key for the given item. This helps speed up
- // React's rendering a *TON*.
- //
- // Type: getKey(item: Item) -> String
- //
- // Example:
- //
- // getKey: item => `my-tree-item-${item.uniqueId}`
- getKey: PropTypes.func.isRequired,
- // A function to get whether an item is expanded or not. If an item is not
- // expanded, then it must be collapsed.
- //
- // Type: isExpanded(item: Item) -> Boolean
- //
- // Example:
- //
- // isExpanded: item => item.expanded,
- isExpanded: PropTypes.func.isRequired,
- // The height of an item in the tree including margin and padding, in
- // pixels.
- itemHeight: PropTypes.number.isRequired,
- // Optional props
- // The currently focused item, if any such item exists.
- focused: PropTypes.any,
- // Handle when a new item is focused.
- onFocus: PropTypes.func,
- // The depth to which we should automatically expand new items.
- autoExpandDepth: PropTypes.number,
- // Optional event handlers for when items are expanded or collapsed. Useful
- // for dispatching redux events and updating application state, maybe lazily
- // loading subtrees from a worker, etc.
- //
- // Type:
- // onExpand(item: Item)
- // onCollapse(item: Item)
- //
- // Example:
- //
- // onExpand: item => dispatchExpandActionToRedux(item)
- onExpand: PropTypes.func,
- onCollapse: PropTypes.func,
- },
- getDefaultProps() {
- return {
- autoExpandDepth: AUTO_EXPAND_DEPTH,
- };
- },
- getInitialState() {
- return {
- scroll: 0,
- height: window.innerHeight,
- seen: new Set(),
- };
- },
- componentDidMount() {
- window.addEventListener("resize", this._updateHeight);
- this._autoExpand();
- this._updateHeight();
- },
- componentWillReceiveProps(nextProps) {
- this._autoExpand();
- this._updateHeight();
- },
- componentWillUnmount() {
- window.removeEventListener("resize", this._updateHeight);
- },
- _autoExpand() {
- if (!this.props.autoExpandDepth) {
- return;
- }
- // Automatically expand the first autoExpandDepth levels for new items. Do
- // not use the usual DFS infrastructure because we don't want to ignore
- // collapsed nodes.
- const autoExpand = (item, currentDepth) => {
- if (currentDepth >= this.props.autoExpandDepth ||
- this.state.seen.has(item)) {
- return;
- }
- this.props.onExpand(item);
- this.state.seen.add(item);
- const children = this.props.getChildren(item);
- const length = children.length;
- for (let i = 0; i < length; i++) {
- autoExpand(children[i], currentDepth + 1);
- }
- };
- const roots = this.props.getRoots();
- const length = roots.length;
- for (let i = 0; i < length; i++) {
- autoExpand(roots[i], 0);
- }
- },
- _preventArrowKeyScrolling(e) {
- switch (e.key) {
- case "ArrowUp":
- case "ArrowDown":
- case "ArrowLeft":
- case "ArrowRight":
- e.preventDefault();
- e.stopPropagation();
- if (e.nativeEvent) {
- if (e.nativeEvent.preventDefault) {
- e.nativeEvent.preventDefault();
- }
- if (e.nativeEvent.stopPropagation) {
- e.nativeEvent.stopPropagation();
- }
- }
- }
- },
- /**
- * 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) {
- traversal.push({ item, depth: _depth });
- if (!this.props.isExpanded(item)) {
- return traversal;
- }
- const nextDepth = _depth + 1;
- if (nextDepth > maxDepth) {
- return traversal;
- }
- const children = this.props.getChildren(item);
- const length = children.length;
- for (let i = 0; i < length; i++) {
- this._dfs(children[i], maxDepth, traversal, nextDepth);
- }
- return traversal;
- },
- /**
- * Perform a pre-order depth-first search over the whole forest.
- */
- _dfsFromRoots(maxDepth = Infinity) {
- const traversal = [];
- const roots = this.props.getRoots();
- const length = roots.length;
- for (let i = 0; i < length; i++) {
- this._dfs(roots[i], maxDepth, traversal);
- }
- return traversal;
- },
- /**
- * Expands current row.
- *
- * @param {Object} item
- * @param {Boolean} expandAllChildren
- */
- _onExpand: oncePerAnimationFrame(function (item, expandAllChildren) {
- if (this.props.onExpand) {
- this.props.onExpand(item);
- if (expandAllChildren) {
- const children = this._dfs(item);
- const length = children.length;
- for (let i = 0; i < length; i++) {
- this.props.onExpand(children[i].item);
- }
- }
- }
- }),
- /**
- * Collapses current row.
- *
- * @param {Object} item
- */
- _onCollapse: oncePerAnimationFrame(function (item) {
- if (this.props.onCollapse) {
- this.props.onCollapse(item);
- }
- }),
- /**
- * Sets the passed in item to be the focused item.
- *
- * @param {Number} index
- * The index of the item in a full DFS traversal (ignoring collapsed
- * nodes). Ignored if `item` is undefined.
- *
- * @param {Object|undefined} item
- * The item to be focused, or undefined to focus no item.
- */
- _focus(index, item) {
- if (item !== undefined) {
- const itemStartPosition = index * this.props.itemHeight;
- const itemEndPosition = (index + 1) * this.props.itemHeight;
- // Note that if the height of the viewport (this.state.height) is less
- // than `this.props.itemHeight`, we could accidentally try and scroll both
- // up and down in a futile attempt to make both the item's start and end
- // positions visible. Instead, give priority to the start of the item by
- // checking its position first, and then using an "else if", rather than
- // a separate "if", for the end position.
- if (this.state.scroll > itemStartPosition) {
- this.refs.tree.scrollTo(0, itemStartPosition);
- } else if ((this.state.scroll + this.state.height) < itemEndPosition) {
- this.refs.tree.scrollTo(0, itemEndPosition - this.state.height);
- }
- }
- if (this.props.onFocus) {
- this.props.onFocus(item);
- }
- },
- /**
- * Sets the state to have no focused item.
- */
- _onBlur() {
- this._focus(0, 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: oncePerAnimationFrame(function (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.props.focused == null) {
- return;
- }
- // Allow parent nodes to use navigation arrows with modifiers.
- if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
- return;
- }
- this._preventArrowKeyScrolling(e);
- switch (e.key) {
- case "ArrowUp":
- this._focusPrevNode();
- return;
- case "ArrowDown":
- this._focusNextNode();
- return;
- case "ArrowLeft":
- if (this.props.isExpanded(this.props.focused)
- && this.props.getChildren(this.props.focused).length) {
- this._onCollapse(this.props.focused);
- } else {
- this._focusParentNode();
- }
- return;
- case "ArrowRight":
- if (!this.props.isExpanded(this.props.focused)) {
- this._onExpand(this.props.focused);
- } else {
- this._focusNextNode();
- }
- return;
- }
- },
- /**
- * Sets the previous node relative to the currently focused item, to focused.
- */
- _focusPrevNode: oncePerAnimationFrame(function () {
- // 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;
- let prevIndex;
- const traversal = this._dfsFromRoots();
- const length = traversal.length;
- for (let i = 0; i < length; i++) {
- const item = traversal[i].item;
- if (item === this.props.focused) {
- break;
- }
- prev = item;
- prevIndex = i;
- }
- if (prev === undefined) {
- return;
- }
- this._focus(prevIndex, prev);
- }),
- /**
- * Handles the down arrow key which will focus either the next child
- * or sibling row.
- */
- _focusNextNode: oncePerAnimationFrame(function () {
- // 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();
- const length = traversal.length;
- let i = 0;
- while (i < length) {
- if (traversal[i].item === this.props.focused) {
- break;
- }
- i++;
- }
- if (i + 1 < traversal.length) {
- this._focus(i + 1, traversal[i + 1].item);
- }
- }),
- /**
- * Handles the left arrow key, going back up to the current rows'
- * parent row.
- */
- _focusParentNode: oncePerAnimationFrame(function () {
- const parent = this.props.getParent(this.props.focused);
- if (!parent) {
- return;
- }
- const traversal = this._dfsFromRoots();
- const length = traversal.length;
- let parentIndex = 0;
- for (; parentIndex < length; parentIndex++) {
- if (traversal[parentIndex].item === parent) {
- break;
- }
- }
- this._focus(parentIndex, parent);
- }),
- render() {
- const traversal = this._dfsFromRoots();
- // 'begin' and 'end' are the index of the first (at least partially) visible item
- // and the index after the last (at least partially) visible item, respectively.
- // `NUMBER_OF_OFFSCREEN_ITEMS` is removed from `begin` and added to `end` so that
- // the top and bottom of the page are filled with the `NUMBER_OF_OFFSCREEN_ITEMS`
- // previous and next items respectively, which helps the user to see fewer empty
- // gaps when scrolling quickly.
- const { itemHeight } = this.props;
- const { scroll, height } = this.state;
- const begin = Math.max(((scroll / itemHeight) | 0) - NUMBER_OF_OFFSCREEN_ITEMS, 0);
- const end = Math.ceil((scroll + height) / itemHeight) + NUMBER_OF_OFFSCREEN_ITEMS;
- const toRender = traversal.slice(begin, end);
- const topSpacerHeight = begin * itemHeight;
- const bottomSpacerHeight = Math.max(traversal.length - end, 0) * itemHeight;
- const nodes = [
- dom.div({
- key: "top-spacer",
- style: {
- padding: 0,
- margin: 0,
- height: topSpacerHeight + "px"
- }
- })
- ];
- for (let i = 0; i < toRender.length; i++) {
- const index = begin + i;
- const first = index == 0;
- const last = index == traversal.length - 1;
- const { item, depth } = toRender[i];
- nodes.push(TreeNode({
- key: this.props.getKey(item),
- index,
- first,
- last,
- item,
- depth,
- renderItem: this.props.renderItem,
- focused: this.props.focused === item,
- expanded: this.props.isExpanded(item),
- hasChildren: !!this.props.getChildren(item).length,
- onExpand: this._onExpand,
- onCollapse: this._onCollapse,
- onFocus: () => this._focus(begin + i, item),
- onFocusedNodeUnmount: () => this.refs.tree && this.refs.tree.focus(),
- }));
- }
- nodes.push(dom.div({
- key: "bottom-spacer",
- style: {
- padding: 0,
- margin: 0,
- height: bottomSpacerHeight + "px"
- }
- }));
- return dom.div(
- {
- className: "tree",
- ref: "tree",
- onKeyDown: this._onKeyDown,
- onKeyPress: this._preventArrowKeyScrolling,
- onKeyUp: this._preventArrowKeyScrolling,
- onScroll: this._onScroll,
- style: {
- padding: 0,
- margin: 0
- }
- },
- nodes
- );
- }
- });
- /**
- * 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({
- componentDidMount() {
- if (this.props.focused) {
- this.refs.button.focus();
- }
- },
- componentDidUpdate() {
- if (this.props.focused) {
- this.refs.button.focus();
- }
- },
- componentWillUnmount() {
- // If this node is being destroyed and has focus, transfer the focus manually
- // to the parent tree component. Otherwise, the focus will get lost and keyboard
- // navigation in the tree will stop working. This is a workaround for a XUL bug.
- // See bugs 1259228 and 1152441 for details.
- // DE-XUL: Remove this hack once all usages are only in HTML documents.
- if (this.props.focused) {
- this.refs.button.blur();
- if (this.props.onFocusedNodeUnmount) {
- this.props.onFocusedNodeUnmount();
- }
- }
- },
- _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",
- }
- },
- render() {
- const arrow = ArrowExpander({
- item: this.props.item,
- expanded: this.props.expanded,
- visible: this.props.hasChildren,
- onExpand: this.props.onExpand,
- onCollapse: this.props.onCollapse,
- });
- let classList = [ "tree-node", "div" ];
- if (this.props.index % 2) {
- classList.push("tree-node-odd");
- }
- if (this.props.first) {
- classList.push("tree-node-first");
- }
- if (this.props.last) {
- classList.push("tree-node-last");
- }
- return dom.div(
- {
- className: classList.join(" "),
- onFocus: this.props.onFocus,
- onClick: this.props.onFocus,
- onBlur: this.props.onBlur,
- "data-expanded": this.props.expanded ? "" : undefined,
- "data-depth": this.props.depth,
- style: {
- padding: 0,
- margin: 0
- }
- },
- this.props.renderItem(this.props.item,
- this.props.depth,
- this.props.focused,
- arrow,
- this.props.expanded),
- // XXX: OSX won't focus/blur regular elements even if you set tabindex
- // unless there is an input/button child.
- dom.button(this._buttonAttrs)
- );
- }
- }));
- /**
- * Create a function that calls the given function `fn` only once per animation
- * frame.
- *
- * @param {Function} fn
- * @returns {Function}
- */
- function oncePerAnimationFrame(fn) {
- let animationId = null;
- let argsToPass = null;
- return function (...args) {
- argsToPass = args;
- if (animationId !== null) {
- return;
- }
- animationId = requestAnimationFrame(() => {
- fn.call(this, ...argsToPass);
- animationId = null;
- argsToPass = null;
- });
- };
- }
|