likes
comments
collection
share

十二.玩转可视化拖拽之原生篇

作者站长头像
站长
· 阅读数 73

前言

接上一篇,通过插件实现了拖拽缩放等功能,但是也产生一些问题,这次针对这些问题,使用原生方法来进行优化和调整。

1.vuedraggable我们只是使用了克隆元素,拖动到指定盒子,其他很多功能没有用到,不如使用原生方便;

2.vue-drag-resize没有吸附对齐,冲突检测,辅助线等功能,交互体检上不太好,所以这里打算尝试一下用原生办法实现这些功能

原生实现

原生实现前提,需要对js中drop、dragover、mousedown等有一定的了解。 draggable:通过设置这个属性,可以使元素可被拖拽;

dragstart:当开始拖动元素时触发;

dragover:当元素被拖拽到另一个元素上移动时触发;

drop:当元素拖拽到另一个元素释放时触发;

mousedown:鼠标按下时触发;

mousemove:鼠标按下后移动时触发;

mouseuo:鼠标移开释放时触发。

拖动元素到画布

首先,我们需要给元素设置draggable属性,表示元素可被拖拽,然后为了能拿到当前拖动的元素下标,我们可以给元素设置 :data-index="index",然后再通过dragstart,获取到当前拖动元素的下标。

左侧元素

<div class="drag-component" @dragstart="handleDragStart">
    <div
      class="drag-component-item"
      draggable
      :data-index="index"
      v-for="(item, index) in componentList"
      :key="index"
    >
      <el-icon name="circle-plus-outline" class="icon"></el-icon
      >{{ item.title }}
    </div>
</div>

data(){
  return{
    componentList: [
        {
          title: "矩形盒子",
          componentName: "dragBox",
          id: "",
          config: {
            w: 150,
            h: 150,
            x: "",
            y: "",
          },
        },
        {
          title: "圆形盒子",
          componentName: "dragCircle",
          id: "",
          config: {
            w: 150,
            h: 150,
            x: "",
            y: "",
          },
        },
      ],
      currentIndex:null
  }
}
methods:{
   handleDragStart(event) {
      this.currentIndex = event.target.dataset.index;
    },
}

右边画布区域: 画布区域主要做两件事:1.dragover时取消事件默认行为和设置光标为copy;2.drop释放时,记录当前在画布的位置,赋值top,left等。

 <div
    class="drag-canvas"
    id="dragCanvas"
    @drop="handleDropEnd"
    @dragover="handleDragOver">
      <component
          v-for="(com, index) in drawList"
          :key="index"
          :is="com.componentName"
          v-bind="com.config"
          :style="getStyle(com.config)"
        />
 </div>
 computed: {
    getStyle() {
      return (com) => {
        return {
          width: com.w + "px",
          height: com.h + "px",
          left: com.x + "px",
          top: com.y + "px",
          position: "absolute",
        };
      };
    },
  },
 methods:{
  //拖动结束
    handleDropEnd(e) {
      e.preventDefault();
      e.stopPropagation();
      e.dataTransfer.clearData()
      const index = this.currentIndex;
      //获取画布区域的位置信息
      const rectInfo = document
        .getElementById("dragCanvas")
        .getBoundingClientRect();
      if (index || index === 0) {
        const component = JSON.parse(JSON.stringify(this.componentList[index]));
        component.config.y = (e.clientY - rectInfo.y);
        component.config.x = (e.clientX - rectInfo.x);
        component.id = "box_" + new Date().getTime();
        this.drawList.push({ ...component });
      }
    },

    //移动中
    handleDragOver(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = "copy";
    },
 }

这样即可实现左边元素克隆拖拽到右边画布区域。

十二.玩转可视化拖拽之原生篇

画布移动

针对需要画布元素移动和缩放,我对前面的<component/>外层定义了一个<drag-item></drag-item>,所有的移动缩放操作都需要在这个组件里面进行,所以右边的画布代码就变成:
 <div
    class="drag-canvas"
    id="dragCanvas"
    @drop="handleDropEnd"
    @dragover="handleDragOver">

      <drag-item
        v-for="com in drawList"
        :key="com.id"
        :w.sync="com.config.w"
        :h.sync="com.config.h"
        :x.sync="com.config.x"
        :y.sync="com.config.y"
      >
        <component :is="com.componentName" v-bind="com.config" />
      </drag-item>
   </div>

然后就开始对这个drag-item组件进行封装设置:

1.最外层盒子,需要将前面的computedgetStyle计算挪到这边来,这里多说一下,我们不能直接使用传递过来的width,height,top,left,不然你进行拖动或缩放,会直接改变当前的width等,违背了props单向数据流,所以这里直接在这个组件定义一个boxStyle参数,用来接收位置信息等。(可以用watch,进行赋值操作)

2.需要初始化定义几种状态,例如:active,draging,resizing,分别代表当前盒子是否处于活跃,是否处于拖动中和缩放中;

3.所谓对盒子进行移动,首先需要对盒子移动的位置进行记录(即鼠标按下时记录当前位置),然后在盒子移动中获取新的位置信息,然后减去旧的盒子位置,就是当前挪动的距离。

4.禁止移动操作,可以通过props传递isDraggable这个属性,判断当前是否允许移动。

<div
    :class="[
      'drag-item',
      {
        active: active,
        'vue-drag-moving': draging,
        'vue-drag-resizing': resizing,
      },
    ]"
    :style="dragStyle"
    @mousedown.stop.prevent="handleDragStart($event)"
  >
</div>

data(){
  return{
      draging: false,
      resizing: false,
      active: false,
      boxStyle: {
        top: "",
        left: "",
        width: "",
        height: "",
      },
      //移动前的位置信息
      beforeMove: {
        left: null,
        top: null,
        width: null,
        height: null,
        oldPageX: null,
        oldPageY: null,
      },
  }
}

methods:{
  //鼠标按下时,记录当前位置
  handleDragStart(event){
      this.active = true;
      event.stopPropagation();
      //如果为false,禁止移动
      if (!this.isDraggable) {
        return;
      }
      //记录当前位置的pageX和pageY
      const { pageX, pageY } = event;
      this.setBeforeMove({ oldPageX: pageX, oldPageY: pageY });
      this.draging = true;
      document.onmousemove = (event) => this.handleDragMove(event);
      document.onmouseup =  () => {
       //拖动结束,将数据清空
        document.onmousemove = null;
        document.onmouseup = null;
        this.$emit("draged", {
          left: this.boxStyle.left,
          top: this.boxStyle.top,
        });
        this.beforeMove = {
          left: null,
          top: null,
          width: null,
          height: null,
          oldPageX: null,
          oldPageY: null,
        };
        this.draging = false;
      };
  },
  
  //移动中的盒子
  handleDragMove(event){
      //当前位置信息
      let { pageX, pageY } = event;
      //移动前位置信息
      let { top, left, oldPageX, oldPageY } = this.beforeMove;
      //这里就是移动后的位置
      let newTop = pageY - oldPageY + top,
        newLeft =pageX - oldPageX + left;
        
      Object.assign(this.boxStyle, snapObj);
      this.$emit("update:x", newLeft);
      this.$emit("update:y", newTop);
      this.$emit("draging", this.boxStyle);
  }
  
 //存储resize或移动前位置信息
   setBeforeMove(position) {
      const { left, top, height, width } = this.boxStyle;
      this.beforeMove = {
        left,
        top,
        height,
        width,
        ...position,
      };
    },
}

十二.玩转可视化拖拽之原生篇

画布缩放

进行缩放需要考虑几个点:

1.缩放角的位置展示;

2.记录缩放前的位置信息,这里稍微比移动复杂点,后面详细说;

3.禁止缩放操作,可以通过props传递isResizable这个属性,判断当前是否允许缩放。

可缩放角的实现

十二.玩转可视化拖拽之原生篇

先定义sticks=["tl", "tm", "tr", "ml", "mr", "bl", "bm", "br"]代表对应的几个角,那这几个角的位置信息如何展示呢? 假设这个时候每个缩放角的高宽都是8px

tl:即top left,左上角的点位,他的位置应该是top:-4px,left:-4px

tm:即top middle,头部中间的点位,他的位置应该是:top:-4px,margin-left:-4px,left:50%;

tr:即top right,右上角的点位,他的位置应该是:top:-4px,right:-4px;

ml:即middle left,中间左边的点位,他的位置应该是:left:-4px,margin-top:-4px,top:50%;

mr:即middle right,中间右边的点位,他的位置应该是:right:-4px,margin-top:-4px,top:50%;

bl:即bottom left,左下角的点位,他的位置应该是:bottom:-4px,left:-4px;

bm:即bottom middle,底部中间的点位,他的位置应该是:bottom:-4px,margin-left:-4px,left:50%;

br:即bottom right,右下角的点位,他的位置应该是:bottom:-4px,right:-4px;

这里参考vue-drag-resize源码的方法,定义好x轴和y轴对应的位置,然后通过css设置ml,mr,tm,bm对应top:50%或left:50%,这样即可实现8个角的位置。

const stickPosition = {
  x: {
    l: "left",
    m: "marginLeft",
    r: "right",
  },
  y: {
    t: "top",
    m: "marginTop",
    b: "bottom",
  },
};
  <div
    v-for="stick in sticks"
    :key="stick"
    :class="['vue-drag-stick', `vue-drag-stick-${stick}`]"
    :style="stickStyle(stick)"
    @mousedown.stop.prevent="handleResizeStart(stick, $event)"
   ></div>
   

 //这里stickSize是传来的缩放角大小,默认为8
 computed: {
    stickStyle() {
      const { stickSize } = this;
      return (stick) => {
        let stickObj = {
          width: stickSize + "px",
          height: stickSize + "px",
        };
        stickObj[stickPosition.y[stick[0]]] = -stickSize / 2 + "px";
        stickObj[stickPosition.x[stick[1]]] = -stickSize / 2 + "px";
        return stickObj;
      };
    },
  },

缩放功能的实现

同样的,还是通过鼠标按下,移动中,鼠标弹起三个事件:

  handleResizeStart(stick, event) {
      event.stopPropagation();
      //记录当前是哪个角进行的缩放
      this.currentStick = stick;
      const { pageX, pageY } = event;
      this.resizing = true;
      //和前面一样,记录缩放前位置
      this.setBeforeMove({ oldPageX: pageX, oldPageY: pageY });
      document.onmousemove = (event) => this.handleResizeMove(event);
      document.onmouseup =  () => {
        //缩放结束,清空值
        document.onmousemove = null;
        document.onmouseup = null;
        this.currentStick = null;
        this.$emit("resized", this.boxStyle);
        this.beforeMove = {
          left: null,
          top: null,
          width: null,
          height: null,
          oldPageX: null,
          oldPageY: null,
        };
        this.resizing = false;
      };
   },

关于这个缩放中,这里着重说一下,它分为几种情况:

这里的oldPageY,oldPageX,height,width,left,top是缩放前的位置信息,pageX,pageY是新的位置信息

1.缩放tm,即头部中间,它会改变的是top和height,如果往上拉,它的top减少,height增加,计算公式为:

 newHeight = oldPageY - pageY + height;
 newTop = pageY - oldPageY + top;

2.缩放tl,即左上角,它会改变的是top和height,width和left,计算公式为:

 newTop = pageY - oldPageY + top;
 newLeft = pageX - oldPageX + left;
 newHeight = oldPageY - pageY + height;
 newWidth = oldPageX - pageX + width;

3.缩放tr,即右上角,它会改变的是top和height,width,计算公式为:

newTop = pageY - oldPageY + top;
newHeight = oldPageY - pageY + height;
newWidth = pageX - oldPageX + width;

4.缩放ml,即中左边,它会改变的是left和width,计算公式为:

 newLeft = pageX - oldPageX + left;
 newWidth = oldPageX - pageX + width;

5.缩放mr,即中右边,它会改变的是width,计算公式为:

newWidth = pageX - oldPageX + width;

6.缩放bl,即左下角,它会改变的是width,left,和heigth,计算公式为:

newLeft = pageX - oldPageX + left;
newWidth = oldPageX - pageX + width;
newHeight = pageY - oldPageY + height;

7.缩放bm,即底中部,它会改变的是heigth,计算公式为:

newHeight = pageY - oldPageY + height;

8.缩放br,即右下角,它会改变的是heigth和width,计算公式为:

newHeight = pageY - oldPageY + height;
newWidth = pageX - oldPageX + width;

根据上方的计算公式,缩放逻辑即可得出:

 handleResizeMove(event) {
      let { pageX, pageY } = event;
      let { top, left, height, width, oldPageX, oldPageY } = this.beforeMove;
      let newHeight = height,
        newWidth = width,
        newTop = top,
        newLeft = left;
      switch (this.currentStick) {
        case "tl":
          newTop = pageY - oldPageY + top;
          newLeft = pageX - oldPageX + left;
          newHeight = oldPageY - pageY + height;
          newWidth = oldPageX - pageX + width;
          break;
        case "tm":
          newHeight = oldPageY - pageY + height;
          newTop = pageY - oldPageY + top;
          break;
        case "tr":
          newTop = pageY - oldPageY + top;
          newHeight = oldPageY - pageY + height;
          newWidth = pageX - oldPageX + width;
          break;

        case "ml":
          newLeft = pageX - oldPageX + left;
          newWidth = oldPageX - pageX + width;
          break;
        case "mr":
          newWidth = pageX - oldPageX + width;
          break;
        case "bl":
          newLeft = pageX - oldPageX + left;
          newWidth = oldPageX - pageX + width;
          newHeight = pageY - oldPageY + height;
          break;
        case "bm":
          newHeight = pageY - oldPageY + height;
          break;
        case "br":
          newHeight = pageY - oldPageY + height;
          newWidth = pageX - oldPageX + width;
          break;
        default:
          break;
      }
      this.$emit("resizing", {
        width: this.boxStyle.width,
        height: this.boxStyle.height,
        left: this.boxStyle.left,
        top: this.boxStyle.top,
      });
    }

十二.玩转可视化拖拽之原生篇

冲突检测

前面已经实现盒子的缩放和拖动功能,但是出现了两个盒子重叠的问题,它会在缩放和移动中都会出现这种问题,所以这里进行下优化操作: 在缩放和移动结束(mouseup)里面,判断当前位置是否和其他盒子冲突,如果有,就还原之前的位置。

如何判断冲突?这里也分几种情况:

这里的boxStyle已经是当前移动后的位置了,move的时候已经修改了这个值了

1.当前盒子的右边>其他盒子的左边,即left+width>otherLeft,右冲突

2.其他的盒子的右边>当前盒子的左边,即otherLeft+otherWidth>left,左冲突

3.当前盒子的底部>其他盒子的头部,即top+height>otherTop,下冲突

4.其他的盒子的底部>当前盒子的头部,即otherTop+otherHeight>top,上冲突

conflictCheck(){
  return new Promise((resolve) => {
        let childNode = this.$el.parentNode.childNodes;
        const { top, left, width, height } = this.boxStyle;
        //获取父盒子下面的所有子盒子
        for (let i = 0; i < childNode.length - 1; i++) {
          let node = childNode[i];
          if (
            node.className.includes("vue-drag-moving") ||
            node.className.includes("vue-drag-resizing")
          ) {
            continue;
          }
          let style = node.style;
          if (
            left + width > parseFloat(style.left) &&
            parseFloat(style.left) + parseFloat(style.width) > left &&
            top + height > parseFloat(style.top) &&
            parseFloat(style.top) + parseFloat(style.height) > top
          ) {
            resolve(true);
          }
        }
        resolve(false);
    });
}

然后根据这个返回的值,判断是否需要复原初始位置。

十二.玩转可视化拖拽之原生篇

吸附对齐

吸附对齐主要是为了优化拖拽,防止拖拽时无法对齐的问题,同时这里衍生出另一个功能: 每次拖拽固定移动距离

固定移动距离:设置每次移动股东的距离,主要实现方法:

前面提起过,每次移动的距离如下,假设定义一个参数:snapNumber=10

newTop = pageY - oldPageY + top,
newLeft =pageX - oldPageX + left;

固定移动距离后:
newTop =Math.round((pageY - oldPageY) / snapNumber) * snapNumber + top;
newLeft =Math.round((pageX - oldPageX) / snapNumber) *snapNumber +left;

接下来言归正传,实现吸附功能,具体实现逻辑为:移动盒子的时候,获取当前画布上的除移动盒子的外的所有盒子,判断是否他们的lefttop的差在一个范围内,如果在,直接就把固定盒子的lefttop赋值给移动的盒子,完成吸附功能。

他主要分为几种情况:x轴的吸附和y轴的吸附

这里定义 width,left,top,height为移动盒子的属性,cl,cw,ch,ct为其他盒子的属性,通过这个我们可以得出计算公式,计算方法同上面缩放差不多。

左对左吸附:

const lTol = Math.abs(cl - left) < snapNumber;

右对右吸附:

const rTor = Math.abs(cl + cw - left - width) < snapNumber;

左对右吸附:

 const lToR = Math.abs(cw + cl - left) < snapNumber;

右对左吸附:

 const rTol = Math.abs(cl - left - width) < snapNumber;

中对中吸附:

const cToc = Math.abs(cl + cw / 2 - left - width / 2) < snapNumber;

顶对顶吸附:

 const tTot = Math.abs(ct - top) < snapNumber;

顶对底吸附:

  const tTob = Math.abs(ct - top - height) < snapNumber;

底对底吸附:

 const bTob = Math.abs(ct + ch - top - height) < snapNumber;

底对顶吸附:

 const bTot = Math.abs(ct + ch - top) < snapNumber;

中对中吸附:

const CToC = Math.abs(ct + ch / 2 - top - height / 2) < snapNumber;

然后再判断如果这个里面那个为true,就把对应的top,left值=cl或ct,这样即可实现吸附效果(完整代码在最下方地址)

辅助线

辅助线功能这个走了点弯路,当时想着把辅助线放在当前盒子里面,但是发现了一个问题,他的top,left是根据当前盒子的定位,我们获取到的位置信息是外面画布的定位,所以这里把辅助线单独写了一个组件,和盒子同级,通过eventBus进行传值。

其实如果实现了吸附功能,辅助线功能也就水到渠成,根据吸附功能,我们可以拿到当前吸附的位置信息,然后通过eventBus传递给辅助线组件即可,这里定义y为Y轴的辅助线,左中右分别为:l,c,r; x为X轴的辅助线,上中下分为t,c,b

左对左辅助线:

const lTol = Math.abs(cl - left) < snapNumber;
eventBus.$emit("moving", "yl", left);

右对右辅助线:

const rTor = Math.abs(cl + cw - left - width) < snapNumber;
eventBus.$emit("moving", "yr", cl + cw);

左对右辅助线:

 const lToR = Math.abs(cw + cl - left) < snapNumber;
 eventBus.$emit("moving", "yl", left);

右对左辅助线:

 const rTol = Math.abs(cl - left - width) < snapNumber;
 eventBus.$emit("moving", "yr", cl);

中对中辅助线:

const cToc = Math.abs(cl + cw / 2 - left - width / 2) < snapNumber;
eventBus.$emit("moving", "yc", cl + cw / 2);

顶对顶辅助线:

 const tTot = Math.abs(ct - top) < snapNumber;
 eventBus.$emit("moving", "xt", top);

顶对底辅助线:

  const tTob = Math.abs(ct - top - height) < snapNumber;
  eventBus.$emit("moving", "xt", ct);

底对底辅助线:

 const bTob = Math.abs(ct + ch - top - height) < snapNumber;
 eventBus.$emit("moving", "xt", ct + ch);

底对顶辅助线:

 const bTot = Math.abs(ct + ch - top) < snapNumber;
 eventBus.$emit("moving", "xt", top);

中对中辅助线:

const CToC = Math.abs(ct + ch / 2 - top - height / 2) < snapNumber;
eventBus.$emit("moving", "xc", ct + ch / 2);

对应markLine组件,先将6条线展示上去:

 <div
      v-for="line in lines"
      :key="line.type"
      v-show="line.show"
      :class="['drag-line', `drag-line-${line.type}`]"
      :style="lineStyle(line)"
  ></div>
    
 lines: [
        {
          type: "xt",
          position: "",
          show: false,
        },
        {
          type: "xc",
          position: "",
          show: false,
        },
        {
          type: "xb",
          position: "",
          show: false,
        },
        {
          type: "yl",
          position: "",
          show: false,
        },
        {
          type: "yc",
          position: "",
          show: false,
        },
        {
          type: "yr",
          position: "",
          show: false,
        },
 ],
 computed: {
    lineStyle() {
      return (line) => {
        return {
        //这里的guideBackground,guideSize可以传进来,代表意思是辅助线的颜色和宽度
          background: this.guideBackground,
          top: line.type.indexOf("y") ? line.position + "px" : "unset",
          left: line.type.indexOf("x") ? line.position + "px" : "unset",
          width: line.type.indexOf("x") ? this.guideSize + "px" : "100%",
          height: line.type.indexOf("y") ? this.guideSize + "px" : "100%",
        };
      };
    },
  },

然后再监听盒子组件传递过来的movingmoved事件,每次moveing触发的时候都先把lineshow变为false,然后再判断当前传递的type是哪个,然后把他对应的postionshow赋值即可;moved移动结束后,将所有show变成false;

  mounted() {
    eventBus.$on("moving", (type, position) => {
      this.lines.forEach((item) => (item.show = false));
      this.draging = true;
      if (type) {
        this.setMarkLine(type, position);
      }
    });
    eventBus.$on("moved", () => {
      this.lines.forEach((item) => (item.show = false));
      this.draging = false;
    });
  },
  
 setMarkLine(type, position) {
      for (let i = 0; i < this.lines.length; i++) {
        let item = this.lines[i];
        if (item.type == type) {
          item.show = true;
          item.position = position;
          break;
        }
      }
 },

这样,我们初步设想的功能都完成啦~

十二.玩转可视化拖拽之原生篇

后续

后续可能还会加上一些其他功能,如不可超出父盒子,setting可视化配置等,不断优化中...

最后

到这里,通过原生实现已经完成了,希望该文章能对你有帮助,如果疑问和建议,可在评论区留言,如果觉得本文写的不错,麻烦点个赞。

在线预览地址:vue-drag-template 项目地址:vue-drag-template

其他文章

转载自:https://juejin.cn/post/7095576145097129992
评论
请登录