├── .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 | 18 | 20 | 38 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 72 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /lib/group-open.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 38 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 72 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /lib/item.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 41 | 48 | 49 | 51 | 52 | 54 | image/svg+xml 55 | 57 | 58 | 59 | 60 | 61 | 66 | 69 | 75 | 81 | 87 | 93 | 94 | 95 | 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 | --------------------------------------------------------------------------------