;
16 | }) {
17 | const { item, depth } = state.rows[index];
18 | return (
19 | e && state.itemToDOM.set(item, e)}
21 | //draggable={!currentFocus.isTextInput}
22 | draggable
23 | onDragStart={state.onRowDragStart.bind(state, index)}
24 | onDragEnd={state.onRowDragEnd.bind(state, index)}
25 | onDragEnter={state.onRowDragEnter.bind(state, index)}
26 | onDrop={state.onRowDrop.bind(state, index)}
27 | onDragOver={state.onRowDragOver.bind(state, index)}
28 | >
29 | {state.props.renderRow({
30 | rows: state.rows,
31 | index: index,
32 | item,
33 | depth,
34 | indentation: state.indentation,
35 | })}
36 |
37 | );
38 | }
39 |
40 | // Background
41 |
42 | function Background({
43 | state,
44 | }: {
45 | state: TreeViewState;
46 | }) {
47 | return (
48 |
59 | {state.props.background}
60 |
61 | );
62 | }
63 |
64 | //// DropIndicator
65 |
66 | function DropIndicator({
67 | state,
68 | }: {
69 | state: TreeViewState;
70 | }) {
71 | const [dropLocation, setDropLocation] =
72 | useState | undefined>();
73 |
74 | useEffect(() => {
75 | state.addListener("dropLocationChange", setDropLocation);
76 | return () => {
77 | state.removeListener("dropLocationChange", setDropLocation);
78 | };
79 | }, [state, setDropLocation]);
80 |
81 | const indicator = dropLocation?.indication;
82 | if (!indicator) {
83 | return null;
84 | }
85 |
86 | if (indicator.type === "between") {
87 | return state.props.dropBetweenIndicator({
88 | top: indicator.top,
89 | left: indicator.depth * state.indentation + state.dropIndicatorOffset,
90 | });
91 | } else {
92 | return state.props.dropOverIndicator({
93 | top: indicator.top,
94 | height: indicator.height,
95 | });
96 | }
97 | }
98 |
99 | //// TreeView
100 |
101 | export function TreeView(
102 | props: TreeViewProps
103 | ): JSX.Element | null {
104 | const dragImageRef = createRef();
105 |
106 | const [state] = useState(() => new TreeViewState(props));
107 | state.setProps(props);
108 |
109 | return (
110 | {
119 | state.dropLocation = undefined;
120 | }}
121 | >
122 |
129 |
140 |
141 |
(state.headerDOM = e ?? undefined)}>
142 | {props.header}
143 |
144 | {state.rows.map((row, i) => (
145 |
151 | ))}
152 | {props.footer}
153 |
154 |
155 |
156 | );
157 | }
158 |
--------------------------------------------------------------------------------
/src/stories/TreeView.stories.tsx:
--------------------------------------------------------------------------------
1 | import { loremIpsum } from "lorem-ipsum";
2 | import React, { useState } from "react";
3 | import { TreeView, TreeViewItem } from "../react-draggable-tree";
4 | import { TreeViewItemRow } from "../TreeViewItemRow";
5 | import { Node } from "./Node";
6 |
7 | function generateNode(
8 | depth: number,
9 | minChildCount: number,
10 | maxChildCount: number
11 | ): Node {
12 | const text: string = loremIpsum({
13 | sentenceLowerBound: 2,
14 | sentenceUpperBound: 4,
15 | });
16 | const hasChild = depth > 1;
17 | let children: Node[] = [];
18 | if (hasChild) {
19 | children = [];
20 | const childCount = Math.round(
21 | Math.random() * (maxChildCount - minChildCount) + minChildCount
22 | );
23 | for (let i = 0; i < childCount; ++i) {
24 | children.push(generateNode(depth - 1, minChildCount, maxChildCount));
25 | }
26 | }
27 |
28 | const node = new Node();
29 | node.name = text;
30 | node.append(...children);
31 | node.type = hasChild ? "branch" : "leaf";
32 | return node;
33 | }
34 |
35 | interface NodeTreeViewItem extends TreeViewItem {
36 | readonly node: Node;
37 | }
38 |
39 | function createItem(node: Node, parent?: NodeTreeViewItem): NodeTreeViewItem {
40 | const item: NodeTreeViewItem = {
41 | key: node.key,
42 | parent,
43 | children: [],
44 | node,
45 | };
46 | if (!node.collapsed) {
47 | item.children = node.children.map((child) => createItem(child, item));
48 | }
49 | return item;
50 | }
51 |
52 | const TreeRow: React.FC<{
53 | rows: readonly TreeViewItemRow[];
54 | index: number;
55 | item: NodeTreeViewItem;
56 | depth: number;
57 | indentation: number;
58 | onChange: () => void;
59 | }> = ({ rows, index, item, depth, indentation, onChange }) => {
60 | const node = item.node;
61 |
62 | const onCollapseButtonClick = (e: React.MouseEvent) => {
63 | e.stopPropagation();
64 | node.collapsed = !node.collapsed;
65 | onChange();
66 | };
67 |
68 | const onClick = (event: React.MouseEvent) => {
69 | if (event.metaKey) {
70 | if (node.selected) {
71 | node.deselect();
72 | } else {
73 | node.select();
74 | }
75 | } else if (event.shiftKey) {
76 | let minSelectedIndex = index;
77 | let maxSelectedIndex = index;
78 |
79 | for (const [i, row] of rows.entries()) {
80 | if (row.item.node.selected) {
81 | minSelectedIndex = Math.min(minSelectedIndex, i);
82 | maxSelectedIndex = Math.max(maxSelectedIndex, i);
83 | }
84 | }
85 |
86 | for (let i = minSelectedIndex; i <= maxSelectedIndex; ++i) {
87 | rows[i].item.node.select();
88 | }
89 | } else {
90 | node.root.deselect();
91 | node.select();
92 | }
93 |
94 | onChange();
95 | };
96 |
97 | return (
98 |
110 | {node.firstChild !== undefined && (
111 |
112 | {node.collapsed ? "[+]" : "[-]"}
113 |
114 | )}
115 | {node.name}
116 |
117 | );
118 | };
119 |
120 | export default {
121 | component: TreeView,
122 | };
123 |
124 | export const Basic: React.FC = () => {
125 | const [root] = useState(() => generateNode(5, 3, 5));
126 | const [item, setItem] = useState(() => createItem(root));
127 | const update = () => {
128 | setItem(createItem(root));
129 | };
130 |
131 | return (
132 | {
146 | item.node.deselect();
147 | update();
148 | }}
149 | />
150 | }
151 | dropBetweenIndicator={({ top, left }) => (
152 |
163 | )}
164 | dropOverIndicator={({ top, height }) => (
165 |
177 | )}
178 | handleDragStart={({ item }) => {
179 | if (!item.node.selected) {
180 | item.node.root.deselect();
181 | item.node.select();
182 | update();
183 | }
184 | return true;
185 | }}
186 | canDrop={({ item, draggedItem }) => {
187 | return !!draggedItem && item.node.type === "branch";
188 | }}
189 | handleDrop={({ item, draggedItem, before }) => {
190 | if (draggedItem) {
191 | for (const node of item.node.root.selectedDescendants) {
192 | item.node.insertBefore(node, before?.node);
193 | }
194 | update();
195 | }
196 | }}
197 | renderRow={(props) => }
198 | />
199 | );
200 | };
201 |
202 | export const NonReorderable: React.FC = () => {
203 | const [root] = useState(() => generateNode(5, 3, 5));
204 | const [item, setItem] = useState(() => createItem(root));
205 | const update = () => {
206 | setItem(createItem(root));
207 | };
208 |
209 | return (
210 | {
225 | item.node.deselect();
226 | update();
227 | }}
228 | />
229 | }
230 | dropBetweenIndicator={({ top, left }) => (
231 |
242 | )}
243 | dropOverIndicator={({ top, height }) => (
244 |
256 | )}
257 | handleDragStart={({ item }) => {
258 | if (!item.node.selected) {
259 | item.node.root.deselect();
260 | item.node.select();
261 | update();
262 | }
263 | return true;
264 | }}
265 | canDrop={({ item, draggedItem }) => {
266 | return !!draggedItem && item.node.type === "branch";
267 | }}
268 | handleDrop={({ item, draggedItem, before }) => {
269 | if (draggedItem) {
270 | for (const node of item.node.root.selectedDescendants) {
271 | item.node.insertBefore(node, before?.node);
272 | }
273 | update();
274 | }
275 | }}
276 | renderRow={(props) => }
277 | />
278 | );
279 | };
280 |
--------------------------------------------------------------------------------
/src/TreeViewState.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { TypedEmitter } from "tiny-typed-emitter";
3 | import { TreeViewItem } from "./TreeViewItem";
4 | import { assertNonNull, first } from "./utils";
5 | import { TreeViewProps } from "./TreeViewProps";
6 | import { getItemRows, TreeViewItemRow } from "./TreeViewItemRow";
7 |
8 | const DRAG_MIME = "application/x.react-draggable-tree-drag";
9 |
10 | export type DropIndication =
11 | | {
12 | type: "between";
13 | top: number;
14 | depth: number;
15 | }
16 | | {
17 | type: "over";
18 | top: number;
19 | height: number;
20 | };
21 |
22 | export interface DropLocation {
23 | parent: T;
24 | before: T | undefined;
25 | indication: DropIndication;
26 | }
27 |
28 | export class TreeViewState extends TypedEmitter<{
29 | dropLocationChange(location: DropLocation | undefined): void;
30 | }> {
31 | constructor(props: TreeViewProps) {
32 | super();
33 | this.props = props;
34 | this.rows = props.rootItem.children.flatMap((item) => getItemRows(item, 0));
35 | }
36 |
37 | setProps(props: TreeViewProps) {
38 | if (this.props === props) {
39 | return;
40 | }
41 | this.props = props;
42 | this.rows = props.rootItem.children.flatMap((item) => getItemRows(item, 0));
43 | }
44 |
45 | props: TreeViewProps;
46 | rows: readonly TreeViewItemRow[];
47 | draggedItem: T | undefined = undefined;
48 | private _dropLocation: DropLocation | undefined = undefined;
49 | readonly itemToDOM = new WeakMap();
50 | headerDOM: HTMLElement | undefined;
51 |
52 | get dropLocation(): DropLocation | undefined {
53 | return this._dropLocation;
54 | }
55 |
56 | set dropLocation(dropLocation: DropLocation | undefined) {
57 | if (this._dropLocation === dropLocation) {
58 | return;
59 | }
60 | this._dropLocation = dropLocation;
61 | this.emit("dropLocationChange", dropLocation);
62 | }
63 |
64 | get indentation(): number {
65 | return this.props.indentation ?? 16;
66 | }
67 |
68 | get dropIndicatorOffset(): number {
69 | return this.props.dropIndicatorOffset ?? 0;
70 | }
71 |
72 | private canDropData(
73 | location: DropLocation,
74 | event: React.DragEvent,
75 | draggedItem: T | undefined
76 | ): boolean {
77 | return (
78 | this.props.canDrop?.({
79 | item: location.parent,
80 | event,
81 | draggedItem,
82 | }) ?? false
83 | );
84 | }
85 |
86 | private handleDrop(
87 | location: DropLocation,
88 | event: React.DragEvent,
89 | draggedItem: T | undefined
90 | ): boolean {
91 | if (!this.canDropData(location, event, draggedItem)) {
92 | return false;
93 | }
94 | this.props.handleDrop?.({
95 | item: location.parent,
96 | event,
97 | draggedItem,
98 | before: location.before,
99 | });
100 | return true;
101 | }
102 |
103 | private getHeaderBottom(): number {
104 | if (!this.headerDOM) {
105 | return 0;
106 | }
107 | return this.headerDOM.offsetTop + this.headerDOM.offsetHeight;
108 | }
109 |
110 | private getItemDOMTop(item: T): number {
111 | return this.itemToDOM.get(item)?.offsetTop ?? 0;
112 | }
113 | private getItemDOMHeight(item: T): number {
114 | return this.itemToDOM.get(item)?.offsetHeight ?? 0;
115 | }
116 | private getItemDOMBottom(item: T): number {
117 | const dom = this.itemToDOM.get(item);
118 | if (!dom) {
119 | return 0;
120 | }
121 | return dom.offsetTop + dom.offsetHeight;
122 | }
123 |
124 | private getDropDepth(e: React.DragEvent): number {
125 | const rect = e.currentTarget.getBoundingClientRect();
126 | return Math.max(
127 | Math.round(
128 | (e.clientX - rect.left - this.dropIndicatorOffset) / this.indentation
129 | ),
130 | 0
131 | );
132 | }
133 |
134 | private getDropLocationOver(item: T): DropLocation {
135 | return {
136 | parent: item,
137 | before: item.children[0],
138 | indication: {
139 | type: "over",
140 | top: this.getItemDOMTop(item),
141 | height: this.getItemDOMHeight(item),
142 | },
143 | };
144 | }
145 |
146 | private getDropLocationBetween(
147 | index: number,
148 | dropDepth: number
149 | ): DropLocation {
150 | if (this.rows.length === 0) {
151 | return {
152 | parent: this.props.rootItem,
153 | before: undefined,
154 | indication: {
155 | type: "between",
156 | top: 0,
157 | depth: 0,
158 | },
159 | };
160 | }
161 |
162 | if (index === 0) {
163 | return {
164 | parent: assertNonNull(this.rows[0].item.parent),
165 | before: this.rows[0].item,
166 | indication: {
167 | type: "between",
168 | top: this.getItemDOMTop(this.rows[0].item),
169 | depth: this.rows[0].depth,
170 | },
171 | };
172 | }
173 |
174 | const rowPrev = this.rows[index - 1];
175 | const rowNext = index < this.rows.length ? this.rows[index] : undefined;
176 |
177 | if (!rowNext || rowNext.depth < rowPrev.depth) {
178 | if (rowNext && dropDepth <= rowNext.depth) {
179 | // Prev
180 | // ----
181 | // Next
182 | return {
183 | parent: assertNonNull(rowNext.item.parent),
184 | before: rowNext.item,
185 | indication: {
186 | type: "between",
187 | top: this.getItemDOMTop(rowNext.item),
188 | depth: rowNext.depth,
189 | },
190 | };
191 | }
192 |
193 | // Prev
194 | // ----
195 | // Next
196 | // or
197 | // Prev
198 | // ----
199 | // Next
200 | // or
201 | // Prev
202 | // ----
203 | // (no next items)
204 |
205 | const depth = Math.min(dropDepth, rowPrev.depth);
206 | const up = rowPrev.depth - depth;
207 |
208 | let parent = rowPrev.item.parent;
209 | for (let i = 0; i < up; ++i) {
210 | parent = parent?.parent;
211 | }
212 |
213 | return {
214 | parent: assertNonNull(parent),
215 | before: undefined,
216 | indication: {
217 | type: "between",
218 | top: this.getItemDOMBottom(rowPrev.item),
219 | depth: depth,
220 | },
221 | };
222 | } else {
223 | // Prev
224 | // ----
225 | // Next
226 | // or
227 | // Prev
228 | // ----
229 | // Next
230 | //
231 | return {
232 | parent: assertNonNull(rowNext.item.parent),
233 | before: rowNext.item,
234 | indication: {
235 | type: "between",
236 | top: this.getItemDOMTop(rowNext.item),
237 | depth: rowNext.depth,
238 | },
239 | };
240 | }
241 | }
242 |
243 | private getDropLocationForRow(
244 | index: number,
245 | event: React.DragEvent,
246 | draggedItem: T | undefined
247 | ): DropLocation | undefined {
248 | const row = this.rows[index];
249 | const item = row.item;
250 |
251 | if (!item.parent) {
252 | throw new Error("item must have parent");
253 | }
254 |
255 | const rect = event.currentTarget.getBoundingClientRect();
256 | const dropPos = (event.clientY - rect.top) / rect.height;
257 | const dropDepth = this.getDropDepth(event);
258 |
259 | const locationBefore = this.getDropLocationBetween(index, dropDepth);
260 | const locationOver = this.getDropLocationOver(item);
261 | const locationAfter = this.getDropLocationBetween(index + 1, dropDepth);
262 |
263 | if (!this.props.nonReorderable) {
264 | if (this.canDropData(locationOver, event, draggedItem)) {
265 | if (
266 | this.canDropData(locationBefore, event, draggedItem) &&
267 | dropPos < 1 / 4
268 | ) {
269 | return locationBefore;
270 | }
271 | if (
272 | this.canDropData(locationAfter, event, draggedItem) &&
273 | 3 / 4 < dropPos
274 | ) {
275 | return locationAfter;
276 | }
277 | return locationOver;
278 | } else {
279 | if (
280 | this.canDropData(locationBefore, event, draggedItem) &&
281 | dropPos < 1 / 2
282 | ) {
283 | return locationBefore;
284 | }
285 | if (this.canDropData(locationAfter, event, draggedItem)) {
286 | return locationAfter;
287 | }
288 | }
289 | } else {
290 | const locationOverParent = this.getDropLocationOver(item.parent);
291 | if (this.canDropData(locationOver, event, draggedItem)) {
292 | return locationOver;
293 | } else if (this.canDropData(locationOverParent, event, draggedItem)) {
294 | return locationOverParent;
295 | }
296 | }
297 | }
298 |
299 | private getDropLocationForBackground(
300 | e: React.DragEvent
301 | ): DropLocation {
302 | const rect = e.currentTarget.getBoundingClientRect();
303 | const top = e.clientY - rect.top;
304 |
305 | if (top <= this.getHeaderBottom()) {
306 | return {
307 | parent: this.props.rootItem,
308 | before: first(this.rows)?.item,
309 | indication: {
310 | type: "between",
311 | top: this.getHeaderBottom(),
312 | depth: 0,
313 | },
314 | };
315 | }
316 |
317 | return this.getDropLocationBetween(this.rows.length, this.getDropDepth(e));
318 | }
319 |
320 | //// Row drag and drop
321 |
322 | onRowDragStart(
323 | index: number,
324 | e: React.DragEvent,
325 | dragImage?: Element
326 | ) {
327 | const item = this.rows[index].item;
328 |
329 | if (!this.props.handleDragStart?.({ item, event: e })) {
330 | e.preventDefault();
331 | return;
332 | }
333 |
334 | e.dataTransfer.effectAllowed = "copyMove";
335 | e.dataTransfer.setData(DRAG_MIME, "drag");
336 | this.draggedItem = item;
337 |
338 | if (dragImage) {
339 | e.dataTransfer.setDragImage(dragImage, 0, 0);
340 | }
341 | }
342 |
343 | onRowDragEnd(index: number) {
344 | const item = this.rows[index].item;
345 |
346 | this.props.handleDragEnd?.({ item });
347 | }
348 |
349 | onRowDragOver(index: number, e: React.DragEvent) {
350 | const draggedItem = e.dataTransfer.types.includes(DRAG_MIME)
351 | ? this.draggedItem
352 | : undefined;
353 |
354 | this.dropLocation = this.getDropLocationForRow(index, e, draggedItem);
355 |
356 | if (this.dropLocation) {
357 | e.preventDefault();
358 | e.stopPropagation();
359 | }
360 | }
361 |
362 | onRowDragEnter(index: number, e: React.DragEvent) {
363 | const draggedItem = e.dataTransfer.types.includes(DRAG_MIME)
364 | ? this.draggedItem
365 | : undefined;
366 |
367 | this.dropLocation = this.getDropLocationForRow(index, e, draggedItem);
368 | e.preventDefault();
369 | e.stopPropagation();
370 | }
371 |
372 | onRowDrop(index: number, e: React.DragEvent) {
373 | const draggedItem = e.dataTransfer.types.includes(DRAG_MIME)
374 | ? this.draggedItem
375 | : undefined;
376 |
377 | const dropLocation = this.getDropLocationForRow(index, e, draggedItem);
378 | if (dropLocation && this.handleDrop(dropLocation, e, draggedItem)) {
379 | e.preventDefault();
380 | e.stopPropagation();
381 | }
382 | this.dropLocation = undefined;
383 | this.draggedItem = undefined;
384 | }
385 |
386 | //// Background drop
387 |
388 | onBackgroundDragEnter(e: React.DragEvent) {
389 | this.dropLocation = this.getDropLocationForBackground(e);
390 | e.preventDefault();
391 | e.stopPropagation();
392 | }
393 | onBackgroundDragLeave(e: React.DragEvent) {
394 | this.dropLocation = undefined;
395 | e.preventDefault();
396 | e.stopPropagation();
397 | }
398 | onBackgroundDragOver(e: React.DragEvent) {
399 | this.dropLocation = this.getDropLocationForBackground(e);
400 | if (this.canDropData(this.dropLocation, e, this.draggedItem)) {
401 | e.preventDefault();
402 | e.stopPropagation();
403 | }
404 | }
405 | onBackgroundDrop(e: React.DragEvent) {
406 | const dropLocation = this.getDropLocationForBackground(e);
407 | if (this.handleDrop(dropLocation, e, this.draggedItem)) {
408 | e.preventDefault();
409 | e.stopPropagation();
410 | return;
411 | }
412 | }
413 | }
414 |
--------------------------------------------------------------------------------