1/* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16import { 17 ChangeDetectionStrategy, 18 Component, 19 ElementRef, 20 EventEmitter, 21 Inject, 22 Input, 23 Output, 24} from '@angular/core'; 25import {PersistentStore} from 'common/persistent_store'; 26import {TraceType} from 'trace/trace_type'; 27import {HierarchyTreeNode, UiTreeNode, UiTreeUtils} from 'viewers/common/ui_tree_utils'; 28import {nodeStyles, treeNodeDataViewStyles} from 'viewers/components/styles/node.styles'; 29 30@Component({ 31 selector: 'tree-view', 32 changeDetection: ChangeDetectionStrategy.OnPush, 33 template: ` 34 <tree-node 35 *ngIf="item && showNode(item)" 36 class="node" 37 [class.leaf]="isLeaf(this.item)" 38 [class.selected]="isHighlighted(item, highlightedItems)" 39 [class.clickable]="isClickable()" 40 [class.hover]="nodeHover" 41 [class.childHover]="childHover" 42 [isAlwaysCollapsed]="isAlwaysCollapsed" 43 [class]="diffClass(item)" 44 [style]="nodeOffsetStyle()" 45 [item]="item" 46 [flattened]="isFlattened" 47 [isLeaf]="isLeaf(this.item)" 48 [isCollapsed]="isAlwaysCollapsed ?? isCollapsed()" 49 [hasChildren]="hasChildren()" 50 [isPinned]="isPinned()" 51 (toggleTreeChange)="toggleTree()" 52 (click)="onNodeClick($event)" 53 (expandTreeChange)="expandTree()" 54 (pinNodeChange)="propagateNewPinnedItem($event)"></tree-node> 55 56 <div 57 *ngIf="hasChildren()" 58 class="children" 59 [class.flattened]="isFlattened" 60 [hidden]="!isCollapsed()"> 61 <tree-view 62 *ngFor="let child of children(); trackBy: childTrackById" 63 class="childrenTree" 64 [item]="child" 65 [store]="store" 66 [showNode]="showNode" 67 [isLeaf]="isLeaf" 68 [dependencies]="dependencies" 69 [isFlattened]="isFlattened" 70 [useGlobalCollapsedState]="useGlobalCollapsedState" 71 [initialDepth]="initialDepth + 1" 72 [highlightedItems]="highlightedItems" 73 [pinnedItems]="pinnedItems" 74 (highlightedItemChange)="propagateNewHighlightedItem($event)" 75 (pinnedItemChange)="propagateNewPinnedItem($event)" 76 (selectedTreeChange)="propagateNewSelectedTree($event)" 77 [itemsClickable]="itemsClickable" 78 (hoverStart)="childHover = true" 79 (hoverEnd)="childHover = false"></tree-view> 80 </div> 81 `, 82 styles: [nodeStyles, treeNodeDataViewStyles], 83}) 84export class TreeComponent { 85 diffClass = UiTreeUtils.diffClass; 86 isHighlighted = UiTreeUtils.isHighlighted; 87 88 // TODO (b/263779536): this array is passed down from viewers/presenters and is used to generate 89 // an identifier supposed to be unique for each viewer. Let's just use a proper identifier 90 // instead. Each viewer/presenter could pass down a random magic number, an UUID, ... 91 @Input() dependencies: TraceType[] = []; 92 93 @Input() item?: UiTreeNode; 94 @Input() store!: PersistentStore; 95 @Input() isFlattened? = false; 96 @Input() initialDepth = 0; 97 @Input() highlightedItems: string[] = []; 98 @Input() pinnedItems?: HierarchyTreeNode[] = []; 99 @Input() itemsClickable?: boolean; 100 @Input() useGlobalCollapsedState?: boolean; 101 @Input() isAlwaysCollapsed?: boolean; 102 @Input() showNode = (item: UiTreeNode) => true; 103 @Input() isLeaf = (item?: UiTreeNode) => { 104 return !item || !item.children || item.children.length === 0; 105 }; 106 107 @Output() highlightedItemChange = new EventEmitter<string>(); 108 @Output() selectedTreeChange = new EventEmitter<UiTreeNode>(); 109 @Output() pinnedItemChange = new EventEmitter<UiTreeNode>(); 110 @Output() hoverStart = new EventEmitter<void>(); 111 @Output() hoverEnd = new EventEmitter<void>(); 112 113 isCollapsedByDefault = true; 114 localCollapsedState = this.isCollapsedByDefault; 115 nodeHover = false; 116 childHover = false; 117 readonly levelOffset = 24; 118 nodeElement: HTMLElement; 119 120 childTrackById(index: number, child: UiTreeNode): string { 121 if (child.stableId !== undefined) { 122 return child.stableId; 123 } 124 if (!(child instanceof HierarchyTreeNode) && typeof child.propertyKey === 'string') { 125 return child.propertyKey; 126 } 127 128 throw Error('Missing stable id or property key on node'); 129 } 130 131 constructor(@Inject(ElementRef) public elementRef: ElementRef) { 132 this.nodeElement = elementRef.nativeElement.querySelector('.node'); 133 this.nodeElement?.addEventListener('mousedown', this.nodeMouseDownEventListener); 134 this.nodeElement?.addEventListener('mouseenter', this.nodeMouseEnterEventListener); 135 this.nodeElement?.addEventListener('mouseleave', this.nodeMouseLeaveEventListener); 136 } 137 138 ngOnInit() { 139 if (this.isCollapsedByDefault) { 140 this.setCollapseValue(this.isCollapsedByDefault); 141 } 142 } 143 144 ngOnChanges() { 145 if ( 146 this.item instanceof HierarchyTreeNode && 147 UiTreeUtils.isHighlighted(this.item, this.highlightedItems) 148 ) { 149 this.selectedTreeChange.emit(this.item); 150 } 151 } 152 153 ngOnDestroy() { 154 this.nodeElement?.removeEventListener('mousedown', this.nodeMouseDownEventListener); 155 this.nodeElement?.removeEventListener('mouseenter', this.nodeMouseEnterEventListener); 156 this.nodeElement?.removeEventListener('mouseleave', this.nodeMouseLeaveEventListener); 157 } 158 159 onNodeClick(event: MouseEvent) { 160 event.preventDefault(); 161 if (window.getSelection()?.type === 'range') { 162 return; 163 } 164 165 const isDoubleClick = event.detail % 2 === 0; 166 if (!this.isLeaf(this.item) && isDoubleClick) { 167 event.preventDefault(); 168 this.toggleTree(); 169 } else { 170 this.updateHighlightedItems(); 171 } 172 } 173 174 nodeOffsetStyle() { 175 const offset = this.levelOffset * this.initialDepth + 'px'; 176 177 return { 178 marginLeft: '-' + offset, 179 paddingLeft: offset, 180 }; 181 } 182 183 private updateHighlightedItems() { 184 if (this.item?.stableId) { 185 this.highlightedItemChange.emit(`${this.item.stableId}`); 186 } 187 } 188 189 isPinned() { 190 if (this.item instanceof HierarchyTreeNode) { 191 return this.pinnedItems?.map((item) => `${item.stableId}`).includes(`${this.item.stableId}`); 192 } 193 return false; 194 } 195 196 propagateNewHighlightedItem(newId: string) { 197 this.highlightedItemChange.emit(newId); 198 } 199 200 propagateNewPinnedItem(newPinnedItem: UiTreeNode) { 201 this.pinnedItemChange.emit(newPinnedItem); 202 } 203 204 propagateNewSelectedTree(newTree: UiTreeNode) { 205 this.selectedTreeChange.emit(newTree); 206 } 207 208 isClickable() { 209 return !this.isLeaf(this.item) || this.itemsClickable; 210 } 211 212 toggleTree() { 213 this.setCollapseValue(!this.isCollapsed()); 214 } 215 216 expandTree() { 217 this.setCollapseValue(true); 218 } 219 220 isCollapsed() { 221 if (this.isAlwaysCollapsed || this.isLeaf(this.item)) { 222 return true; 223 } 224 225 if (this.useGlobalCollapsedState) { 226 return ( 227 this.store.get(`collapsedState.item.${this.dependencies}.${this.item?.stableId}`) === 228 'true' ?? this.isCollapsedByDefault 229 ); 230 } 231 return this.localCollapsedState; 232 } 233 234 children(): UiTreeNode[] { 235 return this.item?.children ?? []; 236 } 237 238 hasChildren() { 239 if (!this.item) { 240 return false; 241 } 242 const isParentEntryInFlatView = 243 UiTreeUtils.isParentNode(this.item.kind ?? '') && this.isFlattened; 244 return (!this.isFlattened || isParentEntryInFlatView) && !this.isLeaf(this.item); 245 } 246 247 private setCollapseValue(isCollapsed: boolean) { 248 if (this.useGlobalCollapsedState) { 249 this.store.add( 250 `collapsedState.item.${this.dependencies}.${this.item?.stableId}`, 251 `${isCollapsed}` 252 ); 253 } else { 254 this.localCollapsedState = isCollapsed; 255 } 256 } 257 258 private nodeMouseDownEventListener = (event: MouseEvent) => { 259 if (event.detail > 1) { 260 event.preventDefault(); 261 return false; 262 } 263 return true; 264 }; 265 266 private nodeMouseEnterEventListener = () => { 267 this.nodeHover = true; 268 this.hoverStart.emit(); 269 }; 270 271 private nodeMouseLeaveEventListener = () => { 272 this.nodeHover = false; 273 this.hoverEnd.emit(); 274 }; 275} 276