├── .editorconfig ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.e2e.json ├── package-lock.json ├── package.json ├── readme.gif ├── src ├── app │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ └── app.module.ts ├── assets │ └── .gitkeep ├── browserslist ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── styles.css ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── tslint.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [8.x, 10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, build, and test 21 | run: | 22 | npm ci 23 | npm run build --if-present 24 | npm test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alessio Rubis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DraggableMatTree 2 | 3 | ![](./readme.gif) 4 | 5 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 6.0.8. 6 | 7 | ## Working demo 8 | 9 | https://stackblitz.com/edit/angular-draggable-mat-tree 10 | 11 | ## Installation 12 | 13 | Run `npm install`. 14 | 15 | ## Development server 16 | 17 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 18 | 19 | ## Build 20 | 21 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 22 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "draggable-mat-tree": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/draggable-mat-tree", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "src/tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "src/styles.css" 27 | ], 28 | "scripts": [] 29 | }, 30 | "configurations": { 31 | "production": { 32 | "fileReplacements": [ 33 | { 34 | "replace": "src/environments/environment.ts", 35 | "with": "src/environments/environment.prod.ts" 36 | } 37 | ], 38 | "optimization": true, 39 | "outputHashing": "all", 40 | "sourceMap": false, 41 | "extractCss": true, 42 | "namedChunks": false, 43 | "aot": true, 44 | "extractLicenses": true, 45 | "vendorChunk": false, 46 | "buildOptimizer": true 47 | } 48 | } 49 | }, 50 | "serve": { 51 | "builder": "@angular-devkit/build-angular:dev-server", 52 | "options": { 53 | "browserTarget": "draggable-mat-tree:build" 54 | }, 55 | "configurations": { 56 | "production": { 57 | "browserTarget": "draggable-mat-tree:build:production" 58 | } 59 | } 60 | }, 61 | "extract-i18n": { 62 | "builder": "@angular-devkit/build-angular:extract-i18n", 63 | "options": { 64 | "browserTarget": "draggable-mat-tree:build" 65 | } 66 | }, 67 | "test": { 68 | "builder": "@angular-devkit/build-angular:karma", 69 | "options": { 70 | "main": "src/test.ts", 71 | "polyfills": "src/polyfills.ts", 72 | "tsConfig": "src/tsconfig.spec.json", 73 | "karmaConfig": "src/karma.conf.js", 74 | "styles": [ 75 | "src/styles.css" 76 | ], 77 | "scripts": [], 78 | "assets": [ 79 | "src/favicon.ico", 80 | "src/assets" 81 | ] 82 | } 83 | }, 84 | "lint": { 85 | "builder": "@angular-devkit/build-angular:tslint", 86 | "options": { 87 | "tsConfig": [ 88 | "src/tsconfig.app.json", 89 | "src/tsconfig.spec.json" 90 | ], 91 | "exclude": [ 92 | "**/node_modules/**" 93 | ] 94 | } 95 | } 96 | } 97 | }, 98 | "draggable-mat-tree-e2e": { 99 | "root": "e2e/", 100 | "projectType": "application", 101 | "architect": { 102 | "e2e": { 103 | "builder": "@angular-devkit/build-angular:protractor", 104 | "options": { 105 | "protractorConfig": "e2e/protractor.conf.js", 106 | "devServerTarget": "draggable-mat-tree:serve" 107 | }, 108 | "configurations": { 109 | "production": { 110 | "devServerTarget": "draggable-mat-tree:serve:production" 111 | } 112 | } 113 | }, 114 | "lint": { 115 | "builder": "@angular-devkit/build-angular:tslint", 116 | "options": { 117 | "tsConfig": "e2e/tsconfig.e2e.json", 118 | "exclude": [ 119 | "**/node_modules/**" 120 | ] 121 | } 122 | } 123 | } 124 | } 125 | }, 126 | "defaultProject": "draggable-mat-tree" 127 | } -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './src/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: require('path').join(__dirname, './tsconfig.e2e.json') 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to draggable-mat-tree!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draggable-mat-tree", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^6.1.8", 15 | "@angular/cdk": "^6.4.7", 16 | "@angular/common": "^6.0.3", 17 | "@angular/compiler": "^6.0.3", 18 | "@angular/core": "^6.0.3", 19 | "@angular/forms": "^6.0.3", 20 | "@angular/http": "^6.0.3", 21 | "@angular/material": "^6.4.7", 22 | "@angular/platform-browser": "^6.0.3", 23 | "@angular/platform-browser-dynamic": "^6.0.3", 24 | "@angular/router": "^6.0.3", 25 | "core-js": "^2.5.4", 26 | "rxjs": "^6.0.0", 27 | "zone.js": "^0.8.26" 28 | }, 29 | "devDependencies": { 30 | "@angular-devkit/build-angular": "~0.6.8", 31 | "@angular/cli": "~6.0.8", 32 | "@angular/compiler-cli": "^6.0.3", 33 | "@angular/language-service": "^6.0.3", 34 | "@types/jasmine": "~2.8.6", 35 | "@types/jasminewd2": "~2.0.3", 36 | "@types/node": "~8.9.4", 37 | "codelyzer": "~4.2.1", 38 | "jasmine-core": "~2.99.1", 39 | "jasmine-spec-reporter": "~4.2.1", 40 | "karma": "^1.7.1", 41 | "karma-chrome-launcher": "~2.2.0", 42 | "karma-coverage-istanbul-reporter": "~2.0.0", 43 | "karma-jasmine": "~1.1.1", 44 | "karma-jasmine-html-reporter": "^0.2.2", 45 | "karma-phantomjs-launcher": "^1.0.4", 46 | "protractor": "~5.3.0", 47 | "ts-node": "~5.0.1", 48 | "tslint": "~5.9.1", 49 | "typescript": "~2.7.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /readme.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alerubis/angular-draggable-mat-tree/0789f570147a11455722c2ad6c3cb9f5cd4fbf67/readme.gif -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .drop-above { 2 | border-top: 10px solid #dddddd; 3 | margin-top: -10px; 4 | } 5 | 6 | .drop-below { 7 | border-bottom: 10px solid #dddddd; 8 | margin-bottom: -10px; 9 | } 10 | 11 | .drop-center { 12 | background-color: #dddddd; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | {{node.item}} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 35 | {{node.item}} 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTreeModule } from '@angular/material'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async(() => { 7 | TestBed.configureTestingModule({ 8 | imports: [ 9 | MatButtonModule, 10 | MatCheckboxModule, 11 | MatFormFieldModule, 12 | MatIconModule, 13 | MatInputModule, 14 | MatTreeModule 15 | ], 16 | declarations: [ 17 | AppComponent 18 | ], 19 | }).compileComponents(); 20 | })); 21 | it('should create the app', async(() => { 22 | const fixture = TestBed.createComponent(AppComponent); 23 | const app = fixture.debugElement.componentInstance; 24 | expect(app).toBeTruthy(); 25 | })); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { SelectionModel } from '@angular/cdk/collections'; 2 | import { FlatTreeControl } from '@angular/cdk/tree'; 3 | import { Component, Injectable, ElementRef, ViewChild } from '@angular/core'; 4 | import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; 5 | import { BehaviorSubject } from 'rxjs'; 6 | 7 | /** 8 | * Node for to-do item 9 | */ 10 | export class TodoItemNode { 11 | children: TodoItemNode[]; 12 | item: string; 13 | } 14 | 15 | /** Flat to-do item node with expandable and level information */ 16 | export class TodoItemFlatNode { 17 | item: string; 18 | level: number; 19 | expandable: boolean; 20 | } 21 | 22 | /** 23 | * The Json object for to-do list data. 24 | */ 25 | const TREE_DATA = { 26 | Groceries: { 27 | 'Almond Meal flour': null, 28 | 'Organic eggs': null, 29 | 'Protein Powder': null, 30 | Fruits: { 31 | Apple: null, 32 | Berries: ['Blueberry', 'Raspberry'], 33 | Orange: null 34 | } 35 | }, 36 | Reminders: [ 37 | 'Cook dinner', 38 | 'Read the Material Design spec', 39 | 'Upgrade Application to Angular' 40 | ] 41 | }; 42 | 43 | /** 44 | * Checklist database, it can build a tree structured Json object. 45 | * Each node in Json object represents a to-do item or a category. 46 | * If a node is a category, it has children items and new items can be added under the category. 47 | */ 48 | @Injectable() 49 | export class ChecklistDatabase { 50 | dataChange = new BehaviorSubject([]); 51 | 52 | get data(): TodoItemNode[] { return this.dataChange.value; } 53 | 54 | constructor() { 55 | this.initialize(); 56 | } 57 | 58 | initialize() { 59 | // Build the tree nodes from Json object. The result is a list of `TodoItemNode` with nested 60 | // file node as children. 61 | const data = this.buildFileTree(TREE_DATA, 0); 62 | 63 | // Notify the change. 64 | this.dataChange.next(data); 65 | } 66 | 67 | /** 68 | * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. 69 | * The return value is the list of `TodoItemNode`. 70 | */ 71 | buildFileTree(obj: object, level: number): TodoItemNode[] { 72 | return Object.keys(obj).reduce((accumulator, key) => { 73 | const value = obj[key]; 74 | const node = new TodoItemNode(); 75 | node.item = key; 76 | 77 | if (value != null) { 78 | if (typeof value === 'object') { 79 | node.children = this.buildFileTree(value, level + 1); 80 | } else { 81 | node.item = value; 82 | } 83 | } 84 | 85 | return accumulator.concat(node); 86 | }, []); 87 | } 88 | 89 | /** Add an item to to-do list */ 90 | insertItem(parent: TodoItemNode, name: string): TodoItemNode { 91 | if (!parent.children) { 92 | parent.children = []; 93 | } 94 | const newItem = { item: name } as TodoItemNode; 95 | parent.children.push(newItem); 96 | this.dataChange.next(this.data); 97 | return newItem; 98 | } 99 | 100 | insertItemAbove(node: TodoItemNode, name: string): TodoItemNode { 101 | const parentNode = this.getParentFromNodes(node); 102 | const newItem = { item: name } as TodoItemNode; 103 | if (parentNode != null) { 104 | parentNode.children.splice(parentNode.children.indexOf(node), 0, newItem); 105 | } else { 106 | this.data.splice(this.data.indexOf(node), 0, newItem); 107 | } 108 | this.dataChange.next(this.data); 109 | return newItem; 110 | } 111 | 112 | insertItemBelow(node: TodoItemNode, name: string): TodoItemNode { 113 | const parentNode = this.getParentFromNodes(node); 114 | const newItem = { item: name } as TodoItemNode; 115 | if (parentNode != null) { 116 | parentNode.children.splice(parentNode.children.indexOf(node) + 1, 0, newItem); 117 | } else { 118 | this.data.splice(this.data.indexOf(node) + 1, 0, newItem); 119 | } 120 | this.dataChange.next(this.data); 121 | return newItem; 122 | } 123 | 124 | getParentFromNodes(node: TodoItemNode): TodoItemNode { 125 | for (let i = 0; i < this.data.length; ++i) { 126 | const currentRoot = this.data[i]; 127 | const parent = this.getParent(currentRoot, node); 128 | if (parent != null) { 129 | return parent; 130 | } 131 | } 132 | return null; 133 | } 134 | 135 | getParent(currentRoot: TodoItemNode, node: TodoItemNode): TodoItemNode { 136 | if (currentRoot.children && currentRoot.children.length > 0) { 137 | for (let i = 0; i < currentRoot.children.length; ++i) { 138 | const child = currentRoot.children[i]; 139 | if (child === node) { 140 | return currentRoot; 141 | } else if (child.children && child.children.length > 0) { 142 | const parent = this.getParent(child, node); 143 | if (parent != null) { 144 | return parent; 145 | } 146 | } 147 | } 148 | } 149 | return null; 150 | } 151 | 152 | updateItem(node: TodoItemNode, name: string) { 153 | node.item = name; 154 | this.dataChange.next(this.data); 155 | } 156 | 157 | deleteItem(node: TodoItemNode) { 158 | this.deleteNode(this.data, node); 159 | this.dataChange.next(this.data); 160 | } 161 | 162 | copyPasteItem(from: TodoItemNode, to: TodoItemNode): TodoItemNode { 163 | const newItem = this.insertItem(to, from.item); 164 | if (from.children) { 165 | from.children.forEach(child => { 166 | this.copyPasteItem(child, newItem); 167 | }); 168 | } 169 | return newItem; 170 | } 171 | 172 | copyPasteItemAbove(from: TodoItemNode, to: TodoItemNode): TodoItemNode { 173 | const newItem = this.insertItemAbove(to, from.item); 174 | if (from.children) { 175 | from.children.forEach(child => { 176 | this.copyPasteItem(child, newItem); 177 | }); 178 | } 179 | return newItem; 180 | } 181 | 182 | copyPasteItemBelow(from: TodoItemNode, to: TodoItemNode): TodoItemNode { 183 | const newItem = this.insertItemBelow(to, from.item); 184 | if (from.children) { 185 | from.children.forEach(child => { 186 | this.copyPasteItem(child, newItem); 187 | }); 188 | } 189 | return newItem; 190 | } 191 | 192 | deleteNode(nodes: TodoItemNode[], nodeToDelete: TodoItemNode) { 193 | const index = nodes.indexOf(nodeToDelete, 0); 194 | if (index > -1) { 195 | nodes.splice(index, 1); 196 | } else { 197 | nodes.forEach(node => { 198 | if (node.children && node.children.length > 0) { 199 | this.deleteNode(node.children, nodeToDelete); 200 | } 201 | }); 202 | } 203 | } 204 | } 205 | 206 | @Component({ 207 | selector: 'app-root', 208 | templateUrl: './app.component.html', 209 | styleUrls: ['./app.component.css'], 210 | providers: [ChecklistDatabase] 211 | }) 212 | export class AppComponent { 213 | /** Map from flat node to nested node. This helps us finding the nested node to be modified */ 214 | flatNodeMap = new Map(); 215 | 216 | /** Map from nested node to flattened node. This helps us to keep the same object for selection */ 217 | nestedNodeMap = new Map(); 218 | 219 | /** A selected parent node to be inserted */ 220 | selectedParent: TodoItemFlatNode | null = null; 221 | 222 | /** The new item's name */ 223 | newItemName = ''; 224 | 225 | treeControl: FlatTreeControl; 226 | 227 | treeFlattener: MatTreeFlattener; 228 | 229 | dataSource: MatTreeFlatDataSource; 230 | 231 | /** The selection for checklist */ 232 | checklistSelection = new SelectionModel(true /* multiple */); 233 | 234 | /* Drag and drop */ 235 | dragNode: any; 236 | dragNodeExpandOverWaitTimeMs = 300; 237 | dragNodeExpandOverNode: any; 238 | dragNodeExpandOverTime: number; 239 | dragNodeExpandOverArea: string; 240 | @ViewChild('emptyItem') emptyItem: ElementRef; 241 | 242 | constructor(private database: ChecklistDatabase) { 243 | this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); 244 | this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); 245 | this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); 246 | 247 | database.dataChange.subscribe(data => { 248 | this.dataSource.data = []; 249 | this.dataSource.data = data; 250 | }); 251 | } 252 | 253 | getLevel = (node: TodoItemFlatNode) => node.level; 254 | 255 | isExpandable = (node: TodoItemFlatNode) => node.expandable; 256 | 257 | getChildren = (node: TodoItemNode): TodoItemNode[] => node.children; 258 | 259 | hasChild = (_: number, _nodeData: TodoItemFlatNode) => _nodeData.expandable; 260 | 261 | hasNoContent = (_: number, _nodeData: TodoItemFlatNode) => _nodeData.item === ''; 262 | 263 | /** 264 | * Transformer to convert nested node to flat node. Record the nodes in maps for later use. 265 | */ 266 | transformer = (node: TodoItemNode, level: number) => { 267 | const existingNode = this.nestedNodeMap.get(node); 268 | const flatNode = existingNode && existingNode.item === node.item 269 | ? existingNode 270 | : new TodoItemFlatNode(); 271 | flatNode.item = node.item; 272 | flatNode.level = level; 273 | flatNode.expandable = (node.children && node.children.length > 0); 274 | this.flatNodeMap.set(flatNode, node); 275 | this.nestedNodeMap.set(node, flatNode); 276 | return flatNode; 277 | } 278 | 279 | /** Whether all the descendants of the node are selected */ 280 | descendantsAllSelected(node: TodoItemFlatNode): boolean { 281 | const descendants = this.treeControl.getDescendants(node); 282 | return descendants.every(child => this.checklistSelection.isSelected(child)); 283 | } 284 | 285 | /** Whether part of the descendants are selected */ 286 | descendantsPartiallySelected(node: TodoItemFlatNode): boolean { 287 | const descendants = this.treeControl.getDescendants(node); 288 | const result = descendants.some(child => this.checklistSelection.isSelected(child)); 289 | return result && !this.descendantsAllSelected(node); 290 | } 291 | 292 | /** Toggle the to-do item selection. Select/deselect all the descendants node */ 293 | todoItemSelectionToggle(node: TodoItemFlatNode): void { 294 | this.checklistSelection.toggle(node); 295 | const descendants = this.treeControl.getDescendants(node); 296 | this.checklistSelection.isSelected(node) 297 | ? this.checklistSelection.select(...descendants) 298 | : this.checklistSelection.deselect(...descendants); 299 | } 300 | 301 | /** Select the category so we can insert the new item. */ 302 | addNewItem(node: TodoItemFlatNode) { 303 | const parentNode = this.flatNodeMap.get(node); 304 | this.database.insertItem(parentNode, ''); 305 | this.treeControl.expand(node); 306 | } 307 | 308 | /** Save the node to database */ 309 | saveNode(node: TodoItemFlatNode, itemValue: string) { 310 | const nestedNode = this.flatNodeMap.get(node); 311 | this.database.updateItem(nestedNode, itemValue); 312 | } 313 | 314 | handleDragStart(event, node) { 315 | // Required by Firefox (https://stackoverflow.com/questions/19055264/why-doesnt-html5-drag-and-drop-work-in-firefox) 316 | event.dataTransfer.setData('foo', 'bar'); 317 | event.dataTransfer.setDragImage(this.emptyItem.nativeElement, 0, 0); 318 | this.dragNode = node; 319 | this.treeControl.collapse(node); 320 | } 321 | 322 | handleDragOver(event, node) { 323 | event.preventDefault(); 324 | 325 | // Handle node expand 326 | if (node === this.dragNodeExpandOverNode) { 327 | if (this.dragNode !== node && !this.treeControl.isExpanded(node)) { 328 | if ((new Date().getTime() - this.dragNodeExpandOverTime) > this.dragNodeExpandOverWaitTimeMs) { 329 | this.treeControl.expand(node); 330 | } 331 | } 332 | } else { 333 | this.dragNodeExpandOverNode = node; 334 | this.dragNodeExpandOverTime = new Date().getTime(); 335 | } 336 | 337 | // Handle drag area 338 | const percentageX = event.offsetX / event.target.clientWidth; 339 | const percentageY = event.offsetY / event.target.clientHeight; 340 | if (percentageY < 0.25) { 341 | this.dragNodeExpandOverArea = 'above'; 342 | } else if (percentageY > 0.75) { 343 | this.dragNodeExpandOverArea = 'below'; 344 | } else { 345 | this.dragNodeExpandOverArea = 'center'; 346 | } 347 | } 348 | 349 | handleDrop(event, node) { 350 | event.preventDefault(); 351 | if (node !== this.dragNode) { 352 | let newItem: TodoItemNode; 353 | if (this.dragNodeExpandOverArea === 'above') { 354 | newItem = this.database.copyPasteItemAbove(this.flatNodeMap.get(this.dragNode), this.flatNodeMap.get(node)); 355 | } else if (this.dragNodeExpandOverArea === 'below') { 356 | newItem = this.database.copyPasteItemBelow(this.flatNodeMap.get(this.dragNode), this.flatNodeMap.get(node)); 357 | } else { 358 | newItem = this.database.copyPasteItem(this.flatNodeMap.get(this.dragNode), this.flatNodeMap.get(node)); 359 | } 360 | this.database.deleteItem(this.flatNodeMap.get(this.dragNode)); 361 | this.treeControl.expandDescendants(this.nestedNodeMap.get(newItem)); 362 | } 363 | this.dragNode = null; 364 | this.dragNodeExpandOverNode = null; 365 | this.dragNodeExpandOverTime = 0; 366 | } 367 | 368 | handleDragEnd(event) { 369 | this.dragNode = null; 370 | this.dragNodeExpandOverNode = null; 371 | this.dragNodeExpandOverTime = 0; 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 3 | import { NgModule } from '@angular/core'; 4 | 5 | import { AppComponent } from './app.component'; 6 | 7 | import { MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTreeModule } from '@angular/material'; 8 | 9 | @NgModule({ 10 | declarations: [ 11 | AppComponent 12 | ], 13 | imports: [ 14 | BrowserModule, 15 | BrowserAnimationsModule, 16 | 17 | MatButtonModule, 18 | MatCheckboxModule, 19 | MatFormFieldModule, 20 | MatIconModule, 21 | MatInputModule, 22 | MatTreeModule 23 | ], 24 | providers: [], 25 | bootstrap: [AppComponent] 26 | }) 27 | export class AppModule { } 28 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alerubis/angular-draggable-mat-tree/0789f570147a11455722c2ad6c3cb9f5cd4fbf67/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/browserslist: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # For IE 9-11 support, please uncomment the last line of the file and adjust as needed 5 | > 0.5% 6 | last 2 versions 7 | Firefox ESR 8 | not dead 9 | # IE 9-11 -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * In development mode, to ignore zone related error stack frames such as 11 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 12 | * import the following file, but please comment it out in production mode 13 | * because it will have performance impact when throw error 14 | */ 15 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 16 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alerubis/angular-draggable-mat-tree/0789f570147a11455722c2ad6c3cb9f5cd4fbf67/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | DraggableMatTree 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-phantomjs-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['PhantomJS'], 29 | singleRun: true 30 | }); 31 | }; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | import 'core-js/es6/symbol'; 23 | import 'core-js/es6/object'; 24 | import 'core-js/es6/function'; 25 | import 'core-js/es6/parse-int'; 26 | import 'core-js/es6/parse-float'; 27 | import 'core-js/es6/number'; 28 | import 'core-js/es6/math'; 29 | import 'core-js/es6/string'; 30 | import 'core-js/es6/date'; 31 | import 'core-js/es6/array'; 32 | import 'core-js/es6/regexp'; 33 | import 'core-js/es6/map'; 34 | import 'core-js/es6/weak-map'; 35 | import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 38 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 39 | 40 | /** IE10 and IE11 requires the following for the Reflect API. */ 41 | // import 'core-js/es6/reflect'; 42 | 43 | 44 | /** Evergreen browsers require these. **/ 45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 46 | import 'core-js/es7/reflect'; 47 | 48 | 49 | /** 50 | * Web Animations `@angular/platform-browser/animations` 51 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 52 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 53 | **/ 54 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 55 | 56 | /** 57 | * By default, zone.js will patch all possible macroTask and DomEvents 58 | * user can disable parts of macroTask/DomEvents patch by setting following flags 59 | */ 60 | 61 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 62 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 63 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 64 | 65 | /* 66 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 67 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 68 | */ 69 | // (window as any).__Zone_enable_cross_context_check = true; 70 | 71 | /*************************************************************************************************** 72 | * Zone JS is required by default for Angular itself. 73 | */ 74 | import 'zone.js/dist/zone'; // Included with Angular CLI. 75 | 76 | 77 | 78 | /*************************************************************************************************** 79 | * APPLICATION IMPORTS 80 | */ 81 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import "~@angular/material/prebuilt-themes/indigo-pink.css"; 3 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "es2015", 6 | "types": [] 7 | }, 8 | "exclude": [ 9 | "src/test.ts", 10 | "**/*.spec.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ], 10 | "target": "es5" 11 | }, 12 | "files": [ 13 | "test.ts", 14 | "polyfills.ts" 15 | ], 16 | "include": [ 17 | "**/*.spec.ts", 18 | "**/*.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es5", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2017", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-use-before-declare": true, 76 | "no-var-keyword": true, 77 | "object-literal-sort-keys": false, 78 | "one-line": [ 79 | true, 80 | "check-open-brace", 81 | "check-catch", 82 | "check-else", 83 | "check-whitespace" 84 | ], 85 | "prefer-const": true, 86 | "quotemark": [ 87 | true, 88 | "single" 89 | ], 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always" 94 | ], 95 | "triple-equals": [ 96 | true, 97 | "allow-null-check" 98 | ], 99 | "typedef-whitespace": [ 100 | true, 101 | { 102 | "call-signature": "nospace", 103 | "index-signature": "nospace", 104 | "parameter": "nospace", 105 | "property-declaration": "nospace", 106 | "variable-declaration": "nospace" 107 | } 108 | ], 109 | "unified-signatures": true, 110 | "variable-name": false, 111 | "whitespace": [ 112 | true, 113 | "check-branch", 114 | "check-decl", 115 | "check-operator", 116 | "check-separator", 117 | "check-type" 118 | ], 119 | "no-output-on-prefix": true, 120 | "use-input-property-decorator": true, 121 | "use-output-property-decorator": true, 122 | "use-host-property-decorator": true, 123 | "no-input-rename": true, 124 | "no-output-rename": true, 125 | "use-life-cycle-interface": true, 126 | "use-pipe-transform-interface": true, 127 | "component-class-suffix": true, 128 | "directive-class-suffix": true 129 | } 130 | } 131 | --------------------------------------------------------------------------------