UNPKG

vitessce

Version:

Vitessce app and React component library

499 lines (478 loc) 13.5 kB
import React, { useState } from 'react'; import clsx from 'clsx'; import { TreeNode as RcTreeNode } from 'rc-tree'; import { getDataAndAria } from 'rc-tree/es/util'; import classNames from 'classnames'; import range from 'lodash/range'; import isEqual from 'lodash/isEqual'; import PopoverMenu from './PopoverMenu'; import HelpTooltip from './HelpTooltip'; import { callbackOnKeyPress, colorArrayToString, getLevelTooltipText } from './utils'; import { ReactComponent as MenuSVG } from '../../assets/menu.svg'; import { getDefaultColor } from '../utils'; /** * Construct a `menuConfig` array for the PopoverMenu component. * @param {object} props The props for the TreeNode component. * @returns {object[]} An array of menu items to pass to PopoverMenu. */ function makeNodeViewMenuConfig(props) { const { path, level, height, onCheckNode, onNodeRemove, onNodeSetIsEditing, onExportLevelZeroNodeJSON, onExportLevelZeroNodeTabular, onExportSetJSON, checkable, editable, exportable, checked, } = props; return [ ...(editable ? [ { title: 'Rename', handler: () => { onNodeSetIsEditing(path, true); }, handlerKey: 'r', }, { title: 'Delete', confirm: true, handler: () => { onNodeRemove(path); }, handlerKey: 'd', }, ] : []), ...(level === 0 && exportable ? [ { title: 'Export hierarchy', subtitle: '(to JSON file)', handler: () => { onExportLevelZeroNodeJSON(path); }, handlerKey: 'j', }, ...(height <= 1 ? [ { title: 'Export hierarchy', subtitle: '(to CSV file)', handler: () => { onExportLevelZeroNodeTabular(path); }, handlerKey: 't', }, ] : []), ] : []), ...(level > 0 ? [ ...(checkable ? [ { title: (checked ? 'Uncheck' : 'Check'), handler: () => { onCheckNode(path, !checked); }, handlerKey: 's', }, ] : []), ...(exportable ? [ { title: 'Export set', subtitle: '(to JSON file)', handler: () => { onExportSetJSON(path); }, handlerKey: 'e', }, ] : []), ] : []), ]; } /** * The "static" node component to render when the user is not renaming. * @param {object} props The props for the TreeNode component. */ function NamedSetNodeStatic(props) { const { title, path, nodeKey, level, height, color, checkbox, isChecking, isLeaf, onNodeSetColor, onNodeView, expanded, onCheckLevel, checkedLevelPath, checkedLevelIndex, disableTooltip, size, datatype, editable, theme, } = props; const shouldCheckNextLevel = (level === 0 && !expanded); const nextLevelToCheck = ( (checkedLevelIndex && isEqual(path, checkedLevelPath) && checkedLevelIndex < height) ? checkedLevelIndex + 1 : 1 ); const numberFormatter = new Intl.NumberFormat('en-US'); const niceSize = numberFormatter.format(size); let tooltipText; if (shouldCheckNextLevel) { tooltipText = getLevelTooltipText(nextLevelToCheck); } else if (isLeaf || !expanded) { tooltipText = `Color individual set (${niceSize} ${datatype}${(size === 1 ? '' : 's')})`; } else { tooltipText = 'Color by expanded descendants'; } // If this is a level zero node and is _not_ expanded, then upon click, // the behavior should be to color by the first or next cluster level. // If this is a level zero node and _is_ expanded, or if any other node, // click should trigger onNodeView. const onClick = (level === 0 && !expanded ? () => onCheckLevel(nodeKey, nextLevelToCheck) : () => onNodeView(path) ); const tooltipProps = (disableTooltip ? { visible: false } : {}); const popoverMenuConfig = makeNodeViewMenuConfig(props); return ( <span> <HelpTooltip title={tooltipText} {...tooltipProps}> <button type="button" onClick={onClick} onKeyPress={e => callbackOnKeyPress(e, 'v', () => onNodeView(path))} className="title-button" > {title} </button> </HelpTooltip> {popoverMenuConfig.length > 0 ? ( <PopoverMenu menuConfig={makeNodeViewMenuConfig(props)} color={level > 0 && editable ? (color || getDefaultColor(theme)) : null} setColor={c => onNodeSetColor(path, c)} > <MenuSVG className="node-menu-icon" /> </PopoverMenu> ) : null} {level > 0 && isChecking ? checkbox : null} {level > 0 && (<span className="node-size-label">{niceSize}</span>)} </span> ); } /** * The "editing" node component to render when the user is renaming, * containing a text input field and a save button. * @param {object} props The props for the TreeNode component. */ function NamedSetNodeEditing(props) { const { title, path, onNodeSetName, onNodeCheckNewName, } = props; const [currentTitle, setCurrentTitle] = useState(title); // Do not allow the user to save a potential name if it conflicts with // another name in the hierarchy. const hasConflicts = onNodeCheckNewName(path, currentTitle); function trySetName() { if (!hasConflicts) { onNodeSetName(path, currentTitle, true); } } return ( <span className="title-button-with-input"> <input // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus className="title-input" type="text" value={currentTitle} onChange={(e) => { setCurrentTitle(e.target.value); }} onKeyPress={e => callbackOnKeyPress( e, 'Enter', trySetName, )} onFocus={e => e.target.select()} /> {!hasConflicts && ( <button type="button" className="title-save-button" onClick={trySetName} > Save </button> )} </span> ); } /** * A "delegation" component, to decide whether to render * an "editing" vs. "static" node component. * @param {object} props The props for the TreeNode component. */ function NamedSetNode(props) { const { isEditing, isCurrentSet, } = props; return ( (isEditing || isCurrentSet) ? (<NamedSetNodeEditing {...props} />) : (<NamedSetNodeStatic {...props} />) ); } /** * Buttons for viewing each hierarchy level, * rendered below collapsed level zero nodes. * @param {object} props The props for the (level zero) TreeNode component. */ function LevelsButtons(props) { const { nodeKey, path, height, onCheckLevel, checkedLevelPath, checkedLevelIndex, hasColorEncoding, } = props; function onCheck(event) { if (event.target.checked) { const newLevel = parseInt(event.target.value, 10); onCheckLevel(nodeKey, newLevel); } } return ( <div className="level-buttons-container"> {range(1, height + 1).map((i) => { const isChecked = isEqual(path, checkedLevelPath) && i === checkedLevelIndex; return ( <div className="level-buttons" key={i}> <HelpTooltip title={getLevelTooltipText(i)}> <input className={clsx('level-radio-button', { checked: isChecked && !hasColorEncoding })} type="checkbox" value={i} checked={isChecked && hasColorEncoding} onChange={onCheck} /> </HelpTooltip> </div> ); })} </div> ); } /** * Render the "switcher" icon. * Arrow for collapsed/expanded non-leaf nodes, * or square for leaf nodes. * @param {object} props The props for the TreeNode component. */ function SwitcherIcon(props) { const { isLeaf, isOpen, color, } = props; const hexColor = (color ? colorArrayToString(color) : undefined); if (isLeaf) { return ( <i className="anticon anticon-circle rc-tree-switcher-icon" > <svg viewBox="0 0 1024 1024" focusable="false" data-icon="caret-down" width="1em" height="1em" aria-hidden="true" > <rect fill={hexColor} x={600 / 2} y={600 / 2} width={1024 - 600} height={1024 - 600} /> </svg> </i> ); } return ( <i className="anticon anticon-caret-down rc-tree-switcher-icon" > <svg viewBox="0 0 1024 1024" focusable="false" data-icon="caret-down" width="1em" height="1em" aria-hidden="true" > <path fill={(isOpen ? '#444' : hexColor)} d="M840.4 300H183.6c-19.7 0-30.7 20.8-18.5 35l328.4 380.8c9.4 10.9 27.5 10.9 37 0L858.9 335c12.2-14.2 1.2-35-18.5-35z" /> </svg> </i> ); } /** * A custom TreeNode component. * @extends {RcTreeNode} TreeNode from the rc-tree library. */ export default class TreeNode extends RcTreeNode { /** * Override the main node text elements. */ renderSelector = () => { const { title, isCurrentSet, isSelected, isEditing, onDragStart: onDragStartProp, } = this.props; const { rcTree: { prefixCls: prefixClass, draggable, }, } = this.context; const onDragStart = (e) => { onDragStartProp(); this.onDragStart(e); }; const wrapClass = `${prefixClass}-node-content-wrapper`; const isDraggable = (!isCurrentSet && !isEditing && draggable); return ( <span ref={this.setSelectHandle} title={title} className={classNames( wrapClass, `${wrapClass}-${this.getNodeState() || 'normal'}`, isSelected && `${prefixClass}-node-selected`, isDraggable && 'draggable', )} draggable={isDraggable} aria-grabbed={isDraggable} onDragStart={isDraggable ? onDragStart : undefined} > <NamedSetNode {...this.props} prefixClass={prefixClass} checkbox={this.renderCheckbox()} /> {this.renderLevels()} </span> ); }; /** * Render the LevelsButtons component if this node * is a collapsed level zero node. */ renderLevels = () => { const { level, expanded } = this.props; if (level !== 0 || expanded) { return null; } return ( <LevelsButtons {...this.props} /> ); } /** * Override the switcher element. */ renderSwitcher = () => { const { expanded, isLeaf, color } = this.props; const { rcTree: { prefixCls: prefixClass, onNodeExpand, }, } = this.context; const onNodeExpandWrapper = (e) => { // Do not call onNodeExpand if the node is a leaf node. if (!isLeaf) { onNodeExpand(e, this); } }; const switcherClass = classNames( `${prefixClass}-switcher`, { [`${prefixClass}-switcher_${(expanded ? 'open' : 'close')}`]: !isLeaf }, ); return ( <span className={switcherClass} onClick={onNodeExpandWrapper} onKeyPress={e => callbackOnKeyPress(e, 'd', onNodeExpandWrapper)} role="button" tabIndex="0" > <SwitcherIcon isLeaf={isLeaf} isOpen={expanded} color={color} /> </span> ); }; /** * Override main render function, * to enable overriding the sub-render functions * for switcher, selector, etc. */ render() { const { style, loading, level, dragOver, dragOverGapTop, dragOverGapBottom, isLeaf, expanded, selected, checked, halfChecked, onDragEnd: onDragEndProp, expandable, ...otherProps } = this.props; const { rcTree: { prefixCls: prefixClass, filterTreeNode, draggable, }, } = this.context; const disabled = this.isDisabled(); const dataAndAriaAttributeProps = getDataAndAria(otherProps); const onDragEnd = (e) => { onDragEndProp(); this.onDragEnd(e); }; return ( <li className={classNames('rc-tree-treenode', `level-${level}-treenode`, { [`${prefixClass}-treenode-disabled`]: disabled, [`${prefixClass}-treenode-switcher-${expanded ? 'open' : 'close'}`]: !isLeaf, [`${prefixClass}-treenode-checkbox-checked`]: checked, [`${prefixClass}-treenode-checkbox-indeterminate`]: halfChecked, [`${prefixClass}-treenode-selected`]: selected, [`${prefixClass}-treenode-loading`]: loading, 'drag-over': !disabled && dragOver, 'drag-over-gap-top': !disabled && dragOverGapTop, 'drag-over-gap-bottom': !disabled && dragOverGapBottom, 'filter-node': filterTreeNode && filterTreeNode(this), })} style={style} role="treeitem" onDragEnter={draggable ? this.onDragEnter : undefined} onDragOver={draggable ? this.onDragOver : undefined} onDragLeave={draggable ? this.onDragLeave : undefined} onDrop={draggable ? this.onDrop.bind(this) : undefined} onDragEnd={draggable ? onDragEnd : undefined} {...dataAndAriaAttributeProps} > {expandable ? this.renderSwitcher() : null} {this.renderSelector()} {this.renderChildren()} </li> ); } }