/** * Render Node of Tree * * @author: Viktor Magarlamov * @date: 2019-06-20 */ import * as React from 'react'; import {ITreeColumn, ITreeNode} from './TreeType'; import { Button, DropDown, INTENT, IStringKeyMap, joinClassNames, PLACEMENT, SIZE } from '../../index'; import {safeInvoke} from '../../utils/safeInvoke'; import * as styles from './tree.m.scss'; import {Hierarchy} from './Hierarchy'; import {Cell} from './Cell'; import {IconName} from '@unidata/icon'; export type ConditionFunc = (node: ITreeNode, column?: ITreeColumn) => boolean; export type ActionItem = { label: string; action: (node: ITreeNode) => void; icon?: IconName; resolver?: (node: ITreeNode) => boolean; } export interface IDropConfig { parentNode?: ITreeNode; filledParent: boolean; previousNode?: ITreeNode; filledPrevious: boolean; globalDropLevel: number; configFilled: boolean; } export const ROW_HEIGHT = 24; const VERTICAL_HIERARCHY_WIDTH = 19; const HORIZONTAL_HIERARCHY_WIDTH = 17; interface IProps { node: ITreeNode; level: number; preventChildrenRender?: boolean; columns: Array>; readOnly?: boolean; isLastChild: boolean; selectedItems: IStringKeyMap; disabledItems: IStringKeyMap; canSelectDisabled: boolean; onNodeSelect?: (node: ITreeNode) => void; onNodeExpand?: (node: ITreeNode) => void; onNodeCollapse?: (node: ITreeNode) => void; onNodeDelete?: (node: ITreeNode) => void; onBeforeNodeSelect?: (node: ITreeNode) => boolean; // isScrollToMe?: boolean; isDeletable?: boolean | ConditionFunc; filter?: (node: ITreeNode) => boolean; isEditable?: boolean | ConditionFunc; onEdit?: (node: ITreeNode, newValue: string, cellId: string) => void; actions: Array>; isActionsEnabled?: (node: ITreeNode) => boolean; rightExtraItem?: (nodeData: T) => React.ReactNode; isDraggable?: boolean; // Can we drop nodes as child of current node canHasChildren?: boolean | ConditionFunc; // Fired when node is starts dragged onDragStart?: (event: React.DragEvent, node: ITreeNode) => void; // Fired when node is dropped not on the Nodes (Out of the tree) onDragEnd?: (event: React.DragEvent,) => void; // Fired when another dragged node is dropped on this onBackwardDrop?: (event: React.DragEvent, node: ITreeNode, dropConfig: IDropConfig) => void; // Callback to determine which nodes available for drop (by default all nodes allowed) allowDragOver?: (event: React.DragEvent, dropToNode: ITreeNode, draggedNode?: ITreeNode) => boolean; // Current dragged node in tree draggedNode?: ITreeNode; // Length of last child streak lastChildStreak: number; isExpanded: boolean; } interface IState { isRowHovered: boolean; isDropDownOpen: boolean; expanded: boolean; isDropAsNext?: boolean; localDropLevel: number; // onDragEnter, onDragLeave is also fired for children of html element // We need a counter to do Enter and Leave logic once dragEnterCounter: number; } export class Node extends React.PureComponent, IState> { override state: IState = { isRowHovered: false, expanded: (this.props.node.children) ? this.props.isExpanded : false, isDropDownOpen: false, isDropAsNext: undefined, localDropLevel: 0, dragEnterCounter: 0 }; hoverWhenDragTimeout: number = 0; nodeRef: React.RefObject = React.createRef(); override componentDidUpdate (prevProps: Readonly>, prevState: Readonly, snapshot?: any): void { if (this.props.node.children !== null) { if (prevProps.node.children === null) { this.setState({expanded: true}); } if (prevProps.isExpanded !== this.props.isExpanded) { this.setState({expanded: this.props.node.expanded}); } } } get selected () { return Boolean(this.props.selectedItems[this.props.node.key]); } get disabled () { return Boolean(this.props.disabledItems[this.props.node.key]); } getFirstCellPositionX (level: number) { return level * VERTICAL_HIERARCHY_WIDTH + HORIZONTAL_HIERARCHY_WIDTH; } onExpandClick = () => { if (this.props.node.children !== null) { if (this.state.expanded === false) { safeInvoke(this.props.onNodeExpand, this.props.node); } else { safeInvoke(this.props.onNodeCollapse, this.props.node); } this.setState((prevState) => { return { expanded: !prevState.expanded }; }); } }; onRowClick = (e: React.SyntheticEvent) => { e.stopPropagation(); this.setState({isRowHovered: true}); if (!this.disabled || this.props.canSelectDisabled === true) { if (this.props.onBeforeNodeSelect && !this.props.onBeforeNodeSelect(this.props.node)) { return; } safeInvoke(this.props.onNodeSelect, this.props.node); } }; // ToDo Platon Fedorov Do scroll to dragged element // todo Brauer Ilya - no "isScrollToMe" in code // override componentDidMount (): void { // if (this.props.item.row.match && this.props.item.row.isScrollToMe && this.ref.current) { // this.ref.current.scrollIntoView(); // } // } onRowDelete = (e: React.SyntheticEvent) => { e.stopPropagation(); safeInvoke(this.props.onNodeDelete, this.props.node); }; onMouseEnter = () => { this.setState({isRowHovered: true}); }; onMouseLeave = () => { if (this.state.isDropDownOpen === false) { this.setState({isRowHovered: false}); } }; renderChildrenNodes = () => { const node = this.props.node; if (this.state.expanded && node.children && !this.props.preventChildrenRender) { const childrenList = node.children; return childrenList.map((childNode, index) => { const isLastChild = index === childrenList.length - 1; return ( {...this.props} key={childNode.key} node={childNode} isExpanded={childNode.expanded} level={this.props.level + 1} isLastChild={isLastChild} lastChildStreak={isLastChild ? this.props.lastChildStreak + 1 : 0} onBackwardDrop={this.onBackwardDrop} /> ); }); } return null; }; handleDragStart = (event: React.DragEvent) => { if (!this.props.isDraggable) { return; } if (this.state.expanded) { this.onExpandClick(); } safeInvoke(this.props.onDragStart, event, this.props.node); }; handleDragEnd = (event: React.DragEvent) => { safeInvoke(this.props.onDragEnd, event); event.dataTransfer.clearData(); } handleDragOver = (event: React.DragEvent) => { if (this.props.allowDragOver) { if (this.props.allowDragOver(event, this.props.node, this.props.draggedNode)) { event.preventDefault(); } else { return; } } else { // always allow drop by default event.preventDefault(); } if (!this.nodeRef.current || !this.props.isDraggable) { return; } const node = this.props.node; const dragNodeY = event.pageY; const dragNodeX = event.pageX; const currentNodeMeanY = this.nodeRef.current.getBoundingClientRect().top + Math.floor(ROW_HEIGHT / 2); const currentNodeX = this.nodeRef.current.getBoundingClientRect().left + this.getFirstCellPositionX(this.props.level); const deltaY = dragNodeY - currentNodeMeanY; const deltaX = dragNodeX - currentNodeX; const canHasChildren = (typeof this.props.canHasChildren === 'function' ? this.props.canHasChildren(node) : this.props.canHasChildren) && !this.disabled; if (deltaY < 0) { this.setState({ isDropAsNext: false, localDropLevel: 0 }); } else { let localDropLevel = Math.floor(Math.abs(deltaX) / VERTICAL_HIERARCHY_WIDTH); if (this.state.expanded && node.children && node.children.length > 0) { localDropLevel = 1; } else { localDropLevel = deltaX > 0 ? Math.min(localDropLevel, 1) : -1 * Math.min(localDropLevel + 1, this.props.lastChildStreak); } if (node === this.props.draggedNode || !canHasChildren) { localDropLevel = Math.min(localDropLevel, 0); } this.setState({ isDropAsNext: true, localDropLevel: localDropLevel }); } } handleDrop = (event: React.DragEvent) => { const {isDropAsNext, localDropLevel} = this.state; const {node, level, draggedNode} = this.props; const globalDropLevel = level + localDropLevel; if (localDropLevel === 1) { // Drop node as child of current node safeInvoke(this.props.onBackwardDrop, event, node, { previousNode: undefined, filledPrevious: true, parentNode: node, filledParent: true, globalDropLevel: globalDropLevel, configFilled: true }); } else if (localDropLevel < 0) { // Drop node as child of one of parent nodes safeInvoke(this.props.onBackwardDrop, event, node, { previousNode: undefined, filledPrevious: false, parentNode: undefined, filledParent: false, globalDropLevel: globalDropLevel, configFilled: false }); } else if (localDropLevel === 0 && node !== draggedNode) { // Drop node as child of current node safeInvoke(this.props.onBackwardDrop, event, node, { previousNode: isDropAsNext ? node : undefined, filledPrevious: isDropAsNext ? true : false, parentNode: undefined, filledParent: false, globalDropLevel: globalDropLevel, configFilled: false }); } event.dataTransfer.clearData(); window.clearTimeout(this.hoverWhenDragTimeout); this.setState({ isDropAsNext: undefined, localDropLevel: 0, dragEnterCounter: 0 }); }; handleDragEnter = () => { this.setState((prevState) => { if (prevState.dragEnterCounter === 0 && this.props.draggedNode !== this.props.node) { this.hoverWhenDragTimeout = window.setTimeout(() => { if (!this.state.expanded) { this.onExpandClick(); } }, 600); } return { dragEnterCounter: prevState.dragEnterCounter + 1 }; }); } handleDragLeave = () => { this.setState((prevState) => { const dragEnterCounter = prevState.dragEnterCounter; if (dragEnterCounter === 1) { window.clearTimeout(this.hoverWhenDragTimeout); return { dragEnterCounter: prevState.dragEnterCounter - 1, isDropAsNext: undefined, localDropLevel: 0 }; } else { return { dragEnterCounter: prevState.dragEnterCounter - 1, isDropAsNext: prevState.isDropAsNext, localDropLevel: prevState.localDropLevel }; } }); } onBackwardDrop = (event: React.DragEvent, node: ITreeNode, dropConfig: IDropConfig) => { if (dropConfig.configFilled) { safeInvoke(this.props.onBackwardDrop, event, node, dropConfig); return; } const currentNode = this.props.node; if (dropConfig.globalDropLevel === this.props.level + 1) { if (!dropConfig.filledPrevious) { const nodeIdx = currentNode.children?.findIndex((child) => child.key === node.key); if (nodeIdx === undefined || !currentNode.children) { return; } if (nodeIdx === 0) { dropConfig.previousNode = undefined; } else { dropConfig.previousNode = currentNode.children[nodeIdx - 1]; } dropConfig.filledPrevious = true; } dropConfig.parentNode = currentNode; dropConfig.filledParent = true; dropConfig.configFilled = true; } else if (dropConfig.globalDropLevel === this.props.level) { dropConfig.previousNode = currentNode; dropConfig.filledPrevious = true; } safeInvoke(this.props.onBackwardDrop, event, node, dropConfig); } handleActionClick = (action: (node: ITreeNode) => void) => () => { action(this.props.node); this.handleCloseDD(); }; handleOpenDD = () => { this.setState({isDropDownOpen: true}); }; handleCloseDD = () => { this.setState({isDropDownOpen: false, isRowHovered: false}); }; renderDropPlace = (isDropAsNext: boolean | undefined, dropLevel: number) => { if (isDropAsNext === undefined || !this.nodeRef.current) { return null; } const left = this.getFirstCellPositionX(this.props.level + dropLevel); const style: React.CSSProperties = { left: `${left}px`, width: `calc(100% - ${left}px)` }; const className = joinClassNames( styles.dropPlaceLine, [styles.dropAsPrevious, !isDropAsNext], [styles.dropAsNext, isDropAsNext] ); return
; } getRightExtraItems = () => { return typeof this.props.rightExtraItem === 'function' ? this.props.rightExtraItem(this.props.node.row) : null; } get actions () { return this.props.actions.filter((action) => { const resolver = action.resolver; if (resolver) { return resolver(this.props.node); } return true; }); } dropdownMenuActions = () => { return (
{Boolean(this.getRightExtraItems()) ?
{this.getRightExtraItems()}
: null} {this.renderDropPlace(isDropAsNext, localDropLevel)} {this.renderChildrenNodes()} ); } }