在 Web 上实现窗口

本文最后更新于:2021年11月2日 中午

场景

现有的 vue-draggable-resizable 存在一些问题,所以无法直接使用。幸运的是,react-rnd 实现了完善的 web 窗口功能,它又基于更基础的库 react-draggablere-resizable

最近在实践的基于 web 的扩展系统需要支持子应用创建 ui,所以使用了 iframe + “窗口” 的方式。

窗口的关键功能

  • 拖拽:鼠标在指定位置可以拖拽整个元素
  • 缩放:鼠标在边界上能够缩放这个元素,不同边界的缩放方向不同

窗口拖拽

基本思路是监听鼠标事件,然后修改 dom 元素的位置。

此处实现需要特别注意的是需要以某种方式确定哪些区域可以拖拽,哪些区域不行,所以使用 data-drag/data-no-drag 来标识。

效果图

窗口拖拽

窗口缩放

效果图

窗口缩放

拖拽与缩放遇到的问题

  • 拖拽和缩放都并未检测窗口是否超出可是边缘
  • 当拖拽到边缘或鼠标移动过快时,鼠标会在拖拽元素内部。如果容器内包含文本,则有可能被选中;如果容器内是 iframe,则页面会失去焦点,无法再响应 mousemove 事件

拖拽与缩放遇到的问题

整合拖拽与缩放

主要目标如下

  • 避免将窗口拖拽或缩放到可视区域之外
  • 从左(和或)上方向上缩放同时控制窗口位置和大小
  • 解决容器内文字可能在拖拽或缩放时被选中
  • 解决鼠标移至 iframe 区域内会失去响应的问题

效果图

整合拖拽与缩放

避免将窗口拖拽或缩放到可视区域之外

主要思路是计算窗口的尺寸+位置不能超过容器大小,关键代码如下

function onDrag(pos: Pos) {
  const container = unref(draggableRef)!.container!;
  const containerSize = props.containerSize!;
  state.pos = {
    x: Math.min(
      Math.max(pos.x, 0),
      containerSize.width - container.clientWidth
    ),
    y: Math.min(
      Math.max(pos.y, 0),
      containerSize.height - container.clientHeight
    ),
  };
  // console.log('onDrag: ', pos.x, containerSize.width, container.clientWidth)
}
function onResize(size: Size, deviation: Deviation, dir: Direction[]) {
  // 解决 从左(和或)上方向上缩放同时控制窗口位置和大小 的场景
  if (dir.includes("left")) {
    state.pos.x = Math.max(unref(pos)!.x + deviation.dx, 0);
    if (state.pos.x === 0) {
      return;
    }
  }
  if (dir.includes("top")) {
    state.pos.y = Math.max(unref(pos)!.y + deviation.dy, 0);
    if (state.pos.y === 0) {
      return;
    }
  }
  // 避免将窗口拖拽或缩放到可视区域之外
  const containerSize = props.containerSize!;
  state.size = {
    width: Math.min(Math.max(size.width, 0), containerSize.width - state.pos.x),
    height: Math.min(
      Math.max(size.height, 0),
      containerSize.height - state.pos.y
    ),
  };
}

这里也同时解决了第二个问题

解决容器内文字可能在拖拽或缩放时被选中

主要思路是拖拽时禁用所有子元素的文字可选中功能

.disableSelect * {
  user-select: none;
}

解决鼠标移至 iframe 区域内会失去响应的问题

主要思路是拖拽时将拖拽元素上覆盖一层透明遮罩,避免鼠标进入到 iframe 区域内。由于鼠标在拖拽过程中可能移动到其他 iframe 内部而失去焦点,所以在拖拽时还必须为所有可拖拽元素覆盖一层遮罩,这里使用 provide/inject 实现。

<!-- RndProvider.vue -->
<script lang="ts">
  import { computed, defineComponent, provide, ref } from "vue";
  import { Size } from "../model/RndModel";
  import { useElementSize } from "@vueuse/core";
  import { RndContext } from "./RndContext";

  export default defineComponent({
    name: "RndProvider",
    setup() {
      const container = ref<HTMLDivElement>();
      const _containerSize = useElementSize(container);
      const containerSize = computed<Size>(() => ({
        width: _containerSize.width.value,
        height: _containerSize.height.value,
      }));
      const isDown = ref(false);
      provide(RndContext, { containerSize, isDown });
      return { container, containerSize };
    },
  });
</script>

<template>
  <div class="rndProvider" ref="container">
    <slot />
  </div>
</template>

<style scoped>
  .rndProvider {
    position: relative;
    height: 100%;
    width: 100%;
  }
</style>
<!-- Rnd.vue -->
<script lang="ts">
  export default defineComponent({
    setup(props) {
      const { isDown } = inject(RndContext)!;

      return {
        isDown,
      };
    },
  });
</script>

<template>
  <div v-show="isDown" class="mask"></div>
</template>

<style scoped>
  /*避免 iframe 在缩放时获取到焦点*/
  .mask {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
  }
</style>

性能优化

  • 动态绑定 mousemove/mouseup 事件,避免每多一个窗口就多一个全局回调
function _onDragStart(ev: MouseEvent) {
  // 其他代码...
  window.addEventListener("mousemove", _onDrag);
  window.addEventListener("mouseup", _onDragStop);
}
function _onDragStop() {
  // 其他代码...
  window.removeEventListener("mousemove", _onDrag);
  window.removeEventListener("mouseup", _onDragStop);
}
onUnmounted(() => {
  window.removeEventListener("mousemove", _onDrag);
  window.removeEventListener("mouseup", _onDragStop);
});

使用 Rnd 组件

目前,以上代码已抽离为单独的 npm 包 vue-rnd,可以在需要的地方自行安装使用,下面是一个使用示例(上面整合拖拽与缩放的效果图就是以下代码实现的)

<script lang="ts">
  import { computed, defineComponent } from 'vue'
  import Rnd from '../component/Rnd.vue'
  import { Size } from '../model/RndModel'
  import { useWindowSize } from '@vueuse/core'
  import RndProvider from '../component/RndProvider.vue'

  export default defineComponent({
    name: 'RndBasic',
    components: { RndProvider, Rnd },
    setup() {
      const windowSize = useWindowSize()
      const containerSize = computed<Size>(() => ({
        width: windowSize.width.value,
        height: windowSize.height.value,
      }))
      return { containerSize }
    },
  })
</script>

<template>
  <RndProvider>
    <Rnd
      :init="{
        pos: { x: 0, y: 0 },
        size: { width: 500, height: 400 },
      }"
      :containerSize="containerSize"
    >
      <div class="container">
        <h2 class="title" data-drag="true">标题栏</h2>
        <iframe src="https://www.bing.com/" width="100%" height="100%" />
      </div>
    </Rnd>
    <Rnd
      :init="{
        pos: { x: 100, y: 100 },
        size: { width: 500, height: 400 },
      }"
      :containerSize="containerSize"
    >
      <div class="container">
        <h2 class="title" data-drag="true">标题栏</h2>
        <iframe src="https://www.google.com/webhp?igu=1" width="100%" height="100%" />
      </div>
    </Rnd>
  </RndProvider>
</template>

<style scoped>
  .container {
    width: 100%;
    height: 100%;
    display: grid;
    grid-template-rows: auto 1fr;
  }
  .title {
    cursor: move;
    user-select: none;
  }
</style>

在 iframe 加载时添加 loading

现有方案

  • 使用 gif 作为 iframe 背景图
  • 在 iframe 上面添加遮罩层

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!