前言
hello, 大家好, 我是徐小夕, 今天又到了我们的博学时间。
本文是 100+前端几何学应用案例 专栏的第二篇文章, 在第一篇文章几何学在前端边界计算中的应用和原理分析 中我介绍了几何学在前端领域里的应用, 同时用 vue3 带大家一起实现了常见图形的边界计算算法, 并且分享了如何用几何原理和Web Dom生成任意三角形的方式:
image.png
如果大家感兴趣可以在 gitee 查看我的具体代码实现: https://gitee.com/lowcode-china/euryd
接下来就继续这个话题, 我们进一步扩展, 来从零实现一个几何画板。
你将收获
- vue3 + vite的实用技巧
- 几何画板的基本开发思路
- 元素创建,
- 编辑,
- 拖拽,
- 图层管理
- 撤销和重做
- 导入导出
- 利用几何和代数学知识解决前端问题
demo演示
在分享方案之前, 我先给大家演示一下做好的demo, 这样可以更好的理解我们接下来要做的事情:
2022-10-15 10.16.31.gif
技术实现
我们继续沿用上一篇文章几何学在前端边界计算中的应用和原理分析的工程, 由于几何画板相当于一个独立的小应用, 具备一定的复杂度, 这里我们来对 vite 工程配置一下对 less 的支持:
- 安装 less 和less-loader (推荐yarn, pnpm)
- 在vite.config.ts里做如下配置:
export default defineConfig({
plugins: [vue()],
css: {
preprocessorOptions: {
less: {
modifyVars: {
hack: `true; @import (reference) "${path.resolve("src/base.less")}";`,
},
javascriptEnabled: true,
},
},
},
})
这样配置完成之后我们就可以在 vite项目 里用 less 的方式写样式代码了, modifyVars属性里面的配置是为了指定 less 全局变量的地址, 这样我们可以把主题, 通用样式放在该目录下, 以便直接在项目的任何页面直接使用。
好了, 准备工作完成了, 我们开始接下来的实现部分。
1. 画板搭建
画板搭建主要是静态和交互部分, 这里简单和大家介绍一下基本构造:
image.png
上图可知画板主要分两个部分:
- 画布区(包含记录鼠标移动坐标的文本提示)
- 侧边控件区
画布的点阵背景我们用 css 的背景样式实现, 这块网上也有很多教程, 我就不一一和大家分析了,这里直接上实现的代码, 大家可以拿来就用:
section .card {
position: relative;
height: 480px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
background-image: radial-gradient(rgba(9, 89, 194, 0.3) 6%, transparent 0),
radial-gradient(#faf9f8 6%, transparent 0);
background-size: 10px 10px;
background-position: 0 0, 2px 2px;
}
整个画板应用的基本结构如下:
image.png
如果大家对这一块知识感兴趣的可以参考我实现的代码, 具体代码地址: https://gitee.com/lowcode-china/euryd
接下来我们开始进行比较核心的方案设计。
2. 创建并绘制几何图形
因为是画版应用, 所以图形的创建一定要简单且灵活, 控制权交给用户和鼠标, 所以这里实现的效果如下:
2022-10-15 11.33.07.gif
用户只需要选择对应的图形, 用鼠标在画布里拖动即可创建任意大小比例的图形, 为了实现这一效果, 我们需要做如下准备:
- 定义图形的schema结构
- 根据鼠标光标的位置计算图形创建的元信息(图形id, 顶点坐标, 宽高样式等属性)
1.定义图形的schema结构
像任何可视化低代码产品一样, 我们都需要一个统一且可扩展的组件schema结构, 目的是为了更好的识别组件和派发属性。画板应用里的图形, 我们可以设计如下 schhema 结构:
type IShapeTypes = 'rect' | 'circle' | 'line';
interface IShapeProps {
id: string,
type: IShapeTypes,
style: {
width: number,
height: number,
left: number,
top: number,
...otherStyle
},
isEditable: boolean,
...otherSchema
}
具体配置只要具备通用性, 我们就可以结合自己的业务来配置。
定义好了 schema, 我们只需要实现对应的图形即可, 这里以矩形为例和大家分享一下实现细节。
2. 根据鼠标光标的位置计算图形创建的元信息
我们都知道, 要想通过鼠标拖动来创建任意一个矩形, 我们需要知道几个条件:
- 鼠标按下的初始点的坐标
- 鼠标拖动过程中的实时位置
这两个问题其实都可以在全局实现, 基于组件设计的原子化原则, 我们可以在画布组件里捕获并计算出鼠标的实时位置, 然后派发给其他组件消费, 这样我们也可以是实现记录鼠标移动坐标的文本提示 这一功能了。
在上一篇文章中已经介绍了如何用 vue3 的组合式函数来实现通用 hooks, 我们接下来要做的就是把 useMouse 获取到的结果加工后让其他组件能使用, 这里我用 vue3 的toRefs 来实现。先来看一下代码:
// BaseBoard.tsx
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRefs } from "vue";
import { useMouse } from "../hooks/mouse";
import { getElPagePos } from "../utils/math";
const props = defineProps<{
msg: string;
onMouseChange: (x: number, y: number) => void;
}>();
const { onMouseChange } = toRefs(props);
const cardOffset = ref({ x: 0, y: 0 });
const boardDom = ref<any>(null);
const { x, y } = useMouse(window, (x, y) => {
onMouseChange && onMouseChange.value(x - cardOffset.value.x, y - cardOffset.value.y);
});
onMounted(() => {
const { x, y } = getElPagePos(boardDom.value);
cardOffset.value.x = x;
cardOffset.value.y = y;
});
onUnmounted(() => {});
defineExpose({ boardDom });
</script>
<template>
<section>
<h3>{{ msg }}</h3>
<div class="card" ref="boardDom">
<slot></slot>
<div style="user-select: none">
x: {{ x - cardOffset.x }} , y: {{ y - cardOffset.y }}
</div>
</div>
</section>
</template>
BaseBoard 就是我们的画布组件, 我们使用这个组件可以在页面上创建任意数量的画布, 同时由于vue3 的组合函数支持使用defineProps 来定义组件的props, 所以我们可以通过它定义组件的属性, 这里对外暴露了两个属性:
- msg 用来在外部控制画布的名称
- onMouseChange 用来将内部鼠标监听的事件传到外部, 让外部可以拿到内部是事件运行时
我们使用 useMouse 的时候就可以实时拿到鼠标的x, y的绝对坐标, 再减去画布在页面的实际偏移cardOffset.x, cardOffset.y, 就可以得出鼠标在画布中正确的坐标:
image.png
这样我们就可以通过onMouseChange回调把鼠标相对画布的坐标实时传给父组件了:
const { x, y } = useMouse(window, (x, y) => {
onMouseChange &&
onMouseChange.value(x - cardOffset.value.x, y - cardOffset.value.y);
});
同时我们在代码中发现了 defineExpose, 这个 api 作用就是把需要暴露的数据导出,供父组件使用,相当于子传父, 我们可以在父组件里拿到暴露的值, 在这里我们把画布的 dom 暴露出来, 让父组件可以拿到子组件的dom。
有了以上的前提, 我们就可以来创建矩形元素了, 为了更好的管理画布中的元素, 我们定义一个元素集合canvasBox:
type shapeType = "rect" | "circle" | "line";
interface IBaseShapeProp {
type: shapeType;
key: string;
style: any;
}
const canvasBox = ref<{ [key in shapeType]: IBaseShapeProp[] }>({
rect: [],
circle: [],
line: [],
});
当用户选择一个图形, 在画布中按下鼠标的那一刻, 我们创建一个基本的元数据:
const handleMouseDown = () => {
const { x, y } = mouseAbsPos.value;
if (curShape.value) {
templateDot = [x, y];
templateDot[2] = Date.now() + "";
canvasBox.value["rect"].push({
type: "rect",
key: templateDot[2],
style: {},
});
}
};
由上面的代码可知, 我们会创建一个矩形的元数据, 包含了矩形的:
- 元素类型
- 矩形的唯一key(方便后续快速查找该图形)
- 矩形的初始化样式
同时我们在 templateDot 变量中缓存了鼠标的初始位置, 方便后续生成矩形完整的元数据。
我们在图中可以看出当拖动鼠标时矩形是实时跟随鼠标创建的, 要想实现这个效果, 我们需要对鼠标的mousemove 进行监听, 并动态更新矩形的元数据, 如下:
const handleMouseChange = (x: number, y: number) => {
mouseAbsPos.value = { x, y };
// 1.如果有选中的元素, 则判断为移动当前选中元素
if (curSelect.value && templateDot.length) {
// something...
return;
}
// 2.否则则生成元素
const [a1, b1, key] = templateDot;
if (curShape.value && templateDot.length) {
let dx = x - a1;
let dy = y - b1;
let curIndex = canvasBox.value["rect"].findIndex((v) => v.key === key);
if (curIndex > -1) {
canvasBox.value["rect"][curIndex] = {
...canvasBox.value["rect"][curIndex],
style: {
left: (dx > 0 ? a1 : x) + "px",
top: (dy > 0 ? b1 : y) + "px",
width: Math.abs(dx) + "px",
height: Math.abs(dy) + "px",
},
};
}
}
};
由代码可知我是通过实时改变矩形元素的 left 和 top 来实现矩形跟随鼠标实时更新的, 我们使用 transform 也可以实现同样的效果, 感兴趣的朋友可以尝试一下。
这里顺便扩展一下, 我们平时看到的拖拽框架, 对组件进行多选操作时也用了同样的方式, 通过鼠标拖拽滑动来产生多选区域:
2022-10-15 20.20.10.gif
感兴趣的朋友可以把这个方案进行扩展, 实现更有意思的应用场景。
3. 移动, 编辑几何图形
有了上面创建元素的基础, 我们继续来实现移动和编辑元素的功能。
3.1 移动元素
首先我们需要找到当前要移动的元素, 然后动态改变它的位置, 因为每个元素我都设置唯一的key, 所以当元素被选中的时候我们就可以根据key找到此元素, 并只对该元素进行操作:
// 如果有选中的元素, 则判断为移动当前选中元素
if (curSelect.value && templateDot.length) {
const [x0, y0] = templateDot;
canvasBox.value["rect"] = canvasBox.value["rect"].map((v) => {
if (v.key === curSelect.value) {
const { left, top } = v.style;
templateDot = [x, y];
return {
...v,
style: {
...v.style,
left: parseFloat(left) + (x - x0) + "px",
top: parseFloat(top) + (y - y0) + "px",
},
};
}
return v;
});
return;
}
以上代码中主要是通过计算鼠标移动的位置差(通过缓存鼠标上一步的坐标)来改变元素的 left 和top 值, 在 mouseup 时重置缓存变量即可完成一次移动过程。
const handleMouseUp = () => {
const { x, y } = mouseAbsPos.value;
if (curShape.value) {
// 1. 如果开始点和结束点一样,则不创建
if (templateDot[0] === x && templateDot[1] === y) {
canvasBox.value["rect"] = canvasBox.value["rect"].filter(
(v) => v.key !== templateDot[2]
);
templateDot = [];
return;
}
}
// 重置
templateDot = [];
};
这里有一个细节需要注意, 就是如果在鼠标按下之后没有拖动(也就是好点击画布的操作), 其实需要把mousedown创建的元素清空删除, 所以才有了上述代码的第一步判断。
3.2 编辑元素
编辑元素其实和移动元素的模式差不多, 改变的是元素的静态属性, 比如我们可以编辑元素的背景颜色, 边框样式等, 这里我以删除元素为例给大家介绍一下实现过程。
2022-10-15 20.46.21.gif
首先我们展示一下元素的 dom 结构:
<div
v-for="item in canvasBox.rect"
:key="item.key"
:class="['shape', 'rect', curSelect === item.key ? 'active' : '']"
:style="{
left: item.style.left,
top: item.style.top,
width: item.style.width,
height: item.style.height,
}"
:data-key="item.key"
@dblclick.stop="handleSelected(item.key)"
>
<span v-if="curSelect === item.key" @click="handleDel(item.key)">x</span>
</div>
当我们双击元素的时候, 我们通过key会给当前选中元素一个激活态, 此时v-if的删除按钮就会显示, 我们绑定一个删除方法 handleDel :
const handleDel = (key: string) => {
canvasBox.value["rect"] = canvasBox.value["rect"].filter((v) => v.key !== key);
curSelect.value = "";
templateDot = [];
};
删除元素的方法是典型的单向操作, 比较简单, 如果我们要改变元素的整体属性, 我们需要设计一个属性面板,并实现表单渲染器来动态的更新元素的属性, 类似于 H5-Dooring 中的编辑面板:
2022-10-15 20.55.10.gif
在后面的文章中我会实现一个min版的属性编辑器来完善我们的几何画板。
4. 图层管理, 图片导出等方案介绍
图层管理也是编辑器常用的功能, 有了我们之前设计的 canvasBox, 我们就很容易实现一个图层管理面板了, 我们只需要把存储在canvasBox 元素数组遍历到图层面板, 并对其绑定操作方法即可实现涂图层管理的常用功能, 比如:
- 显示隐藏
- 快捷删除
- 批量删除
- 多选
- 图层移动
- 切换元素
等等功能, 如 H5-Dooring 中的图层管理面板:
image.png
5. 后期规划
- 撤销重做,
- 图层管理面板,
- 样式编辑,
- 图片导出