1<!-- Copyright (C) 2017 The Android Open Source Project 2 3 Licensed under the Apache License, Version 2.0 (the "License"); 4 you may not use this file except in compliance with the License. 5 You may obtain a copy of the License at 6 7 http://www.apache.org/licenses/LICENSE-2.0 8 9 Unless required by applicable law or agreed to in writing, software 10 distributed under the License is distributed on an "AS IS" BASIS, 11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 See the License for the specific language governing permissions and 13 limitations under the License. 14--> 15<template> 16 <div class="tree-view" v-if="item"> 17 <div class="node" 18 :class="[{ 19 leaf: isLeaf, 20 selected: isSelected, 21 'child-selected': immediateChildSelected, 22 clickable: isClickable, 23 hover: nodeHover, 24 'child-hover': childHover, 25 }, diffClass]" 26 :style="nodeOffsetStyle" 27 @click="clicked" 28 @contextmenu.prevent="openContextMenu" 29 ref="node" 30 > 31 <button 32 class="toggle-tree-btn" 33 @click="toggleTree" 34 v-if="!isLeaf && !flattened" 35 v-on:click.stop 36 > 37 <i aria-hidden="true" class="md-icon md-theme-default material-icons"> 38 {{isCollapsed ? "chevron_right" : "expand_more"}} 39 </i> 40 </button> 41 <div class="leaf-node-icon-wrapper" v-else> 42 <i class="leaf-node-icon"/> 43 </div> 44 <div class="description"> 45 <div v-if="elementView"> 46 <component 47 :is="elementView" 48 :item="item" 49 :simplify-names="simplifyNames" 50 /> 51 </div> 52 <div v-else> 53 <DefaultTreeElement 54 :item="item" 55 :simplify-names="simplifyNames" 56 :errors="errors" 57 :transitions="transitions" 58 /> 59 </div> 60 </div> 61 <div v-show="isCollapsed"> 62 <button 63 class="expand-tree-btn" 64 :class="[{ 65 'child-selected': isCollapsed && childIsSelected 66 }, collapseDiffClass]" 67 v-if="children" 68 @click="expandTree" 69 v-on:click.stop 70 > 71 <i 72 aria-hidden="true" 73 class="md-icon md-theme-default material-icons" 74 > 75 more_horiz 76 </i> 77 </button> 78 </div> 79 </div> 80 81 <node-context-menu 82 ref="nodeContextMenu" 83 v-on:collapseAllOtherNodes="collapseAllOtherNodes" 84 /> 85 86 <div class="children" v-if="children" v-show="!isCollapsed" :style="childrenIndentation()"> 87 <tree-view 88 v-for="(c,i) in children" 89 :item="c" 90 @item-selected="childItemSelected" 91 :selected="selected" 92 :key="i" 93 :filter="childFilter(c)" 94 :flattened="flattened" 95 :onlyVisible="onlyVisible" 96 :simplify-names="simplifyNames" 97 :flickerTraceView="flickerTraceView" 98 :presentTags="currentTags" 99 :presentErrors="currentErrors" 100 :force-flattened="applyingFlattened" 101 v-show="filterMatches(c)" 102 :items-clickable="itemsClickable" 103 :initial-depth="depth + 1" 104 :collapse="collapseChildren" 105 :collapseChildren="collapseChildren" 106 :useGlobalCollapsedState="useGlobalCollapsedState" 107 v-on:hoverStart="childHover = true" 108 v-on:hoverEnd="childHover = false" 109 v-on:selected="immediateChildSelected = true" 110 v-on:unselected="immediateChildSelected = false" 111 :elementView="elementView" 112 v-on:collapseSibling="collapseSibling" 113 v-on:collapseAllOtherNodes="collapseAllOtherNodes" 114 v-on:closeAllContextMenus="closeAllContextMenus" 115 ref="children" 116 /> 117 </div> 118 </div> 119</template> 120 121<script> 122import DefaultTreeElement from './DefaultTreeElement.vue'; 123import NodeContextMenu from './NodeContextMenu.vue'; 124import {DiffType} from './utils/diff.js'; 125import {isPropertyMatch} from './utils/utils.js'; 126 127/* in px, must be kept in sync with css, maybe find a better solution... */ 128const levelOffset = 24; 129 130export default { 131 name: 'tree-view', 132 props: [ 133 'item', 134 'selected', 135 'filter', 136 'simplify-names', 137 'flattened', 138 'force-flattened', 139 'items-clickable', 140 'initial-depth', 141 'collapse', 142 'collapseChildren', 143 // Allows collapse state to be tracked by Vuex so that collapse state of 144 // items with same stableId can remain consisten accross time and easily 145 // toggled from anywhere in the app. 146 // Should be true if you are using the same TreeView to display multiple 147 // trees throughout the component's lifetime to make sure same nodes are 148 // toggled when switching back and forth between trees. 149 // If true, requires all nodes in tree to have a stableId. 150 'useGlobalCollapsedState', 151 // Custom view to use to render the elements in the tree view 152 'elementView', 153 'onlyVisible', 154 'flickerTraceView', 155 'presentTags', 156 'presentErrors', 157 ], 158 data() { 159 const isCollapsedByDefault = this.collapse ?? false; 160 161 return { 162 isChildSelected: false, 163 immediateChildSelected: false, 164 clickTimeout: null, 165 isCollapsedByDefault, 166 localCollapsedState: isCollapsedByDefault, 167 collapseDiffClass: null, 168 nodeHover: false, 169 childHover: false, 170 diffSymbol: { 171 [DiffType.NONE]: '', 172 [DiffType.ADDED]: '+', 173 [DiffType.DELETED]: '-', 174 [DiffType.MODIFIED]: '.', 175 [DiffType.MOVED]: '.', 176 }, 177 currentTags: [], 178 currentErrors: [], 179 transitions: [], 180 errors: [], 181 }; 182 }, 183 watch: { 184 stableId() { 185 // Update anything that is required to change when item changes. 186 this.updateCollapsedDiffClass(); 187 }, 188 hasDiff(hasDiff) { 189 if (!hasDiff) { 190 this.collapseDiffClass = null; 191 } else { 192 this.updateCollapsedDiffClass(); 193 } 194 }, 195 currentTimestamp() { 196 // Update anything that is required to change when time changes. 197 this.currentTags = this.getCurrentItems(this.presentTags); 198 this.currentErrors = this.getCurrentItems(this.presentErrors); 199 this.transitions = this.getCurrentTransitions(); 200 this.errors = this.getCurrentErrorTags(); 201 this.updateCollapsedDiffClass(); 202 }, 203 isSelected(isSelected) { 204 if (isSelected) { 205 this.$emit('selected'); 206 } else { 207 this.$emit('unselected'); 208 } 209 }, 210 }, 211 methods: { 212 setCollapseValue(isCollapsed) { 213 if (this.useGlobalCollapsedState) { 214 this.$store.commit('setCollapsedState', { 215 item: this.item, 216 isCollapsed, 217 }); 218 } else { 219 this.localCollapsedState = isCollapsed; 220 } 221 }, 222 toggleTree() { 223 this.setCollapseValue(!this.isCollapsed); 224 if (!this.isCollapsed) { 225 this.openedToSeeAttributeField(this.item.name) 226 } 227 }, 228 expandTree() { 229 this.setCollapseValue(false); 230 }, 231 selectNext(found, inCollapsedTree) { 232 // Check if this is the next visible item 233 if (found && this.filterMatches(this.item) && !inCollapsedTree) { 234 this.select(); 235 return false; 236 } 237 238 // Set traversal state variables 239 if (this.isSelected) { 240 found = true; 241 } 242 if (this.isCollapsed) { 243 inCollapsedTree = true; 244 } 245 246 // Travers children trees recursively in reverse to find currently 247 // selected item and select the next visible one 248 if (this.$refs.children) { 249 for (const c of this.$refs.children) { 250 found = c.selectNext(found, inCollapsedTree); 251 } 252 } 253 254 return found; 255 }, 256 selectPrev(found, inCollapsedTree) { 257 // Set inCollapseTree flag to make sure elements in collapsed trees are 258 // not selected. 259 const isRootCollapse = !inCollapsedTree && this.isCollapsed; 260 if (isRootCollapse) { 261 inCollapsedTree = true; 262 } 263 264 // Travers children trees recursively in reverse to find currently 265 // selected item and select the previous visible one 266 if (this.$refs.children) { 267 for (const c of [...this.$refs.children].reverse()) { 268 found = c.selectPrev(found, inCollapsedTree); 269 } 270 } 271 272 // Unset inCollapseTree flag as we are no longer in a collapsed tree. 273 if (isRootCollapse) { 274 inCollapsedTree = false; 275 } 276 277 // Check if this is the previous visible item 278 if (found && this.filterMatches(this.item) && !inCollapsedTree) { 279 this.select(); 280 return false; 281 } 282 283 // Set found flag so that the next visited visible item can be selected. 284 if (this.isSelected) { 285 found = true; 286 } 287 288 return found; 289 }, 290 childItemSelected(item) { 291 this.isChildSelected = true; 292 this.$emit('item-selected', item); 293 }, 294 select() { 295 this.$emit('item-selected', this.item); 296 }, 297 clicked(e) { 298 if (window.getSelection().type === 'range') { 299 // Ignore click if is selection 300 return; 301 } 302 303 if (!this.isLeaf && e.detail % 2 === 0) { 304 // Double click collapsable node 305 this.toggleTree(); 306 } else { 307 this.select(); 308 } 309 }, 310 filterMatches(c) { 311 // If a filter is set, consider the item matches if the current item or 312 // any of its children matches. 313 if (this.filter) { 314 const thisMatches = this.filter(c); 315 const childMatches = (child) => this.filterMatches(child); 316 return thisMatches || (!this.applyingFlattened && 317 c.children && c.children.some(childMatches)); 318 } 319 return true; 320 }, 321 childFilter(c) { 322 if (this.filter) { 323 if (this.filter(c)) { 324 // Filter matched c, don't apply further filtering on c's children. 325 return undefined; 326 } 327 } 328 return this.filter; 329 }, 330 isCurrentSelected() { 331 return this.selected === this.item; 332 }, 333 updateCollapsedDiffClass() { 334 // NOTE: Could be memoized in $store map like collapsed state if 335 // performance ever becomes a problem. 336 if (this.item) { 337 this.collapseDiffClass = this.computeCollapseDiffClass(); 338 } 339 }, 340 getAllDiffTypesOfChildren(item) { 341 if (!item.children) { 342 return new Set(); 343 } 344 345 const classes = new Set(); 346 for (const child of item.children) { 347 if (child.diff) { 348 classes.add(child.diff.type); 349 } 350 for (const diffClass of this.getAllDiffTypesOfChildren(child)) { 351 classes.add(diffClass); 352 } 353 } 354 355 return classes; 356 }, 357 computeCollapseDiffClass() { 358 if (!this.isCollapsed) { 359 return ''; 360 } 361 362 const childrenDiffClasses = this.getAllDiffTypesOfChildren(this.item); 363 364 childrenDiffClasses.delete(DiffType.NONE); 365 childrenDiffClasses.delete(undefined); 366 367 if (childrenDiffClasses.size === 0) { 368 return ''; 369 } 370 if (childrenDiffClasses.size === 1) { 371 const diff = childrenDiffClasses.values().next().value; 372 return diff; 373 } 374 375 return DiffType.MODIFIED; 376 }, 377 collapseAllOtherNodes() { 378 this.$emit('collapseAllOtherNodes'); 379 this.$emit('collapseSibling', this.item); 380 }, 381 collapseSibling(item) { 382 if (!this.$refs.children) { 383 return; 384 } 385 386 for (const child of this.$refs.children) { 387 if (child.item === item) { 388 continue; 389 } 390 391 child.collapseAll(); 392 } 393 }, 394 collapseAll() { 395 this.setCollapseValue(true); 396 397 if (!this.$refs.children) { 398 return; 399 } 400 401 for (const child of this.$refs.children) { 402 child.collapseAll(); 403 } 404 }, 405 openContextMenu(e) { 406 this.closeAllContextMenus(); 407 // vue-context takes in the event and uses clientX and clientY to 408 // determine the position of the context meny. 409 // This doesn't satisfy our use case so we specify our own positions for 410 // this. 411 this.$refs.nodeContextMenu.open({ 412 clientX: e.x, 413 clientY: e.y, 414 }); 415 }, 416 closeAllContextMenus(requestOrigin) { 417 this.$refs.nodeContextMenu.close(); 418 this.$emit('closeAllContextMenus', this.item); 419 this.closeAllChildrenContextMenus(requestOrigin); 420 }, 421 closeAllChildrenContextMenus(requestOrigin) { 422 if (!this.$refs.children) { 423 return; 424 } 425 426 for (const child of this.$refs.children) { 427 if (child.item === requestOrigin) { 428 continue; 429 } 430 431 child.$refs.nodeContextMenu.close(); 432 child.closeAllChildrenContextMenus(); 433 } 434 }, 435 childrenIndentation() { 436 if (this.flattened || this.forceFlattened) { 437 return { 438 marginLeft: '0px', 439 paddingLeft: '0px', 440 marginTop: '0px', 441 } 442 } else { 443 //Aligns border with collapse arrows 444 return { 445 marginLeft: '12px', 446 paddingLeft: '11px', 447 borderLeft: '1px solid rgb(238, 238, 238)', 448 marginTop: '0px', 449 } 450 } 451 }, 452 453 /** Performs check for id match between entry and present tags/errors 454 * exits once match has been found 455 */ 456 matchItems(flickerItems) { 457 var match = false; 458 flickerItems.every(flickerItem => { 459 if (isPropertyMatch(flickerItem, this.item)) { 460 match = true; 461 return false; 462 } 463 }); 464 return match; 465 }, 466 /** Returns check for id match between entry and present tags/errors */ 467 isEntryTagMatch() { 468 return this.matchItems(this.currentTags) || this.matchItems(this.currentErrors); 469 }, 470 471 getCurrentItems(items) { 472 if (!items) return []; 473 else return items.filter(item => item.timestamp===this.currentTimestamp); 474 }, 475 getCurrentTransitions() { 476 var transitions = []; 477 var ids = []; 478 this.currentTags.forEach(tag => { 479 if (!ids.includes(tag.id) && isPropertyMatch(tag, this.item)) { 480 transitions.push(tag.transition); 481 ids.push(tag.id); 482 } 483 }); 484 return transitions; 485 }, 486 getCurrentErrorTags() { 487 return this.currentErrors.filter(error => isPropertyMatch(error, this.item)); 488 }, 489 }, 490 computed: { 491 hasDiff() { 492 return this.item?.diff !== undefined; 493 }, 494 stableId() { 495 return this.item?.stableId; 496 }, 497 currentTimestamp() { 498 return this.$store.state.currentTimestamp; 499 }, 500 isCollapsed() { 501 if (!this.item.children || this.item.children?.length === 0) { 502 return false; 503 } 504 505 if (this.useGlobalCollapsedState) { 506 return this.$store.getters.collapsedStateStoreFor(this.item) ?? 507 this.isCollapsedByDefault; 508 } 509 510 return this.localCollapsedState; 511 }, 512 isSelected() { 513 return this.selected === this.item; 514 }, 515 childIsSelected() { 516 if (this.$refs.children) { 517 for (const c of this.$refs.children) { 518 if (c.isSelected || c.childIsSelected) { 519 return true; 520 } 521 } 522 } 523 524 return false; 525 }, 526 diffClass() { 527 return this.item.diff ? this.item.diff.type : ''; 528 }, 529 applyingFlattened() { 530 return (this.flattened && this.item.flattened) || this.forceFlattened; 531 }, 532 children() { 533 return this.applyingFlattened ? this.item.flattened : this.item.children; 534 }, 535 isLeaf() { 536 return !this.children || this.children.length === 0; 537 }, 538 isClickable() { 539 return !this.isLeaf || this.itemsClickable; 540 }, 541 depth() { 542 return this.initialDepth || 0; 543 }, 544 nodeOffsetStyle() { 545 const offset = levelOffset * (this.depth + this.isLeaf) + 'px'; 546 547 var display = ""; 548 if (!this.item.timestamp 549 && this.flattened 550 && (this.onlyVisible && !this.item.isVisible || 551 this.flickerTraceView && !this.isEntryTagMatch())) { 552 display = 'none'; 553 } 554 555 return { 556 marginLeft: '-' + offset, 557 paddingLeft: offset, 558 display: display, 559 }; 560 }, 561 }, 562 mounted() { 563 // Prevent highlighting on multiclick of node element 564 this.nodeMouseDownEventListner = (e) => { 565 if (e.detail > 1) { 566 e.preventDefault(); 567 return false; 568 } 569 570 return true; 571 }; 572 this.$refs.node?.addEventListener('mousedown', 573 this.nodeMouseDownEventListner); 574 575 this.updateCollapsedDiffClass(); 576 577 this.nodeMouseEnterEventListener = (e) => { 578 this.nodeHover = true; 579 this.$emit('hoverStart'); 580 }; 581 this.$refs.node?.addEventListener('mouseenter', 582 this.nodeMouseEnterEventListener); 583 584 this.nodeMouseLeaveEventListener = (e) => { 585 this.nodeHover = false; 586 this.$emit('hoverEnd'); 587 }; 588 this.$refs.node?.addEventListener('mouseleave', 589 this.nodeMouseLeaveEventListener); 590 }, 591 beforeDestroy() { 592 this.$refs.node?.removeEventListener('mousedown', 593 this.nodeMouseDownEventListner); 594 this.$refs.node?.removeEventListener('mouseenter', 595 this.nodeMouseEnterEventListener); 596 this.$refs.node?.removeEventListener('mouseleave', 597 this.nodeMouseLeaveEventListener); 598 }, 599 components: { 600 DefaultTreeElement, 601 NodeContextMenu, 602 }, 603}; 604</script> 605<style> 606.data-card > .tree-view { 607 border: none; 608} 609 610.tree-view { 611 display: block; 612} 613 614.tree-view .node { 615 display: flex; 616 padding: 2px; 617 align-items: flex-start; 618} 619 620.tree-view .node.clickable { 621 cursor: pointer; 622} 623 624.tree-view .node:hover:not(.selected) { 625 background: #f1f1f1; 626} 627 628.tree-view .node:not(.selected).added, 629.tree-view .node:not(.selected).addedMove, 630.tree-view .expand-tree-btn.added, 631.tree-view .expand-tree-btn.addedMove { 632 background: #03ff35; 633} 634 635.tree-view .node:not(.selected).deleted, 636.tree-view .node:not(.selected).deletedMove, 637.tree-view .expand-tree-btn.deleted, 638.tree-view .expand-tree-btn.deletedMove { 639 background: #ff6b6b; 640} 641 642.tree-view .node:not(.selected).modified, 643.tree-view .expand-tree-btn.modified { 644 background: cyan; 645} 646 647.tree-view .node.addedMove:after, 648.tree-view .node.deletedMove:after { 649 content: 'moved'; 650 margin: 0 5px; 651 background: #448aff; 652 border-radius: 5px; 653 padding: 3px; 654 color: white; 655} 656 657.tree-view .node.child-selected + .children { 658 border-left: 1px solid #b4b4b4; 659} 660 661.tree-view .node.selected + .children { 662 border-left: 1px solid rgb(200, 200, 200); 663} 664 665.tree-view .node.child-hover + .children { 666 border-left: 1px solid #b4b4b4; 667} 668 669.tree-view .node.hover + .children { 670 border-left: 1px solid rgb(200, 200, 200); 671} 672 673.kind { 674 color: #333; 675 font-weight: bold; 676} 677 678.selected { 679 background-color: #365179; 680 color: white; 681} 682 683.childSelected { 684 border-left: 1px solid rgb(233, 22, 22) 685} 686 687.selected .kind { 688 color: #e9e9e9; 689} 690 691.toggle-tree-btn, .expand-tree-btn { 692 background: none; 693 color: inherit; 694 border: none; 695 padding: 0; 696 font: inherit; 697 cursor: pointer; 698 outline: inherit; 699} 700 701.expand-tree-btn { 702 margin-left: 5px; 703} 704 705.expand-tree-btn.child-selected { 706 color: #3f51b5; 707} 708 709.description { 710 display: flex; 711 flex: 1 1 auto; 712} 713 714.description > div { 715 display: flex; 716 flex: 1 1 auto; 717} 718 719.leaf-node-icon-wrapper { 720 width: 24px; 721 height: 24px; 722 display: inline-flex; 723 align-content: center; 724 align-items: center; 725 justify-content: center; 726} 727 728.leaf-node-icon { 729 content: ""; 730 display: inline-block; 731 height: 5px; 732 width: 5px; 733 margin-top: -2px; 734 border-radius: 50%; 735 background-color: #9b9b9b; 736} 737 738</style> 739