작업 노트

Vue Tree Custom 만들어보기 본문

Vue 프로젝트 진행하기

Vue Tree Custom 만들어보기

달빛가면 2020. 10. 14. 16:09

오늘은 Vue Tree를 만들어 보도록 하겠습다.

컴포넌트로 만들어서 어디서든 가저다가 사용 가능 하도록 구성하였습니다.

트리 구성할때 구글링으로 여러곳을 찾아서 해보았지만..... npm으로 설치를 하여 세세한 동작들을 마음대로 하지 못해서

답답 했습니다.

일단 제가 CSS에 대한 지식이 부족하므로 디자인적인 것은 .... 다중에 퍼블님들에게 요청하시고

그럼 시작 하겠습니다.

 

구성으로는 

TreeView.vue > msf-tree.vue > msf-tree-item.vue로 구성 하였습니다.

다른 의미는 없고 각각의 역활을 맡도록 하기 위해 구성 했습니다.

<template>
  <div class="mu-container">
    <section>
      <!-- mu-left-content -->
      <div class="mu-left-content">
        <div class="mu-search-box">
          <label>파일명</label>
          <input type="text" class="mu-input" placeholder="search..." v-model="searchFileName">
          <button type="button" class="mu-btn mu-btn-icon" @click="searchTreeList"><i class="mu-icon-img icon-search"></i><span>조회</span></button>
        </div>
        <!-- tree-wrap -->
        <div class="mu-tree-wrap">
          <msf-tree :source="contentTree"
            :activeItem="activeItemObj"
            :selectedList="selectedItemList"
            id-field="directoryName"
            label-field="directoryName"
            ref="tree"
            @itemClick="treeItemClick"
            style="width:100%; height:100%;font-size: 12pt;"
          ></msf-tree>
        </div>
      </div>
    </section>
  </div>
</template>

<script>
import MsfTree from '@/common/component/tree/msf-tree'

export default {
  name: 'contentmetainput',
  components: {MsfTree},
  data () {
    return {
      contentTree: [{
        groupId: 0,
        directoryName: '1뎁스',
        children: [
          {groupId: 1, groupName: '부서1'},
          {groupId: 2,
            directoryName: '부서2',
            children: [
              {groupId: 5, directoryName: '2뎁스1'},
              {groupId: 6, directoryName: '3뎁스2'},
              {groupId: 7, directoryName: '3뎁스3'}
            ]},
          {groupId: 3, directoryName: '부서3'},
          {groupId: 4, directoryName: '부서4'}
        ]
      }],
      activeItemObj: {}, // 활성화 시킬 객체
      selectedItemList: [] // 선택시킬 객체
    }
  },
  .
  .
  .
  .
</script>

일단 TreeView.vue입니다.

data에 contentTree에 그림과 같이 구성이 되어야 합니다. 중요하게 생각하셔야 할 것은 contentTree

data에 directoryName 와 children, 그리고 children을 object형식으로 만들어 주셔야 합니다.

안그러면 ㅡ.ㅡ 그냥 소스 잘 보시고 수정하세요 전 저런 식으로 데이터가 넘어와서 저렇게 만들었으니까요 ^^

 

<template>
  <div class="tree-wrap">
    <VirtualList ref="virtualList" :size="size" :remain="remain" class="tree-list">
      <msf-tree-item v-on:expand="expand" v-on:checkClick="checkclick" v-on:itemClick="itemClick" v-on:itemDblClick="itemDblClick" v-for="item of list" :key="item.key" :data="item" :checked="item.checked"
                     :expanded="item.expanded" :depth="item.depth" :label="item.labelValue" :half-checked="item.halfChecked" :active="item.active"/>
    </VirtualList>
  </div>
</template>

<script>
import MsfTreeItem from './msf-tree-item.vue'
import VirtualList from 'vue-virtual-scroll-list'

export default {
  name: 'msf-tree',
  components: {MsfTreeItem, VirtualList},
  props: {
    source: Array,
    filterFunction: Function,
    labelField: String,
    idField: String,
    selectedList: Array,
    activeItem: Object,
    expandDepth: {
      default: 1,
      type: Number
    }
  },
  data () {
    return {
      items: [], // 전체 데이터
      filteredItems: [],
      list: [], // 닫쳐있는것 제외 하고 필터링 된것 제외한 데이터
      size: 10,
      remain: 84,
      keyField: 'id',
      activeList: []
    }
  },
  watch: {
    source: function (value) {
      this.setSource(value)
    },
    selectedList: function (value) {
      if (!value || value.length < 1) {
        this.clearCheck('checked')
        this.refresh()
        return
      }

      for (let i = 0; i < this.items.length; i++) {
        let obj = this.items[i]
        for (let j = 0; j < value.length; j++) {
          this.setCheckedData(obj, 'checked')
        }
      }
      this.refresh()
    },
    activeItem: function (value) {
      this.activeList = [value]
      if (!this.activeList || this.activeList.length < 1) {
        this.clearCheck('active')
        this.refresh()
        return
      }

      for (let i = 0; i < this.items.length; i++) {
        let obj = this.items[i]
        for (let j = 0; j < this.activeList.length; j++) {
          this.setCheckedData(obj, 'active')
        }
      }
      this.refresh()
    }
  },
  methods: {
    addItem: function (items, obj, depth, parent) {
      obj.depth = depth
      obj.expanded = false
      let expandAll = this.expandDepth === 0 && obj.hasOwnProperty('children') && obj.children && obj.children.length > 0
      if (expandAll || depth < this.expandDepth) {
        obj.expanded = true
      }
      if (this.idField) { obj.id = obj[this.idField] }
      obj.key = Math.random()
      obj.visible = true
      // obj.checked = false
      this.setCheckedData(obj, 'checked')
      this.setCheckedData(obj, 'active')

      obj.parent = parent
      obj.labelValue = obj[this.labelField]
      obj.childAllCheck = false
      items.push(obj)
      if (obj.hasOwnProperty('children') && obj.children && obj.children.length > 0) {
        for (var i = 0; i < obj.children.length; i++) {
          this.addItem(items, obj.children[i], depth + 1, obj)
        }
      }
    },
    setCheckedData: function (item, field) {
      let value = field === 'checked' ? this.selectedList : this.activeList
      item[field] = false

      if (!value || value.length < 1) { return }

      for (let j = 0; j < value.length; j++) {
        if (value[j] && value[j].id === item.id) {
          item[field] = true
          if (field === 'active') {
            item.expanded = true
          }
          this.parentExpand(item)
        }
      }
    },
    parentExpand (item) {
      item.expanded = true
      this.expand(item)
      if (item.parent) {
        this.parentExpand(item.parent)
      }
    },
    getItem: function (value) {
      for (let i = 0; i < this.items.length; i++) {
        if (this.items[i][this.keyField] + '' === value + '') {
          return this.items[i]
        }
      }
      return null
    },
    hasChild: function (data) {
      return data && data.hasOwnProperty('children') && data.children && data.children.length > 0
    },
    treeFilter: function (item) {
      var returnValue = false
      if (item.parent) {
        returnValue = item.visible && this.isExpanded(item.parent)
      } else {
        returnValue = item.visible
      }
      if (this.filterFunction) {
        returnValue = returnValue && this.isFilteredData(item)
      }
      return returnValue
    },
    childVisibleChange (item, visible) {
      if (this.hasChild(item)) {
        let child = item.children
        for (var i = 0; i < child.length; i++) {
          child[i].visible = visible
          this.childVisibleChange(child[i], visible)
        }
      }
    },
    isFilteredData: function (item) {
      if (!item) { return false }
      if (this.hasChild(item)) {
        let children = item.children
        let returnVal = item.isFiltered
        for (var i = 0; i < children.length; i++) {
          let child = children[i]
          returnVal = returnVal || this.isFilteredData(child)
        }
        return returnVal
      }
      return item.isFiltered
    },
    expand: function (item) {
      this.childVisibleChange(item, item.expanded)
      this.refresh()
    },
    isExpanded: function (item) {
      if (item.parent) {
        return this.isExpanded(item.parent) && item.expanded
      } else {
        return true
      }
    },
    checkclick: function (item) {
      let idx = this.filteredItems.indexOf(item)
      let bool = item.checked
      for (let i = idx + 1; i < this.filteredItems.length; i++) {
        let obj = this.filteredItems[i]
        if (obj.depth <= item.depth) { break }
        obj.checked = bool
      }
      // 절반체크 확인
      for (let i = this.items.length - 1; i >= 0; i--) {
        let obj = this.items[i]
        if (!this.hasChild(obj)) {
          obj.halfChecked = false
          continue
        } else { // 자식이 있다
          this.setCheckType(obj)
        }
      }
      this.refresh()
    },
    clearCheck: function () {
      for (let i = 0; i < this.items.length; i++) {
        let item = this.items[i]
        item.checked = false
      }
    },
    itemClick: function (item) { // 아이템 클릭 이벤트 버블링
      item.expanded = true
      if (item.children && item.children.length > 0) { this.expand(item) }
      this.$emit('itemClick', item)
    },
    itemDblClick: function (item) { // 아이템 클릭 이벤트 버블링
      this.$emit('itemDblClick', item)
    },
    setCheckType: function (item) {
      let idx = this.items.indexOf(item)
      let isAllChecked = true
      let isAllUnChecked = true

      for (let i = idx + 1; i < this.items.length; i++) {
        let obj = this.items[i]
        if (obj.depth <= item.depth) { break }

        if (this.hasChild(obj)) { // 자식이 있는건 그룹이라 패스~
          continue
        }

        isAllChecked = isAllChecked && obj.checked
        isAllUnChecked = isAllUnChecked && !obj.checked
      }

      if (isAllChecked) {
        item.halfChecked = false
        item.checked = true
      } else if (isAllUnChecked) {
        item.halfChecked = false
        item.checked = false
      } else {
        item.halfChecked = true
        item.checked = false
      }
    },
    refresh: function () {
      if (this.filterFunction) { // 필터 내용이 있으면 확인
        var ar = []
        for (let i = 0; i < this.items.length; i++) {
          let item = this.items[i]
          item.isFiltered = false // 필터 되지 않은 내용
          if (this.hasChild(item)) { // 그룹은 신경쓰지 않는다.
            if (this.filterFunction(item)) {
              item.isFiltered = true
            }
            ar.push(item)
          } else {
            if (this.filterFunction(item)) {
              item.isFiltered = true // 필터링된 내용   필터링된 내용을가지고 있으면 해당 부모는 다 보여야 한다.
              ar.push(item)
            }
          }
        }
        this.filteredItems = ar
      } else {
        this.filteredItems = this.items
      }
      this.list = this.filteredItems.filter(this.treeFilter)
      if (this.list && this.list.length > 0) {
        this.list[0].key = Math.random()
      }

      let self = this
      if (this.$refs.virtualList) {
        setTimeout(function () {
          if (self.$refs.virtualList) {
            self.$refs.virtualList.forceRender()
          }
        }, 50)
      }
    },
    getCheckedData: function () {
      var ar = []
      for (let i = 0; i < this.items.length; i++) {
        let obj = this.items[i]
        if (this.hasChild(obj)) { // 자식이 있는건 그룹이라 패스~
          continue
        }
        if (obj.checked) { ar.push(obj) }
      }
      return ar
    },
    setCheck: function (ar, key) {
      for (let i = 0; i < this.items.length; i++) {
        let obj = this.items[i]

        if (this.hasChild(obj)) { // 자식이 있는건 그룹이라 패스~
          continue
        }
        for (let j = 0; j < ar.length; j++) {
          if (obj[key] === ar[j]) {
            obj.checked = true
            this.checkclick(obj)
            break
          }
        }
      }
      this.refresh()
    },
    setSource: function (sampleData) {
      // 계층 구조로 들어온 목록을 2차원으로 변형 하면서 필요한 프로퍼티를 입력  datas 는 하이라키 구조의 데이터
      let ar = []
      if (!sampleData) { sampleData = [] }
      for (let i = 0; i < sampleData.length; i++) {
        var obj = sampleData[i]
        this.addItem(ar, obj, 0, null)
      }
      this.items = ar
      this.refresh()
    },
    calculateHeight () {
      let parentHeight = this.$el.parentElement.offsetHeight
      if (parentHeight == null || parentHeight === 0) {
        parentHeight = 300
      }
      this.remain = parentHeight / this.size + 20
    }
  },
  created () {
    this.setSource(this.source)
  },
  mounted () {
    this.calculateHeight()
  },
  updated () {
    this.calculateHeight()
  }
}
</script>

msf-tree.vue

<template>
  <div class="treeitem" :class="'level-' + (data.depth + 1)">
    <!--<div :style="{ width: data.depth * 20 + 'px'}"></div>-->
    <div class="expanded" :style="{cursor : hasChild(data) ? 'pointer' : ''}"
         :class="hasChild(data) ? expanded ? 'open' : 'closed' : 'empty'" v-on:click="onClick">
    </div>
    <div class="item" :class="[isActive, isSelected, isOvered]" v-on:click="itemClick()" v-on:mouseover="treeItemOver()"
         v-on:mouseout="treeItemOut()" v-on:dblclick="itemDoubleClick()"><i class="ico-item" :class="getIcon()"></i>
      <div> {{ label }}</div>
    </div>
    <div><input type="checkbox" class="checkbox"/></div>
  </div>
</template>

<script>
export default {
  name: 'msf-tree-item',
  props: {
    data: Object,
    checked: Boolean,
    active: Boolean,
    expanded: Boolean,
    depth: Number,
    label: String,
    halfChecked: Boolean
  },
  data () {
    return {
      itemOvered: false
    }
  },
  methods: {
    treeItemOver: function () {
      this.itemOvered = true
    },
    treeItemOut: function () {
      this.itemOvered = false
    },
    getIcon: function () {
      let data = this.data
      var iconClass = []
      iconClass[0] = 'sk'
      iconClass[1] = 'data-center'
      iconClass[2] = 'floor'
      iconClass[3] = 'room'
      iconClass[4] = 'rack'
      iconClass[5] = 'bm-server'
      iconClass[6] = 'v-server' // vm
      iconClass[7] = 'switch'
      iconClass[8] = 'storage'
      iconClass[10] = 'enclouser'
      iconClass[11] = 'blade-server'
      iconClass[12] = 'ups'
      iconClass[13] = 'pdu'
      iconClass[14] = 'air'
      iconClass[15] = 'etc'
      iconClass[16] = 'etc'

      if (data && data.hasOwnProperty('type')) {
        if (data.type === 'USER') { return 'i-sk-user' } else if (data.id + '' === '51' && data.userGroupName === this.$t('COMMON.UNDESIGNATED')) {
          return 'i-non-group'
        } else if (data.type === 'USERGROUP') {
          if (data.id === 0 || data.id === '0') {
            return 'i-sk'
          }
          return 'i-user-group'
        }
      }

      // i-non-node
      // console.log(data.resourceName, data.resourceTypeId)
      if (data && data.hasOwnProperty('resourceTypeId') && data.resourceTypeId) {
        let icoName = iconClass[data.resourceTypeId]
        return 'i-' + icoName
      }
      // resourceId === 52 이 미지정
      let noAssignedFlag = data.resourceName === this.$t('COMMON.UNDESIGNATED') && (data.resourceId === '1' || data.resourceId === '52')
      return data.resourceId === 0 || data.resourceId === '0' ? 'i-sk' : noAssignedFlag ? 'i-non-group' : 'i-user-group'
    },
    hasChild: function (data) {
      return data && data.hasOwnProperty('children') && data.children && data.children.length > 0
    },
    itemClick: function () {
      this.$emit('itemClick', this.data)
    },
    checkClick: function () {
      let bool = !this.data.checked
      this.data.checked = bool
      this.$emit('checkclick', this.data)
    },
    onClick: function () {
      let item = this.data
      if (!this.hasChild(item)) {
        return
      }
      item.expanded = !item.expanded
      this.$emit('expand', item)
    },
    itemDoubleClick: function () {
      this.$emit('itemDblClick', this.data)
    }
  },
  watch: {
    checked: function (newVal, oldVal) { // watch it
    },
    selected: function (newVal, oldVal) { // watch it
    }
  },
  computed: {
    isChecked: function () {
      if (this.checked) {
        return 'checked'
      } else {
        if (this.halfChecked) {
          return 'halfchecked'
        } else {
          return ''
        }
      }
    },
    isActive: function () {
      if (this.active) {
        return 'active'
      }
      return ''
    },
    isSelected: function () {
      if (this.checked) {
        return 'checked'
      }
      return ''
    },
    isOvered () {
      return this.itemOvered ? 'overed' : ''
    }
  }
}
</script>
<style>
  .treeitem {
    width: 100%;
  }

  .treeitem div {
    display: inline-block;
  }

  .treeitem .expanded {
    width: 20px;
  }

  .treeitem .checkbox {
    width: 17px;
    height: 17px;
    border: 1px solid #CCCCCC
  }

  .treeitem .empty {
    width: 20px;
  }

  .checked {
    background-color: #00FF00
  }

  .halfchecked {
    background-color: #FFFF00
  }

  .treeitem .item {
    cursor: pointer;
    height: 20px;
  }
</style>

msf-tree-item.vue

/* icon 좌우 반전 */
.x-rotate {-webkit-transform:scaleX(-1); transform:scaleX(-1);}

/* icon 사용중,정상 / 사용정지,계정만료,오류 / 진행중 */
.ico-use {width:12px; height:12px; margin:0 3px; background:#00b40b; vertical-align:-2px !important;}
.ico-not-use {width:12px; height:12px; margin:0 3px; background:#ed1941; vertical-align:-2px !important;}
.ico-ing {width:12px; height:12px; margin:0 3px; background:#2583bd; vertical-align:-2px !important;}

/* icon serverity critical, major, minor, normal */
.ico-critical {width:12px; height:12px; margin:0 3px; border-radius:50%; border:1px solid rgba(0,0,0,.15); background:#ed1941;vertical-align:-2px !important;}
.ico-major {width:12px; height:12px; margin:0 3px; border-radius:50%; border:1px solid rgba(0,0,0,.15); background:#f3950f; vertical-align:-2px !important;}
.ico-minor {width:12px; height:12px; margin:0 3px; border-radius:50%; border:1px solid rgba(0,0,0,.15); background:#ffe400; vertical-align:-2px !important;}
.ico-normal {width:12px; height:12px; margin:0 3px; border-radius:50%; border:1px solid rgba(0,0,0,.15); background:#00b40b; vertical-align:-2px !important;}

/* icon + text serverity critical, major, minor, normal */
span[class^="count-"] {display:block; height:25px; line-height:23px; padding:0 3px; border-radius:2px; font-weight:700; text-align:center;}
.count-critical {border:1px solid rgba(0,0,0,.15); background:#ed1941; color:#fff;}
.count-major {border:1px solid rgba(0,0,0,.15); background:#f3950f; color:#fff;}
.count-minor {border:1px solid rgba(0,0,0,.15); background:#ffe400; color:#333;}
.count-normal {border:1px solid rgba(0,0,0,.15); background:#00b40b; color:#fff;}
.inner-right span[class^="count-"]{display:inline-block;}

/* 알람 아이콘 등급별 그룹핑 1/2/3개일때 critical, major, minor */
.level-group {display:inline-table; border-collapse:separate; width:30px; height:15px; background:transparent; vertical-align:middle;}
.level-group > i[class^="ico-"] {display:table-cell; min-width:10px; border-radius:2px;}
.level-group > i[class^="ico-"]:not(:first-child):not(:last-child) {border-radius:0; border-left:none; border-right:none;}
.level-group > i[class^="ico-"]:first-child:not(:last-child) {border-top-right-radius:0; border-bottom-right-radius:0; border-right:none;}
.level-group > i[class^="ico-"]:last-child:not(:first-child) {border-top-left-radius:0; border-bottom-left-radius:0; border-left:none;}

/* Vertical 그룹핑 1/2/3/4개일때 critical, major, minor, normal (장비기준 등급별) */
.level-v-group {display:table; border-collapse:separate; border-spacing:0 3px; width:100%; height:100%; min-height:169px; padding:0 10px;}
.level-v-group > li {display:table-row;}
.level-v-group > li > span[class^="count-"] {display:table-cell; width:100%; height:auto; line-height:normal; padding:0; border:1px solid rgba(0,0,0,.15); color:#fff; font-size:14px; text-align:center; vertical-align:middle;}
.level-v-group[class*="bg-"] {cursor:pointer;}/* 분류(알람 발생 시 알람 발생 영역) */
span[class^="count-"]:only-of-type {margin:0 2px; border-color:#333;}/* 분류별 상세 */
.level-v-group > li > .count-minor,
.level-v-group > li > .count-none {color:#333 !important;}

/* Horizontal 그룹핑 1/2/3개일때 critical, major, minor (알람기준 등급별) */
.level-h-group {display:table; border-collapse:separate; width:100%; height:100%;}
.level-h-group > li {display:table-cell; vertical-align:middle; border-left:1px solid #bfc3c7;}
.level-h-group > li:first-child {border-left:none;}
.level-h-group > li >* {display:block; font-weight:700; text-align:center; white-space:nowrap;}
.level-h-group > li > [class^="count-"] {width:100%; border:none; background:transparent; font-size:19px;}
.level-h-group > li > .count-critical {color:#ed1941;}
.level-h-group > li > .count-major {color:#f3950f;}
.level-h-group > li > .count-minor {color:#efd600;}
.level-h-group.disabled > li >* {color:#333; opacity:.5;}

/* treegrid icon */
i[class*="ico-item"] {width:18px; height:18px; background-image:url(../images/common/bg_ico_tree_rack.png);}
.ico-item.i-data-center {background-position:0 0;}/* data center */
.ico-item.i-non-rack-node {background-position:-18px 0;}/* 미지정 rack/node */
.ico-item.i-room {background-position:-36px 0;}/* room */
.ico-item.i-non-rack {background-position:-54px 0;}/* 미지정 rack */
.ico-item.i-non-node {background-position:-72px 0;}/* 미지정 node */
.ico-item.i-rack {background-position:-90px 0;}/* rack */
.ico-item.i-bm-server {background-position:-108px 0;}/* bm server */
.ico-item.i-blade-server {background-position:-126px 0;}/* blade server */
.ico-item.i-switch {background-position:-144px 0;}/* switch */
.ico-item.i-storage {background-position:-162px 0;}/* storage */
.ico-item.i-enclouser {background-position:-180px 0;}/* enclouser */
.ico-item.i-etc {background-position:-198px 0;}/* etc */
.ico-item.i-pdu {background-position:-216px 0;}/* 분전반 */
.ico-item.i-ups {background-position:-234px 0;}/* UPS */
.ico-item.i-air {background-position:-252px 0;}/* 항온항습기 */
.ico-item.i-use-room {background-position:-270px 0;}/* 사용면적 */
.ico-item.i-reserve-room {background-position:-288px 0;}/* 예약면적 */
.ico-item.i-disabled {background-position:-306px 0;}/* 사용불가 */
.ico-item.i-bm-blade {background-position:-324px 0;}/* BM/Blade Server */
.ico-item.i-floor {background-position:-342px 0;}/* floor */
.ico-item.i-sk {background-position:-360px 0;}/* SK */
.ico-item.i-category {background-position:-378px 0;}/* category */
.ico-item.i-alarm {background-position:-396px 0;}/* alarm */
.ico-item.i-service {background-position:-414px 0;}/* service */
.ico-item.i-user-group {background-position:-432px 0;}/* 논리계층 사용자용 */
.ico-item.i-non-group {background-position:-468px 0;}/* 미지정 그룹 */
.ico-item.i-v-server {background-position:-486px 0;}/* 가상 server */
.ico-item.i-sk-user {background-position:-504px 0;}/* user */
.item.active > .i-sk {background-position:-450px 0;}/* SK active */


/* treeitem */
.tree-wrap .tree-list {overflow-y:auto; height:100% !important; padding:5px; text-align:left;}
.treeitem {padding:0; font-size:0; white-space:nowrap;}
.treeitem div {display:inline-block; vertical-align:middle; font-size:12px; cursor:pointer;}
.treeitem .item:hover {border-radius:3px; background-color:#e9ecff; color:#333;}
.treeitem .item.active {border-radius:3px; background-color:#a9b7ff; color:#333;}/* 현재 경로 표시 */
.treeitem .item.checked {border-radius:3px; background-color:#465fce; color:#fff; font-weight:700;}/* 선택된 최하위 단계 표시 */
.treeitem .item.selected {opacity:.6;}
.treeitem .item i {margin:1px;}
.treeitem .item div {line-height:20px; padding:0 3px; white-space:pre;}
.treeitem .item + span[class^="txt-"] {margin:0 5px 0 15px;}
.treeitem .expanded,
.treeitem .empty {width:20px; height:20px;}
.treeitem .expanded.open {background:url(../images/common/node-opened.png) no-repeat 50% 50%;}
.treeitem .expanded.closed {background:url(../images/common/node-closed.png) no-repeat 50% 50%;}
.treeitem[class*="level-"] {}
.treeitem.level-1 {padding-left:0;}
.treeitem.level-2 {padding-left:20px;}
.treeitem.level-3 {padding-left:40px;}
.treeitem.level-4 {padding-left:60px;}
.treeitem.level-5 {padding-left:80px;}
.treeitem.level-6 {padding-left:100px;}
.treeitem.level-7 {padding-left:120px;}

/* Animation effect */
.ani-ripple {animation:ani-ripple 1s ease-out;}
.ani-spin {animation:ani-spin 2s infinite linear;}
.ani-fadein-right {right:0; animation:ani-fadein-right 500ms; opacity:1;}
.ani-fadeout-right {right:0; animation:ani-fadeout-right 500ms; opacity:0;}

이정도면 가능하지 않을까 싶네요 노가다 피하고 싶으신분들 참고하세요~

Comments