my-radial-menu/src/RadialMenu.vue

<template>
  <transition name="my-radial-menu">
    <div v-if="visible && flashHelper"
         v-clickoutside="handleClickOutside"
         class="my-radial-menu"
         :style="styles">
      <svg class="my-radial-menu__icons">
        <symbol id="return" viewBox="0 0 489.394 489.394">
          <path
            d="M375.789,92.867H166.864l17.507-42.795c3.724-9.132,1-19.574-6.691-25.744c-7.701-6.166-18.538-6.508-26.639-0.879L9.574,121.71c-6.197,4.304-9.795,11.457-9.563,18.995c0.231,7.533,4.261,14.446,10.71,18.359l147.925,89.823c8.417,5.108,19.18,4.093,26.481-2.499c7.312-6.591,9.427-17.312,5.219-26.202l-19.443-41.132h204.886c15.119,0,27.418,12.536,27.418,27.654v149.852c0,15.118-12.299,27.19-27.418,27.19h-226.74c-20.226,0-36.623,16.396-36.623,36.622v12.942c0,20.228,16.397,36.624,36.623,36.624h226.74c62.642,0,113.604-50.732,113.604-113.379V206.709C489.395,144.062,438.431,92.867,375.789,92.867z"></path>
        </symbol>
        <symbol id="close" viewBox="0 0 41.756 41.756">
          <path
            d="M27.948,20.878L40.291,8.536c1.953-1.953,1.953-5.119,0-7.071c-1.951-1.952-5.119-1.952-7.07,0L20.878,13.809L8.535,1.465c-1.951-1.952-5.119-1.952-7.07,0c-1.953,1.953-1.953,5.119,0,7.071l12.342,12.342L1.465,33.22c-1.953,1.953-1.953,5.119,0,7.071C2.44,41.268,3.721,41.755,5,41.755c1.278,0,2.56-0.487,3.535-1.464l12.343-12.342l12.343,12.343c0.976,0.977,2.256,1.464,3.535,1.464s2.56-0.487,3.535-1.464c1.953-1.953,1.953-5.119,0-7.071L27.948,20.878z"></path>
        </symbol>
      </svg>
      <svg class="my-radial-menu__menu" viewBox="-50 -50 100 100" :width="size" :height="size">
        <MenuItem v-for="item in menus"
                  v-bind="item"
                  :key="item.index"
                  :scale="scale"
                  :radius="radius"
                  :innerRadius="innerRadius"
                  @click="handleMenuClick"></MenuItem>

        <g class="my-radial-menu__center" @click="handleCenterClick">
          <circle :cx="centerCircle.cx" :cy="centerCircle.cy" :r="centerCircle.r"></circle>
          <use :x="centerCircle.cx"
               :y="centerCircle.cy"
               :width="centerCircle.size"
               :height="centerCircle.size"
               :transform="centerCircle.transform"
               fill="white"
               :xlink:href="centerCircle.icon"></use>
        </g>
      </svg>
    </div>
  </transition>
</template>

<script>
  /**
   * 圆形菜单组件
   * @module $ui/components/my-radial-menu
   */
  import MenuItem from './MenuItem'
  import {resolveLoopIndex, numberToString, setClassAndWaitForTransition} from './utils'
  import clickoutside from 'element-ui/lib/utils/clickoutside'

  export default {
    name: 'MyRadialMenu',
    components: {
      MenuItem
    },
    directives: {
      clickoutside
    },
    provide() {
      return {
        radialMenu: this
      }
    },
    /**
     * 属性参数
     * @member props
     * @property {number} [200] 菜单尺寸,直径
     * @property {Array} [items] 菜单数据项 [{id, label, icon, children}]
     * @property {boolean} [closeOnClick=true] 点击菜单后关闭
     * @property {boolean} [closeOnClickOutside=true] 点击菜单外部关闭
     * @property {number} [miniSectors=6] 最小菜单项数量
     * @property {string} [defaultSelected] 默认选中的菜单项id
     * @property {boolean} [resetOnClose=true] 关闭菜单后重置为初始状态
     * @property {number[]} [position] 菜单显示的坐标,圆心是参照点
     * @property {boolean} [visible=true] 显示菜单,支持 sync 修饰符
     * @property {number} [zIndex=1] 显示层级
     * @property {boolean} [appendToBody] 渲染 html 到 body
     */
    props: {
      size: {
        type: Number,
        default: 200
      },
      items: {
        type: Array,
        default() {
          return []
        }
      },
      closeOnClick: {
        type: Boolean,
        default: true
      },
      closeOnClickOutside: {
        type: Boolean,
        default: true
      },
      miniSectors: {
        type: Number,
        default: 6
      },
      defaultSelected: {
        type: [String, Number]
      },
      resetOnClose: {
        type: Boolean,
        default: true
      },
      position: {
        type: Array
      },
      visible: {
        type: Boolean,
        default: true
      },
      zIndex: {
        type: Number,
        default: 1
      },
      appendToBody: {
        type: Boolean
      }
    },
    data() {
      const radius = 50
      return {
        radius,
        innerRadius: radius * 0.3,
        sectorSpace: radius * 0.06,
        levelItems: [],
        parentMenus: [],
        nested: false,
        selected: null,
        flashHelper: true
      }
    },
    watch: {
      defaultSelected: {
        immediate: true,
        handler(val) {
          this.selected = this.defaultSelected
        }
      },
      items: {
        immediate: true,
        handler(val) {
          this.levelItems = val || []
        }
      },
      visible(val) {
        if (val) {
          /**
           * 显示时触发
           * @event open
           */
          this.$emit('open')
        } else {
          /**
           * 关闭时触发
           * @event close
           */
          this.$emit('close')
          if (this.resetOnClose) {
            this.reset()
          }
        }
      }
    },
    computed: {
      styles() {
        const radius = this.size / 2
        const [x, y] = this.position || [radius, radius]
        return {
          width: `${this.size}px`,
          height: `${this.size}px`,
          left: `${x - radius}px`,
          top: `${y - radius}px`,
          zIndex: this.zIndex
        }
      },
      sectorCount() {
        return Math.max(this.levelItems.length, this.miniSectors)
      },
      scale() {
        const totalSpace = this.sectorSpace * this.sectorCount;
        const circleLength = Math.PI * 2 * this.radius;
        const radiusDelta = this.radius - (circleLength - totalSpace) / (Math.PI * 2);
        return (this.radius - radiusDelta) / this.radius;
      },
      indexOffset() {
        const length = this.levelItems.length
        if (length < this.sectorCount) {
          switch (length) {
            case 1:
              return -2;
            case 2:
              return -2;
            case 3:
              return -2;
            default:
              return -1;
          }
        } else {
          return -1;
        }
      },
      menus() {
        const angleStep = 360 / this.sectorCount
        const angleShift = angleStep / 2 + 270
        const length = this.levelItems.length
        const items = []
        for (let i = 0; i < this.sectorCount; i++) {
          const startAngle = angleShift + angleStep * i;
          const endAngle = angleShift + angleStep * (i + 1);
          const index = resolveLoopIndex(this.sectorCount - i + this.indexOffset, this.sectorCount);
          const item = (index >= 0 && index < length) ? this.levelItems[index] : null
          items.push({
            startAngle,
            endAngle,
            selected: (item && item.id === this.selected),
            index,
            item
          })
        }
        return items
      },
      centerCircle() {
        const size = 8
        return {
          cx: numberToString(0),
          cy: numberToString(0),
          r: this.innerRadius - this.sectorSpace / 3,
          size: 8,
          icon: this.nested ? '#return' : '#close',
          transform: `translate(-${numberToString(size / 2)},-${numberToString(size / 2)})`
        }
      }
    },
    methods: {
      handleCenterClick() {
        if (this.parentMenus.length > 0) {
          this.flashHelper = false
          this.$nextTick(() => {
            this.levelItems = this.parentMenus.pop()
            this.nested = this.parentMenus.length > 0
            this.flashHelper = true
          })
          this.outerEffect()
        } else {
          this.$emit('update:visible', false)
        }
      },
      outerEffect() {
        this.clean()
        this.cloneEl = this.$el.cloneNode(true)
        this.$el.parentNode.appendChild(this.cloneEl)
        this.timerId = setTimeout(() => {
          setClassAndWaitForTransition(this.cloneEl, 'my-radial-menu-outer').then(this.clean)
        }, 20)
      },
      handleMenuClick(item) {
        if (item.children) {
          this.flashHelper = false
          this.$nextTick(() => {
            this.parentMenus.push(this.levelItems.slice(0))
            this.levelItems = item.children
            this.nested = true
            this.flashHelper = true
          })
          this.outerEffect()
        } else {
          this.selected = item.id
          this.closeOnClick && this.$emit('update:visible', false)
          /**
           * 点击菜单时触发
           * @event click
           * @param {object} item 菜单项对象
           */
          this.$emit('click', item)
        }
      },
      handleClickOutside() {
        this.closeOnClickOutside && this.$emit('update:visible', false)
      },
      reset() {
        this.nested = false
        this.selected = this.defaultSelected
        this.levelItems = this.items
        this.parentMenus = []
      },
      clean() {
        this.timerId && clearTimeout(this.timerId)
        if (this.cloneEl) {
          this.cloneEl.parentNode.removeChild(this.cloneEl)
        }
        this.timerId = null
        this.cloneEl = null
      }
    },
    mounted() {
      if (this.appendToBody) {
        document.body.appendChild(this.$el)
      }
    },
    beforeDestroy() {
      this.clean()
    }
  }
</script>