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