基于 vue
,下面代码部分直接添加为一个组件显示即可运行,是完整代码
业务痛点
- 固定类型数据展示页面重复开发 (活动页面重复开发)
- 需求改变频繁
- 开发时间短
- 开发人力不足
- 开发工作零碎 维护成本高
- 开发流程涉及产品/运营 沟通成本高
解决:
- 组件复用
- 拖拽生成
- 配置修改
类似游戏引擎
要点
- 组件化
- 动态组件 根据组件信息生成对应类型组件
- dashboard 画布
- 数组保存每个组件
- 拖拽 缩放 删除 图层 放大缩小 吸附 标线 配置
- 配置表单 JSON Schema
- 组件交互
- eventbus 发布订阅
- 固定类型 固定事件
- 初始化时绑定
- 组件数据
- 配置请求 api
- 页面预览
- 修改页面状态 读取缓存页面数据
- 页面挂载
- 实时渲染快
- 可能污染编辑器页面
- 后台渲染
- 页面分享
- 页面数据保存 得到数据 ID 根据 id 生成分享页面
- 实时数据更新
- websocket 对应 id 请求
布局
整体页面布局:
- 工具栏:快捷操作
- 组件列表:可以生成的组件
- 画布:dashboard 用来放置组件
- 属性编辑区域:修改组件属性
思路:
- 用一个数组
componentData
维护画布上的组件数据 - 拖拽组件到画布上,使用
push
将组件的数据加入到componentData
- 使用动态组件和
v-for
来把componentData
中到组件渲染上去
拖拽
思路:
dragstart
开始拖拽将组件信息缓存drop
拖拽结束将缓存信息加入到componentData
,触发drop
事件时,使用dataTransfer.getData()
接收传输过来的索引数据,然后根据索引找到对应的组件数据,再添加到画布,从而渲染组件。- 首先需要将画布设为相对定位
position: relative
,然后将每个组件设为绝对定位position: absolute
。除了这一点外,还要通过监听三个事件来进行移动:
mousedown
事件,按下鼠标时,记录组件当前的位置mousemove
事件,每次鼠标移动时,都用当前最新的坐标减去最开始的坐标,从而计算出移动距离,再改变组件位置mouseup
事件,鼠标抬起时结束移动。
<template>
<div class="content-wrap">
<!-- 组件列表 -->
<div @dragstart="handleDragStart" class="component-list">
<div
v-for="(item, index) in componentList"
:key="index"
class="list"
draggable
:data-index="index"
>
<!-- 自定义组件 -->
<span>{{ item.label }}</span>
</div>
</div>
<!-- 画板 -->
<div
class="content"
@drop="handleDrop"
@dragover="handleDragOver"
@mousedown="handleMouseDown"
ref="content"
>
<div
v-for="(item, index) in componentData"
:key="index"
class="list"
:data-index="index"
:style="item.style"
>
<!-- 自定义组件 -->
<span>{{ item.label }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
componentList: [
{
label: "文本1",
},
{
label: "文本2",
},
{
label: "文本3",
},
],
componentData: [],
operation: "",
};
},
methods: {
handleDragStart(e) {
e.dataTransfer.setData("index", e.target.dataset.index);
this.operation = "drag";
},
handleDrop(e) {
e.preventDefault();
e.stopPropagation();
// 拖拽的组件
let selected = this.componentList[e.dataTransfer.getData("index")];
if (!selected) return false;
const component = JSON.parse(JSON.stringify(selected));
component.style = {
top: e.offsetY + "px",
left: e.offsetX + "px",
};
const tmp = [...this.componentData, component];
// 把组件信息加入到画布组件信息队列中
this.$set(this, "componentData", tmp);
},
handleDragOver(e) {
// 需要这样设置才能触发 drop
// 1. 屏蔽默认事件
// 2. 设置 dropEffect = copy
e.preventDefault();
if (this.operation === "drag") e.dataTransfer.dropEffect = "copy";
if (this.operation === "move") {
return false;
}
},
handleMouseDown(e) {
e.stopPropagation();
this.operation = "move";
// 移动选中的组件
let selected = this.componentData[e.target.parentNode.dataset.index];
if (!selected) return false;
const component = JSON.parse(JSON.stringify(selected));
const pos = component.style;
const startY = e.clientY;
const startX = e.clientX;
const startTop = Number(pos.top.replace("px", ""));
const startLeft = Number(pos.left.replace("px", ""));
// mousemove 修改位置
const move = (moveEvent) => {
const currX = moveEvent.clientX;
const currY = moveEvent.clientY;
pos.top = currY - startY + startTop;
pos.left = currX - startX + startLeft;
// 修改当前组件样式
selected.style = {
top: pos.top + "px",
left: pos.left + "px",
};
};
// mouseup 解除事件绑定
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
},
},
};
</script>
<style>
.content-wrap {
display: flex;
flex-direction: row;
}
.component-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 10px;
height: 200px;
width: 100px;
border: 1px solid lightblue;
}
.content {
height: 200px;
flex: 1;
border: 1px solid lightblue;
padding: 10px;
margin-left: 10px;
position: relative;
}
.list {
height: fit-content;
width: fit-content;
border: 1px solid grey;
cursor: grab;
margin-bottom: 10px;
text-align: center;
color: #333;
padding: 2px 5px;
display: flex;
align-items: center;
justify-content: center;
}
.content .list {
position: absolute;
}
</style>
复制代码
删除组件,调整图层
由于拖拽组件到画布中是有先后顺序的,所以可以按照数据顺序来分配图层层级。
改变图层层级,即是改变组件数据在 componentData
数组中的顺序。
<template>
<!-- 画板 -->
<div
class="content"
@mousedown="handleMouseDown"
ref="content"
@click="showMenu = false"
>
<div
v-for="(item, index) in componentData"
:key="index"
class="list"
:data-index="index"
:style="Object.assign(item.style, { zIndex: index })"
@contextmenu="handleMenu"
>
<!-- 自定义组件 -->
<span>{{ item.label }}</span>
</div>
<div class="menu" v-show="showMenu" :style="menuPos" @click="handleCommand">
<div data-command="up">上移</div>
<div data-command="down">下移</div>
<div data-command="bottom">置底</div>
<div data-command="top">置顶</div>
<div data-command="delete">删除</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
componentData: [
{
style: {
top: "0",
left: "0",
background: "lightblue",
},
label: "文本1",
},
{
style: {
top: "20px",
left: "10px",
background: "lightgrey",
},
label: "文本2",
},
{
style: {
top: "40px",
left: "20px",
background: "lightgreen",
},
label: "文本3",
},
],
showMenu: false,
menuPos: {},
currentComponent: null,
};
},
methods: {
handleMouseDown(e) {
e.stopPropagation();
this.operation = "move";
// 移动选中的组件
let selected = this.componentData[e.target.parentNode.dataset.index];
if (!selected) return false;
const component = JSON.parse(JSON.stringify(selected));
const pos = component.style;
const startY = e.clientY;
const startX = e.clientX;
const startTop = Number(pos.top.replace("px", ""));
const startLeft = Number(pos.left.replace("px", ""));
// mousemove 修改位置
const move = (moveEvent) => {
const currX = moveEvent.clientX;
const currY = moveEvent.clientY;
pos.top = currY - startY + startTop;
pos.left = currX - startX + startLeft;
// 修改当前组件样式
selected.style = {
top: pos.top + "px",
left: pos.left + "px",
background: pos.background,
};
};
// mouseup 解除事件绑定
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
},
handleMenu(e) {
e.preventDefault();
this.currentComponent = e.currentTarget.dataset.index;
this.menuPos = {
top: e.offsetY + "px",
left: e.offsetX + "px",
};
this.showMenu = true;
},
handleCommand(e) {
const command = e.target.dataset.command;
const len = this.componentData.length - 1;
const tmp = [...this.componentData];
const curIndex = +this.currentComponent;
switch (command) {
case "up":
if (curIndex != len) {
[tmp[curIndex + 1], tmp[curIndex]] = [
tmp[curIndex],
tmp[curIndex + 1],
];
this.$set(this, "componentData", tmp);
}
break;
case "down":
if (curIndex != 0) {
[tmp[curIndex - 1], tmp[curIndex]] = [
tmp[curIndex],
tmp[curIndex - 1],
];
this.$set(this, "componentData", tmp);
}
break;
case "bottom":
if (curIndex != 0) {
tmp.unshift(...tmp.splice(curIndex, 1));
this.$set(this, "componentData", tmp);
}
break;
case "top":
if (curIndex != len) {
tmp.push(...tmp.splice(curIndex, 1));
this.$set(this, "componentData", tmp);
}
break;
case "delete":
tmp.splice(curIndex, 1);
this.$set(this, "componentData", tmp);
break;
}
},
},
};
</script>
<style>
.content {
height: 200px;
border: 1px solid lightblue;
padding: 10px;
margin-left: 10px;
position: relative;
}
.list {
height: fit-content;
width: fit-content;
border: 1px solid grey;
cursor: grab;
margin-bottom: 10px;
text-align: center;
color: #333;
padding: 2px 5px;
display: flex;
align-items: center;
justify-content: center;
}
.content .list {
position: absolute;
}
.menu {
border: 1px solid #000;
width: fit-content;
z-index: 999;
position: absolute;
background: lightcyan;
}
.menu div {
border: 1px solid #000;
cursor: pointer;
padding: 0 3px;
}
.menu div:hover {
background: lightblue;
}
</style>
复制代码
放大缩小
选中画布上的组件,组件外圈会出现 8 个小圆点可以拖动进行放大缩小。
思路:
- 每个组件外层包裹一层组件,包裹组件包含 8 个小圆点和插槽,插槽用来放置组件
- 点击组件通过样式控制显示小圆点
- 计算每个小圆点的位置(要显示在组件外层,还需要计算小圆点的大小,记小圆点长宽都为
w
):
- 左上
left:0-w top:0-w
- 右上
left:width top:0-w
- 左下
left:0-w top:height
- 右下
left:width top:height
- 中间的点
width/2 height/2
同理计算
- 点击小圆点可以进行缩放操作
- 点击小圆点,记录初始坐标
- 向下拖动就用新的 y 坐标减去初始坐标可以得到移动距离,再把距离加上组件的高度计算得到新的高度(用
movement
鼠标移动距离也可以计算) - 上下只能修改高度 左右只能修改宽度,西北方向特殊处理,需要限制对应圆点的功能
<template>
<!-- 画板 -->
<div class="content" @click="contentClick">
<!-- 模拟外层包裹组件 -->
<div
v-for="(item, index) in componentData"
:key="index"
class="component"
:style="item.style"
:data-index="index"
@click="
(e) => {
e.stopPropagation();
selected = index;
}
"
>
<!-- 自定义组件 -->
<div>自定义组件</div>
<div
class="dots"
v-for="(dot, i) in dots"
:key="i"
:style="{
left: dot[0] + 'px',
top: dot[1] + 'px',
width: dotSize + 'px',
height: dotSize + 'px',
cursor: dot[2],
}"
@mousedown="handleMouseDown"
:data-type="dot[2].split('-')[0]"
></div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
componentData: [
{
style: {
top: "30px",
left: "30px",
height: "100px",
width: "100px",
background: "lightblue",
},
},
],
selected: null,
dotSize: 4,
};
},
methods: {
contentClick() {
this.selected = null;
},
// componentClick(index) {
// return function(e) {
// e.stopPropagation();
// this.selected = index;
// };
// },
handleMouseDown(e) {
const component = this.componentData[this.selected];
const type = e.target.dataset.type;
const move = (me) => {
let t = { ...component.style };
// 东西方向能修改宽度
if (type.indexOf("e") > -1)
t.width = Number(t.width.replace("px", "")) + me.movementX + "px";
// 南北方向能修改高度
if (type.indexOf("s") > -1)
t.height = Number(t.height.replace("px", "")) + me.movementY + "px";
// 西北方向需要修改 left top 宽高修改相反
if (type.indexOf("w") > -1) {
t.left = Number(t.left.replace("px", "")) + me.movementX + "px";
t.width = Number(t.width.replace("px", "")) - me.movementX + "px";
}
if (type.indexOf("n") > -1) {
t.top = Number(t.top.replace("px", "")) + me.movementY + "px";
t.height = Number(t.height.replace("px", "")) - me.movementY + "px";
}
this.$set(this.componentData[this.selected], "style", t);
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
},
},
computed: {
dots() {
if (this.selected !== null) {
const component = this.componentData[this.selected];
const width = +component.style.width.replace("px", "");
const height = +component.style.height.replace("px", "");
return [
[0 - this.dotSize, 0 - this.dotSize, "nw-resize"],
[0 - this.dotSize, height, "sw-resize"],
[width, 0 - this.dotSize, "ne-resize"],
[width, height, "se-resize"],
[width / 2, 0 - this.dotSize, "n-resize"],
[width / 2, height, "s-resize"],
[0 - this.dotSize, height / 2, "w-resize"],
[width, height / 2, "e-resize"],
];
}
return [];
},
},
};
</script>
<style>
.content {
height: 200px;
flex: 1;
border: 1px solid lightblue;
padding: 10px;
margin-left: 10px;
position: relative;
}
.component {
position: absolute;
height: fit-content;
width: fit-content;
text-align: center;
color: #333;
display: flex;
align-items: center;
justify-content: center;
}
.dots {
border: 1px solid lightblue;
position: absolute;
}
</style>
复制代码
吸附 对齐
参考 Sketch
墨刀
可以看到,一个组件在画布中可以由 6 条线 (vt / vm / vb | hl / hm / hr)
来表示,组件移动过程中的对齐其实就是组件的 6 条线到其它组件线的集合中寻找临近线,找到后考虑 吸附 + 对齐 的过程。
对齐吸附:横向左移动为例,它的 hl / hm / hr
会不断的去查找与这 3 条线相邻最近的线。
- 当没有找到相邻线时,组件跟随鼠标移动
- 当初次找到时,组件便移动一个较大距离吸附过去
- 当在吸附线上再次移动时,继续查找相邻线,看是否有下一条吸附线
- 如果有,则移动到下一条吸附线上
- 如果没有,则在鼠标移动一定距离后,组件离开
具体(a 是移动组件 b 是固定组件):
- 向下移动 依次会出现下面情况
- a 底部 = b 顶部 显示 水平底部线
- a 顶部 = b 顶部 显示 水平顶部线
- a 中间 = b 中间 显示 水平中线
- a 底部 = b 底部 显示 水平底部线
- a 顶部 = b 底部 显示 水平顶部线
- 向左向右同理
- 向上移动和向下出现顺序相反
上下和左右分别构造两个数组来保存:
- 判断条件 是否满足吸附
- 显示的线的类型
- 吸附后 线的位置
- 吸附后 组件的位置
按照顺序遍历数组,只要满足条件就退出,同时一个方向只会显示一条线
<template>
<!-- 画板 -->
<div class="content" @click="selected = null">
<!-- 模拟外层包裹组件 -->
<div
v-for="(item, index) in componentData"
:key="index"
class="component"
:style="item.style"
:data-index="index"
@mousedown="handleMouseDown"
>
<!-- 自定义组件 -->
<!-- <div>自定义组件</div> -->
</div>
<!-- 标线 -->
<div class="mark-line">
<div
v-for="line in lines"
:key="line"
class="line"
:class="line.includes('x') ? 'xline' : 'yline'"
:ref="line"
v-show="lineStatus[line] || false"
></div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
componentData: [
{
style: {
top: "0",
left: "0",
width: "75px",
height: "75px",
background: "lightblue",
},
},
{
style: {
top: "100px",
left: "100px",
width: "100px",
height: "100px",
background: "lightgrey",
},
},
],
currentComponent: null,
lines: ["xt", "xc", "xb", "yl", "yc", "yr"], // 分别对应三条横线和三条竖线
diff: 5, // 相距 dff 像素将自动吸附
lineStatus: {
xt: false, // 水平顶部
xc: false, // 水平中间
xb: false, // 水平底部
yl: false, // 垂直左边
yc: false, // 垂直中间
yr: false, // 垂直右边
},
};
},
methods: {
hideLine() {
Object.keys(this.lineStatus).forEach((line) => {
this.lineStatus[line] = false;
});
},
showLine(currentComponent, index, top, left, toDown, toRight) {
const rest = this.componentData.filter((c, i) => i != index)[0];
const rwidth = +rest.style.width.replace("px", "");
const rheight = +rest.style.height.replace("px", "");
const rtop = +rest.style.top.replace("px", "");
const rleft = +rest.style.left.replace("px", "");
const width = +currentComponent.style.width.replace("px", "");
const height = +currentComponent.style.height.replace("px", "");
this.hideLine();
const lines = this.$refs;
let changeLeft = left,
changeTop = top;
const rules = {
updown: [
{
condition: top + height < rtop && top + height + this.diff >= rtop,
line: "xb",
ltop: rtop,
top: rtop - height,
},
{
condition: top < rtop && top + this.diff >= rtop,
line: "xt",
ltop: rtop,
top: rtop,
},
{
condition:
top + height / 2 < rtop + rheight / 2 &&
top + height / 2 + this.diff >= rtop + rheight / 2,
line: "xc",
ltop: rtop + rheight / 2,
top: rtop + rheight / 2 - height / 2,
},
{
condition:
top + height < rtop + rheight &&
top + height + this.diff >= rtop + rheight,
line: "xb",
ltop: rtop + rheight,
top: rtop + rheight - height,
},
{
condition:
top < rtop + rheight && top + this.diff >= rtop + rheight,
line: "xt",
ltop: rtop + rheight,
top: rtop + rheight,
},
],
leftright: [
{
condition:
left + width < rleft && left + width + this.diff >= rleft,
line: "yr",
lleft: rleft,
left: rleft - width,
},
{
condition: left < rleft && left + this.diff >= rleft,
line: "yl",
lleft: rleft,
left: rleft,
},
{
condition:
left + width / 2 < rleft + rwidth / 2 &&
left + width / 2 + this.diff >= rleft + rwidth / 2,
line: "yc",
lleft: rleft + rwidth / 2,
left: rleft + rwidth / 2 - width / 2,
},
{
condition:
left + width < rleft + rwidth &&
left + width + this.diff >= rleft + rwidth,
line: "yr",
lleft: rleft + rwidth,
left: rleft + rwidth - width,
},
{
condition:
left < rleft + rwidth && left + this.diff >= rleft + rwidth,
line: "yl",
lleft: rleft + rwidth,
left: rleft + rwidth,
},
],
};
// 向上顺序相反
if (!toDown) {
rules.updown.reverse();
}
for (let t of rules.updown) {
if (t.condition) {
this.lineStatus[t.line] = true;
lines[t.line][0].style.left = 0;
lines[t.line][0].style.top = t.ltop + "px";
changeTop = t.top;
break;
}
}
// 向左相反
if (!toRight) {
rules.leftright.reverse();
}
for (let t of rules.leftright) {
if (t.condition) {
this.lineStatus[t.line] = true;
lines[t.line][0].style.left = t.lleft + "px";
lines[t.line][0].style.top = 0;
changeLeft = t.left;
break;
}
}
const style = currentComponent.style;
this.$set(this.componentData, index, {
style: Object.assign(style, {
left: changeLeft + "px",
}),
});
this.$set(this.componentData, index, {
style: Object.assign(style, {
top: changeTop + "px",
}),
});
},
handleMouseDown(e) {
e.stopPropagation();
// 移动选中的组件
let selected = this.componentData[e.target.dataset.index];
if (!selected) return false;
const component = JSON.parse(JSON.stringify(selected));
const pos = component.style;
const startY = e.clientY;
const startX = e.clientX;
const startTop = Number(pos.top.replace("px", ""));
const startLeft = Number(pos.left.replace("px", ""));
// mousemove 修改位置
const move = (moveEvent) => {
const currX = moveEvent.clientX;
const currY = moveEvent.clientY;
const toDown = currY - startY > 0 ? true : false;
const toRight = currX - startX > 0 ? true : false;
pos.top = currY - startY + startTop;
pos.left = currX - startX + startLeft;
this.showLine(
selected,
e.target.dataset.index,
pos.top,
pos.left,
toDown,
toRight
);
// 修改当前组件样式
selected.style = {
top: pos.top + "px",
left: pos.left + "px",
background: pos.background,
width: pos.width,
height: pos.height,
};
};
// mouseup 解除事件绑定
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
this.hideLine();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
},
},
};
</script>
<style>
.content {
height: 200px;
flex: 1;
border: 1px solid lightblue;
margin-left: 10px;
position: relative;
}
.component {
position: absolute;
height: fit-content;
width: fit-content;
text-align: center;
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.mark-line {
height: 100%;
}
.line {
background: #59c7f9;
position: absolute;
z-index: 1000;
}
.xline {
width: 100%;
height: 1px;
}
.yline {
width: 1px;
height: 100%;
}
</style>
复制代码
属性设置
点击组件显示对应的属性设置,修改属性组件样式应用修改。
思路:
- 点击组件把组件的样式对象绑定到属性设置上
- 利用双向绑定在修改属性的时候修改对应样式
<template>
<div class="prop-wrapper">
<!-- 画板 -->
<div class="prop-content" ref="content" @click="currentComponent = null">
<div
v-for="(item, index) in componentData"
:key="index"
class="list"
:data-index="index"
:style="Object.assign(item.style)"
@click="handleClick"
>
<!-- 自定义组件 -->
</div>
</div>
<div class="prop-div">
属性设置
<div
v-show="currentComponent"
v-for="(key, index) in Object.keys(
currentComponent ? currentComponent.style : {}
)"
:key="index"
>
<label> {{ key }}:</label>
<!-- 这里使用 v-model 实现双向绑定 -->
<input v-model="currentComponent.style[key]" />
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
componentData: [
{
style: {
top: "20px",
left: "20px",
background: "lightblue",
height: "100px",
width: "100px",
},
},
{
style: {
top: "20px",
left: "110px",
background: "lightgrey",
height: "100px",
width: "100px",
},
},
],
currentComponent: null,
};
},
methods: {
handleClick(e) {
e.stopPropagation();
console.log(e.target);
this.currentComponent = this.componentData[e.target.dataset.index];
},
},
};
</script>
<style>
.prop-wrapper {
border: 1px solid lightblue;
display: flex;
flex-direction: row;
padding: 10px;
}
.prop-div {
margin: auto;
width: 300px;
height: 200px;
border: 1px solid lightblue;
}
.prop-div div {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 5px;
}
.prop-div div label {
width: 120px;
}
.prop-content {
height: 200px;
border: 1px solid lightblue;
margin: 0 20px;
flex: 1;
position: relative;
}
.prop-content .list {
position: absolute;
height: fit-content;
width: fit-content;
border: 1px solid grey;
cursor: grab;
margin-bottom: 10px;
text-align: center;
color: #333;
padding: 2px 5px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
复制代码
数据请求
默认自定义数据可以添加在 componentData
里面的一个属性
远程数据可以配置一个 url
属性利用 websocket
根据组件 id
订阅更新
组合 拆分
技术点:
- 选中区域
- 一个加边框
div
来显示 mouseDown
- 画布决定起始位置 设置
showArea = true
- 画布决定起始位置 设置
mouseMove
- 更新区域大小 根据
offet-start
正负情况修改 定位样式
- 更新区域大小 根据
mouseUp
- 判断选中的组件 显示选中区域
- 根据左上角和宽高 是否完全包裹组件的左上角和宽高 符合条件就加入数组
- 遍历数组更新四个顶点 根据顶点构建选中区域样式
- 移除事件绑定 设置
showArea = false
- 判断选中的组件 显示选中区域
- 问题
- 移动的时候 offsetY 有的时候会变成一半,起初以为是冒泡导致,发现阻止冒泡也没效果
- 原因是 mousemove 过程中,触发的事件源元素可能会变成区域元素导致相对位置变化
- 解决:在区域元素样式上添加
pointer-events: none;
使得区域元素永远不会成为鼠标事件的target
- 移动的时候 offsetY 有的时候会变成一半,起初以为是冒泡导致,发现阻止冒泡也没效果
- 一个加边框
- 组合后的移动、
旋转 - 组合后的放大缩小
- 拆分后子组件样式的恢复
组合后,把组件信息从数组中刪除,把选中的组件信息重新生成组合组件,再加入数组
拆分后,把组合组件的每个组件信息重新计算,再添加进组件数组
注:下面代码只做了选中部分
<template>
<!-- 画板 -->
<div
class="content"
ref="content"
@click="selected = null"
@mousedown="contentMouseDown"
>
<!-- 模拟外层包裹组件 -->
<div
v-for="(item, index) in componentData"
:key="index"
class="combine-component"
:style="item.style"
:data-index="index"
@mousedown="handleMouseDown"
>
<!-- 自定义组件 -->
</div>
<!-- 区域 -->
<div class="area" v-show="showArea" :style="areaStyle"></div>
<!-- 选中区域 -->
<div class="area" v-show="showSelectArea" :style="selectAreaStyle"></div>
</div>
</template>
<script>
export default {
data() {
return {
componentData: [
{
style: {
top: "30px",
left: "30px",
width: "50px",
height: "50px",
background: "lightblue",
},
},
{
style: {
top: "30px",
left: "100px",
width: "50px",
height: "50px",
background: "lightgrey",
},
},
],
currentComponent: null,
showArea: false,
showSelectArea: false,
areaStyle: {},
selectAreaStyle: {},
result: [],
};
},
methods: {
handleMouseDown() {},
contentMouseDown(e) {
this.result = [];
const startX = e.offsetX;
const startY = e.offsetY;
// 画布宽高
const width = this.$refs.content.clientWidth;
const height = this.$refs.content.clientHeight;
const move = (e) => {
const curX = e.offsetX;
const curY = e.offsetY;
this.areaStyle = Object.assign(
{},
{
position: "absolute",
width: Math.abs(curX - startX) + "px",
height: Math.abs(curY - startY) + "px",
},
curX - startX >= 0
? { left: startX + "px" }
: { right: width - startX + "px" },
curY - startY >= 0
? { top: startY + "px" }
: { bottom: height - startY + "px" }
);
this.showArea = true;
};
const up = (e) => {
console.log(e);
const style = Object.assign({}, this.areaStyle);
for (let k in style) {
style[k] = +style[k].replace("px", "");
}
const x = style.left || width - (style.right + style.width);
const y = style.top || height - (style.bottom + style.height);
const awidth = style.width;
const aheight = style.height;
this.componentData.forEach((component) => {
let { left, top } = component.style;
let cwidth = component.style.width;
let cheight = component.style.height;
left = +left.replace("px", "");
top = +top.replace("px", "");
cwidth = +cwidth.replace("px", "");
cheight = +cheight.replace("px", "");
if (
x <= left &&
y <= top &&
left + cwidth <= x + awidth &&
top + cheight <= y + aheight
) {
this.result.push(component);
}
});
this.showArea = false;
this.areaStyle = {};
this.$refs.content.removeEventListener("mousemove", move);
this.$refs.content.removeEventListener("mouseup", up);
};
this.$refs.content.addEventListener("mousemove", move);
this.$refs.content.addEventListener("mouseup", up);
},
},
watch: {
result(val) {
if (val.length) {
let aleft = Infinity,
atop = Infinity,
aright = -Infinity,
abottom = -Infinity;
val.forEach((v) => {
let { left, top, width, height } = v.style;
left = +left.replace("px", "");
top = +top.replace("px", "");
height = +height.replace("px", "");
width = +width.replace("px", "");
if (left < aleft) aleft = left;
if (top < atop) atop = top;
if (left + width > aright) aright = left + width;
if (top + height > abottom) abottom = top + height;
});
this.selectAreaStyle = {
position: "absolute",
top: atop + "px",
left: aleft + "px",
width: aright - aleft + "px",
height: abottom - atop + "px",
};
this.showSelectArea = true;
} else {
this.showSelectArea = false;
this.selectAreaStyle = {};
}
},
},
};
</script>
<style>
.content {
height: 200px;
flex: 1;
border: 1px solid lightblue;
margin-left: 10px;
position: relative;
}
.combine-component {
position: absolute;
height: fit-content;
width: fit-content;
text-align: center;
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: none;
}
.area {
border: 1px solid lightseagreen;
pointer-events: none;
}
</style>
复制代码
参考
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END