// @ts-nocheck
import React, { Component } from 'react'
import withScrolling, {
createHorizontalStrength,
createScrollingComponent,
createVerticalStrength,
} from '@nosferatu500/react-dnd-scrollzone'
import isEqual from 'lodash.isequal'
import { DndContext, DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { Virtuoso } from 'react-virtuoso'
import NodeRendererDefault from './node-renderer-default'
import PlaceholderRendererDefault from './placeholder-renderer-default'
import './react-sortable-tree.css'
import TreeNode from './tree-node'
import TreePlaceholder from './tree-placeholder'
import { classnames } from './utils/classnames'
import {
defaultGetNodeKey,
defaultSearchMethod,
} from './utils/default-handlers'
import { wrapPlaceholder, wrapSource, wrapTarget } from './utils/dnd-manager'
import { slideRows } from './utils/generic-utils'
import {
memoizedGetDescendantCount,
memoizedGetFlatDataFromTree,
memoizedInsertNode,
} from './utils/memoized-tree-data-utils'
import {
changeNodeAtPath,
find,
insertNode,
removeNode,
toggleExpandedForAll,
walk,
} from './utils/tree-data-utils'
let treeIdCounter = 1
const mergeTheme = (props) => {
const merged = {
...props,
style: { ...props.theme.style, ...props.style },
innerStyle: { ...props.theme.innerStyle, ...props.innerStyle },
}
const overridableDefaults = {
nodeContentRenderer: NodeRendererDefault,
placeholderRenderer: PlaceholderRendererDefault,
scaffoldBlockPxWidth: 44,
slideRegionSize: 100,
rowHeight: 62,
treeNodeRenderer: TreeNode,
}
for (const propKey of Object.keys(overridableDefaults)) {
// If prop has been specified, do not change it
// If prop is specified in theme, use the theme setting
// If all else fails, fall back to the default
if (props[propKey] === undefined) {
merged[propKey] =
typeof props.theme[propKey] !== 'undefined'
? props.theme[propKey]
: overridableDefaults[propKey]
}
}
return merged
}
class ReactSortableTree extends Component {
// returns the new state after search
static search(props, state, seekIndex, expand, singleSearch) {
const {
onChange,
getNodeKey,
searchFinishCallback,
searchQuery,
searchMethod,
searchFocusOffset,
onlyExpandSearchedNodes,
} = props
const { instanceProps } = state
// Skip search if no conditions are specified
if (!searchQuery && !searchMethod) {
if (searchFinishCallback) {
searchFinishCallback([])
}
return { searchMatches: [] }
}
const newState = { instanceProps: {} }
// if onlyExpandSearchedNodes collapse the tree and search
const { treeData: expandedTreeData, matches: searchMatches } = find({
getNodeKey,
treeData: onlyExpandSearchedNodes
? toggleExpandedForAll({
treeData: instanceProps.treeData,
expanded: false,
})
: instanceProps.treeData,
searchQuery,
searchMethod: searchMethod || defaultSearchMethod,
searchFocusOffset,
expandAllMatchPaths: expand && !singleSearch,
expandFocusMatchPaths: !!expand,
})
// Update the tree with data leaving all paths leading to matching nodes open
if (expand) {
newState.instanceProps.ignoreOneTreeUpdate = true // Prevents infinite loop
onChange(expandedTreeData)
}
if (searchFinishCallback) {
searchFinishCallback(searchMatches)
}
let searchFocusTreeIndex
if (
seekIndex &&
searchFocusOffset !== undefined &&
searchFocusOffset < searchMatches.length
) {
searchFocusTreeIndex = searchMatches[searchFocusOffset].treeIndex
}
newState.searchMatches = searchMatches
newState.searchFocusTreeIndex = searchFocusTreeIndex
return newState
}
// Load any children in the tree that are given by a function
// calls the onChange callback on the new treeData
static loadLazyChildren(props, state) {
const { instanceProps } = state
walk({
treeData: instanceProps.treeData,
getNodeKey: props.getNodeKey,
callback: ({ node, path, lowerSiblingCounts, treeIndex }) => {
// If the node has children defined by a function, and is either expanded
// or set to load even before expansion, run the function.
if (
node.children &&
typeof node.children === 'function' &&
(node.expanded || props.loadCollapsedLazyChildren)
) {
// Call the children fetching function
node.children({
node,
path,
lowerSiblingCounts,
treeIndex,
// Provide a helper to append the new data when it is received
done: (childrenArray) =>
props.onChange(
changeNodeAtPath({
treeData: instanceProps.treeData,
path,
newNode: ({ node: oldNode }) =>
// Only replace the old node if it's the one we set off to find children
// for in the first place
oldNode === node
? {
...oldNode,
children: childrenArray,
}
: oldNode,
getNodeKey: props.getNodeKey,
})
),
})
}
},
})
}
constructor(props) {
super(props)
this.listRef = React.createRef()
const { dndType, nodeContentRenderer, treeNodeRenderer, slideRegionSize } =
mergeTheme(props)
// Wrapping classes for use with react-dnd
this.treeId = `rst__${treeIdCounter}`
treeIdCounter += 1
this.dndType = dndType || this.treeId
this.nodeContentRenderer = wrapSource(
nodeContentRenderer,
this.startDrag,
this.endDrag,
this.dndType
)
this.treePlaceholderRenderer = wrapPlaceholder(
TreePlaceholder,
this.treeId,
this.drop,
this.dndType
)
// Prepare scroll-on-drag options for this list
this.scrollZoneVirtualList = (createScrollingComponent || withScrolling)(
React.forwardRef((props, ref) => {
const { dragDropManager, rowHeight, ...otherProps } = props
return
})
)
this.vStrength = createVerticalStrength(slideRegionSize)
this.hStrength = createHorizontalStrength(slideRegionSize)
this.state = {
draggingTreeData: undefined,
draggedNode: undefined,
draggedMinimumTreeIndex: undefined,
draggedDepth: undefined,
searchMatches: [],
searchFocusTreeIndex: undefined,
dragging: false,
// props that need to be used in gDSFP or static functions will be stored here
instanceProps: {
treeData: [],
ignoreOneTreeUpdate: false,
searchQuery: undefined,
searchFocusOffset: undefined,
},
}
this.treeNodeRenderer = wrapTarget(
treeNodeRenderer,
this.canNodeHaveChildren,
this.treeId,
this.props.maxDepth,
this.props.canDrop,
this.drop,
this.dragHover,
this.dndType
)
this.toggleChildrenVisibility = this.toggleChildrenVisibility.bind(this)
this.moveNode = this.moveNode.bind(this)
this.startDrag = this.startDrag.bind(this)
this.dragHover = this.dragHover.bind(this)
this.endDrag = this.endDrag.bind(this)
this.drop = this.drop.bind(this)
this.handleDndMonitorChange = this.handleDndMonitorChange.bind(this)
}
componentDidMount() {
ReactSortableTree.loadLazyChildren(this.props, this.state)
const stateUpdate = ReactSortableTree.search(
this.props,
this.state,
true,
true,
false
)
this.setState(stateUpdate)
// Hook into react-dnd state changes to detect when the drag ends
// TODO: This is very brittle, so it needs to be replaced if react-dnd
// offers a more official way to detect when a drag ends
this.clearMonitorSubscription = this.props.dragDropManager
.getMonitor()
.subscribeToStateChange(this.handleDndMonitorChange)
}
static getDerivedStateFromProps(nextProps, prevState) {
const { instanceProps } = prevState
const newState = {}
const isTreeDataEqual = isEqual(instanceProps.treeData, nextProps.treeData)
// make sure we have the most recent version of treeData
instanceProps.treeData = nextProps.treeData
if (!isTreeDataEqual) {
if (instanceProps.ignoreOneTreeUpdate) {
instanceProps.ignoreOneTreeUpdate = false
} else {
newState.searchFocusTreeIndex = undefined
ReactSortableTree.loadLazyChildren(nextProps, prevState)
Object.assign(
newState,
ReactSortableTree.search(nextProps, prevState, false, false, false)
)
}
newState.draggingTreeData = undefined
newState.draggedNode = undefined
newState.draggedMinimumTreeIndex = undefined
newState.draggedDepth = undefined
newState.dragging = false
} else if (!isEqual(instanceProps.searchQuery, nextProps.searchQuery)) {
Object.assign(
newState,
ReactSortableTree.search(nextProps, prevState, true, true, false)
)
} else if (
instanceProps.searchFocusOffset !== nextProps.searchFocusOffset
) {
Object.assign(
newState,
ReactSortableTree.search(nextProps, prevState, true, true, true)
)
}
instanceProps.searchQuery = nextProps.searchQuery
instanceProps.searchFocusOffset = nextProps.searchFocusOffset
newState.instanceProps = { ...instanceProps, ...newState.instanceProps }
return newState
}
// listen to dragging
componentDidUpdate(prevProps, prevState) {
// if it is not the same then call the onDragStateChanged
if (
this.state.dragging !== prevState.dragging &&
this.props.onDragStateChanged
) {
this.props.onDragStateChanged({
isDragging: this.state.dragging,
draggedNode: this.state.draggedNode,
})
}
}
componentWillUnmount() {
this.clearMonitorSubscription()
}
handleDndMonitorChange() {
const monitor = this.props.dragDropManager.getMonitor()
// If the drag ends and the tree is still in a mid-drag state,
// it means that the drag was canceled or the dragSource dropped
// elsewhere, and we should reset the state of this tree
if (!monitor.isDragging() && this.state.draggingTreeData) {
setTimeout(() => {
this.endDrag()
})
}
}
getRows(treeData) {
return memoizedGetFlatDataFromTree({
ignoreCollapsed: true,
getNodeKey: this.props.getNodeKey,
treeData,
})
}
startDrag = ({ path }) => {
this.setState((prevState) => {
const {
treeData: draggingTreeData,
node: draggedNode,
treeIndex: draggedMinimumTreeIndex,
} = removeNode({
treeData: prevState.instanceProps.treeData,
path,
getNodeKey: this.props.getNodeKey,
})
return {
draggingTreeData,
draggedNode,
draggedDepth: path.length - 1,
draggedMinimumTreeIndex,
dragging: true,
}
})
}
dragHover = ({
node: draggedNode,
depth: draggedDepth,
minimumTreeIndex: draggedMinimumTreeIndex,
}) => {
// Ignore this hover if it is at the same position as the last hover
if (
this.state.draggedDepth === draggedDepth &&
this.state.draggedMinimumTreeIndex === draggedMinimumTreeIndex
) {
return
}
this.setState(({ draggingTreeData, instanceProps }) => {
// Fall back to the tree data if something is being dragged in from
// an external element
const newDraggingTreeData = draggingTreeData || instanceProps.treeData
const addedResult = memoizedInsertNode({
treeData: newDraggingTreeData,
newNode: draggedNode,
depth: draggedDepth,
minimumTreeIndex: draggedMinimumTreeIndex,
expandParent: true,
getNodeKey: this.props.getNodeKey,
})
const rows = this.getRows(addedResult.treeData)
const expandedParentPath = rows[addedResult.treeIndex].path
return {
draggedNode,
draggedDepth,
draggedMinimumTreeIndex,
draggingTreeData: changeNodeAtPath({
treeData: newDraggingTreeData,
path: expandedParentPath.slice(0, -1),
newNode: ({ node }) => ({ ...node, expanded: true }),
getNodeKey: this.props.getNodeKey,
}),
// reset the scroll focus so it doesn't jump back
// to a search result while dragging
searchFocusTreeIndex: undefined,
dragging: true,
}
})
}
endDrag = (dropResult) => {
const { instanceProps } = this.state
// Drop was cancelled
if (!dropResult) {
this.setState({
draggingTreeData: undefined,
draggedNode: undefined,
draggedMinimumTreeIndex: undefined,
draggedDepth: undefined,
dragging: false,
})
} else if (dropResult.treeId !== this.treeId) {
// The node was dropped in an external drop target or tree
const { node, path, treeIndex } = dropResult
let shouldCopy = this.props.shouldCopyOnOutsideDrop
if (typeof shouldCopy === 'function') {
shouldCopy = shouldCopy({
node,
prevTreeIndex: treeIndex,
prevPath: path,
})
}
let treeData = this.state.draggingTreeData || instanceProps.treeData
// If copying is enabled, a drop outside leaves behind a copy in the
// source tree
if (shouldCopy) {
treeData = changeNodeAtPath({
treeData: instanceProps.treeData, // use treeData unaltered by the drag operation
path,
newNode: ({ node: copyNode }) => ({ ...copyNode }), // create a shallow copy of the node
getNodeKey: this.props.getNodeKey,
})
}
this.props.onChange(treeData)
this.props.onMoveNode({
treeData,
node,
treeIndex: undefined,
path: undefined,
nextPath: undefined,
nextTreeIndex: undefined,
prevPath: path,
prevTreeIndex: treeIndex,
})
}
}
drop = (dropResult) => {
this.moveNode(dropResult)
}
canNodeHaveChildren = (node) => {
const { canNodeHaveChildren } = this.props
if (canNodeHaveChildren) {
return canNodeHaveChildren(node)
}
return true
}
toggleChildrenVisibility({ node: targetNode, path }) {
const { instanceProps } = this.state
const treeData = changeNodeAtPath({
treeData: instanceProps.treeData,
path,
newNode: ({ node }) => ({ ...node, expanded: !node.expanded }),
getNodeKey: this.props.getNodeKey,
})
this.props.onChange(treeData)
this.props.onVisibilityToggle({
treeData,
node: targetNode,
expanded: !targetNode.expanded,
path,
})
}
moveNode({
node,
path: prevPath,
treeIndex: prevTreeIndex,
depth,
minimumTreeIndex,
}) {
const {
treeData,
treeIndex,
path,
parentNode: nextParentNode,
} = insertNode({
treeData: this.state.draggingTreeData,
newNode: node,
depth,
minimumTreeIndex,
expandParent: true,
getNodeKey: this.props.getNodeKey,
})
this.props.onChange(treeData)
this.props.onMoveNode({
treeData,
node,
treeIndex,
path,
nextPath: path,
nextTreeIndex: treeIndex,
prevPath,
prevTreeIndex,
nextParentNode,
})
}
renderRow(
row,
{ listIndex, style, getPrevRow, matchKeys, swapFrom, swapDepth, swapLength }
) {
const { node, parentNode, path, lowerSiblingCounts, treeIndex } = row
const {
canDrag,
generateNodeProps,
scaffoldBlockPxWidth,
searchFocusOffset,
rowHeight,
} = mergeTheme(this.props)
const TreeNodeRenderer = this.treeNodeRenderer
const NodeContentRenderer = this.nodeContentRenderer
const nodeKey = path[path.length - 1]
const isSearchMatch = nodeKey in matchKeys
const isSearchFocus =
isSearchMatch && matchKeys[nodeKey] === searchFocusOffset
const callbackParams = {
node,
parentNode,
path,
lowerSiblingCounts,
treeIndex,
isSearchMatch,
isSearchFocus,
}
const nodeProps = !generateNodeProps
? {}
: generateNodeProps(callbackParams)
const rowCanDrag =
typeof canDrag !== 'function' ? canDrag : canDrag(callbackParams)
const sharedProps = {
treeIndex,
scaffoldBlockPxWidth,
node,
path,
treeId: this.treeId,
}
return (
)
}
render() {
const {
dragDropManager,
style,
className,
innerStyle,
placeholderRenderer,
getNodeKey,
} = mergeTheme(this.props)
const {
searchMatches,
searchFocusTreeIndex,
draggedNode,
draggedDepth,
draggedMinimumTreeIndex,
instanceProps,
} = this.state
const treeData = this.state.draggingTreeData || instanceProps.treeData
let rows
let swapFrom
let swapLength
if (draggedNode && draggedMinimumTreeIndex !== undefined) {
const addedResult = memoizedInsertNode({
treeData,
newNode: draggedNode,
depth: draggedDepth,
minimumTreeIndex: draggedMinimumTreeIndex,
expandParent: true,
getNodeKey,
})
const swapTo = draggedMinimumTreeIndex
swapFrom = addedResult.treeIndex
swapLength = 1 + memoizedGetDescendantCount({ node: draggedNode })
rows = slideRows(
this.getRows(addedResult.treeData),
swapFrom,
swapTo,
swapLength
)
} else {
rows = this.getRows(treeData)
}
// Get indices for rows that match the search conditions
const matchKeys = {}
for (const [i, { path }] of searchMatches.entries()) {
matchKeys[path[path.length - 1]] = i
}
// Seek to the focused search result if there is one specified
if (searchFocusTreeIndex !== undefined) {
this.listRef.current.scrollToIndex({
index: searchFocusTreeIndex,
align: 'center',
})
}
let containerStyle = style
let list
if (rows.length === 0) {
const Placeholder = this.treePlaceholderRenderer
const PlaceholderContent = placeholderRenderer
list = (
)
} else {
containerStyle = { height: '100%', ...containerStyle }
const ScrollZoneVirtualList = this.scrollZoneVirtualList
// Render list with react-virtuoso
list = (
this.renderRow(rows[index], {
listIndex: index,
getPrevRow: () => rows[index - 1] || undefined,
matchKeys,
swapFrom,
swapDepth: draggedDepth,
swapLength,
})
}
/>
)
}
return (
{list}
)
}
}
type SearchParams = {
node: any
path: number[]
treeIndex: number
searchQuery: string
}
type SearchFinishCallbackParams = {
node: any
path: number[]
treeIndex: number
}[]
type GenerateNodePropsParams = {
node: any
path: number[]
treeIndex: number
lowerSiblingCounts: number[]
isSearchMatch: boolean
isSearchFocus: boolean
}
type ShouldCopyOnOutsideDropParams = {
node: any
prevPath: number[]
prevTreeIndex: number
}
type OnMoveNodeParams = {
treeData: any[]
node: any
nextParentNode: any
prevPath: number[]
prevTreeIndex: number
nextPath: number[]
nextTreeIndex: number
}
type CanDropParams = {
node: any
prevPath: number[]
prevParent: any
prevTreeIndex: number
nextPath: number[]
nextParent: any
nextTreeIndex: number
}
type OnVisibilityToggleParams = {
treeData: any[]
node: any
expanded: boolean
path: number[]
}
type OnDragStateChangedParams = {
isDragging: boolean
draggedNode: any
}
export type ReactSortableTreeProps = {
dragDropManager?: {
getMonitor: () => unknown
}
// Tree data in the following format:
// [{title: 'main', subtitle: 'sub'}, { title: 'value2', expanded: true, children: [{ title: 'value3') }] }]
// `title` is the primary label for the node
// `subtitle` is a secondary label for the node
// `expanded` shows children of the node if true, or hides them if false. Defaults to false.
// `children` is an array of child nodes belonging to the node.
treeData: any[]
// Style applied to the container wrapping the tree (style defaults to {height: '100%'})
style?: any
// Class name for the container wrapping the tree
className?: string
// Style applied to the inner, scrollable container (for padding, etc.)
innerStyle?: any
// Size in px of the region near the edges that initiates scrolling on dragover
slideRegionSize?: number
// The width of the blocks containing the lines representing the structure of the tree.
scaffoldBlockPxWidth?: number
// Maximum depth nodes can be inserted at. Defaults to infinite.
maxDepth?: number
// The method used to search nodes.
// Defaults to a function that uses the `searchQuery` string to search for nodes with
// matching `title` or `subtitle` values.
// NOTE: Changing `searchMethod` will not update the search, but changing the `searchQuery` will.
searchMethod?: (params: SearchParams) => boolean
// Used by the `searchMethod` to highlight and scroll to matched nodes.
// Should be a string for the default `searchMethod`, but can be anything when using a custom search.
searchQuery?: string
// Outline the <`searchFocusOffset`>th node and scroll to it.
searchFocusOffset?: number
// Get the nodes that match the search criteria. Used for counting total matches, etc.
searchFinishCallback?: (params: SearchFinishCallbackParams) => void
// Generate an object with additional props to be passed to the node renderer.
// Use this for adding buttons via the `buttons` key,
// or additional `style` / `className` settings.
generateNodeProps?: (params: GenerateNodePropsParams) => any
treeNodeRenderer?: any
// Override the default component for rendering nodes (but keep the scaffolding generator)
// This is an advanced option for complete customization of the appearance.
// It is best to copy the component in `node-renderer-default.js` to use as a base, and customize as needed.
nodeContentRenderer?: any
// Override the default component for rendering an empty tree
// This is an advanced option for complete customization of the appearance.
// It is best to copy the component in `placeholder-renderer-default.js` to use as a base,
// and customize as needed.
placeholderRenderer?: any
theme?: {
style: any
innerStyle: any
scaffoldBlockPxWidth: number
slideRegionSize: number
treeNodeRenderer: any
nodeContentRenderer: any
placeholderRenderer: any
}
// Sets the height of a given tree row item in pixels. Can either be a number
// or a function to calculate dynamically
rowHeight?: number | ((treeIndex: number, node: any, path: any[]) => number)
// Determine the unique key used to identify each node and
// generate the `path` array passed in callbacks.
// By default, returns the index in the tree (omitting hidden nodes).
getNodeKey?: (node) => string
// Called whenever tree data changed.
// Just like with React input elements, you have to update your
// own component's data to see the changes reflected.
onChange: (treeData) => void
// Called after node move operation.
onMoveNode?: (params: OnMoveNodeParams) => void
// Determine whether a node can be dragged. Set to false to disable dragging on all nodes.
canDrag?: (params: GenerateNodePropsParams) => boolean
// Determine whether a node can be dropped based on its path and parents'.
canDrop?: (params: CanDropParams) => boolean
// Determine whether a node can have children
canNodeHaveChildren?: (node) => boolean
// When true, or a callback returning true, dropping nodes to react-dnd
// drop targets outside of this tree will not remove them from this tree
shouldCopyOnOutsideDrop?: (params: ShouldCopyOnOutsideDropParams) => boolean
// Called after children nodes collapsed or expanded.
onVisibilityToggle?: (params: OnVisibilityToggleParams) => void
dndType?: string
// Called to track between dropped and dragging
onDragStateChanged?: (params: OnDragStateChangedParams) => void
// Specify that nodes that do not match search will be collapsed
onlyExpandSearchedNodes?: boolean
debugMode?: boolean
overscan?: number | { main: number; reverse: number }
}
ReactSortableTree.defaultProps = {
canDrag: true,
canDrop: undefined,
canNodeHaveChildren: () => true,
className: '',
dndType: undefined,
generateNodeProps: undefined,
getNodeKey: defaultGetNodeKey,
innerStyle: {},
maxDepth: undefined,
treeNodeRenderer: undefined,
nodeContentRenderer: undefined,
onMoveNode: () => {},
onVisibilityToggle: () => {},
placeholderRenderer: undefined,
scaffoldBlockPxWidth: undefined,
searchFinishCallback: undefined,
searchFocusOffset: undefined,
searchMethod: undefined,
searchQuery: undefined,
shouldCopyOnOutsideDrop: false,
slideRegionSize: undefined,
style: {},
theme: {},
onDragStateChanged: () => {},
onlyExpandSearchedNodes: false,
debugMode: false,
overscan: 0,
}
const SortableTreeWithoutDndContext = (props: ReactSortableTreeProps) => {
return (
{({ dragDropManager }) =>
dragDropManager === undefined ? undefined : (
)
}
)
}
const SortableTree = (props: ReactSortableTreeProps) => {
return (
)
}
// Export the tree component without the react-dnd DragDropContext,
// for when component is used with other components using react-dnd.
// see: https://github.com/gaearon/react-dnd/issues/186
export { SortableTreeWithoutDndContext }
export default SortableTree