Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ const { ref, width, height } = useResizeObserver();
</div>
```

### Multiple trees

You can have multiple trees in the same app and the ability to drag and drop between them.

Ensure that each tree has a unique id.

```jsx
<Tree id="tree1" />
<Tree id="tree2" />
```

Note that tree items are supposed to have unique ids. It is your responsibility when dragging items between trees that node ids are always unique. It is recommended to namespace your node ids to each tree to prevent duplicates when dragging.


## API Reference

- Components
Expand All @@ -275,6 +289,14 @@ These are all the props you can pass to the Tree component.

```ts
interface TreeProps<T> {

/* Unique id - required for multiple trees */
id?: string;

/* Cross-tree drag and drop support */
allowCrossTreeDrop?: boolean;
allowCrossTreeDrag?: boolean;

/* Data Options */
data?: readonly T[];
initialData?: readonly T[];
Expand All @@ -284,6 +306,8 @@ interface TreeProps<T> {
onMove?: handlers.MoveHandler<T>;
onRename?: handlers.RenameHandler<T>;
onDelete?: handlers.DeleteHandler<T>;
onCrossTreeAdd?: handlers.CrossTreeAddHandler<T>;
onCrossTreeDelete?: handlers.CrossTreeDeleteHandler<T>;

/* Renderers*/
children?: ElementType<renderers.NodeRendererProps<T>>;
Expand All @@ -310,14 +334,7 @@ interface TreeProps<T> {
disableMultiSelection?: boolean;
disableEdit?: string | boolean | BoolFunc<T>;
disableDrag?: string | boolean | BoolFunc<T>;
disableDrop?:
| string
| boolean
| ((args: {
parentNode: NodeApi<T>;
dragNodes: NodeApi<T>[];
index: number;
}) => boolean);
disableDrop?: DisableDrop<T>;

/* Event Handlers */
onActivate?: (node: NodeApi<T>) => void;
Expand Down
1 change: 1 addition & 0 deletions modules/react-arborist/src/components/default-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function DefaultRow<T>({
ref={innerRef}
onFocus={(e) => e.stopPropagation()}
onClick={node.handleClick}
onKeyUp={node.handleKeyUp}
>
{children}
</div>
Expand Down
94 changes: 94 additions & 0 deletions modules/react-arborist/src/dnd/_drop-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { DragItem } from "../types/dnd";
import { TreeApi } from "../interfaces/tree-api";
import { NodeApi } from "../interfaces/node-api";
import { ComputedDrop, computeDrop } from "./compute-drop";
import { actions as dnd } from "../state/dnd-slice";
import { DropTargetMonitor } from "react-dnd";

export const handleDropComputation = (
outerDrop: boolean,
el: { current: HTMLElement | null },
tree: TreeApi<unknown>,
node: NodeApi<unknown> | null,
monitor: DropTargetMonitor<unknown,unknown>
): ComputedDrop|null => {
const offset = monitor.getClientOffset();
if (!el.current || !offset) {
tree.hideCursor();
return null;
}
const prevNode = (outerDrop || node === null)
? tree.visibleNodes[tree.visibleNodes.length - 1]
: node.prev;

return computeDrop({
element: el.current,
offset,
indent: tree.indent,
node,
prevNode: prevNode,
nextNode: node?.next ?? null,
targetTree: tree,
});
};

export const getDropResultOrNull = (result: ComputedDrop | null) => {
return result?.drop ?? null;
}

export const createDropHandlers = (
outerDrop: boolean,
el: { current: HTMLElement | null },
tree: TreeApi<unknown>,
node: NodeApi<unknown> | null = null
) => ({
canDrop: (_item: DragItem, monitor: DropTargetMonitor<unknown,unknown>) => {
if (_item.sourceTree.props.id !== tree.props.id && tree.props.allowCrossTreeDrop === false) {
return false;
}

if (_item.sourceTree.props.id !== tree.props.id && _item.sourceTree.props.allowCrossTreeDrag === false) {
return false;
}

const result = handleDropComputation(outerDrop, el, tree, node, monitor);
return tree.canDrop(
_item.sourceTree,
getDropResultOrNull(result)
);
},

hover: (_item: DragItem, monitor: DropTargetMonitor<unknown,unknown>) => {
if (!outerDrop && _item.sourceTree.state.dnd.lastTree !== tree) {
_item.sourceTree.state.dnd.lastTree?.hideCursor();
_item.sourceTree.dispatch(dnd.setLastTree(tree));
}

if (outerDrop && !monitor.isOver({ shallow: true })) return;

const result = handleDropComputation(outerDrop, el, tree, node, monitor);
if (result?.drop) {
tree.dispatch(dnd.hovering(result.drop.parentId, result.drop.index));
}

if (tree.canDrop(_item.sourceTree, getDropResultOrNull(result))) {
if (result?.cursor) tree.showCursor(result.cursor);
} else {
tree.hideCursor();
}
},

drop: (_item: DragItem, monitor: DropTargetMonitor<unknown,unknown>) => {
if(outerDrop && monitor.didDrop()) return;

//const result = handleDropComputation(outerDrop, el, tree, node, monitor);
if (!monitor.canDrop()) return;

tree.hideCursor();
return {
parentId: tree.state.dnd.parentId,
index: tree.state.dnd.index,
targetTree: tree
};
}
});
28 changes: 17 additions & 11 deletions modules/react-arborist/src/dnd/compute-drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isOpenWithEmptyChildren,
} from "../utils";
import { DropResult } from "./drop-hook";
import { TreeApi } from "../interfaces/tree-api";

function measureHover(el: HTMLElement, offset: XYCoord) {
const rect = el.getBoundingClientRect();
Expand All @@ -31,6 +32,7 @@ function getNodesAroundCursor(
next: NodeApi | null,
hover: HoverData
): [NodeApi | null, NodeApi | null] {

if (!node) {
// We're hovering over the empty part of the list, not over an item,
// Put the cursor below the last item which is "prev"
Expand Down Expand Up @@ -60,6 +62,7 @@ type Args = {
node: NodeApi | null;
prevNode: NodeApi | null;
nextNode: NodeApi | null;
targetTree: TreeApi<unknown> | null;
};

export type ComputedDrop = {
Expand All @@ -69,9 +72,10 @@ export type ComputedDrop = {

function dropAt(
parentId: string | undefined,
index: number | null
index: number | null,
targetTree: TreeApi<unknown> | null
): DropResult {
return { parentId: parentId || null, index };
return { parentId: parentId || null, index, targetTree };
}

function lineCursor(index: number, level: number) {
Expand All @@ -95,14 +99,14 @@ function highlightCursor(id: string) {
};
}

function walkUpFrom(node: NodeApi, level: number) {
function walkUpFrom(node: NodeApi, level: number, targetTree: TreeApi<unknown>|null) {
let drop = node;
while (drop.parent && drop.level > level) {
drop = drop.parent;
}
const parentId = drop.parent?.id || null;
const index = indexOf(drop) + 1;
return { parentId, index };
return { parentId, index, targetTree };
}

export type LineCursor = ReturnType<typeof lineCursor>;
Expand All @@ -115,15 +119,17 @@ export type Cursor = LineCursor | NoCursor | HighlightCursor;
*/
export function computeDrop(args: Args): ComputedDrop {
const hover = measureHover(args.element, args.offset);

const indent = args.indent;
const hoverLevel = Math.round(Math.max(0, hover.x - indent) / indent);
const { node, nextNode, prevNode } = args;
const [above, below] = getNodesAroundCursor(node, prevNode, nextNode, hover);
const targetTree = args.targetTree;

/* Hovering over the middle of a folder */
if (node && node.isInternal && hover.inMiddle) {
return {
drop: dropAt(node.id, null),
drop: dropAt(node.id, null, targetTree),
cursor: highlightCursor(node.id),
};
}
Expand All @@ -136,7 +142,7 @@ export function computeDrop(args: Args): ComputedDrop {
/* There is no node above the cursor line */
if (!above) {
return {
drop: dropAt(below?.parent?.id, 0),
drop: dropAt(below?.parent?.id, 0, targetTree),
cursor: lineCursor(0, 0),
};
}
Expand All @@ -145,7 +151,7 @@ export function computeDrop(args: Args): ComputedDrop {
if (isItem(above)) {
const level = bound(hoverLevel, below?.level || 0, above.level);
return {
drop: walkUpFrom(above, level),
drop: walkUpFrom(above, level, targetTree),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
Expand All @@ -154,7 +160,7 @@ export function computeDrop(args: Args): ComputedDrop {
if (isClosed(above)) {
const level = bound(hoverLevel, below?.level || 0, above.level);
return {
drop: walkUpFrom(above, level),
drop: walkUpFrom(above, level, targetTree),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
Expand All @@ -165,21 +171,21 @@ export function computeDrop(args: Args): ComputedDrop {
if (level > above.level) {
/* Will be the first child of the empty folder */
return {
drop: dropAt(above.id, 0),
drop: dropAt(above.id, 0, targetTree),
cursor: lineCursor(above.rowIndex! + 1, level),
};
} else {
/* Will be a sibling or grandsibling of the empty folder */
return {
drop: walkUpFrom(above, level),
drop: walkUpFrom(above, level, targetTree),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
}

/* The node above the cursor is a an open folder with children */
return {
drop: dropAt(above?.id, 0),
drop: dropAt(above?.id, 0, targetTree),
cursor: lineCursor(above.rowIndex! + 1, above.level + 1),
};
}
54 changes: 49 additions & 5 deletions modules/react-arborist/src/dnd/drag-hook.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,75 @@
import { useEffect } from "react";

import { ConnectDragSource, useDrag } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTreeApi } from "../context";
import { NodeApi } from "../interfaces/node-api";
import { DragItem } from "../types/dnd";
import { DropResult } from "./drop-hook";
import { actions as dnd } from "../state/dnd-slice";
import { safeRun } from "../utils";
import { ROOT_ID } from "../data/create-root";
import { useEffect } from "react";

export function useDragHook<T>(node: NodeApi<T>): ConnectDragSource {
const tree = useTreeApi();
const ids = tree.selectedIds;

const [_, ref, preview] = useDrag<DragItem, DropResult, void>(
() => ({
canDrag: () => node.isDraggable,
type: "NODE",
item: () => {
// This is fired once at the begging of a drag operation
// This is fired once at the beginning of a drag operation
const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id];
tree.dispatch(dnd.dragStart(node.id, dragIds));
return { id: node.id, dragIds };
tree.dispatch(dnd.setLastTree(tree));
return { id: node.id, dragIds, sourceTree: tree };
},
end: () => {
end: (_item, monitor) => {
tree.hideCursor();

const dropResult = monitor.getDropResult();

if (dropResult && tree.canDrop(_item.sourceTree, dropResult)) {
const targetTree = dropResult.targetTree;

const draggedNodes:T[] = [];
for(const node of tree.dragNodes as NodeApi<T>[]) {
draggedNodes.push({...node.data});
}

// In the same tree, just do a standard move
if(tree === targetTree) {
safeRun(tree.props.onMove, {
dragIds: tree.state.dnd.dragIds,
parentId: dropResult.parentId === ROOT_ID ? null : dropResult.parentId,
index: dropResult.index ?? 0, // When it's null it was dropped over a folder
dragNodes: draggedNodes,
treeId: tree.props.id
});
} else {
safeRun(tree.props.onCrossTreeDelete, {
ids: tree.state.dnd.dragIds,
treeId: tree.props.id
});

safeRun(targetTree?.props.onCrossTreeAdd, {
ids: tree.state.dnd.dragIds,
parentId: dropResult.parentId === ROOT_ID ? null : dropResult.parentId,
index: dropResult.index ?? 0, // When it's null it was dropped over a folder
dragNodes: draggedNodes,
treeId: targetTree?.props.id
});
}

if (dropResult.parentId && dropResult.parentId !== ROOT_ID && targetTree !== null) {
targetTree.open(dropResult.parentId);
}
}
tree.dispatch(dnd.dragEnd());
},
}),
[ids, node],
[ids, node]
);

useEffect(() => {
Expand Down
Loading