在 Web 上实现窗口
本文最后更新于:2022年7月31日 晚上
场景
现有的 vue-draggable-resizable 存在一些问题,所以无法直接使用。幸运的是,react-rnd 实现了完善的 web 窗口功能,它又基于更基础的库 react-draggable 和 re-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 上面添加遮罩层
在 Web 上实现窗口
https://blog.rxliuli.com/p/5683c73c8ccc4aeeb4685c2a9b0c5f9a/