前端Web实战:从零打造一个类Visio的流程图拓扑图绘图工具
前言
大家好,本系列从Web前端实战的角度,给大家分享介绍如何从零打造一个自己专属的绘图工具,实现流程图、拓扑图、脑图等类Visio的绘图工具。
你将收获
- 免费好用、专属自己的绘图工具
- 前端项目实战学习
- 如何从0搭建一个前端项目等基础框架
- 项目设计思路及优雅的架构技巧
- 开源项目学习
- 热门可视化引擎Meta2d.js等学习使用
技术栈
Meta2d.js - 国产开源免费好用的可视化引擎
Vue3 - 流行的简单易用等前端Web框架
Vite - 高效好用的前端热门构建工具
TDesign - 支持Vue3的前端UI组件库
需要提前掌握
- 前端基础工具node.js安装(仅安装即可)
- npm(pnpm、yarn)基本使用
- package.json基本认识
以上基础知识可自行网上学习
一、 Vite + Vue3框架搭建
1.1 搭建vue3的vite项目
参考vite文档(开始 | Vite 官方中文文档)的pnpm的方式创建项目:
pnpm create vite
按照命令行提示,简单设置如下配置:
1.2 修改package.json
【注意】因为当前vite更新比较频繁,经常直接使用脚手架命令生成的框架运行会报错。可以尝试切换不同的包管理工具(pnpm、yarn、npm)试试;或看看vite、vue等是否有最新版本号,修改package.json升级。
当前,我们使用pnpm i安装依赖包后,发现运行错误。查看有新的vite@4.4.2,手动修改package.json升级。
另外,我个人习惯,把package.json中的dev重命名为start。
1.3 运行检查基础框架
// 安装依赖包
pnpm i
// 本地运行。脚手架默认命令为:pnpm dev
pnpm start
根据命令行提示,在浏览器打开:http://127.0.0.1:5173/ 正常运行,基础框架完成。
1.4 丰富框架
- 在package.json中添加meta2d.js、vue-router、tdesign、postcss等项目需要用的依赖包。
{
"name": "diagram-editor-vue3",
"private": true,
"version": "0.0.1",
"scripts": {
"start": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@meta2d/activity-diagram": "^1.0.0",
"@meta2d/chart-diagram": "^1.0.3",
"@meta2d/class-diagram": "^1.0.0",
"@meta2d/core": "^1.0.19",
"@meta2d/flow-diagram": "^1.0.0",
"@meta2d/form-diagram": "^1.0.3",
"@meta2d/fta-diagram": "^1.0.0",
"@meta2d/le5le-charts": "^1.0.2",
"@meta2d/sequence-diagram": "^1.0.0",
"@meta2d/svg": "^1.0.2",
"tdesign-vue-next": "^1.3.10",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.6",
"postcss-import": "^14.1.0",
"postcss-nested": "^6.0.1",
"typescript": "^5.0.2",
"vite": "^4.4.2",
"vue-tsc": "^1.8.3"
}
}
- 添加postcss支持
- 在package.json中删除:"type": "module"选项。
- 添加postcss.config.js文件:
module.exports = {
plugins: {
'postcss-import': {},
'postcss-nested': {},
autoprefixer: {},
},
};
1.5 修改index.html
修改index.html为符合项目描述内容
1.6 初始化css
修改style.css为符合项目的默认初始样式
1.7 添加router
新增src/router.ts文件:
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{ path: '/', component: () => import('./views/Index.vue') },
{ path: '/preview', component: () => import('./views/Preview.vue') },
];
const router = createRouter({
history: createWebHistory('/'),
routes,
});
export default router;
其中:
'/' - 编辑器页面
'/preview' - 预览页面
1.8 加载vue-router、tdesign
在main.ts中加载vue-router、tdesign等基础服务。
import { createApp } from 'vue';
import './style.css';
import App from './App.vue';
import router from './router.ts';
import TDesign from 'tdesign-vue-next';
const app = createApp(App);
// 加载基础服务
app.use(router).use(TDesign);
// end
app.mount('#app');
1.9 设置路由
- 添加路由页面:src/views/Index.vue、src/views/Preview.vue
- 修改App.vue内容为加载路由
1.10 设置@路径支持
- vue配置:vite.config.ts
安装依赖库:pnpm add -D path
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import * as path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src/'),
},
},
});
- typescript配置:tsconfig.json
{
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
},
},
...
}
1.11 运行
运行pnpm start并在浏览器打开:
至此,基础框架搭建完成。
二、创建编辑器
2.0 编辑器布局
拆分编辑器为:菜单工具栏(Header)、图形库(Graphics)、编辑器画布(View)、属性面板(Props)
Index.vue直接由编辑器各个子组件构成:
<template>
<div class="app-page">
<Header />
<div class="designer">
<Graphics />
<View />
<Props />
</div>
</div>
</template>
<script lang="ts" setup>
import Header from '../components/Header.vue';
import Graphics from '../components/Graphics.vue';
import View from '../components/View.vue';
import Props from '../components/Props.vue';
</script>
<style lang="postcss" scoped>
.app-page {
height: 100vh;
overflow: hidden;
}
</style>
2.1 创建编辑器画布 View
2.1.1 挂载
Meta2d画布实例必须挂载在html中DOM元素上
<div id="meta2d"></div>
2.1.2导入Meta2d类
import { Meta2d } from '@meta2d/core';
2.2.3 创建实例
创建实例必须等挂载容器(DOM元素)创建完成。因此我们一般在onMounted中创建实例。注意,如果挂载容器存在动画或其他原因导致挂载容器大小、位置不稳定时,需要等挂载容器样式稳定后在创建。
onMounted(() => {
const myMeta2d = new Meta2d('meta2d', meta2dOptions);
});
通过new Meta2d创建实例后,默认会把当前实例挂载到global.meta2d全局变量上。后续可以直接通过meta2d来操作画布。
2.2.4 注册图形库
根据需求,按需注册图形库。
onMounted(() => {
// 创建实例
new Meta2d('meta2d', meta2dOptions);
// 按需注册图形库
// 以下为自带基础图形库
register(flowPens());
registerAnchors(flowAnchors());
register(activityDiagram());
registerCanvasDraw(activityDiagramByCtx());
register(classPens());
register(sequencePens());
registerCanvasDraw(sequencePensbyCtx());
registerEcharts();
registerCanvasDraw(formPens());
registerCanvasDraw(chartsPens());
register(ftaPens());
registerCanvasDraw(ftaPensbyCtx());
registerAnchors(ftaAnchors());
// 注册其他自定义图形库
// ...
});
2.2 创建菜单工具栏Header
2.2.1 创建菜单栏
使用TDesign的Dropdown下拉菜单创建菜单栏
<div class="app-header">
<a class="logo" href="https://le5le.com" target="_blank">
<img src="/favicon.ico" />
<span>乐吾乐</span>
</a>
<t-dropdown
:minColumnWidth="200"
:maxHeight="560"
overlayClassName="header-dropdown"
>
<a> 文件 </a>
<t-dropdown-menu>
<t-dropdown-item @click="newFile">
<a>新建文件</a>
</t-dropdown-item>
<t-dropdown-item @click="openFile" divider="true">
<a>打开文件</a>
</t-dropdown-item>
<t-dropdown-item divider="true">
<a @click="downloadJson">下载JSON文件</a>
</t-dropdown-item>
<t-dropdown-item>
<a @click="downloadPng">下载为PNG</a>
</t-dropdown-item>
<t-dropdown-item>
<a @click="downloadSvg">下载为SVG</a>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
<t-dropdown
:minColumnWidth="180"
:maxHeight="500"
overlayClassName="header-dropdown"
>
<a> 编辑 </a>
<t-dropdown-menu>
<t-dropdown-item>
<a @click="onUndo">
<div class="flex">
撤销 <span class="flex-grow"></span> Ctrl + Z
</div>
</a>
</t-dropdown-item>
<t-dropdown-item divider="true">
<a @click="onRedo">
<div class="flex">
恢复 <span class="flex-grow"></span> Ctrl + Y
</div>
</a>
</t-dropdown-item>
<t-dropdown-item>
<a @click="onCut">
<div class="flex">
剪切 <span class="flex-grow"></span> Ctrl + X
</div>
</a>
</t-dropdown-item>
<t-dropdown-item>
<a @click="onCopy">
<div class="flex">
复制 <span class="flex-grow"></span> Ctrl + C
</div>
</a>
</t-dropdown-item>
<t-dropdown-item divider="true">
<a @click="onPaste">
<div class="flex">
粘贴 <span class="flex-grow"></span> Ctrl + V
</div>
</a>
</t-dropdown-item>
<t-dropdown-item>
<a @click="onAll">
<div class="flex">
全选 <span class="flex-grow"></span> Ctrl + A
</div>
</a>
</t-dropdown-item>
<t-dropdown-item>
<a @click="onDelete">
<div class="flex">删除 <span class="flex-grow"></span> DELETE</div>
</a>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
<t-dropdown
:minColumnWidth="180"
:maxHeight="500"
:delay2="[10, 150]"
overlayClassName="header-dropdown"
>
<a> 帮助 </a>
<t-dropdown-menu>
<t-dropdown-item v-for="item in assets.helps" :divider="item.divider">
<a :href="item.url" target="_blank">{{ item.name }}</a>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
</div>
新建文件
新建文件是通过打开一个空白画布来实现
// 打开默认空白文件
const newFile = () => {
meta2d.open();
};
// 打开一个指定名称的空白文件
const newFile = () => {
meta2d.open({ name: '新建项目', pens: [] } as any);
};
打开文件
function readFile(file: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsText(file);
});
}
const openFile = () => {
// 1. 显示选择文件对话框
const input = document.createElement('input');
input.type = 'file';
input.onchange = async (event) => {
const elem = event.target as HTMLInputElement;
if (elem.files && elem.files[0]) {
// 2. 读取文件字符串内容
const text = await readFile(elem.files[0]);
try {
// 3. 打开文件内容
meta2d.open(JSON.parse(text));
// 可选:缩放到窗口大小展示
meta2d.fitView();
} catch (e) {
console.log(e);
}
}
};
input.click();
};
保存为JSON文件
- 安装file-saver
pnpm add file-saver
- 下载文件
const downloadJson = () => {
const data: any = meta2d.data();
FileSaver.saveAs(
new Blob([JSON.stringify(data)], {
type: 'text/plain;charset=utf-8',
}),
`${data.name || 'le5le.meta2d'}.json`
);
};
保存为PNG文件
const downloadPng = () => {
let name = (meta2d.store.data as any).name;
if (name) {
name += '.png';
}
meta2d.downloadPng(name);
};
保存为SVG文件
- 下载canvas2svg.js
- 在index.html中加载
- 下载svg
// 判断该画笔 是否是组合为状态中 展示的画笔
function isShowChild(pen: any, store: any) {
let selfPen = pen;
while (selfPen && selfPen.parentId) {
const oldPen = selfPen;
selfPen = store.pens[selfPen.parentId];
const showChildIndex = selfPen?.calculative?.showChild;
if (showChildIndex != undefined) {
const showChildId = selfPen.children[showChildIndex];
if (showChildId !== oldPen.id) {
return false;
}
}
}
return true;
}
const downloadSvg = () => {
if (!C2S) {
MessagePlugin.error('请先加载乐吾乐官网下的canvas2svg.js');
return;
}
const rect: any = meta2d.getRect();
rect.x -= 10;
rect.y -= 10;
const ctx = new C2S(rect.width + 20, rect.height + 20);
ctx.textBaseline = 'middle';
for (const pen of meta2d.store.data.pens) {
if (pen.visible == false || !isShowChild(pen, meta2d.store)) {
continue;
}
meta2d.renderPenRaw(ctx, pen, rect);
}
let mySerializedSVG = ctx.getSerializedSvg();
if (meta2d.store.data.background) {
mySerializedSVG = mySerializedSVG.replace('{{bk}}', '');
mySerializedSVG = mySerializedSVG.replace(
'{{bkRect}}',
`<rect x="0" y="0" width="100%" height="100%" fill="${meta2d.store.data.background}"></rect>`
);
} else {
mySerializedSVG = mySerializedSVG.replace('{{bk}}', '');
mySerializedSVG = mySerializedSVG.replace('{{bkRect}}', '');
}
mySerializedSVG = mySerializedSVG.replace(/--le5le--/g, '&#x');
const urlObject: any = (window as any).URL || window;
const export_blob = new Blob([mySerializedSVG]);
const url = urlObject.createObjectURL(export_blob);
const a = document.createElement('a');
a.setAttribute(
'download',
`${(meta2d.store.data as any).name || 'le5le.meta2d'}.svg`
);
a.setAttribute('href', url);
const evt = document.createEvent('MouseEvents');
evt.initEvent('click', true, true);
a.dispatchEvent(evt);
};
撤销
const onUndo = () => {
meta2d.undo();
};
重做
const onRedo = () => {
meta2d.redo();
};
剪切
const onCut = () => {
meta2d.cut();
};
复制
const onCopy = () => {
meta2d.copy();
};
粘贴
const onPaste = () => {
meta2d.paste();
};
全选
const onAll = () => {
meta2d.activeAll();
};
删除
const onPaste = () => {
meta2d.paste();
};
其他
2.2.2 创建工具栏
画直线
设置html DOM元素属性,支持拖拽和点击
<t-tooltip content="直线">
<span
:draggable="true"
@dragstart="onAddShape($event, 'line')"
@click="onAddShape($event, 'line')"
>
<t-icon name="slash" />
</span>
</t-tooltip>
设置图元数据
const onAddShape = (event: DragEvent | MouseEvent, name: string) => {
event.stopPropagation();
let data: any;
if (name === 'text') {
data = {
text: 'text',
width: 100,
height: 20,
name: 'text',
};
} else if (name === 'line') {
data = {
anchors: [
{ id: '0', x: 1, y: 0 },
{ id: '1', x: 0, y: 1 },
],
width: 100,
height: 100,
name: 'line',
lineName: 'line',
type: 1,
};
}
if (!(event as DragEvent).dataTransfer) {
meta2d.canvas.addCaches = deepClone([data]);
} else {
(event as DragEvent).dataTransfer?.setData('Meta2d', JSON.stringify(data));
}
};
添加文字
设置html DOM元素属性,支持拖拽和点击
<t-tooltip content="文字">
<span
:draggable="true"
@dragstart="onAddShape($event, 'text')"
@click="onAddShape($event, 'text')"
>
<svg class="l-icon" aria-hidden="true">
<use xlink:href="#l-text"></use>
</svg>
</span>
</t-tooltip>
设置图元数据
const onAddShape = (event: DragEvent | MouseEvent, name: string) => {
event.stopPropagation();
let data: any;
if (name === 'text') {
data = {
text: 'text',
width: 100,
height: 20,
name: 'text',
};
} else if (name === 'line') {
data = {
anchors: [
{ id: '0', x: 1, y: 0 },
{ id: '1', x: 0, y: 1 },
],
width: 100,
height: 100,
name: 'line',
lineName: 'line',
type: 1,
};
}
if (!(event as DragEvent).dataTransfer) {
meta2d.canvas.addCaches = deepClone([data]);
} else {
(event as DragEvent).dataTransfer?.setData('Meta2d', JSON.stringify(data));
}
};
连线
设置click事件
<t-tooltip content="连线">
<svg
width="1em"
height="1em"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
@click="drawLine"
:style="{
color: isDrawLine ? ' #1677ff' : '',
}"
>
<path
d="M192 64a128 128 0 0 1 123.968 96H384a160 160 0 0 1 159.68 149.504L544 320v384a96 96 0 0 0 86.784 95.552L640 800h68.032a128 128 0 1 1 0 64.064L640 864a160 160 0 0 1-159.68-149.504L480 704V320a96 96 0 0 0-86.784-95.552L384 224l-68.032 0.064A128 128 0 1 1 192 64z m640 704a64 64 0 1 0 0 128 64 64 0 0 0 0-128zM192 128a64 64 0 1 0 0 128 64 64 0 0 0 0-128z"
fill="currentColor"
></path>
</svg>
</t-tooltip>
实现连线
// 连线状态
const isDrawLine = ref<boolean>(false);
// 连线实现
const drawLine = () => {
if (isDrawLine.value) {
isDrawLine.value = false;
meta2d.finishDrawLine();
meta2d.drawLine();
meta2d.store.options.disableAnchor = true;
} else {
isDrawLine.value = true;
meta2d.drawLine(meta2d.store.options.drawingLineName);
meta2d.store.options.disableAnchor = false;
}
};
设置连线类型
设置html属性
<t-dropdown
:minColumnWidth="160"
:maxHeight="560"
overlayClassName="header-dropdown"
>
<a>
<svg class="l-icon" aria-hidden="true">
<use
:xlink:href="
lineTypes.find((item) => item.value === currentLineType)?.icon
"
></use>
</svg>
</a>
<t-dropdown-menu>
<t-dropdown-item v-for="item in lineTypes">
<div class="flex middle" @click="changeLineType(item.value)">
{{ item.name }} <span class="flex-grow"></span>
<svg class="l-icon" aria-hidden="true">
<use :xlink:href="item.icon"></use>
</svg>
</div>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
连线类型设置
const lineTypes = reactive([
{ name: '曲线', icon: '#l-curve2', value: 'curve' },
{ name: '线段', icon: '#l-polyline', value: 'polyline' },
{ name: '直线', icon: '#l-line', value: 'line' },
{ name: '脑图曲线', icon: '#l-mind', value: 'mind' },
]);
const currentLineType = ref('curve');
const changeLineType = (value: string) => {
currentLineType.value = value;
if (meta2d) {
meta2d.store.options.drawingLineName = value;
meta2d.canvas.drawingLineName && (meta2d.canvas.drawingLineName = value);
meta2d.store.active?.forEach((pen) => {
meta2d.updateLineType(pen, value);
});
}
};
设置连线箭头
设置html属性
<t-dropdown
:minColumnWidth="160"
:maxHeight="560"
:delay2="[10, 150]"
overlayClassName="header-dropdown"
>
<a>
<svg class="l-icon" aria-hidden="true">
<use
:xlink:href="
fromArrows.find((item) => item.value === fromArrow)?.icon
"
></use>
</svg>
</a>
<t-dropdown-menu>
<t-dropdown-item v-for="item in fromArrows">
<div
class="flex middle"
style="height: 30px"
@click="changeFromArrow(item.value)"
>
<svg class="l-icon" aria-hidden="true">
<use :xlink:href="item.icon"></use>
</svg>
</div>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
<t-dropdown
:minColumnWidth="160"
:maxHeight="560"
:delay2="[10, 150]"
overlayClassName="header-dropdown"
>
<a>
<svg class="l-icon" aria-hidden="true">
<use
:xlink:href="toArrows.find((item) => item.value === toArrow)?.icon"
></use>
</svg>
</a>
<t-dropdown-menu>
<t-dropdown-item v-for="item in toArrows">
<div
class="flex middle"
style="height: 30px"
@click="changeToArrow(item.value)"
>
<svg class="l-icon" aria-hidden="true">
<use :xlink:href="item.icon"></use>
</svg>
</div>
</t-dropdown-item>
</t-dropdown-menu>
</t-dropdown>
箭头设置
const fromArrow = ref('');
const fromArrows = [
{ icon: '#l-line', value: '' },
{ icon: '#l-from-triangle', value: 'triangle' },
{ icon: '#l-from-diamond', value: 'diamond' },
{ icon: '#l-from-circle', value: 'circle' },
{ icon: '#l-from-lineDown', value: 'lineDown' },
{ icon: '#l-from-lineUp', value: 'lineUp' },
{ icon: '#l-from-triangleSolid', value: 'triangleSolid' },
{ icon: '#l-from-diamondSolid', value: 'diamondSolid' },
{ icon: '#l-from-circleSolid', value: 'circleSolid' },
{ icon: '#l-from-line', value: 'line' },
];
const toArrow = ref('');
const toArrows = [
{ icon: '#l-line', value: '' },
{ icon: '#l-to-triangle', value: 'triangle' },
{ icon: '#l-to-diamond', value: 'diamond' },
{ icon: '#l-to-circle', value: 'circle' },
{ icon: '#l-to-lineDown', value: 'lineDown' },
{ icon: '#l-to-lineUp', value: 'lineUp' },
{ icon: '#l-to-triangleSolid', value: 'triangleSolid' },
{ icon: '#l-to-diamondSolid', value: 'diamondSolid' },
{ icon: '#l-to-circleSolid', value: 'circleSolid' },
{ icon: '#l-to-line', value: 'line' },
];
const changeFromArrow = (value: string) => {
fromArrow.value = value;
// 画布默认值
meta2d.store.data.fromArrow = value;
// 活动层的箭头都变化
if (meta2d.store.active) {
meta2d.store.active.forEach((pen: Pen) => {
if (pen.type === PenType.Line) {
pen.fromArrow = value;
meta2d.setValue(
{
id: pen.id,
fromArrow: pen.fromArrow,
},
{
render: false,
}
);
}
});
meta2d.render();
}
};
const changeToArrow = (value: string) => {
toArrow.value = value;
// 画布默认值
meta2d.store.data.toArrow = value;
// 活动层的箭头都变化
if (meta2d.store.active) {
meta2d.store.active.forEach((pen: Pen) => {
if (pen.type === PenType.Line) {
pen.toArrow = value;
meta2d.setValue(
{
id: pen.id,
toArrow: pen.toArrow,
},
{
render: false,
}
);
}
});
meta2d.render();
}
};
画布缩放
- 监听当前画布比例
onMounted(() => {
const timer = setInterval(() => {
if (meta2d) {
clearInterval(timer);
// 获取初始缩放比例
scaleSubscriber(meta2d.store.data.scale);
// 监听缩放
// @ts-ignore
meta2d.on('scale', scaleSubscriber);
}
}, 200);
});
const scaleSubscriber = (val: number) => {
scale.value = Math.round(val * 100);
};
- 缩放到100%
const onScaleDefault = () => {
meta2d.scale(1);
meta2d.centerView();
};
- 缩放到窗口大小
const onScaleWindow = () => {
meta2d.fitView();
};
运行查看
这里由于是单机环境,数据保存在前本地存储。
无论是否单机环境,运行查看大致流程基本上是:保存数据(这里是前端本地存储)-> 跳转运行页面 -> 新页面读取加载数据。
- 添加click事件
<t-tooltip content="运行查看">
<t-icon name="play-circle-stroke" @click="onView" />
</t-tooltip>
- 保存数据到本地存储
- 跳转运行页面
const onView = () => {
// 先停止动画,避免数据波动
meta2d.stopAnimate();
// 本地存储
const data: any = meta2d.data();
localStorage.setItem('meta2d', JSON.stringify(data));
// 跳转到预览页面
router.push({
path: '/preview',
query: {
r: Date.now() + '',
id: data._id,
},
});
};
- 加载数据
Preview.vue
<template>
<div class="app-page">
<View />
</div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import View from '../components/View.vue';
onMounted(() => {
// 读取本地存储
let data: any = localStorage.getItem('meta2d');
if (data) {
data = JSON.parse(data);
// 设置为预览模式
data.locked = 1;
}
meta2d.open(data);
});
</script>
<style lang="postcss" scoped>
.app-page {
height: 100vh;
}
</style>
返回编辑
返回编辑的基本流程是: 跳转编辑页面 -> 新页面读取加载数据。
这和运行查看有重复的逻辑(新页面读取加载数据),因此,我们可以把这部分放到公共的View.vue组件里面实现。
View.vue
...
onMounted(() => {
// 创建实例
new Meta2d('meta2d', meta2dOptions);
// 按需注册图形库
// 以下为自带基础图形库
register(flowPens());
registerAnchors(flowAnchors());
register(activityDiagram());
registerCanvasDraw(activityDiagramByCtx());
register(classPens());
register(sequencePens());
registerCanvasDraw(sequencePensbyCtx());
registerEcharts();
registerCanvasDraw(formPens());
registerCanvasDraw(chartsPens());
register(ftaPens());
registerCanvasDraw(ftaPensbyCtx());
registerAnchors(ftaAnchors());
// 注册其他自定义图形库
// ...
// 加载数据
let data: any = localStorage.getItem('meta2d');
if (data) {
data = JSON.parse(data);
// 判断是否为运行查看,是-设置为预览模式
if (location.pathname === '/preview') {
data.locked = 1;
} else {
data.locked = 0;
}
meta2d.open(data);
}
});
...
自动保存
这里是单机环境,我们自动保存到前端本地存储。
- 监听数据变化
- 自动保存
Index.Vue
let timer: any;
function save() {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
const data: any = meta2d.data();
localStorage.setItem('meta2d', JSON.stringify(data));
timer = undefined;
}, 1000);
}
onMounted(() => {
meta2d.on('scale', save);
meta2d.on('add', save);
meta2d.on('opened', save);
meta2d.on('undo', save);
meta2d.on('redo', save);
meta2d.on('add', save);
meta2d.on('delete', save);
meta2d.on('rotatePens', save);
meta2d.on('translatePens', save);
});
2.3 创建图形库Graphics
2.3.1 定义图元数据列表
因为是内置基础图元,我们暂时直接写死数组。实际项目中,可以通过API接口获取图元数据列表。
const graphicGroups = [
{
name: '基本形状', // 分组名称
list: [
{
name: '正方形', // 图元显示名称
icon: 'l-rect', // 图元显示图标,这里用的是iconfont图标
data: { // Meta2d.js图元数据
width: 100,
height: 100,
name: 'square',
},
},
]
},
{
name: '脑图',
list: [...]
}
]
由于篇幅问题,这里仅展示数据结构示意,详细可参考文末教程相关代码。
上面数据结构列表包含2种数据:
- “Meta2d.js图元数据”- Meta2d.js可视化引擎需要的数据,实际绘图数据
- 其他 - Vue UI用的数据,编辑器显示用的数据
2.3.2 显示图元列表
这里我们使用折叠面板来实现图元列表显示。
<t-collapse :defaultExpandAll="true">
<t-collapse-panel
:header="item.name"
v-for="item in graphicGroups"
:key="item.name"
>
<template v-for="elem in item.list">
<div
class="graphic"
:draggable="true"
@dragstart="dragStart($event, elem)"
@click.prevent="dragStart($event, elem)"
>
<svg class="l-icon" aria-hidden="true">
<use :xlink:href="'#' + elem.icon"></use>
</svg>
<p :title="elem.name">{{ elem.name }}</p>
</div>
</template>
</t-collapse-panel>
</t-collapse>
2.3.3 图元拖拽
由于Meta2d.js已经内置接收拖拽数据的功能。这里,我们只用实现拖拽绑定数据过程即可,只需2步,简单方便。
const dragStart = (e: any, elem: any) => {
if (!elem) {
return;
}
e.stopPropagation();
// 拖拽事件
if (e instanceof DragEvent) {
// 设置拖拽数据
e.dataTransfer?.setData('Meta2d', JSON.stringify(elem.data));
} else {
// 支持单击添加图元。平板模式
meta2d.canvas.addCaches = [elem.data];
}
};
2.3.4 平板模式单击添加图元
Meta2d.js支持单击图元添加,方便触摸场景。
- 设置单击事件
这里为了方便,直接合并在拖拽函数里面了
- 绑定单击数据
2.4 创建属性面板Props
这里,我们属性面板包含2种(实际项目中,根据需求设计): 图纸属性、图元属性。
我们通过鼠标点击的不同,切换不同的属性面板:
- 点击画布空白地方:显示图纸属性;
- 点击图元:显示图元属性;
2.4.1 组合式函数
这里,我们学习下非常有用的Vue知识和一些优雅的架构技巧:组合式函数、状态管理
什么是组合式函数
组合式函数(Composite function)是一种通过将多个独立的函数组合起来,来解决复合问题的函数。组合式函数的好处在于可以通过简单地组合多个函数来减少代码量,提高代码的可读性,并提高程序的灵活性和可扩展性。以下是组合式函数的一些主要优点:
- 代码重用:通过组合多个函数,可以减少代码量,提高代码的可读性和可维护性。在实际编程过程中,我们常常需要重复使用某些功能,组合式函数可以帮助我们更轻松地实现代码重用。
- 模块化:通过将函数组合在一起,可以实现程序的模块化,使得代码结构更清晰,模块之间的关系更明确。这有助于提高程序的可维护性和可读性。
- 提高代码的可读性:组合式函数将多个相关的函数组合在一起,有助于提高代码的可读性。通过这种方式,开发者可以更容易地理解函数的作用,以及各个函数之间的关系。
- 灵活性:组合式函数可以根据需要动态地调整各个函数的顺序、参数或调用方式,以便更好地满足问题的需求。这使得程序具有更高的灵活性和可扩展性。
- 复用逻辑:组合式函数可以将一些常用的逻辑代码封装起来,使得这些代码可以在程序的多个地方复用。这有助于减少重复代码,提高代码的质量。
- 可测试性:组合式函数更容易编写单元测试,因为每个函数都可以独立测试。这有助于提高程序的可测试性,降低调试成本。
- 易于维护和扩展:通过将函数组合在一起,开发者可以更容易地发现和解决程序中的问题,从而提高程序的维护和扩展能力。
总之,组合式函数具有代码重用、模块化、提高可读性、灵活性、复用逻辑、可测试性和易于维护和扩展等优点,可以帮助开发者编写更高效、更简洁的代码。
状态管理
【注意注意】【敲黑板】这里的状态管理不是Pinia,而是我们自己实现的:响应式+组合式函数
为什么不用Pinia
- 不为了使用而使用
- 有入侵性
- 响应式+组合式函数更高内聚低耦合
什么时候使用Pinia
- 项目规定
- 时间轴或时间旅行等调试功能
组合式函数 useSelection
我们定义一个useSelection来表示图元不同的选中状态(暂时2种):选中图纸;选中单个图元;
新建一个src/services/selections.ts文件
import { Pen } from '@meta2d/core';
import { reactive } from 'vue';
// 选中对象类型:0 - 画布;1 - 单个图元
export enum SelectionMode {
File,
Pen,
}
const selections = reactive<{
mode: SelectionMode;
pen?: Pen;
}>({
mode: SelectionMode.File,
pen: undefined,
});
export const useSelection = () => {
const select = (pens?: Pen[]) => {
if (!pens || pens.length !== 1) {
selections.mode = SelectionMode.File;
selections.pen = undefined;
return;
}
selections.mode = SelectionMode.Pen;
selections.pen = pens[0];
};
return {
selections,
select,
};
};
【注意注意】【敲黑板】优雅的架构技巧
- 组合式函数的数据为什么放在组合式函数外面
方便实现状态管理
- 什么时候数据放在组合式函数里面
每次使用组合式函数希望拥有独立的数据拷贝,不与其他使用者冲突
2.4.2 事件监听
监听画布的acitve事件实现面板切换。在View.vue文件中新增:
import { useSelection } from '@/services/selections';
const { select } = useSelection();
onMounted(() => {
// 创建实例
new Meta2d('meta2d', meta2dOptions);
...
meta2d.on('active', active);
meta2d.on('inactive', inactive);
});
const active = (pens?: Pen[]) => {
select(pens);
};
const inactive = () => {
select();
};
2.4.3 属性面板
Props.Vue中根据不同的管理状态,显示不同子组件即可
<template>
<div class="app-props">
{{ selections.mode }}
<FileProps v-if="selections.mode === SelectionMode.File" />
<PenProps v-else-if="selections.mode === SelectionMode.Pen" />
</div>
</template>
<script lang="ts" setup>
import FileProps from './FileProps.vue';
import PenProps from './PenProps.vue';
import { useSelection, SelectionMode } from '@/services/selections';
const { selections } = useSelection();
</script>
<style lang="postcss" scoped>
.app-props {
border-left: 1px solid var(--color-border);
z-index: 2;
height: calc(100vh - 80px);
overflow-y: auto;
}
</style>
2.4.4 图纸属性面板
这里暂时设置图纸属性有:图纸名称、网格、标尺、颜色等。
【注意注意注意】:
图纸名称、颜色属于图纸数据,参考Meta2d.js文档。图纸名称属于自定义业务数据,自己扩展定义的;
网格、标尺即可以在图纸数据设置,也可以在Meta2d.js Options选项设置。这里,我们在Options选项设置。
Options被视为独立于图纸外的默认通用样式,而图纸数据则归属于图纸专属数据。
A. 定义Vue组件数据
// 图纸数据
const data = reactive<any>({
name: '',
background: undefined,
color: undefined,
});
// 画布选项
const options = reactive<any>({
grid: false,
gridSize: 10,
gridRotate: undefined,
gridColor: undefined,
rule: true,
});
B. 定义组件UI
<template>
<div class="props-panel">
<t-form label-align="left">
<h5 class="mb-24">图纸</h5>
<t-form-item label="图纸名称" name="name">
<t-input v-model="data.name" @change="onChangeData" />
</t-form-item>
<t-divider />
<t-form-item label="网格" name="grid">
<t-switch v-model="options.grid" @change="onChangeOptions" />
</t-form-item>
<t-form-item label="网格大小" name="gridSize">
<t-input v-model.number="options.gridSize" @change="onChangeOptions" />
</t-form-item>
<t-form-item label="网格角度" name="gridRotate">
<t-input
v-model.number="options.gridRotate"
@change="onChangeOptions"
/>
</t-form-item>
<t-form-item label="网格颜色" name="gridColor">
<t-color-picker
class="w-full"
v-model="options.gridColor"
:show-primary-color-preview="false"
format="CSS"
:color-modes="['monochrome']"
@change="onChangeOptions"
/>
</t-form-item>
<t-divider />
<t-form-item label="标尺" name="rule">
<t-switch v-model="options.rule" @change="onChangeOptions" />
</t-form-item>
<t-divider />
<t-form-item label="背景颜色" name="background">
<t-color-picker
class="w-full"
v-model="data.background"
:show-primary-color-preview="false"
format="CSS"
:color-modes="['monochrome']"
@change="onChangeData"
/>
</t-form-item>
<t-form-item label="图元默认颜色" name="color">
<t-color-picker
class="w-full"
v-model="data.color"
:show-primary-color-preview="false"
format="CSS"
:color-modes="['monochrome']"
@change="onChangeData"
/>
</t-form-item>
</t-form>
</div>
</template>
C. 设置图纸数据
const onChangeData = () => {
Object.assign(meta2d.store.data, data);
meta2d.store.patchFlagsBackground = true;
meta2d.render();
};
因为涉及到背景,需要设置一个背景更新标志:meta2d.store.patchFlagsBackground = true;
D. 设置编辑器选项
const onChangeOptions = () => {
meta2d.setOptions(options);
meta2d.store.patchFlagsTop = true;
meta2d.store.patchFlagsBackground = true;
meta2d.render();
};
因为涉及到标尺,需要设置一个标尺图层更新标志:meta2d.store.patchFlagsTop = true;
2.4.5 图元属性面板
A. 定义图元数据
const pen = ref<any>();
// 位置数据。当前版本位置需要动态计算获取
const rect = ref<any>();
这里由于图元位置需要动态计算,因此需要单独定义。
B. 获取选中图元数据
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useSelection } from '@/services/selections';
const { selections } = useSelection();
onMounted(() => {
getPen();
});
const getPen = () => {
pen.value = selections.pen;
if (pen.value.globalAlpha == undefined) {
pen.value.globalAlpha = 1;
}
rect.value = meta2d.getPenRect(pen.value);
};
// 监听选中不同图元
// @ts-ignore
const watcher = watch(() => selections.pen.id, getPen);
onUnmounted(() => {
watcher();
});
C. 编写UI
<template>
<div class="props-panel">
<t-form label-align="left" v-if="pen">
<h5 class="mb-24">图元</h5>
<t-form-item label="文本" name="text">
<t-input v-model="pen.text" @change="changeValue('text')" />
</t-form-item>
<t-form-item label="颜色" name="color">
<t-color-picker
class="w-full"
v-model="pen.color"
:show-primary-color-preview="false"
format="CSS"
:color-modes="['monochrome']"
@change="changeValue('color')"
/>
</t-form-item>
<t-form-item label="背景" name="background">
<t-color-picker
class="w-full"
v-model="pen.background"
:show-primary-color-preview="false"
format="CSS"
:color-modes="['monochrome']"
@change="changeValue('background')"
/>
</t-form-item>
<t-form-item label="线条" name="dash">
<t-select v-model="pen.dash" @change="changeValue('dash')">
<t-option :key="0" :value="0" label="实线"></t-option>
<t-option :key="1" :value="1" label="虚线"></t-option>
</t-select>
</t-form-item>
<t-form-item label="圆角" name="borderRadius">
<t-input-number
:min="0"
:max="1"
:step="0.01"
v-model="pen.borderRadius"
@change="changeValue('borderRadius')"
/>
</t-form-item>
<t-form-item label="不透明度" name="globalAlpha">
<t-slider
v-model="pen.globalAlpha"
:min="0"
:max="1"
:step="0.01"
@change="changeValue('globalAlpha')"
/>
<span class="ml-16" style="width: 50px; line-height: 30px">
{{ pen.globalAlpha }}
</span>
</t-form-item>
<t-divider />
<t-form-item label="X" name="x">
<t-input-number v-model="rect.x" @change="changeRect('x')" />
</t-form-item>
<t-form-item label="Y" name="y">
<t-input-number v-model="rect.y" @change="changeRect('y')" />
</t-form-item>
<t-form-item label="宽" name="width">
<t-input-number v-model="rect.width" @change="changeRect('width')" />
</t-form-item>
<t-form-item label="高" name="height">
<t-input-number v-model="rect.height" @change="changeRect('height')" />
</t-form-item>
<t-divider />
<t-form-item label="文字水平对齐" name="textAlign">
<t-select v-model="pen.textAlign" @change="changeValue('textAlign')">
<t-option key="left" value="left" label="左对齐"></t-option>
<t-option key="center" value="center" label="居中"></t-option>
<t-option key="right" value="right" label="右对齐"></t-option>
</t-select>
</t-form-item>
<t-form-item label="文字垂直对齐" name="textBaseline">
<t-select
v-model="pen.textBaseline"
@change="changeValue('textBaseline')"
>
<t-option key="top" value="top" label="顶部对齐"></t-option>
<t-option key="middle" value="middle" label="居中"></t-option>
<t-option key="bottom" value="bottom" label="底部对齐"></t-option>
</t-select>
</t-form-item>
<t-divider />
<t-space>
<t-button @click="top">置顶</t-button>
<t-button @click="bottom">置底</t-button>
<t-button @click="up">上一层</t-button>
<t-button @click="down">下一层</t-button>
</t-space>
</t-form>
</div>
</template>
D. 设置图元数据
设置图元数据是调用meta2d.setValue实现。
当前需要注意的是:
const lineDashs = [undefined, [5, 5]];
const changeValue = (prop: string) => {
const v: any = { id: pen.value.id };
v[prop] = pen.value[prop];
if (prop === 'dash') {
v.lineDash = lineDashs[v[prop]];
}
meta2d.setValue(v, { render: true });
};
const changeRect = (prop: string) => {
const v: any = { id: pen.value.id };
v[prop] = rect.value[prop];
meta2d.setValue(v, { render: true });
};
E. 设置图元层级
根据Meta2d.js 图元API文档,调用相关函数即可
const top = () => {
meta2d.top();
meta2d.render();
};
const bottom = () => {
meta2d.bottom();
meta2d.render();
};
const up = () => {
meta2d.up();
meta2d.render();
};
const down = () => {
meta2d.down();
meta2d.render();
};
2.4.6 更多图元属性
更多属性功能可参考Meta2d.js 引擎API文档、图元API文档去编写
三、运行查看
因为前面结构规划清晰,所以运行查看比较简单,只需要加载View.vue子组件即可。整个页面只需短短几行代码即可:
<template>
<div class="app-page">
<View />
</div>
</template>
<script lang="ts" setup>
import View from '../components/View.vue';
</script>
<style lang="postcss" scoped>
.app-page {
height: 100vh;
}
</style>
四、开源与代码
Meta2d.js开源地址
Github:https://github.com/le5le-com/meta2d.js
本教程相关代码开源地址
https://github.com/le5le-com/meta2d.js/tree/main/examples/diagram-editor-vue3
开源不易,欢迎大家点星点赞支持
大家的热烈支持,是我们做的更好的动力:
五、其他
如果大家觉得实用、喜欢,欢迎转发点赞留言,共同学习!由于教程都是按照作者自己的视角写的,难免考虑不到所有细节,欢迎大家写一些自己的学习心得分享!
我们计划陆续推出一些系列文章,欢迎关注。
最后,开源不易,写作更不易,欢迎点星支持:https://github.com/le5le-com/meta2d.js