<template>
  <!-- 本体部分 -->
  <div
    :id="id"
    :class="['vue-puzzle-vcode', { show_: show }]"
    @mousedown="onCloseMouseDown"
    @mouseup="onCloseMouseUp"
    @touchend="onCloseMouseUp"
    @touchstart="onCloseMouseDown"
  >
    <div class="vue-auth-box_" @mousedown.stop @touchstart.stop>
      <div class="auth-body_" :style="`height: ${canvasHeight}px`">
        <!-- 主图，有缺口 -->
        <canvas
          ref="canvas1"
          :height="canvasHeight"
          :style="`width:${canvasWidth}px;height:${canvasHeight}px`"
          :width="canvasWidth"
        />
        <!-- 成功后显示的完整图 -->
        <canvas
          ref="canvas3"
          :class="['auth-canvas3_', { show: isSuccess }]"
          :height="canvasHeight"
          :style="`width:${canvasWidth}px;height:${canvasHeight}px`"
          :width="canvasWidth"
        />
        <!-- 小图 -->
        <canvas
          ref="canvas2"
          class="auth-canvas2_"
          :height="canvasHeight"
          :style="`width:${puzzleBaseSize}px;height:${canvasHeight}px;transform:translateX(${
            styleWidth -
            sliderBaseSize -
            (puzzleBaseSize - sliderBaseSize) *
              ((styleWidth - sliderBaseSize) / (canvasWidth - sliderBaseSize))
          }px)`"
          :width="puzzleBaseSize"
        />
        <div :class="['loading-box_', { hide_: !loading }]">
          <div class="loading-gif_">
            <span></span>
            <span></span>
            <span></span>
            <span></span>
            <span></span>
          </div>
        </div>
        <div
          :class="['info-box_', { show: infoBoxShow }, { fail: infoBoxFail }]"
        >
          {{ infoText }}
        </div>
        <div
          :class="['flash_', { show: isSuccess }]"
          :style="`transform: translateX(${
            isSuccess
              ? `${canvasWidth + canvasHeight * 0.578}px`
              : `-${canvasHeight * 0.578}px`
          }) skew(-30deg, 0);`"
        ></div>
        <img class="reset_" :src="resetSvg" @click="reset" />
      </div>
      <div class="auth-control_">
        <div class="range-box" :style="`height:${sliderBaseSize}px`">
          <div class="range-text">
            {{ sliderText }}
          </div>
          <div
            ref="range-slider"
            class="range-slider"
            :style="`width:${styleWidth}px`"
          >
            <div
              :class="['range-btn', { isDown: mouseDown }]"
              :style="`width:${sliderBaseSize}px`"
              @mousedown="onRangeMouseDown($event)"
              @touchstart="onRangeMouseDown($event)"
            >
              <div></div>
              <div></div>
              <div></div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
  /**
   * @document https://github.com/javaLuo/vue-puzzle-vcode/blob/master/src/app.vue
   */
  import resetSvg from './reset.png'

  export default {
    name: 'DgiotPuzzle',
    /** 父级参数 **/
    props: {
      id: {
        type: String,
        default: 'dgiotpuzzle',
      },
      canvasWidth: {
        type: Number,
        default: 310,
      }, // 主canvas的宽
      canvasHeight: {
        type: Number,
        default: 160,
      }, // 主canvas的高
      // 是否出现，由父级控制
      show: {
        type: Boolean,
        default: false,
      },
      puzzleScale: {
        type: Number,
        default: 1,
      }, // 拼图块的大小缩放比例
      sliderSize: {
        type: Number,
        default: 50,
      }, // 滑块的大小
      range: {
        type: Number,
        default: 10,
      }, // 允许的偏差值
      // 所有的背景图片
      imgs: {
        default: () => [],
        type: Array,
      },
      successText: {
        type: String,
        default: '验证通过！',
      },
      failText: {
        type: String,
        default: '验证失败，请重试',
      },
      sliderText: {
        type: String,
        default: '拖动滑块完成拼图',
      },
    },
    /** 私有数据 **/
    data() {
      return {
        mouseDown: false, // 鼠标是否在按钮上按下
        startWidth: 50, // 鼠标点下去时父级的width
        startX: 0, // 鼠标按下时的X
        newX: 0, // 鼠标当前的偏移X
        pinX: 0, // 拼图的起始X
        pinY: 0, // 拼图的起始Y
        loading: true, // 是否正在加在中，主要是等图片onload
        isCanSlide: false, // 是否可以拉动滑动条
        error: false, // 图片加在失败会出现这个，提示用户手动刷新
        infoBoxShow: false, // 提示信息是否出现
        infoText: '', // 提示等信息
        infoBoxFail: false, // 是否验证失败
        timer1: null, // setTimout1
        closeDown: false, // 为了解决Mac上的click BUG
        isSuccess: false, // 验证成功
        resetSvg,
        imgIndex: -1, // 用于自定义图片时不会随机到重复的图片
      }
    },
    /** 计算属性 **/
    computed: {
      // styleWidth是底部用户操作的滑块的父级，就是轨道在鼠标的作用下应该具有的宽度
      styleWidth() {
        const w = this.startWidth + this.newX - this.startX
        return w < this.sliderBaseSize
          ? this.sliderBaseSize
          : w > this.canvasWidth
          ? this.canvasWidth
          : w
      },
      // 图中拼图块的60 * 用户设定的缩放比例计算之后的值 0.2~2
      puzzleBaseSize() {
        return Math.round(
          Math.max(Math.min(this.puzzleScale, 2), 0.2) * 52.5 + 6
        )
      },
      // 处理一下sliderSize，弄成整数，以免计算有偏差
      sliderBaseSize() {
        return Math.max(
          Math.min(
            Math.round(this.sliderSize),
            Math.round(this.canvasWidth * 0.5)
          ),
          10
        )
      },
    },
    /** 监听 **/
    watch: {
      show(newV) {
        // 每次出现都应该重新初始化
        if (newV) {
          document.body.classList.add('vue-puzzle-overflow')
          this.reset()
        } else {
          document.body.classList.remove('vue-puzzle-overflow')
        }
      },
    },
    /** 生命周期 **/
    mounted() {
      document.body.appendChild(this.$el)
      document.addEventListener('mousemove', this.onRangeMouseMove, false)
      document.addEventListener('mouseup', this.onRangeMouseUp, false)
      document.addEventListener('touchmove', this.onRangeMouseMove, {
        passive: false,
      })
      document.addEventListener('touchend', this.onRangeMouseUp, false)
      if (this.show) {
        document.body.classList.add('vue-puzzle-overflow')
      }
      this.reset()
    },
    beforeDestroy() {
      clearTimeout(this.timer1)
      document.body.removeChild(this.$el)
      document.removeEventListener('mousemove', this.onRangeMouseMove, false)
      document.removeEventListener('mouseup', this.onRangeMouseUp, false)
      document.removeEventListener('touchmove', this.onRangeMouseMove, {
        passive: false,
      })
      document.removeEventListener('touchend', this.onRangeMouseUp, false)
    },
    /** 方法 **/
    methods: {
      // 关闭
      onClose() {
        if (!this.mouseDown) {
          clearTimeout(this.timer1)
          this.$emit('close')
        }
      },
      onCloseMouseDown() {
        this.closeDown = true
      },
      onCloseMouseUp() {
        if (this.closeDown) {
          this.onClose()
        }
        this.closeDown = false
      },
      // 鼠标按下准备拖动
      onRangeMouseDown(e) {
        if (this.isCanSlide) {
          this.mouseDown = true
          this.startWidth = this.$refs['range-slider'].clientWidth
          this.newX = e.clientX || e.changedTouches[0].clientX
          this.startX = e.clientX || e.changedTouches[0].clientX
        }
      },
      // 鼠标移动
      onRangeMouseMove(e) {
        if (this.mouseDown) {
          e.preventDefault()
          this.newX = e.clientX || e.changedTouches[0].clientX
        }
      },
      // 鼠标抬起
      onRangeMouseUp() {
        if (this.mouseDown) {
          this.mouseDown = false
          this.submit()
        }
      },
      /**
       * 开始进行
       * @param withCanvas 是否强制使用canvas随机作图
       */
      init(withCanvas) {
        this.loading = true
        this.isCanSlide = false
        const c = this.$refs.canvas1
        const c2 = this.$refs.canvas2
        const c3 = this.$refs.canvas3
        const ctx = c.getContext('2d')
        const ctx2 = c2.getContext('2d')
        const ctx3 = c3.getContext('2d')
        const img = document.createElement('img')
        ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
        ctx2.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
        // 取一个随机坐标，作为拼图块的位置
        this.pinX = this.getRandom(
          this.puzzleBaseSize,
          this.canvasWidth - this.puzzleBaseSize - 20
        ) // 留20的边距
        this.pinY = this.getRandom(
          20,
          this.canvasHeight - this.puzzleBaseSize - 20
        ) // 主图高度 - 拼图块自身高度 - 20边距
        img.crossOrigin = 'anonymous' // 匿名，想要获取跨域的图片
        img.onload = () => {
          const [x, y, w, h] = this.makeImgSize(img)
          ctx.save()
          // 先画小图
          this.paintBrick(ctx)
          ctx.closePath()
          if (
            !(
              navigator.userAgent.indexOf('Firefox') >= 0 &&
              navigator.userAgent.indexOf('Windows') >= 0
            )
          ) {
            // 非火狐，在此画外阴影
            ctx.shadowOffsetX = 0
            ctx.shadowOffsetY = 0
            ctx.shadowColor = '#000'
            ctx.shadowBlur = 3
            ctx.fill()
          }
          ctx.clip() // 按照外阴影区域切割
          ctx.save()
          // 小图外阴影
          ctx.shadowOffsetX = 0
          ctx.shadowOffsetY = 0
          ctx.shadowColor = '#000'
          ctx.shadowBlur = 2
          ctx.fill()
          ctx.restore()
          ctx.drawImage(img, x, y, w, h)
          ctx3.drawImage(img, x, y, w, h)
          // 设置小图的内阴影
          ctx.globalCompositeOperation = 'source-atop'
          this.paintBrick(ctx)
          ctx.arc(
            this.pinX + Math.ceil(this.puzzleBaseSize / 2),
            this.pinY + Math.ceil(this.puzzleBaseSize / 2),
            this.puzzleBaseSize * 1.2,
            0,
            Math.PI * 2,
            true
          )
          ctx.closePath()
          ctx.shadowColor = 'rgba(255, 255, 255, .8)'
          ctx.shadowOffsetX = -1
          ctx.shadowOffsetY = -1
          ctx.shadowBlur = Math.min(Math.ceil(8 * this.puzzleScale), 12)
          ctx.fillStyle = '#ffffaa'
          ctx.fill()
          // 将小图赋值给ctx2
          const imgData = ctx.getImageData(
            this.pinX - 3, // 为了阴影 是从-3px开始截取，判定的时候要+3px
            this.pinY - 20,
            this.pinX + this.puzzleBaseSize + 5,
            this.pinY + this.puzzleBaseSize + 5
          )
          ctx2.putImageData(imgData, 0, this.pinY - 20)
          // 清理
          ctx.restore()
          ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
          // 画缺口
          ctx.save()
          this.paintBrick(ctx)
          ctx.globalAlpha = 0.8
          ctx.fillStyle = '#ffffff'
          ctx.fill()
          ctx.restore()
          // 画缺口的内阴影
          ctx.save()
          ctx.globalCompositeOperation = 'source-atop'
          this.paintBrick(ctx)
          ctx.arc(
            this.pinX + Math.ceil(this.puzzleBaseSize / 2),
            this.pinY + Math.ceil(this.puzzleBaseSize / 2),
            this.puzzleBaseSize * 1.2,
            0,
            Math.PI * 2,
            true
          )
          ctx.shadowColor = '#000'
          ctx.shadowOffsetX = 2
          ctx.shadowOffsetY = 2
          ctx.shadowBlur = 16
          ctx.fill()
          ctx.restore()
          // 画整体背景图
          ctx.save()
          ctx.globalCompositeOperation = 'destination-over'
          ctx.drawImage(img, x, y, w, h)
          ctx.restore()
          this.loading = false
          this.isCanSlide = true
        }
        img.onerror = () => {
          this.init(true) // 如果图片加载错误就重新来，并强制用canvas随机作图
        }
        if (!withCanvas && this.imgs && this.imgs.length) {
          let randomNum = this.getRandom(0, this.imgs.length - 1)
          if (randomNum === this.imgIndex) {
            if (randomNum === this.imgs.length - 1) {
              randomNum = 0
            } else {
              randomNum++
            }
          }
          this.imgIndex = randomNum
          img.src = this.imgs[randomNum]
        } else {
          img.src = this.makeImgWithCanvas()
        }
      },
      // 工具 - 范围随机数
      getRandom(min, max) {
        return Math.ceil(Math.random() * (max - min) + min)
      },
      // 工具 - 设置图片尺寸cover方式贴合canvas尺寸 w/h
      makeImgSize(img) {
        const imgScale = img.width / img.height
        const canvasScale = this.canvasWidth / this.canvasHeight
        let x = 0,
          y = 0,
          w = 0,
          h = 0
        if (imgScale > canvasScale) {
          h = this.canvasHeight
          w = imgScale * h
          y = 0
          x = (this.canvasWidth - w) / 2
        } else {
          w = this.canvasWidth
          h = w / imgScale
          x = 0
          y = (this.canvasHeight - h) / 2
        }
        return [x, y, w, h]
      },
      // 绘制拼图块的路径
      paintBrick(ctx) {
        const moveL = Math.ceil(15 * this.puzzleScale) // 直线移动的基础距离
        ctx.beginPath()
        ctx.moveTo(this.pinX, this.pinY)
        ctx.lineTo(this.pinX + moveL, this.pinY)
        ctx.arcTo(
          this.pinX + moveL,
          this.pinY - moveL / 2,
          this.pinX + moveL + moveL / 2,
          this.pinY - moveL / 2,
          moveL / 2
        )
        ctx.arcTo(
          this.pinX + moveL + moveL,
          this.pinY - moveL / 2,
          this.pinX + moveL + moveL,
          this.pinY,
          moveL / 2
        )
        ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY)
        ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY + moveL)
        ctx.arcTo(
          this.pinX + moveL + moveL + moveL + moveL / 2,
          this.pinY + moveL,
          this.pinX + moveL + moveL + moveL + moveL / 2,
          this.pinY + moveL + moveL / 2,
          moveL / 2
        )
        ctx.arcTo(
          this.pinX + moveL + moveL + moveL + moveL / 2,
          this.pinY + moveL + moveL,
          this.pinX + moveL + moveL + moveL,
          this.pinY + moveL + moveL,
          moveL / 2
        )
        ctx.lineTo(
          this.pinX + moveL + moveL + moveL,
          this.pinY + moveL + moveL + moveL
        )
        ctx.lineTo(this.pinX, this.pinY + moveL + moveL + moveL)
        ctx.lineTo(this.pinX, this.pinY + moveL + moveL)
        ctx.arcTo(
          this.pinX + moveL / 2,
          this.pinY + moveL + moveL,
          this.pinX + moveL / 2,
          this.pinY + moveL + moveL / 2,
          moveL / 2
        )
        ctx.arcTo(
          this.pinX + moveL / 2,
          this.pinY + moveL,
          this.pinX,
          this.pinY + moveL,
          moveL / 2
        )
        ctx.lineTo(this.pinX, this.pinY)
      },
      // 用canvas随机生成图片
      makeImgWithCanvas() {
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')
        canvas.width = this.canvasWidth
        canvas.height = this.canvasHeight
        ctx.fillStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
          100,
          255
        )},${this.getRandom(100, 255)})`
        ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
        // 随机画10个图形
        for (let i = 0; i < 12; i++) {
          ctx.fillStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
            100,
            255
          )},${this.getRandom(100, 255)})`
          ctx.strokeStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
            100,
            255
          )},${this.getRandom(100, 255)})`
          if (this.getRandom(0, 2) > 1) {
            // 矩形
            ctx.save()
            ctx.rotate((this.getRandom(-90, 90) * Math.PI) / 180)
            ctx.fillRect(
              this.getRandom(-20, canvas.width - 20),
              this.getRandom(-20, canvas.height - 20),
              this.getRandom(10, canvas.width / 2 + 10),
              this.getRandom(10, canvas.height / 2 + 10)
            )
            ctx.restore()
          } else {
            // 圆
            ctx.beginPath()
            const ran = this.getRandom(-Math.PI, Math.PI)
            ctx.arc(
              this.getRandom(0, canvas.width),
              this.getRandom(0, canvas.height),
              this.getRandom(10, canvas.height / 2 + 10),
              ran,
              ran + Math.PI * 1.5
            )
            ctx.closePath()
            ctx.fill()
          }
        }
        return canvas.toDataURL('image/png')
      },
      // 开始判定
      submit() {
        // 偏差 x = puzzle的起始X - (用户真滑动的距离) + (puzzle的宽度 - 滑块的宽度) * （用户真滑动的距离/canvas总宽度）
        // 最后+ 的是补上slider和滑块宽度不一致造成的缝隙
        const x = Math.abs(
          this.pinX -
            (this.styleWidth - this.sliderBaseSize) +
            (this.puzzleBaseSize - this.sliderBaseSize) *
              ((this.styleWidth - this.sliderBaseSize) /
                (this.canvasWidth - this.sliderBaseSize)) -
            3
        )
        if (x < this.range) {
          // 成功
          this.infoText = this.successText
          this.infoBoxFail = false
          this.infoBoxShow = true
          this.isCanSlide = false
          this.isSuccess = true
          // 成功后准备关闭
          clearTimeout(this.timer1)
          this.timer1 = setTimeout(() => {
            // 成功的回调
            this.$emit('success', x)
          }, 800)
        } else {
          // 失败
          this.infoText = this.failText
          this.infoBoxFail = true
          this.infoBoxShow = true
          this.isCanSlide = false
          // 失败的回调
          this.$emit('fail', x)
          // 800ms后重置
          clearTimeout(this.timer1)
          this.timer1 = setTimeout(() => {
            this.reset()
          }, 800)
        }
      },
      // 重置
      reset() {
        this.infoBoxFail = false
        this.infoBoxShow = false
        this.isCanSlide = true
        this.isSuccess = false
        this.startWidth = this.sliderBaseSize // 鼠标点下去时父级的width
        this.startX = 0 // 鼠标按下时的X
        this.newX = 0 // 鼠标当前的偏移X
        this.init()
      },
    },
  }
</script>
<style lang="scss">
  .vue-puzzle-vcode {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 999;
    pointer-events: none;
    background-color: rgba(0, 0, 0, 0.3);
    opacity: 0;
    transition: opacity 200ms;

    &.show_ {
      pointer-events: auto;
      opacity: 1;
    }
  }

  .vue-auth-box_ {
    position: absolute;
    top: 40%;
    left: 50%;
    padding: 20px;
    user-select: none;
    background: #fff;
    border-radius: 3px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    transform: translate(-50%, -50%);

    .auth-body_ {
      position: relative;
      overflow: hidden;
      border-radius: 3px;

      .loading-box_ {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        z-index: 20;
        display: flex;
        align-items: center;
        justify-content: center;
        background-color: rgba(0, 0, 0, 0.8);
        opacity: 1;
        transition: opacity 200ms;

        &.hide_ {
          pointer-events: none;
          opacity: 0;

          .loading-gif_ {
            span {
              animation-play-state: paused;
            }
          }
        }

        .loading-gif_ {
          flex: none;
          height: 5px;
          line-height: 0;
          @keyframes load {
            0% {
              opacity: 1;
              transform: scale(1.3);
            }
            100% {
              opacity: 0.2;
              transform: scale(0.3);
            }
          }

          span {
            display: inline-block;
            width: 5px;
            height: 100%;
            margin-left: 2px;
            background-color: #888;
            border-radius: 50%;
            animation: load 1.04s ease infinite;

            &:nth-child(1) {
              margin-left: 0;
            }

            &:nth-child(2) {
              animation-delay: 0.13s;
            }

            &:nth-child(3) {
              animation-delay: 0.26s;
            }

            &:nth-child(4) {
              animation-delay: 0.39s;
            }

            &:nth-child(5) {
              animation-delay: 0.52s;
            }
          }
        }
      }

      .info-box_ {
        position: absolute;
        bottom: 0;
        left: 0;
        z-index: 10;
        width: 100%;
        height: 24px;
        overflow: hidden;
        font-size: 13px;
        line-height: 24px;
        color: #fff;
        text-align: center;
        background-color: #83ce3f;
        opacity: 0;
        transition: all 200ms;
        transform: translateY(24px);

        &.show {
          opacity: 0.95;
          transform: translateY(0);
        }

        &.fail {
          background-color: #ce594b;
        }
      }

      .auth-canvas2_ {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 2;
        width: 60px;
        height: 100%;
      }

      .auth-canvas3_ {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 3;
        opacity: 0;
        transition: opacity 600ms;

        &.show {
          opacity: 1;
        }
      }

      .flash_ {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 3;
        width: 30px;
        height: 100%;
        background-color: rgba(255, 255, 255, 0.1);

        &.show {
          transition: transform 600ms;
        }
      }

      .reset_ {
        position: absolute;
        top: 2px;
        right: 2px;
        z-index: 12;
        width: 35px;
        height: auto;
        cursor: pointer;
        transition: transform 200ms;
        transform: rotate(0deg);

        &:hover {
          transform: rotate(-90deg);
        }
      }
    }

    .auth-control_ {
      .range-box {
        position: relative;
        width: 100%;
        margin-top: 20px;
        background-color: #eef1f8;
        border-radius: 3px;
        box-shadow: 0 0 8px rgba(240, 240, 240, 0.6) inset;

        .range-text {
          position: absolute;
          top: 50%;
          left: 50%;
          width: 100%;
          overflow: hidden;
          font-size: 14px;
          color: #b7bcd1;
          text-align: center;
          text-overflow: ellipsis;
          white-space: nowrap;
          transform: translate(-50%, -50%);
        }

        .range-slider {
          position: absolute;
          width: 50px;
          height: 100%;
          background-color: rgba(106, 160, 255, 0.8);
          border-radius: 3px;

          .range-btn {
            position: absolute;
            right: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            width: 50px;
            height: 100%;
            cursor: pointer;
            background-color: #fff;
            border-radius: 3px;
            box-shadow: 0 0 4px #ccc;

            & > div {
              width: 0;
              height: 40%;

              border: solid 1px #6aa0ff;
              transition: all 200ms;

              &:nth-child(2) {
                margin: 0 4px;
              }
            }

            &:hover,
            &.isDown {
              & > div:first-child {
                height: 0;
                border: solid 4px transparent;
                border-right-color: #6aa0ff;
              }

              & > div:nth-child(2) {
                height: 0;
                margin: 0 6px;
                border-width: 3px;
                border-right-color: #6aa0ff;
                border-radius: 3px;
              }

              & > div:nth-child(3) {
                height: 0;
                border: solid 4px transparent;
                border-left-color: #6aa0ff;
              }
            }
          }
        }
      }
    }
  }

  .vue-puzzle-overflow {
    overflow: hidden !important;
  }
</style>
