图片来自于泼辣有图 | Polayoutu - Free Chinese stock photos,作者摄于苏州昆曲博物馆
这次尝试用Vue做一个Window玩,虽然不能与盖茨的视窗系统相比,但也满足了基本功能,在Web应用中可以试用一番。
按国际惯例还是先看效果,由于知乎扛不住我的动图,只能传到github上。
https://github.com/swordrain/window/blob/master/windows.gif?raw=truegithub.com这里就贴一张静图。
项目搭建
这次我没有用vue-cli亦或是Webpack来搭建出项目,因为从结构上来说实在是太简单了,简单到我只定义了一个组件。
所以,什么都不需要做,只要下载个vue.js
即可,如果网速可以的话,用cdn也行。
虽然总结的时候我是一步到位,但实际开发过程中其实是不停的迭代修改。回过头来思考,现代软件的复杂度就决定了我们很难再去实施以前的瀑布或者螺旋式开发模式——我们对于软件产品的思考是无法一步到位的,进而软件的架构和技术也同样如此。
基本框架
在<body>
标签里,我放入了测试样例代码
<div id="app">
<div class="container side">
<window title="Hello Window"></window>
<window title="Hello Window1" :init-left="50" :init-top="50" :resizable="false"></window>
</div>
<div class="container main">
<window title="Hello Window2"></window>
</div>
</div>
<window>
就是我刚才提到需要定义出的组件。我把页面分为两部分,为的就是显示窗口在各自的父元素中独立——只能限定在父元素的范围。
当然最后的Vue入口肯定是要调用的。
new Vue({
el: "#app"
});
样式
html,
body {
height: 100%;
margin: 0;
}
#app {
height: 100%;
display: flex;
}
.container {
height: 100%;
margin: 0 10px;
border: 1px solid olivedrab;
box-sizing: border-box;
position: relative;
}
.side {
flex: 2;
}
.main {
flex: 3;
}
.window-container {
position: absolute;
display: inline-block;
border: 1px solid lightgray;
box-sizing: border-box;
box-shadow: 1px 1px 5px gray;
display: flex;
flex-direction: column;
background: white; /* 不能透明 */
}
.window-title {
height: 30px;
line-height: 30px;
border-bottom: 1px solid #aaaaaa;
text-align: center;
user-select: none;
}
.window-title:hover {
cursor: move;
}
.window-btn {
float: right;
border: none;
background: none;
outline: none;
}
.window-close:hover {
background: FireBrick;
cursor: pointer;
}
.window-min:hover,
.window-max:hover {
background: #bbbbbb;
cursor: pointer;
}
.window-body {
flex: 1;
position: relative;
}
.window-right-drag-handler {
position: absolute;
right: -3px;
width: 6px;
top: 0;
bottom: 0;
}
.window-right-drag-handler:hover {
cursor: ew-resize;
}
.window-bottom-drag-handler {
position: absolute;
right: 0;
height: 6px;
left: 0;
bottom: -3px;
}
.window-bottom-drag-handler:hover {
cursor: ns-resize;
}
.window-right-bottom-drag-handler {
position: absolute;
width: 6px;
height: 6px;
bottom: -3px;
right: -3px;
}
.window-right-bottom-drag-handler:hover {
cursor: se-resize;
}
因为没有用vue单文件方式开发,所以所有的样式我直接定义到了<style>
中,唯一一个陌生的是可以拖动时鼠标样式。好在地理基础扎实,上北N下南S左西W右东E背得纯熟,也不难理解它们。
组件模板
<div
class="window-container"
@mousedown="makeWindowForground"
:style="{width:position.width,height:isMin?'30px':position.height,left:position.left,top:position.top}"
>
<div class="window-title" @mousedown="startDrag">
{{ title }}
<button class="window-btn window-close" v-if="closable" @click="close">×</button>
<button class="window-btn window-max" v-if="maxmize" @click="max">{{ isMax ? "□" : "+" }}</button>
<button class="window-btn window-min" v-if="minimize" @click="min">-</button>
</div>
<div class="window-body" v-show="!isMin">
<div v-if="resizable" class="window-right-drag-handler" @mousedown="startResize($event,'right')"></div>
<div v-if="resizable" class="window-bottom-drag-handler" @mousedown="startResize($event,'bottom')"></div>
<div v-if="resizable" class="window-right-bottom-drag-handler" @mousedown="startResize($event,'both')"></div>
</div>
</div>
主要是拆分为title和body两大块,为了演示当然也因为懒,这里body里没有加上<slot>
。
title里有三个按钮,而body里也有三个特殊的div,分别对应这右边、下边、右下角三处的拖动,鼠标只要在hover到它们上边时才会显示拖动的样式。另外包括title和三个拖动手柄在内,都只声明了mousedown的事件,而mousemove和mouseup是在mousedown事件里动态加进去的,等到结束时再移除,这个在之前做视频播放器的拖动条时也提到过。
组件数据
这里用数据统称了data和props,每一项上都加了注释,不能理解。其中跟位置有关的props都传入的是初始值,真正变化的值都是在内部的data里来维护的。
{
data: function() {
return {
position: {
width: this.initWidth + "px",
height: this.initHeight + "px",
left: this.initLeft + "px",
top: this.initTop + "px"
}, //窗口都是绝对定位
isMax: false, //当前是否是最大化状态
isMin: false, //当前是否是最小化状态
previousPosition: null, //保存前一个状态的position,供从最大化恢复的时候使用
dragging: false, //是否正在拖动
positionBeforeDragging: null,
resizeType: null, //right 向右拖动, bottom 向下拖动, both 同时
resizeOrigin: null //开始拖动时的初始坐标
};
},
props: {
shadowWidth: {
type: Number,
default: 3
}, // 阴影宽度
title: {
type: String,
default: ""
}, //标题
initWidth: {
type: Number,
default: 300
}, //初始宽度
initHeight: {
type: Number,
default: 200
}, //初始高度
minWidth: {
type: Number,
default: 200
},
minHeight: {
type: Number,
default: 150
},
initLeft: {
type: Number,
default: 10
}, //初始左边距
initTop: {
type: Number,
default: 10
}, //初始上边距
minimize: {
type: Boolean,
default: true
}, //能否最小化
maxmize: {
type: Boolean,
default: true
}, //能否最大化
closable: {
type: Boolean,
default: true
}, //能否关闭
resizable: {
type: Boolean,
default: true
} //能否缩放
}
}
拖动效果
鼠标点下开始拖动的时候,要把当前的位置记录下来,后续要用来计算的,同时把鼠标移动和抬起的事件都绑上。
if (!this.isMax) {
this.dragging = true;
this.positionBeforeDragging = {
x: e.clientX,
y: e.clientY,
left: parseFloat(this.position.left),
top: parseFloat(this.position.top)
};
// 在按下去后才绑定事件
document.addEventListener("mouseup", this.endDrag);
document.addEventListener("mousemove", this.drag);
}
鼠标抬起时只需要把绑定的事件移除以及修改标记即可。
this.dragging = false;
//结束拖动后移除事件
document.removeEventListener("mouseup", this.endDrag);
document.removeEventListener("mousemove", this.drag);
真正复杂的是在拖动的时候,要计算边界。有一点要注意的是,这个事件一定要是挂载在document上的,一开始我挂在title的DOM上,结果一旦拖动快了就不响应了。
//挂载document上,是因为有的时候拖动太快,窗口位置没有跟上鼠标,
//导致鼠标在title之外了而不响应事件了
if (this.dragging) {
const offsetX = e.clientX - this.positionBeforeDragging.x;
const offsetY = e.clientY - this.positionBeforeDragging.y;
//往左移动不能小于0 往右移动不能大于容器边界
let positionLeft = this.positionBeforeDragging.left + offsetX;
if (positionLeft < 0 && offsetX < 0) {
//offsetX < 0表明往左移动
positionLeft = 0;
}
//由于阴影有宽度,一般人的视觉上把阴影也算作窗口的一部分
if (
positionLeft + parseFloat(this.position.width) + this.shadowWidth >
parseFloat(getComputedStyle(this.$el.parentElement).width) &&
offsetX > 0
) {
positionLeft =
parseFloat(getComputedStyle(this.$el.parentElement).width) - parseFloat(this.position.width) - this.shadowWidth;
}
this.position.left = positionLeft + "px";
let positionTop = this.positionBeforeDragging.top + offsetY;
if (positionTop < 0 && offsetY < 0) {
//offsetY < 0表明往上移动
positionTop = 0;
}
if (
positionTop + parseFloat(this.position.height) + this.shadowWidth >
parseFloat(getComputedStyle(this.$el.parentElement).height) &&
offsetY > 0
) {
positionTop =
parseFloat(getComputedStyle(this.$el.parentElement).height) - parseFloat(this.position.height) - this.shadowWidth;
}
this.position.top = positionTop + "px";
}
其实这里组件设计的好的话应该要$emit出一个事件,因为窗口变化后很可能外部是需要感知它的变化的。
缩放效果
缩放操作跟拖动操作很类似,也是需要判断边界情况的,代码留到最后再贴。
同样这里在设计上最好也$emit出一个事件,把窗口当前的大小传递出去。
最大最小化
最大最小化的逻辑我卡了比较久,久到我怀疑到现在也没完全正确,由于一开始调的不顺,后面都有点发怵了。
最大化改变窗口的大小,所以要记录原窗口的位置以便在复原的时候用到。
if (this.isMax) {
this.isMax = false;
this.position = this.previousPosition;
this.previousPosition = null;
} else {
this.isMin = false;
this.isMax = true;
this.previousPosition = this.position;
this.position = {
width: "100%",
height: "100%",
left: 0,
top: 0
};
}
最小化不需要记忆原位置,因为它仅仅是把body部分隐藏起来。
if (this.isMin) {
this.isMin = false;
} else {
this.isMax = false;
this.isMin = true;
}
关闭
关闭的代码有点不确定,因为我最后是手工把DOM给清除的,我找来找去没有找到清理的API,而众所周知的$destroy钩子并不会有清理DOM的功效,如果有谁知道比较官方的用法请留言告诉我。
this.$el.parentNode.removeChild(this.$el);
//这个调用是割断Vue组件和DOM的关系,不是把这个组件删除
//官网的原文是完全销毁一个实例。清理它与其它实例的连接,解绑它的全部指令及事件监听器
//实测是不会删除组件DOM的
this.$destroy();
完整代码
最后很敷衍的来贴上代码的地址,
swordrain/window