import React from 'react' import SearchBar from './SearchBar' import Node from './Node' import cx from '../utils/cx' import DOMHelper from '../utils/DOMHelper' import treeParser from '../utils/treeParser' import URLHelper from '../utils/URLHelper' import VisibleNodesGenerator from '../utils/VisibleNodesGenerator' function getVisibleParentNode(nodes, focusedNode, depths) { const focusedNodeIndex = nodes.indexOf(focusedNode) const focusedNodeDepth = depths.get(focusedNode) let indexOfParentNode = focusedNodeIndex - 1 while ( indexOfParentNode !== -1 && depths.get(nodes[indexOfParentNode]) >= focusedNodeDepth ) { --indexOfParentNode } const parentNode = nodes[indexOfParentNode] return parentNode } export default class List extends React.Component { static defaultProps = { treeData: null, metaData: null, freeze: false, } state = { // generated by this.visibleNodesGenerator visibleNodes: null, } tasksAfterRender = [] visibleNodesGenerator = new VisibleNodesGenerator() componentWillMount() { const { treeData, metaData } = this.props const { root, nodes } = treeParser.parse(treeData, metaData) this.visibleNodesGenerator.plantTree(root, nodes) const currentPath = URLHelper.getCurrentPath(true) this.tasksAfterRender.push(DOMHelper.focusSearchInput) if (currentPath.length) { const nodeExpandedTo = this.visibleNodesGenerator.expandTo(currentPath) if (nodeExpandedTo) { this.visibleNodesGenerator.focusNode(nodeExpandedTo) const { nodes } = this.visibleNodesGenerator.visibleNodes this.tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(nodes.indexOf(nodeExpandedTo))) } } this.updateVisibleNodes() } componentDidMount() { this.execAfterRender() } componentDidUpdate(prevProps, prevState) { this.execAfterRender() } execAfterRender() { for (const task of this.tasksAfterRender) { task() } this.tasksAfterRender.length = 0 } updateVisibleNodes() { const { visibleNodes } = this.visibleNodesGenerator this.setState({ visibleNodes }) } handleKeyDown = event => { const { key } = event const { visibleNodes: { nodes, focusedNode, expandedNodes, depths } } = this.state let shouldStopPropagation = true // prevent body scrolling if (focusedNode) { const focusedNodeIndex = nodes.indexOf(focusedNode) switch (key) { case 'ArrowUp': // focus on previous node if (focusedNodeIndex === 0) { this.focusNode(null) this.tasksAfterRender.push(DOMHelper.focusSearchInput) } else { this.focusNode(nodes[focusedNodeIndex - 1]) } break case 'ArrowDown': // focus on next node if (focusedNodeIndex + 1 < nodes.length) { this.focusNode(nodes[focusedNodeIndex + 1]) } else { this.focusNode(null) this.tasksAfterRender.push(DOMHelper.focusSearchInput) } break case 'ArrowLeft': // collapse node or go to parent node if (expandedNodes.has(focusedNode)) { this.setExpand(focusedNode, false) } else { // go forward to the start of the list, find the closest node with lower depth const parentNode = getVisibleParentNode(nodes, focusedNode, depths) if (parentNode) { this.focusNode(parentNode) } } break // consider the two keys as 'confirm' key case 'ArrowRight': // expand node or focus on first content node or redirect to file page if (focusedNode.type === 'tree') { if (expandedNodes.has(focusedNode)) { const nextNode = nodes[focusedNodeIndex + 1] if (depths.get(nextNode) > depths.get(focusedNode)) { this.focusNode(nextNode) } } else { this.setExpand(focusedNode, true) } } else if (focusedNode.type === 'blob') { DOMHelper.loadWithPJAX(focusedNode.url) } else if (focusedNode.type === 'commit') { // redirect to its parent folder DOMHelper.loadWithPJAX(focusedNode.parent.url) } break case 'Enter': // expand node or redirect to file page if (focusedNode.type === 'tree') { this.setExpand(focusedNode, true) } else if (focusedNode.type === 'blob') { DOMHelper.loadWithPJAX(focusedNode.url) } else if (focusedNode.type === 'commit') { // redirect to its parent folder DOMHelper.loadWithPJAX(focusedNode.parent.url) } break default: shouldStopPropagation = false } } else { // now search input is focused if (nodes.length) { switch (key) { case 'ArrowDown': this.focusNode(nodes[0]) break case 'ArrowUp': this.focusNode(nodes[nodes.length - 1]) break default: shouldStopPropagation = false } } else { shouldStopPropagation = false } } if (shouldStopPropagation) { event.stopPropagation() event.preventDefault() } } handleSearchKeyChange = async event => { const searchKey = event.target.value await this.visibleNodesGenerator.search(searchKey) this.updateVisibleNodes() } setExpand = (node, expand) => { this.visibleNodesGenerator.setExpand(node, expand) this.focusNode(node) this.tasksAfterRender.push(DOMHelper.focusSearchInput) } toggleNodeExpand = (node, skipScrollToNode) => { this.visibleNodesGenerator.toggleExpand(node) this.focusNode(node, skipScrollToNode) this.tasksAfterRender.push(DOMHelper.focusFileExplorer) } focusNode = (node, skipScroll) => { this.visibleNodesGenerator.focusNode(node) if (node && !skipScroll) { // when focus a node not in viewport(by keyboard), scroll to it const { visibleNodes: { nodes } } = this.state const indexOfToBeFocusedNode = nodes.indexOf(node) this.tasksAfterRender.push(() => DOMHelper.scrollToNodeElement(indexOfToBeFocusedNode)) this.tasksAfterRender.push(DOMHelper.focusSearchInput) } this.updateVisibleNodes() } onNodeClick = (node) => { if (node.type === 'tree') { this.toggleNodeExpand(node, true) } else if (node.type === 'blob') { this.focusNode(node, true) DOMHelper.loadWithPJAX(node.url) } else if (node.type === 'commit') { DOMHelper.loadWithPJAX(node.parent.url) } } render() { const { visibleNodes: { nodes, depths, focusedNode, expandedNodes } } = this.state const { freeze } = this.props return (