├── .gitignore
├── .npmignore
├── LICENSE.txt
├── README.md
├── build.js
├── index.d.ts
├── lib
├── group-closed.svg
├── group-open.svg
└── item.svg
├── package.json
└── src
├── index.pug
├── index.styl
├── index.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | **/node_modules
2 | lib/*.html
3 | lib/*.css
4 | lib/*.js
5 | src/*.js
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src/
2 | lib/*
3 | !lib/TreeView.js
4 | typings/
5 | build.js
6 | tsd.json
7 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright © 2014-2016, Sparklin Labs
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any
4 | purpose with or without fee is hereby granted, provided that the above
5 | copyright notice and this permission notice appear in all copies.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
12 | OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
13 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # dnd-tree-view
2 |
3 | Robust, stylable tree view widget for HTML5 apps.
4 | Features multiple item selection, keyboard navigation and cancellable drag'n'drop, suitable for networked apps.
5 |
6 | ## How to install
7 |
8 | npm install dnd-tree-view
9 |
10 | ## Usage
11 |
12 | Check out the [live demo](http://sparklinlabs.github.io/dnd-tree-view/) and its [source code](https://github.com/sparklinlabs/dnd-tree-view/blob/master/src/index.jade).
13 |
14 | * Include `TreeView.js` in your page.
15 | * Create a container element, call `treeView = new TreeView(container)`.
16 | * Create a list item element (`
`), put whatever you want inside.
17 | * Use `treeView.append(listItem, type, optionalParent)` or `treeView.insertBefore(listItem, type, referenceListItem)` with `type` one of `'item'` or `'group'`.
18 |
19 | The `TreeView` constructor takes an optional second `options` parameter. It supports the following keys:
20 |
21 | * `dragStartCallback` and `dropCallback` for handling drag'n'drop operations.
22 | * `multipleSelection` is a boolean indicating whether to enable multiple item selection or not.
23 |
24 | If `dragStartCallback` is not `null`, then dragging elements will be enabled.
25 | It must return a boolean indicating whether to start the drag operation or cancel it.
26 | You can use [`event.dataTransfer.setData(...)`](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations) to setup drag'n'drop metadata.
27 |
28 | If `dropCallback` is not `null`, then dropping will be enabled.
29 | It must return a boolean indicating whether to proceed with the reparenting/reordering or not.
30 |
31 | See [index.d.ts](https://github.com/sparklinlabs/dnd-tree-view/blob/master/index.d.ts) for the full API and arguments.
32 |
33 | ## Building from source
34 |
35 | * Make sure you have a recent version of [Node.js](http://nodejs.org/) installed.
36 | * Clone the repository from `https://github.com/sparklinlabs/dnd-tree-view` and run `npm install` once
37 | * Run `npm run build` to build once or `npm run watch` to start a watcher that will rebuild when changes are detected
38 |
--------------------------------------------------------------------------------
/build.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | try { require("fs").mkdirSync(`${__dirname}/lib`); } catch (err) {}
4 |
5 | const spawn = require("child_process").spawn;
6 | const spawnOptions = { stdio: "inherit" };
7 | const suffix = (process.platform === "win32") ? ".cmd" : "";
8 |
9 | const watchMode = process.argv[2] === "-w";
10 | const bundler = watchMode ? "watchify" : "browserify";
11 | const watchArgs = watchMode ? [ "-w" ] : [];
12 |
13 | spawn(`pug${suffix}`, watchArgs.concat([ `${__dirname}/src/index.pug`, "--out", `${__dirname}/lib` ]), spawnOptions);
14 | spawn(`stylus${suffix}`, watchArgs.concat([ `${__dirname}/src/index.styl`, "--out", `${__dirname}/lib` ]), spawnOptions);
15 |
16 | spawn(`tsc${suffix}`, [ "-p", `${__dirname}/src` ], spawnOptions).on("close", () => {
17 | if (watchMode) spawn(`tsc${suffix}`, watchArgs.concat([ "-p", `${__dirname}/src` ]), spawnOptions);
18 | spawn(`${bundler}${suffix}`, [ `${__dirname}/src/index.js`, "-s", "TreeView", "-o", `${__dirname}/lib/TreeView.js` ], spawnOptions);
19 | });
20 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | declare class TreeView {
2 | treeRoot: HTMLOListElement;
3 | selectedNodes: HTMLLIElement[];
4 |
5 | constructor(container: HTMLElement, options?: {
6 | dragStartCallback?: TreeView.DragStartCallback,
7 | dropCallback?: TreeView.DropCallback,
8 | multipleSelection?: boolean
9 | });
10 |
11 | clear(): void;
12 | append(element: HTMLLIElement, type: "item" | "group", parentGroupElement?: HTMLElement): void;
13 | insertBefore(element: HTMLLIElement, type: string, referenceElement: HTMLLIElement): void;
14 | insertAt(element: HTMLLIElement, type: string, index: number, parentElement?: HTMLLIElement): void;
15 | remove(element: HTMLLIElement): void;
16 |
17 | clearSelection(): void;
18 | addToSelection(element: HTMLLIElement): void;
19 |
20 | scrollIntoView(element: HTMLLIElement): void;
21 | moveVertically(offset: number /* 1 or -1 */): void;
22 | moveHorizontally(offset: number /* 1 or -1 */): void;
23 |
24 | addListener(event: string, listener: Function): TreeView;
25 | on(event: string, listener: Function): TreeView;
26 | once(event: string, listener: Function): TreeView;
27 | removeListener(event: string, listener: Function): TreeView;
28 | removeAllListeners(event?: string): TreeView;
29 | setMaxListeners(n: number): TreeView;
30 | getMaxListeners(): number;
31 | listeners(event: string): Function[];
32 | emit(event: string, ...args: any[]): boolean;
33 | listenerCount(type: string): number;
34 |
35 | on(event: "selectionChange", listener: () => any): TreeView;
36 | on(event: "activate", listener: () => any): TreeView;
37 | }
38 |
39 | declare namespace TreeView {
40 | interface DragStartCallback {
41 | (event: DragEvent, nodeElt: HTMLLIElement): boolean;
42 | }
43 |
44 | interface DropLocation {
45 | target: HTMLLIElement|HTMLOListElement;
46 | where: "above" | "inside" | "below";
47 | }
48 |
49 | interface DropCallback {
50 | (event: DragEvent,
51 | dropLocation: DropLocation,
52 | orderedNodes: HTMLLIElement[]): boolean;
53 | }
54 | }
55 |
56 | declare module "dnd-tree-view" {
57 | export = TreeView;
58 | }
59 |
--------------------------------------------------------------------------------
/lib/group-closed.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
80 |
--------------------------------------------------------------------------------
/lib/group-open.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
80 |
--------------------------------------------------------------------------------
/lib/item.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
96 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dnd-tree-view",
3 | "description": "HTML5 tree view widget",
4 | "version": "4.0.2",
5 | "author": "Elisée Maurer ",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/sparklinlabs/dnd-tree-view"
9 | },
10 | "license": "ISC",
11 | "main": "./lib/TreeView.js",
12 | "scripts": {
13 | "build": "node build.js",
14 | "watch": "node build.js -w",
15 | "start": "http-server lib"
16 | },
17 | "keywords": [
18 | "treeview",
19 | "tree",
20 | "tree view",
21 | "widget",
22 | "reorderable",
23 | "reparent",
24 | "dnd",
25 | "drag'n'drop"
26 | ],
27 | "devDependencies": {
28 | "@types/node": "^6.0.46",
29 | "http-server": "^0.9.0",
30 | "pug-cli": "^1.0.0-alpha6",
31 | "stylus": "^0.54.5",
32 | "typescript": "^2.0.6",
33 | "watchify": "^3.7.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/index.pug:
--------------------------------------------------------------------------------
1 | doctype html
2 | html
3 | head
4 | link(rel="stylesheet",href="index.css")
5 | script(src="TreeView.js")
6 | body
7 | header
8 | h1
9 | a(href="https://github.com/sparklinlabs") sparklinlabs
10 | | /
11 | a(href="https://github.com/sparklinlabs/dnd-tree-view") dnd-tree-view
12 | p An HTML5 tree view widget with drag'n'drop support
13 | main
14 | nav
15 | button(data-type="item").create-item Create item
16 | button(data-type="group").create-group Create group
17 | button.remove-selected Remove selected
18 | .selected-nodes No items selected
19 | .dnd-result
20 | script.
21 | var treeView = new TreeView(document.querySelector("main"), {
22 | dragStartCallback: function(event, node) {
23 | event.dataTransfer.setData("text/plain", node.textContent);
24 | return true;
25 | },
26 | dropCallback: function(event, dropLocation, orderedNodes) {
27 | document.querySelector("nav .dnd-result").textContent =
28 | event.dataTransfer.getData("text/plain") + " was dropped " +
29 | dropLocation.where + " " + dropLocation.target.textContent;
30 | return true;
31 | }
32 | });
33 |
34 | function createItem(label) {
35 | var itemElt = document.createElement("li");
36 |
37 | iconElt = document.createElement("div");
38 | iconElt.classList.add("icon");
39 | itemElt.appendChild(iconElt);
40 |
41 | spanElt = document.createElement("span");
42 | spanElt.textContent = label;
43 | itemElt.appendChild(spanElt);
44 |
45 | return itemElt;
46 | }
47 |
48 | function createGroup(label) {
49 | var groupElt = document.createElement("li");
50 |
51 | spanElt = document.createElement("span");
52 | spanElt.textContent = label;
53 | groupElt.appendChild(spanElt);
54 |
55 | return groupElt;
56 | }
57 |
58 | for (var i = 0; i < 3; i++) {
59 | var group = createGroup("Group " + (i+1));
60 | treeView.append(group, "group");
61 |
62 | for (var j = 0; j < 3; j++) {
63 | var item = createItem("Item " + (i*3+j+1));
64 | treeView.append(item, "item", group);
65 | }
66 | }
67 |
68 | group = createGroup("Empty Group 1");
69 | treeView.append(group, "group", document.querySelector(".group"));
70 |
71 | group = createGroup("Empty Group 2");
72 | treeView.append(group, "group", document.querySelector(".group:last-child"));
73 |
74 | function onClickCreate(event) {
75 | var type = event.target.dataset.type;
76 | var label = prompt("Enter a name", "");
77 | if (label.length === 0) return;
78 | var node = (type === "item") ? createItem(label) : createGroup(label);
79 |
80 | var parentNode = treeView.selectedNodes[0];
81 | if (parentNode != null && !parentNode.classList.contains("group")) {
82 | parentNode = parentNode.parentElement.classList.contains("children") ? parentNode.parentElement.previousSibling : null;
83 | }
84 |
85 | treeView.append(node, type, parentNode);
86 | }
87 |
88 | document.querySelector("nav .create-item").addEventListener("click", onClickCreate);
89 | document.querySelector("nav .create-group").addEventListener("click", onClickCreate);
90 |
91 | document.querySelector("nav .remove-selected").addEventListener("click", function() {
92 | while (treeView.selectedNodes.length > 0) {
93 | treeView.remove(treeView.selectedNodes[treeView.selectedNodes.length - 1]);
94 | }
95 | });
96 |
97 | treeView.on("selectionChange", function() {
98 | var text;
99 | if (treeView.selectedNodes.length > 1) text = "" + treeView.selectedNodes.length + " items selected";
100 | else if (treeView.selectedNodes.length === 1) text = "1 item selected";
101 | else text = "No items selected";
102 |
103 | document.querySelector("nav .selected-nodes").textContent = text;
104 | });
105 |
106 | treeView.on("activate", function() {
107 | alert("Activated " + treeView.selectedNodes[0].querySelector("span").textContent);
108 | });
109 |
--------------------------------------------------------------------------------
/src/index.styl:
--------------------------------------------------------------------------------
1 | * { box-sizing: border-box; }
2 |
3 | html, body {
4 | height: 100%;
5 | margin: 0;
6 | }
7 |
8 | body {
9 | font-family: sans-serif;
10 | font-size: 14px;
11 | display: flex;
12 | flex-flow: column;
13 | background-color: #eee;
14 | }
15 |
16 | a { color: #00f; }
17 | a:hover { text-decoration: none; }
18 |
19 | header {
20 | padding: 1em;
21 | display: flex;
22 | align-items: center;
23 | h1, p { margin: 0; }
24 | p { margin-left: 1em; }
25 | }
26 |
27 | main {
28 | position: relative;
29 | background-color: #fff;
30 | border: 1px solid #888;
31 | flex: 1;
32 | margin: 0 1em;
33 | overflow-y: auto;
34 | }
35 |
36 | nav {
37 | display: flex;
38 | margin: 0.5em 1em 1em 1em;
39 | align-items: center;
40 |
41 | button {
42 | margin-right: 0.5em;
43 | padding: 0.5em;
44 | }
45 |
46 | .dnd-result {
47 | margin-left: 0.5em;
48 | flex: 1;
49 | text-align: right;
50 | color: #666;
51 | }
52 | }
53 |
54 | ol.tree {
55 | position: absolute;
56 | list-style: none;
57 | line-height: 1.5;
58 | margin: 0;
59 | padding: 0.25em 0.25em 2em 0.25em;
60 | width: 100%;
61 | min-height: 100%;
62 |
63 | * { -webkit-user-select: none; }
64 |
65 | &.drop-inside:before {
66 | position: absolute;
67 | content: "";
68 | border-top: 1px solid #888;
69 | left: 0.25em;
70 | right: 0.25em;
71 | top: 0.25em;
72 | }
73 |
74 | ol {
75 | list-style: none;
76 | margin: 0;
77 | padding-left: 24px;
78 |
79 | &:last-of-type.drop-below {
80 | border-bottom: 1px solid #888;
81 | padding-bottom: 0;
82 | }
83 | }
84 |
85 | li.item, li.group {
86 | background-clip: border-box;
87 | height: 28px;
88 | display: flex;
89 | padding: 1px;
90 | cursor: default;
91 | display: flex;
92 | align-items: center;
93 |
94 | > .icon, > .toggle {
95 | margin: -1px;
96 | width: 24px;
97 | height: 24px;
98 | }
99 |
100 | span {
101 | align-self: center;
102 | padding: 0.25em;
103 | }
104 |
105 | &:hover { background-color: #eee; }
106 |
107 | &.drop-above {
108 | border-top: 1px solid #888;
109 | padding-top: 0;
110 | }
111 |
112 | &.drop-inside {
113 | border: 1px solid #888;
114 | padding: 0;
115 | }
116 |
117 | &.selected { background: #beddf4; }
118 | }
119 |
120 | li.item {
121 | > .icon { background-image: url(item.svg); }
122 |
123 | &.drop-below {
124 | border-bottom: 1px solid #888;
125 | padding-bottom: 0;
126 | }
127 | }
128 |
129 | li.group {
130 | color: #444;
131 |
132 | > .toggle {
133 | background-image: url(group-open.svg);
134 | cursor: pointer;
135 | }
136 |
137 | &.drop-below {
138 | + ol {
139 | border-bottom: 1px solid #888;
140 |
141 | &:empty {
142 | margin-top: -1px;
143 | pointer-events: none;
144 | }
145 | }
146 | }
147 | }
148 |
149 | li.group.collapsed {
150 | > .toggle { background-image: url(group-closed.svg); }
151 | + ol > ol, + ol > li { display: none; }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from "events";
2 |
3 | interface DragStartCallback {
4 | (event: DragEvent, nodeElt: HTMLLIElement): boolean;
5 | }
6 |
7 | interface DropLocation {
8 | target: HTMLLIElement|HTMLOListElement;
9 | where: string; // "above", "inside" or "below"
10 | }
11 |
12 | interface DropCallback {
13 | (event: DragEvent,
14 | dropLocation: DropLocation,
15 | orderedNodes: HTMLLIElement[]): boolean;
16 | }
17 |
18 | class TreeView extends EventEmitter {
19 | treeRoot: HTMLOListElement;
20 | selectedNodes: HTMLLIElement[];
21 |
22 | private dragStartCallback: DragStartCallback;
23 | private dropCallback: DropCallback;
24 | private multipleSelection: boolean;
25 |
26 | private firstSelectedNode: HTMLLIElement;
27 | private previousDropLocation: DropLocation;
28 | private hasDraggedOverAfterLeaving: boolean;
29 | private isDraggingNodes: boolean;
30 |
31 | constructor(container: HTMLDivElement, options?: { dragStartCallback?: DragStartCallback, dropCallback?: DropCallback, multipleSelection?: boolean }) {
32 | super();
33 |
34 | if (options == null) options = {};
35 |
36 | this.multipleSelection = (options.multipleSelection != null) ? options.multipleSelection : true;
37 | this.dragStartCallback = options.dragStartCallback;
38 | this.dropCallback = options.dropCallback;
39 | this.treeRoot = document.createElement("ol");
40 | this.treeRoot.tabIndex = 0;
41 | this.treeRoot.classList.add("tree");
42 | container.appendChild(this.treeRoot);
43 |
44 | this.selectedNodes = [];
45 | this.firstSelectedNode = null;
46 |
47 | this.treeRoot.addEventListener("click", this.onClick);
48 | this.treeRoot.addEventListener("dblclick", this.onDoubleClick);
49 | this.treeRoot.addEventListener("keydown", this.onKeyDown);
50 | container.addEventListener("keydown", (event) => {
51 | if (event.keyCode === 37 || event.keyCode === 39) event.preventDefault();
52 | });
53 |
54 | if (this.dragStartCallback != null) {
55 | this.treeRoot.addEventListener("dragstart", this.onDragStart);
56 | this.treeRoot.addEventListener("dragend", this.onDragEnd);
57 | }
58 |
59 | if (this.dropCallback != null) {
60 | this.treeRoot.addEventListener("dragover", this.onDragOver);
61 | this.treeRoot.addEventListener("dragleave", this.onDragLeave);
62 | this.treeRoot.addEventListener("drop", this.onDrop);
63 | }
64 | }
65 |
66 | clearSelection() {
67 | for (const selectedNode of this.selectedNodes) selectedNode.classList.remove("selected");
68 | this.selectedNodes.length = 0;
69 | this.firstSelectedNode = null;
70 | }
71 |
72 | addToSelection(element: HTMLLIElement) {
73 | if (this.selectedNodes.indexOf(element) !== -1) return;
74 |
75 | this.selectedNodes.push(element);
76 | element.classList.add("selected");
77 |
78 | if (this.selectedNodes.length === 1) this.firstSelectedNode = element;
79 | }
80 |
81 | scrollIntoView(element: HTMLLIElement) {
82 | let ancestor = element.parentElement;
83 | while (ancestor != null && ancestor.className === "children") {
84 | ancestor.previousElementSibling.classList.remove("collapsed");
85 | ancestor = ancestor.parentElement;
86 | }
87 |
88 | const elementRect = element.getBoundingClientRect();
89 | const containerRect = this.treeRoot.parentElement.getBoundingClientRect();
90 |
91 | if (elementRect.top < containerRect.top) element.scrollIntoView(true);
92 | else if (elementRect.bottom > containerRect.bottom) element.scrollIntoView(false);
93 | }
94 |
95 | clear() {
96 | this.treeRoot.innerHTML = "";
97 | this.selectedNodes.length = 0;
98 | this.firstSelectedNode = null;
99 | this.hasDraggedOverAfterLeaving = false;
100 | this.isDraggingNodes = false;
101 | }
102 |
103 | append(element: HTMLLIElement, type: string, parentGroupElement?: HTMLLIElement) {
104 | if (type !== "item" && type !== "group") throw new Error("Invalid type");
105 |
106 | let childrenElt: HTMLOListElement;
107 | let siblingsElt: HTMLOListElement;
108 |
109 | if (parentGroupElement != null) {
110 | if (parentGroupElement.tagName !== "LI" || !parentGroupElement.classList.contains("group")) throw new Error("Invalid parent group");
111 | siblingsElt = parentGroupElement.nextSibling as HTMLOListElement;
112 | } else {
113 | siblingsElt = this.treeRoot;
114 | }
115 |
116 | if (!element.classList.contains(type)) {
117 | element.classList.add(type);
118 | if (this.dragStartCallback != null) element.draggable = true;
119 |
120 | if (type === "group") {
121 | const toggleElt = document.createElement("div");
122 | toggleElt.classList.add("toggle");
123 | element.insertBefore(toggleElt, element.firstChild);
124 |
125 | childrenElt = document.createElement("ol");
126 | childrenElt.classList.add("children");
127 | }
128 | } else if (type === "group") {
129 | childrenElt = element.nextSibling as HTMLOListElement;
130 | }
131 |
132 | siblingsElt.appendChild(element);
133 | if (childrenElt != null) siblingsElt.appendChild(childrenElt);
134 |
135 | return element;
136 | }
137 |
138 | insertBefore(element: HTMLLIElement, type: string, referenceElement: HTMLLIElement) {
139 | if (type !== "item" && type !== "group") throw new Error("Invalid type");
140 | if (referenceElement == null) throw new Error("A reference element is required");
141 | if (referenceElement.tagName !== "LI") throw new Error("Invalid reference element");
142 |
143 | let childrenElt: HTMLOListElement;
144 |
145 | if (!element.classList.contains(type)) {
146 | element.classList.add(type);
147 | if (this.dragStartCallback != null) element.draggable = true;
148 |
149 | if (type === "group") {
150 | let toggleElt = document.createElement("div");
151 | toggleElt.classList.add("toggle");
152 | element.insertBefore(toggleElt, element.firstChild);
153 |
154 | childrenElt = document.createElement("ol");
155 | childrenElt.classList.add("children");
156 | }
157 | } else if (type === "group") {
158 | childrenElt = element.nextSibling as HTMLOListElement;
159 | }
160 |
161 | referenceElement.parentElement.insertBefore(element, referenceElement);
162 | if (childrenElt != null) referenceElement.parentElement.insertBefore(childrenElt, element.nextSibling);
163 |
164 | return element;
165 | }
166 |
167 | insertAt(element: HTMLLIElement, type: string, index: number, parentElement?: HTMLLIElement) {
168 | let referenceElt: HTMLLIElement;
169 |
170 | if (index != null) {
171 | referenceElt =
172 | (parentElement != null)
173 | ? (parentElement.nextSibling as HTMLOListElement).querySelector(`:scope > li:nth-of-type(${index + 1})`) as HTMLLIElement
174 | : this.treeRoot.querySelector(`:scope > li:nth-of-type(${index + 1})`) as HTMLLIElement;
175 | }
176 |
177 | if (referenceElt != null) this.insertBefore(element, type, referenceElt);
178 | else this.append(element, type, parentElement);
179 | }
180 |
181 | remove(element: HTMLLIElement) {
182 | const selectedIndex = this.selectedNodes.indexOf(element);
183 | if (selectedIndex !== -1) {
184 | element.classList.remove("selected");
185 | this.selectedNodes.splice(selectedIndex, 1);
186 | }
187 | if (this.firstSelectedNode === element) this.firstSelectedNode = this.selectedNodes[0];
188 |
189 | if (element.classList.contains("group")) {
190 | const childrenElement = element.nextSibling as HTMLElement;
191 |
192 | const removedSelectedNodes: HTMLLIElement[] = [];
193 | for (const selectedNode of this.selectedNodes) {
194 | if (childrenElement.contains(selectedNode)) {
195 | removedSelectedNodes.push(selectedNode);
196 | }
197 | }
198 |
199 | for (const removedSelectedNode of removedSelectedNodes) {
200 | removedSelectedNode.classList.remove("selected");
201 | this.selectedNodes.splice(this.selectedNodes.indexOf(removedSelectedNode), 1);
202 | if (this.firstSelectedNode === removedSelectedNode) this.firstSelectedNode = this.selectedNodes[0];
203 | }
204 |
205 | element.parentElement.removeChild(childrenElement);
206 | }
207 |
208 | element.parentElement.removeChild(element);
209 | }
210 |
211 | private onClick = (event: MouseEvent) => {
212 | // Toggle groups
213 | const element = event.target as HTMLElement;
214 |
215 | if (element.className === "toggle") {
216 | if (element.parentElement.tagName === "LI" && element.parentElement.classList.contains("group")) {
217 | element.parentElement.classList.toggle("collapsed");
218 | return;
219 | }
220 | }
221 |
222 | // Update selection
223 | if (element.tagName === "BUTTON" || element.tagName === "INPUT" || element.tagName === "SELECT") return;
224 | if (this.updateSelection(event)) this.emit("selectionChange");
225 | };
226 |
227 | // Returns whether the selection changed
228 | private updateSelection(event: MouseEvent) {
229 | let selectionChanged = false;
230 |
231 | if ((!this.multipleSelection || (!event.shiftKey && !event.ctrlKey)) && this.selectedNodes.length > 0) {
232 | this.clearSelection();
233 | selectionChanged = true;
234 | }
235 |
236 | let ancestorElement = event.target as HTMLElement;
237 | while (ancestorElement.tagName !== "LI" || (!ancestorElement.classList.contains("item") && !ancestorElement.classList.contains("group"))) {
238 | if (ancestorElement === this.treeRoot) return selectionChanged;
239 | ancestorElement = ancestorElement.parentElement;
240 | }
241 |
242 | const element = ancestorElement as HTMLLIElement;
243 |
244 | if (this.selectedNodes.length > 0 && this.selectedNodes[0].parentElement !== element.parentElement) {
245 | return selectionChanged;
246 | }
247 |
248 | if (this.multipleSelection && event.shiftKey && this.selectedNodes.length > 0) {
249 | const startElement = this.firstSelectedNode;
250 | const elements: HTMLLIElement[] = [];
251 | let inside = false;
252 |
253 | for (let i = 0; i < element.parentElement.children.length; i++) {
254 | const child = element.parentElement.children[i] as HTMLElement;
255 |
256 | if (child === startElement || child === element) {
257 | if (inside || startElement === element ) {
258 | elements.push(child as HTMLLIElement);
259 | break;
260 | }
261 | inside = true;
262 | }
263 |
264 | if (inside && child.tagName === "LI") elements.push(child as HTMLLIElement);
265 | }
266 |
267 | this.clearSelection();
268 | this.selectedNodes = elements;
269 | this.firstSelectedNode = startElement;
270 | for (const selectedNode of this.selectedNodes) selectedNode.classList.add("selected");
271 |
272 | return true;
273 | }
274 |
275 | let index: number;
276 | if (event.ctrlKey && (index = this.selectedNodes.indexOf(element)) !== -1) {
277 | this.selectedNodes.splice(index, 1);
278 | element.classList.remove("selected");
279 |
280 | if (this.firstSelectedNode === element) {
281 | this.firstSelectedNode = this.selectedNodes[0];
282 | }
283 |
284 | return true;
285 | }
286 |
287 | this.addToSelection(element);
288 | return true;
289 | }
290 |
291 | private onDoubleClick = (event: MouseEvent) => {
292 | if (this.selectedNodes.length !== 1) return;
293 |
294 | let element = event.target as HTMLElement;
295 | if (element.tagName === "BUTTON" || element.tagName === "INPUT" || element.tagName === "SELECT") return;
296 | if (element.className === "toggle") return;
297 |
298 | this.emit("activate");
299 | };
300 |
301 | private onKeyDown = (event: KeyboardEvent) => {
302 | if (document.activeElement !== this.treeRoot) return;
303 |
304 | if (this.firstSelectedNode == null) {
305 | // TODO: Remove once we have this.focusedNode
306 | if (event.keyCode === 40) {
307 | this.addToSelection(this.treeRoot.firstElementChild as HTMLLIElement);
308 | this.emit("selectionChange");
309 | event.preventDefault();
310 | }
311 | return;
312 | }
313 |
314 | switch (event.keyCode) {
315 | case 38: // up
316 | case 40: // down
317 | this.moveVertically(event.keyCode === 40 ? 1 : -1);
318 | event.preventDefault();
319 | break;
320 |
321 | case 37: // left
322 | case 39: // right
323 | this.moveHorizontally(event.keyCode === 39 ? 1 : -1);
324 | event.preventDefault();
325 | break;
326 |
327 | case 13:
328 | if (this.selectedNodes.length !== 1) return;
329 | this.emit("activate");
330 | event.preventDefault();
331 | break;
332 | }
333 | };
334 |
335 | moveVertically(offset: number) {
336 | // TODO: this.focusedNode;
337 | let node = this.firstSelectedNode;
338 |
339 | if (offset === -1) {
340 | if (node.previousElementSibling != null) {
341 | let target = node.previousElementSibling as HTMLElement;
342 |
343 | while (target.classList.contains("children")) {
344 | if (!target.previousElementSibling.classList.contains("collapsed") && target.childElementCount > 0) target = target.lastElementChild as HTMLElement;
345 | else target = target.previousElementSibling as HTMLElement;
346 | }
347 | node = target as HTMLLIElement;
348 | } else if (node.parentElement.classList.contains("children")) node = node.parentElement.previousElementSibling as HTMLLIElement;
349 | else return;
350 | } else {
351 | let walkUp = false;
352 | if (node.classList.contains("group")) {
353 | if (!node.classList.contains("collapsed") && node.nextElementSibling.childElementCount > 0) node = node.nextElementSibling.firstElementChild as HTMLLIElement;
354 | else if (node.nextElementSibling.nextElementSibling != null) node = node.nextElementSibling.nextElementSibling as HTMLLIElement;
355 | else walkUp = true;
356 | } else {
357 | if (node.nextElementSibling != null) node = node.nextElementSibling as HTMLLIElement;
358 | else walkUp = true;
359 | }
360 |
361 | if (walkUp) {
362 | if (node.parentElement.classList.contains("children")) {
363 | let target = node.parentElement as HTMLElement;
364 | while (target.nextElementSibling == null) {
365 | target = target.parentElement;
366 | if (!target.classList.contains("children")) return;
367 | }
368 | node = target.nextElementSibling as HTMLLIElement;
369 | } else return;
370 | }
371 | }
372 |
373 | if (node == null) return;
374 |
375 | this.clearSelection();
376 | this.addToSelection(node);
377 | this.scrollIntoView(node);
378 | this.emit("selectionChange");
379 | };
380 |
381 | moveHorizontally = (offset: number) => {
382 | // TODO: this.focusedNode;
383 | let node = this.firstSelectedNode;
384 |
385 | if (offset === -1) {
386 | if (!node.classList.contains("group") || node.classList.contains("collapsed")) {
387 | if (!node.parentElement.classList.contains("children")) return;
388 | node = node.parentElement.previousElementSibling as HTMLLIElement;
389 | } else if (node.classList.contains("group")) {
390 | node.classList.add("collapsed");
391 | }
392 | } else {
393 | if (node.classList.contains("group")) {
394 | if (node.classList.contains("collapsed")) node.classList.remove("collapsed");
395 | else node = node.nextSibling.firstChild as HTMLLIElement;
396 | }
397 | }
398 |
399 | if (node == null) return;
400 |
401 | this.clearSelection();
402 | this.addToSelection(node);
403 | this.scrollIntoView(node);
404 | this.emit("selectionChange");
405 | };
406 |
407 | private onDragStart = (event: DragEvent) => {
408 | const element = event.target as HTMLLIElement;
409 | if (element.tagName !== "LI") return false;
410 | if (!element.classList.contains("item") && !element.classList.contains("group")) return false;
411 |
412 | if (this.selectedNodes.indexOf(element) === -1) {
413 | this.clearSelection();
414 | this.addToSelection(element);
415 | this.emit("selectionChange");
416 | }
417 |
418 | if (this.dragStartCallback != null && !this.dragStartCallback(event, element)) return false;
419 |
420 | this.isDraggingNodes = true;
421 |
422 | return true;
423 | };
424 |
425 | private onDragEnd = (event: DragEvent) => {
426 | this.previousDropLocation = null;
427 |
428 | this.isDraggingNodes = false;
429 | };
430 |
431 | private getDropLocation(event: DragEvent): DropLocation {
432 | let element = event.target as HTMLElement;
433 |
434 | if (element.tagName === "OL" && element.classList.contains("children")) {
435 | element = element.parentElement;
436 | }
437 |
438 | if (element === this.treeRoot) {
439 | element = element.lastChild as HTMLElement;
440 | if (element == null) return { target: this.treeRoot, where: "inside" };
441 | if (element.tagName === "OL") element = element.previousSibling as HTMLElement;
442 | return { target: element as HTMLLIElement, where: "below" };
443 | }
444 |
445 | while (element.tagName !== "LI" || (!element.classList.contains("item") && !element.classList.contains("group"))) {
446 | if (element === this.treeRoot) return null;
447 | element = element.parentElement;
448 | }
449 |
450 | let where = this.getInsertionPoint(element, event.pageY);
451 | if (where === "below") {
452 | if (element.classList.contains("item") && element.nextSibling != null && (element.nextSibling as HTMLElement).tagName === "LI") {
453 | element = element.nextSibling as HTMLElement;
454 | where = "above";
455 | } else if (element.classList.contains("group") && element.nextSibling.nextSibling != null && (element.nextSibling.nextSibling as HTMLElement).tagName === "LI") {
456 | element = element.nextSibling.nextSibling as HTMLElement;
457 | where = "above";
458 | }
459 | }
460 |
461 | return { target: element as HTMLLIElement, where };
462 | }
463 |
464 | private getInsertionPoint(element: HTMLElement, y: number) {
465 | const rect = element.getBoundingClientRect();
466 | const offset = y - rect.top;
467 |
468 | if (offset < rect.height / 4) return "above";
469 | if (offset > rect.height * 3 / 4) return (element.classList.contains("group") && (element.nextSibling as HTMLElement).childElementCount > 0) ? "inside" : "below";
470 | return element.classList.contains("item") ? "below" : "inside";
471 | }
472 |
473 | private onDragOver = (event: DragEvent) => {
474 | const dropLocation = this.getDropLocation(event);
475 |
476 | // Prevent dropping onto null
477 | if (dropLocation == null) return false;
478 |
479 | // If we're dragging nodes from the current tree view
480 | // Prevent dropping into descendant
481 | if (this.isDraggingNodes) {
482 | if (dropLocation.where === "inside" && this.selectedNodes.indexOf(dropLocation.target as HTMLLIElement) !== -1) return false;
483 |
484 | for (const selectedNode of this.selectedNodes) {
485 | if (selectedNode.classList.contains("group") && (selectedNode.nextSibling as HTMLElement).contains(dropLocation.target)) return false;
486 | }
487 | }
488 |
489 | this.hasDraggedOverAfterLeaving = true;
490 |
491 | if (this.previousDropLocation == null || this.previousDropLocation.where != dropLocation.where || this.previousDropLocation.target != dropLocation.target) {
492 | this.previousDropLocation = dropLocation;
493 |
494 | this.clearDropClasses();
495 | dropLocation.target.classList.add(`drop-${dropLocation.where}`);
496 | }
497 |
498 | event.preventDefault();
499 | };
500 |
501 | private clearDropClasses() {
502 | const dropAbove = this.treeRoot.querySelector(".drop-above") as HTMLElement;
503 | if (dropAbove != null) dropAbove.classList.remove("drop-above");
504 |
505 | const dropInside = this.treeRoot.querySelector(".drop-inside") as HTMLElement;
506 | if (dropInside != null) dropInside.classList.remove("drop-inside");
507 |
508 | const dropBelow = this.treeRoot.querySelector(".drop-below") as HTMLElement;
509 | if (dropBelow != null) dropBelow.classList.remove("drop-below");
510 |
511 | // For the rare case where we're dropping a foreign item into an empty tree view
512 | this.treeRoot.classList.remove("drop-inside");
513 | }
514 |
515 | private onDragLeave = (event: DragEvent) => {
516 | this.hasDraggedOverAfterLeaving = false;
517 | setTimeout(() => { if (!this.hasDraggedOverAfterLeaving) this.clearDropClasses(); }, 300);
518 | };
519 |
520 | private onDrop = (event: DragEvent) => {
521 | this.previousDropLocation = null;
522 |
523 | event.preventDefault();
524 | const dropLocation = this.getDropLocation(event);
525 | if (dropLocation == null) return;
526 |
527 | this.clearDropClasses();
528 |
529 | if (!this.isDraggingNodes) {
530 | this.dropCallback(event, dropLocation, null);
531 | return false;
532 | }
533 |
534 | const children = this.selectedNodes[0].parentElement.children;
535 | const orderedNodes: HTMLLIElement[] = [];
536 |
537 | for (let i = 0; i < children.length; i++) {
538 | const child = children[i] as HTMLLIElement;
539 | if (this.selectedNodes.indexOf(child) !== -1) orderedNodes.push(child);
540 | }
541 |
542 | const reparent = (this.dropCallback != null) ? this.dropCallback(event, dropLocation, orderedNodes) : true;
543 | if (!reparent) return;
544 |
545 | let newParent: HTMLElement;
546 | let referenceElt: HTMLElement;
547 |
548 | switch (dropLocation.where) {
549 | case "inside":
550 | if (!dropLocation.target.classList.contains("group")) return;
551 |
552 | newParent = dropLocation.target.nextSibling as HTMLElement;
553 | referenceElt = null;
554 | break;
555 |
556 | case "below":
557 | newParent = dropLocation.target.parentElement;
558 | referenceElt = dropLocation.target.nextSibling as HTMLElement;
559 | if (referenceElt != null && referenceElt.tagName === "OL") referenceElt = referenceElt.nextSibling as HTMLElement;
560 | break;
561 |
562 | case "above":
563 | newParent = dropLocation.target.parentElement;
564 | referenceElt = dropLocation.target;
565 | break;
566 | }
567 |
568 | let draggedChildren: HTMLElement;
569 |
570 | for (const selectedNode of orderedNodes) {
571 | if (selectedNode.classList.contains("group")) {
572 | draggedChildren = selectedNode.nextSibling as HTMLElement;
573 | draggedChildren.parentElement.removeChild(draggedChildren);
574 | }
575 |
576 | if (referenceElt === selectedNode) {
577 | referenceElt = selectedNode.nextSibling as HTMLElement;
578 | }
579 |
580 | selectedNode.parentElement.removeChild(selectedNode);
581 | newParent.insertBefore(selectedNode, referenceElt);
582 | referenceElt = selectedNode.nextSibling as HTMLElement;
583 |
584 | if (draggedChildren != null) {
585 | newParent.insertBefore(draggedChildren, referenceElt);
586 | referenceElt = draggedChildren.nextSibling as HTMLElement;
587 | }
588 | }
589 | };
590 | }
591 |
592 | export = TreeView;
593 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "noImplicitAny": true
6 | }
7 | }
8 |
--------------------------------------------------------------------------------