实现一个可拖拽移动的窗口
zKing 2021-04-07  Vue.js巩固练习
摘要
巩固练习,使用 Vue2.x + TS 实现一个仿 Windows 系统窗口,可拖拽和拉伸
# 代码实现
# 调用方式
<fake-window
  v-model="fakeWindowVisible"
  class="window"
  :closeable="true"
  :draggable="true"
  :resizeable="true"
  :full-screen-able="false"
  :need-reset-position="true"
  @close="fakeWindowCloseHandler"
>
  <span slot="title">标题</span>
  <div slot="content" />
</fake-window>
# 源码
<template>
  <section class="fake-window-container">
    <!-- 需要 fake-window-container 来作为 fake-window 的 offsetParent,防止外部没有设置 postion 属性无法正常拖拽的情况 -->
    <transition :name="transitionName">
      <section
        v-show="visible"
        ref="fake-window"
        :class="{
          'fake-window': true,
          resizeable,
          draggable
        }"
      >
        <div
          v-drag="{ draggable }"
          :class="{
            'fake-window__header': true,
            draggable
          }"
        >
          <div class="title-wrapper">
            <slot name="title" />
          </div>
          <i
            v-if="fullScreenAble"
            class="el-icon-plus fullScreen-icon"
            @click="fullScreenHandler"
          />
          <i
            v-if="closeable"
            class="el-icon-close close-icon"
            @click="fakeWindowCloseHandler"
          />
        </div>
        <div class="fake-window__content">
          <slot name="content" />
        </div>
      </section>
    </transition>
  </section>
</template>
<script lang="ts">
import {
  Vue,
  Component,
  Prop,
  Model,
  Ref,
  Watch
} from "vue-property-decorator";
@Component({
  directives: {
    drag: {
      bind(el, binding) {
        const { draggable } = binding.value!;
        if (draggable) {
          el.onmousedown = e => {
            console.log(e);
            const parentEl = el.parentNode as HTMLElement;
            const { offsetLeft, offsetTop } = parentEl;
            const offsetX = e.clientX - offsetLeft;
            const offsetY = e.clientY - offsetTop;
            const handleMouseMove = (e: MouseEvent) => {
              // 限制只能在可视区域内拖拽的话,需要改很多,会把组件做的比较复杂,不去管它
              const curOffsetLeft = e.clientX - offsetX;
              const curOffsetTop = e.clientY - offsetY;
              parentEl.style.left = `${curOffsetLeft}px`;
              parentEl.style.top = `${curOffsetTop}px`;
            };
            const handleMouseUp = () => {
              document.onmousemove = null;
              document.onmouseup = null;
            };
            document.onmousemove = handleMouseMove;
            document.onmouseup = handleMouseUp;
          };
        }
      },
      unbind(el, binding) {
        const { draggable } = binding.value!;
        if (draggable) {
          el.onmousedown = null;
        }
      }
    }
  }
})
export default class FakeWindow extends Vue {
  @Ref("fake-window") readonly FakeWindowRef!: HTMLElement;
  @Model("input", { default: true }) readonly visible!: boolean;
  @Prop({ default: true }) readonly closeable!: boolean;
  @Prop({ default: true }) readonly draggable!: boolean;
  @Prop({ default: true }) readonly resizeable!: boolean;
  @Prop({ default: false }) readonly fullScreenAble!: boolean;
  @Prop({ default: true }) readonly needResetPosition!: boolean;
  @Prop({ default: "default-fade" }) readonly transitionName!: string;
  private isFullScreen = false;
  public fakeWindowCloseHandler() {
    this.$emit("input", false);
    this.$emit("close");
  }
  @Watch("visible")
  onVisibleChange() {
    if (this.needResetPosition && !this.visible) {
      this.positionResetHandler();
    }
  }
  public positionResetHandler() {
    setTimeout(() => {
      this.FakeWindowRef.style.removeProperty("position");
      this.FakeWindowRef.style.removeProperty("left");
      this.FakeWindowRef.style.removeProperty("top");
      this.FakeWindowRef.style.removeProperty("width");
      this.FakeWindowRef.style.removeProperty("height");
    }, 300);
  }
  public fullScreenHandler() {
    if (!this.isFullScreen) {
      setTimeout(() => {
        this.FakeWindowRef.style.setProperty("position", "fixed");
        this.FakeWindowRef.style.setProperty("left", "0");
        this.FakeWindowRef.style.setProperty("top", "0");
        this.FakeWindowRef.style.setProperty("width", "100vw");
        this.FakeWindowRef.style.setProperty("height", "100vh");
      }, 300);
    } else {
      this.positionResetHandler();
    }
    this.isFullScreen = !this.isFullScreen;
  }
}
</script>
<style lang="scss" scoped>
$border-color: #e4e7ed;
$basic-fontSize: 14px;
$zIndex: 9999; // TODO 需要注意 z-index 的使用
.default-fade-enter-active,
.default-fade-leave-active {
  transition: opacity 0.3s;
}
.default-fade-enter,
.default-fade-leave-to {
  opacity: 0;
}
@mixin textOverflow {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
@mixin baseIcon {
  width: 16px;
  height: 16px;
  padding: 4px;
  cursor: pointer;
}
.fake-window-container {
  position: relative;
  z-index: $zIndex;
  .fake-window {
    position: relative;
    display: flex;
    flex-direction: column;
    min-width: 200px;
    min-height: 200px;
    font-size: $basic-fontSize;
    background: #fff;
    border: 1px solid $border-color;
    border-radius: 4px;
    box-shadow: 0 2px 12px 0 #0000001a;
    &.resizeable {
      overflow: auto;
      resize: both;
    }
    &__header {
      display: flex;
      align-items: center;
      height: $basic-fontSize * 2;
      padding: $basic-fontSize / 3 $basic-fontSize / 2;
      background: #f5f7fa;
      border-bottom: 1px solid $border-color;
      &.draggable {
        cursor: move;
      }
      .title-wrapper {
        flex: 1;
        text-align: center;
        user-select: none;
        @include textOverflow;
      }
      .fullScreen-icon {
        color: #69b9ed;
        @include baseIcon;
      }
      .close-icon {
        color: #f56c6c;
        @include baseIcon;
      }
    }
    &__content {
      flex: 1;
      padding: $basic-fontSize / 3 $basic-fontSize / 2;
      overflow: auto;
      background: #fff;
      &::-webkit-scrollbar {
        width: 8px;
        height: 8px;
        background-color: #cfd8dc;
      }
      &::-webkit-scrollbar-thumb {
        background-color: #0ae;
      }
      &::-webkit-scrollbar-track {
        background-color: #cfd8dc;
      }
    }
  }
}
</style>
# 设计思路
- 由于功能是仿照 windows 窗口,所以需要有以下的特点
- 标题区域,内容区域
 - 可以进行拖拽,可以进行拉伸
 - 可关闭按钮
 - 全屏按钮
 
 - 对应技术点
- 自主拉伸
- css resize 属性
 
 - 自主拖拽
- html 原生 drag 事件
 - js mousemove 事件给元素设置 css 来实现拖拽效果
 
 
 - 自主拉伸
 - 调研结果
- 原生 drag 无法复现窗口移动的效果
 - 可以使用指令 + mousemove 事件来实现拖拽效果
 
 - 遇到的问题
- css resize 不能和 js mousemove 作用同一个元素上———最后采取用父元素来进行位置移动
 
 
# 具体知识点
像 Vue 指令,插槽,还有相关 API 的调用可以不用专门去了解,这次主要的知识点有两个
- 鼠标事件的分类
 - 鼠标事件的相关属性
 
# 鼠标事件的分类
- 鼠标按钮按下时触发 mousedown
 - 鼠标按钮放开时触发 mouseup
 - 鼠标在某个元素上移动时触发 mousemove
 - 鼠标移入到某个元素时触发 mouseenter
 - 鼠标移出某个元素时触发 mouseleave
 - 鼠标不在包含在这个元素或其子元素时触发;当鼠标从一个元素移入其子元素时,也会触发。mouseout
 - 鼠标悬停在某个元素或其子元素之一时触发 mouseover
 
# 鼠标事件的相关属性
以下均为 MouseEvent 只读属性
- 可见区域
- clientX(可以理解为,当前可见区域的横坐标)
- 客户端区域的水平坐标 (与页面坐标不同)。
 - 例如,不论页面是否有水平滚动,当你点击客户端区域的左上角时,鼠标事件的 clientX 值都将为 0
 
 - clientY(可以理解为,当前可见区域的纵坐标)
- 返回触点相对于可见视区(visual viewport)上边沿的的 Y 坐标.
 - 不包括任何滚动偏移.这个值会根据用户对可见视区的缩放行为而发生变化.
 
 
 - clientX(可以理解为,当前可见区域的横坐标)
 - 文档流
- pageX
- 触点相对于 HTML 文档左边沿的的 X 坐标. 和 clientX 属性不同, 这个值是相对于整个 html 文档的坐标, 和用户滚动位置无关. 因此当存在水平滚动的偏移时, 这个值包含了水平滚动的偏移
 
 - pageY
- 触点相对于 HTML 文档上边沿的的 Y 坐标. 和 clientY 属性不同, 这个值是相对于整个 html 文档的坐标, 和用户滚动位置无关. 因此当存在垂直滚动的偏移时, 这个值包含了垂直滚动的偏移.
 
 
 - pageX
 - 屏幕
- sceenX
- 返回触点相对于屏幕左边沿的的 X 坐标. 不包含页面滚动的偏移量.
 
 - sceenY
- 返回触点相对于屏幕上边沿的 Y 坐标. 不包含页面滚动的偏移量.
 
 
 - sceenX
 - 相对于当前 Dom 节点内部,内填充边(padding edge)的左上角为坐标轴
- offsetX
- offsetX 规定了事件对象与目标节点的内填充边(padding edge)在 X 轴方向上的偏移量。
 
 - offsetY
- offsetY 规定了事件对象与目标节点的内填充边(padding edge)在 Y 轴方向上的偏移量
 
 
 - offsetX
 
# 扩展 HTMLDocument
- clientWidth
- 内联元素以及没有 CSS 样式的元素的 clientWidth 属性值为 0。Element.clientWidth 属性表示元素的内部宽度,以像素计。该属性包括内边距,但不包括垂直滚动条(如果有)、边框和外边距。
 
 - offsetLeft
- 相对于父级定位元素(offsetParent)的距离
 
 - offsetTop
- 相对于父级定位元素(offsetParent)的距离
 
 
# 复盘设计
如何让一个 div 能够跟随鼠标拖拽移动,可以设想一下思路
鼠标本身只有三个事件:向下点击(down),左右移动(move),向上释放(up)
- 选中 div,也就是说鼠标要点击到 div,此时 div 应该触发被鼠标点击的事件
 - 监听鼠标的移动,div 需要跟着鼠标移动而移动
 - 监听鼠标的释放,此时需要同步释放鼠标的移动事件
 
最重要的问题就是应该如何移动!根据以上的知识点编写以下简单的代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Mouse</title>
    <style>
      * {
        padding: 0;
        margin: 0;
      }
      .box {
        position: relative;
        width: 200px;
        height: 200px;
        margin: 50px;
        border: 1px solid #000;
      }
    </style>
  </head>
  <body>
    <div class="box"></div>
    <script>
      const boxEl = document.querySelector(".box");
      boxEl.addEventListener("mousedown", evt => {
        let mouseX = evt.clientX;
        let mouseY = evt.clientY;
        const removeHandler = evt => {
          let curMouseX = evt.clientX;
          let curMouseY = evt.clientY;
          let moveX = curMouseX - mouseX;
          let moveY = curMouseY - mouseY;
          boxEl.style.left = `${moveX}px`;
          boxEl.style.top = `${moveY}px`;
        };
        document.addEventListener("mousemove", removeHandler);
        document.addEventListener("mouseup", () => {
          document.removeEventListener("mousemove", removeHandler);
          document.removeEventListener("mouseup", this);
        });
      });
    </script>
  </body>
</html>
但如果只是用以上的代码,其实是有问题的,因为当进行 mouseup 事件后,会发现 boxEl 又回到原来的位置了。因为再次触发事件的时候,是不会以上次的移动距离为基准的。所以,此时需要拿到 boxEl 的偏移位置,刚好 HTMLElement 上有 offsetLeft,offsetTop 等属性可以使用,更改代码如下
<script>
  const boxEl = document.querySelector(".box");
  boxEl.addEventListener("mousedown", evt => {
    let mouseX = evt.clientX;
    let mouseY = evt.clientY;
    let offsetX = mouseX - boxEl.offsetLeft;
    let offsetY = mouseY - boxEl.offsetTop;
    const removeHandler = evt => {
      let curMouseX = evt.clientX;
      let curMouseY = evt.clientY;
      let moveX = curMouseX - offsetX;
      let moveY = curMouseY - offsetY;
      boxEl.style.left = `${moveX}px`;
      boxEl.style.top = `${moveY}px`;
    };
    document.addEventListener("mousemove", removeHandler);
    document.addEventListener("mouseup", () => {
      document.removeEventListener("mousemove", removeHandler);
      document.removeEventListener("mouseup", this);
    });
  });
</script>
以为这样的完美了吗?并没有,因为 .box 中设置了 margin,所以每次触发 mousedown 都会出现偏移现象,所以这个时候需要注意,要么统一使用 left,right,要么统一使用 margin-left,margin-right,从布局来说,为了能够移动元素,需要设置 position 属性,所以应该将原来的 .box 中的 margin 更改掉
.box {
  position: relative;
  top: 50px;
  right: 50px;
  bottom: 50px;
  left: 50px;
  width: 200px;
  height: 200px;
  border: 1px solid #000;
}
这样才是完整的“元素移动”设计