UNPKG

react-virtualized-sticky-tree

Version:

A React component for efficiently rendering tree like structures with support for position: sticky

649 lines (648 loc) 27.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SCROLL_REASON = exports.ScrollReason = void 0; const react_1 = __importStar(require("react")); const vendorSticky_js_1 = __importDefault(require("./vendorSticky.js")); var ScrollReason; (function (ScrollReason) { ScrollReason["OBSERVED"] = "observed"; ScrollReason["REQUESTED"] = "requested"; })(ScrollReason = exports.ScrollReason || (exports.ScrollReason = {})); /** * @Deprecated use ScrollReason */ exports.SCROLL_REASON = ScrollReason; class StickyTree extends react_1.default.PureComponent { constructor(props) { super(props); this.elemRef = (0, react_1.createRef)(); if (this.props.apiRef) { this.props.apiRef(this); } this.state = { scrollTop: 0, currNodePos: 0, scrollTick: false, }; /** * A flattened node array created using post-traversal order. * @type {Array} */ this.nodes = []; this.structureChanged = false; this.onScroll = this.onScroll.bind(this); this.getChildrenCache = {}; this.rowRenderCache = {}; this.rowRenderRange = undefined; } /** * Converts the consumer's tree structure into a flat array with root at index: 0, * including information about the top and height of each node. * * i.e: * [ * { id: 'root', top: 0, index: 0, height: 100. isSticky: true , zIndex: 0, stickyTop: 10 }, * { id: 'child1', top: 10, index: 1, parentIndex: 0 height: 10, isSticky: false }, * ... * ] */ flattenTree(stickyNode, props = this.props, nodes = [], isFirstChild = false, isLastChild = false, parentIndex = undefined, context = { totalHeight: 0 }) { const index = nodes.length; const height = stickyNode.height !== undefined ? stickyNode.height : props.rowHeight; const enhancedParentStickyNode = parentIndex !== undefined ? nodes[parentIndex] : undefined; const { isSticky = false, stickyTop = 0, zIndex = 0, node } = stickyNode; const enhancedStickyNode = { id: node.id, isSticky, stickyTop, zIndex, node, top: context.totalHeight, isLeafNode: false, meta: stickyNode.meta, parentIndex, parentInfo: enhancedParentStickyNode, depth: enhancedParentStickyNode !== undefined ? enhancedParentStickyNode.depth + 1 : 0, height, index, isFirstChild, isLastChild, totalHeight: 0, }; nodes.push(enhancedStickyNode); if (enhancedParentStickyNode !== undefined) { enhancedParentStickyNode.children.push(index); } context.totalHeight += height; const children = props.getChildren(stickyNode.node, enhancedStickyNode); if (props.isModelImmutable) { // If children is undefined, then it is probably a leaf node, so we will have to render this since we don't know if the node // itself has changed. let oldChildren = this.getChildrenCache[enhancedStickyNode.id]; if (children === undefined || oldChildren !== children) { delete this.rowRenderCache[enhancedStickyNode.id]; this.getChildrenCache[enhancedStickyNode.id] = children; // Check for structure changes... if (oldChildren && children && (children.length !== oldChildren.length || !children.every((child, i) => child.node.id === oldChildren[i].node.id))) { this.structureChanged = true; // We need to update the entire branch if the structure has changed. this.getBranchChildrenIds(children).forEach((id) => delete this.rowRenderCache[id]); } } } else { this.structureChanged = true; } if (Array.isArray(children)) { enhancedStickyNode.children = []; for (let i = 0; i < children.length; i++) { // Need to reset parentIndex here as we are recursive. const child = children[i]; this.flattenTree(child, props, nodes, i === 0, i === children.length - 1, index, context); } } else { enhancedStickyNode.isLeafNode = true; } enhancedStickyNode.totalHeight = context.totalHeight - enhancedStickyNode.top; return nodes; } getBranchChildrenIds(children, arr = []) { if (!children) { return arr; } children.forEach((child) => { arr.push(child.node.id); this.getBranchChildrenIds(this.getChildrenCache[child.node.id], arr); }); return arr; } UNSAFE_componentWillMount() { this.refreshCachedMetadata(this.props); this.storeRenderTree(this.props, this.state); } treeDataUpdated(newProps) { return (newProps.root !== this.props.root || newProps.getChildren !== this.props.getChildren || newProps.rowHeight !== this.props.rowHeight); } UNSAFE_componentWillReceiveProps(newProps) { // These two properties will change when the structure changes, so we need to re-build the tree when this happens. if (this.treeDataUpdated(newProps)) { this.refreshCachedMetadata(newProps); } if (newProps.scrollIndex !== undefined && newProps.scrollIndex >= 0) { this.scrollIndexIntoView(newProps.scrollIndex); } } UNSAFE_componentWillUpdate(newProps, newState) { if (newState.scrollTick === this.state.scrollTick || newState.currNodePos !== this.state.currNodePos) { this.storeRenderTree(newProps, newState); } } getNode(nodeId) { var _a; return (_a = this.nodes[this.getNodeIndex(nodeId)]) === null || _a === void 0 ? void 0 : _a.node; } /** * Returns the index of the node in a flat list tree (post-order traversal). * * @param nodeId The node index to get the index for. * @returns {number} */ getNodeIndex(nodeId) { return this.nodes.findIndex((node) => node.id === nodeId); } /** * Returns the node that appears higher than this node (either a parent, sibling or child of the sibling above). * @param nodeId The node to get the previous node of. * @returns {*} */ getPreviousNodeId(nodeId) { const index = this.getNodeIndex(nodeId); if (index !== -1) { const node = this.nodes[index - 1]; if (node) { return node.id; } } return undefined; } /** * Returns the node that appears lower than this node (sibling or sibling of the node's parent). * @param nodeId The node to get the next node of. * @returns {*} */ getNextNodeId(nodeId) { const index = this.getNodeIndex(nodeId); if (index !== -1) { const node = this.nodes[index + 1]; if (node) { return node.id; } } return undefined; } /** * Returns true if the node is completely visible and is not obscured. * This will return false when the node is partially obscured. * * @param nodeId The id of the node to check * @param includeObscured if true, this method will return true for partially visible nodes. * @returns {boolean} */ isNodeVisible(nodeId, includeObscured = false) { return this.isIndexVisible(this.getNodeIndex(nodeId), includeObscured); } /** * Returns true if the node is completely visible and is not obscured, unless includeObscured is specified. * This will return false when the node is partially obscured, unless includeObscured is set to true. * * @param index The index of the node to check, generally retrieved via getNodeIndex() * @param includeObscured if true, this method will return true for partially visible nodes. * @returns {boolean} */ isIndexVisible(index, includeObscured = false) { let inView; const node = this.nodes[index]; if (!node) { return false; } if ((node.isSticky && index === this.state.currNodePos) || this.getParentPath(this.state.currNodePos).includes(this.nodes[index])) { return true; } const scrollTop = this.getScrollTop(); if (!includeObscured) { inView = this.isIndexInViewport(index); } else if (this.elemRef.current) { inView = scrollTop <= node.top + node.height - node.stickyTop && scrollTop + this.props.height >= node.top; } if (inView) { const path = this.getParentPath(index, false); // If this node is in view, new need to check to see if it is obscured by a sticky parent. // Note that this does not handle weird scenarios where the node's parent has a sticky top which is less than other ancestors. // Or any z-index weirdness. for (let i = 0; i < path.length; i++) { const ancestor = path[i]; // If the ancestor is sticky and the node is in view, then it must be stuck to the top if (ancestor.isSticky) { if (!includeObscured && ancestor.stickyTop + ancestor.height > node.top - scrollTop) { return false; } if (includeObscured && ancestor.stickyTop + ancestor.height > node.top + node.height - scrollTop) { return false; } } } return true; } return false; } /** * Returns true if the node is within the view port window. Note this this will return FALSE for visible sticky nodes that are * partially out of view disregarding sticky, which is useful when the node will become unstuck. This may occur when the node is * collapsed in a tree. In this case, you want to scroll this node back into view so that the collapsed node stays in the same position. * * @param nodeId The id of the node to check * @returns {boolean} */ isNodeInViewport(nodeId) { return this.isIndexInViewport(this.getNodeIndex(nodeId)); } /** * Returns true if the node is within the view port window. Note this this will return FALSE for visible sticky nodes that are * partially out of view disregarding sticky, which is useful when the node will become unstuck. This may occur when the node is * collapsed in a tree. In this case, you want to scroll this node back into view so that the collapsed node stays in the same position. * * This also returns false if the node is partially out of view. * * @param index The node index, generally retrieved via getNodeIndex() * @returns {boolean} */ isIndexInViewport(index) { let node = this.nodes[index]; if (!node || !this.elemRef) { return false; } const scrollTop = this.getScrollTop(); return scrollTop <= node.top - node.stickyTop && scrollTop + this.props.height >= node.top + node.height; } /** * Returns the top of the node with the specified id. * @param nodeId */ getNodeTop(nodeId) { return this.getIndexTop(this.getNodeIndex(nodeId)); } /** * Returns the top of the node with the specified index. * @param index */ getIndexTop(index) { const node = this.nodes[index]; return node ? node.top : -1; } /** * Returns the scrollTop of the scrollable element * * @return returns -1 if the elem does not exist. */ getScrollTop() { return this.elemRef.current ? this.elemRef.current.scrollTop : -1; } /** * Returns the scrollLeft of the scrollable element * * @return returns -1 if the elem does not exist. */ getScrollLeft() { return this.elemRef.current ? this.elemRef.current.scrollLeft : -1; } /** * Sets the scrollTop position of the scrollable element. * @param scrollTop */ setScrollTop(scrollTop) { if (!isNaN(scrollTop)) { this.setScrollTopAndClosestNode(scrollTop, this.state.currNodePos, ScrollReason.REQUESTED); } } /** * Sets the scrollLeft position of the scrollable element. * @param scrollLeft */ setScrollLeft(scrollLeft) { if (!isNaN(scrollLeft) && this.elemRef.current) { this.elemRef.current.scrollLeft = scrollLeft; } } /** * Scrolls the node into view so that it is visible. * * @param nodeId The node id of the node to scroll into view. * @param alignToTop if true, the node will aligned to the top of viewport, or sticky parent. If false, the bottom of the node will * be aligned with the bottom of the viewport. */ scrollNodeIntoView(nodeId, alignToTop = true) { this.scrollIndexIntoView(this.getNodeIndex(nodeId), alignToTop); } /** * Scrolls the node into view so that it is visible. * * @param index The index of the node. * @param alignToTop if true, the node will aligned to the top of viewport, or sticky parent. If false, the bottom of the node will * be aligned with the bottom of the viewport. */ scrollIndexIntoView(index, alignToTop = true) { let node = this.nodes[index]; if (node !== undefined) { let scrollTop; if (alignToTop) { if (node.isSticky) { scrollTop = node.top - node.stickyTop; } else { const path = this.getParentPath(index, false); for (let i = 0; i < path.length; i++) { const ancestor = path[i]; if (ancestor.isSticky) { scrollTop = node.top - ancestor.stickyTop - ancestor.height; break; } } if (scrollTop === undefined) { // Fallback if nothing is sticky. scrollTop = node.top; } } } else { scrollTop = node.top - this.props.height + node.height; } this.setScrollTop(scrollTop); } } componentDidUpdate(prevProps, prevState) { if (this.state.scrollReason === ScrollReason.REQUESTED) { if (this.elemRef.current && this.state.scrollTop >= 0 && this.state.scrollTop !== this.elemRef.current.scrollTop) { this.elemRef.current.scrollTop = this.state.scrollTop; } } if (this.props.onRowsRendered !== undefined && (prevState.currNodePos !== this.state.currNodePos || this.treeDataUpdated(prevProps))) { const range = this.rowRenderRange; const visibleStartInfo = this.nodes[range.visibleStart]; const visibleEndInfo = this.nodes[range.visibleEnd]; this.props.onRowsRendered({ overscanStartIndex: range.start, overscanStopIndex: range.end, startIndex: range.visibleStart, stopIndex: range.visibleEnd, startNode: visibleStartInfo && visibleStartInfo, endNode: visibleEndInfo && visibleEndInfo, nodes: this.nodes, }); } } refreshCachedMetadata(props) { this.structureChanged = false; this.nodes = this.flattenTree(props.root, props); if (this.structureChanged) { // Need to re-render as the curr node may not be in view if (this.elemRef) { // We need to find the the closest node to where we are scrolled to since the structure of the // the tree probably has changed. this.setScrollTopAndClosestNode(this.pendingScrollTop || this.getScrollTop(), 0, ScrollReason.REQUESTED); } } } recomputeTree() { if (this.props.root !== undefined && this.props.getChildren !== undefined) { this.refreshCachedMetadata(this.props); this.forceUpdate(); } } storeRenderTree(props, state) { this.treeToRender = this.renderParentTree(props, state); } forceUpdate() { this.getChildrenCache = {}; this.rowRenderCache = {}; this.storeRenderTree(this.props, this.state); super.forceUpdate(); } renderParentTree(props, state) { this.rowRenderRange = this.getRenderRowRange(props, state); const path = this.getParentPath(this.rowRenderRange.start); // Parent nodes to the current range. const indexesToRender = new Set(); for (let i = 0; i < path.length; i++) { indexesToRender.add(path[i].index); } // The rest of the nodes within the range. for (let i = this.rowRenderRange.start; i <= this.rowRenderRange.end; i++) { indexesToRender.add(this.nodes[i].index); } if (this.props.renderRoot) { return (react_1.default.createElement("div", { className: "rv-sticky-node-list", style: { width: '100%', position: 'absolute', top: 0 } }, this.renderChildWithChildren(props, state, this.nodes[0], 0, indexesToRender))); } return this.renderParentContainer(props, state, this.nodes[0], indexesToRender); } renderParentContainer(props, state, parent, indexesToRender) { return (react_1.default.createElement("div", { className: "rv-sticky-node-list", style: { position: 'absolute', width: '100%', height: parent.totalHeight - parent.height } }, this.renderChildren(props, state, parent, indexesToRender))); } getChildContainerStyle(child, top) { return { position: 'absolute', top: top, height: child.totalHeight, width: '100%' }; } renderChildWithChildren(props, state, child, top, indexesToRender) { return (react_1.default.createElement("div", { key: `rv-node-${child.id}`, className: "rv-sticky-parent-node", style: this.getChildContainerStyle(child, top) }, this.renderNode(props, state, child, this.getClientNodeStyle(child)), this.renderParentContainer(props, state, child, indexesToRender))); } getClientNodeStyle(node) { const style = { height: node.height }; if (node.isSticky) { style.position = (0, vendorSticky_js_1.default)(); style.top = node.stickyTop; style.zIndex = node.zIndex; } return style; } getClientLeafNodeStyle(node, top) { return { position: 'absolute', top, height: node.height, width: '100%', }; } renderChildren(props, state, parent, indexesToRender) { const nodes = []; let top = 0; parent.children.forEach((index) => { const child = this.nodes[index]; if (indexesToRender.has(index)) { if ('children' in child && child.children.length > 0) { nodes.push(this.renderChildWithChildren(props, state, child, top, indexesToRender)); } else { // Sticky nodes will need a container so that their top is correct. The sticky node itself will have a top // of the offset where it should stick, which would conflict with the absolute position of the node. if (child.isSticky || props.wrapAllLeafNodes) { nodes.push(react_1.default.createElement("div", { className: "rv-sticky-leaf-node", key: `rv-node-${child.id}`, style: this.getChildContainerStyle(child, top) }, this.renderNode(props, state, child, this.getClientNodeStyle(child)))); } else { nodes.push(this.renderNode(props, state, child, this.getClientLeafNodeStyle(child, top))); } } } // Needs to be on the outside so that we add the the top even if // this node is not visible top += child.totalHeight; }); return nodes; } renderNode(props, state, nodeInfo, style) { // If they have not mutated their getChildren, then no need to call them again for the same structure. if (props.isModelImmutable && this.rowRenderCache[nodeInfo.id]) { return this.rowRenderCache[nodeInfo.id]; } const renderedRow = props.rowRenderer({ node: nodeInfo.node, nodeInfo, style, meta: props.meta }); if (props.isModelImmutable) { this.rowRenderCache[nodeInfo.id] = renderedRow; } return renderedRow; } /** * Determines the start and end number of the range to be rendered. * @returns {{start: number, end: number}} Indexes within nodes */ getRenderRowRange(props, state) { // Needs to be at least 1 let overscanRowCount = props.overscanRowCount > 0 ? props.overscanRowCount : 1; let start = state.currNodePos - overscanRowCount; if (start < 0) { start = 0; } let visibleEnd = state.currNodePos + 1; while (this.nodes[visibleEnd] && this.nodes[visibleEnd].top < state.scrollTop + props.height) { visibleEnd++; } let end = visibleEnd + overscanRowCount; if (end > this.nodes.length - 1) { end = this.nodes.length - 1; } return { start, end, visibleStart: state.currNodePos, visibleEnd }; } /** * Returns the parent path from nodes for the specified index within nodes. * @param nodeIndex * @param topDownOrder if true, the array with index 0 will be the root node, otherwise 0 will be the immediate parent. * @returns {Array<TreeNode>} */ getParentPath(nodeIndex, topDownOrder = true) { let currNode = this.nodes[nodeIndex]; const path = []; while (currNode) { currNode = this.nodes[currNode.parentIndex]; if (currNode) { path.push(currNode); } } return topDownOrder ? path.reverse() : path; } /** * Searches from the current node position downwards to see if the top of nodes above are greater * than or equal to the current scrollTop * @param scrollTop * @param searchPos * @returns {number} */ forwardSearch(scrollTop, searchPos) { const nodes = this.nodes; for (let i = searchPos; i < nodes.length; i++) { if (nodes[i].top >= scrollTop) { return i; } } return nodes.length - 1; } /** * Searches from the current node position upwards to see if the top of nodes above are less than * or equal the current scrollTop. * @param scrollTop * @param searchPos * @returns {number} */ backwardSearch(scrollTop, searchPos) { const nodes = this.nodes; for (let i = Math.min(searchPos, Math.max(nodes.length - 1, 0)); i >= 0; i--) { if (nodes[i].top <= scrollTop) { return i; } } return 0; } /** * Sets the scroll top in state and finds and sets the closest node to that scroll top. */ setScrollTopAndClosestNode(scrollTop, currNodePos, scrollReason) { if (scrollTop === this.state.scrollTop) { return; } if (this.elemRef.current && scrollTop >= this.elemRef.current.scrollHeight - this.elemRef.current.offsetHeight) { scrollTop = this.elemRef.current.scrollHeight - this.elemRef.current.offsetHeight; } if (scrollTop === this.state.scrollTop) { return; } let pos; if (scrollTop > this.state.scrollTop || currNodePos === 0) { pos = this.forwardSearch(scrollTop, currNodePos); } if (scrollTop < this.state.scrollTop && pos === undefined) { pos = this.backwardSearch(scrollTop, currNodePos); } this.pendingScrollTop = scrollTop; this.setState({ currNodePos: pos ? pos : 0, scrollTop, scrollReason }, () => { this.pendingScrollTop = undefined; }); } onScroll(e) { const { scrollTop, scrollLeft } = e.target; const scrollReason = this.state.scrollReason || ScrollReason.OBSERVED; this.setScrollTopAndClosestNode(scrollTop, this.state.currNodePos, scrollReason); if (this.props.onScroll !== undefined) { this.props.onScroll({ scrollTop, scrollLeft, scrollReason }); } this.setState({ scrollTick: !this.state.scrollTick, scrollReason: undefined }); } render() { let style = { overflow: 'auto', position: 'relative' }; if (this.props.inlineWidthHeight !== false) { if (this.props.width) { style.width = this.props.width; } if (this.props.height) { style.height = this.props.height; } } return (react_1.default.createElement("div", { ref: this.elemRef, className: "rv-sticky-tree", style: style, onScroll: this.onScroll }, this.treeToRender)); } } exports.default = StickyTree; StickyTree.defaultProps = { overscanRowCount: 10, renderRoot: true, wrapAllLeafNodes: false, isModelImmutable: false, };