前端Web实战:从零打造一个类Visio的流程图拓扑图绘图工具

news/2024/5/17 14:37:22

前言

大家好,本系列从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);
// endapp.mount('#app');

1.9 设置路由

  1. 添加路由页面:src/views/Index.vue、src/views/Preview.vue
  2. 修改App.vue内容为加载路由

1.10 设置@路径支持

  1. 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/'),},},
});
  1. 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>

菜单事件通过查阅Meta2d.js的API帮助文档来实现

新建文件

新建文件是通过打开一个空白画布来实现

// 打开默认空白文件
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();
};

其他

其他未操作,可查阅Meta2d.js的API帮助文档来实现

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="连线"><svgwidth="1em"height="1em"viewBox="0 0 1024 1024"xmlns="http://www.w3.org/2000/svg"@click="drawLine":style="{color: isDrawLine ? ' #1677ff' : '',}"><pathd="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"><divclass="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"><divclass="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-ignoremeta2d.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"><divclass="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支持单击图元添加,方便触摸场景。

  1. 设置单击事件

这里为了方便,直接合并在拖拽函数里面了

  1. 绑定单击数据

2.4 创建属性面板Props

这里,我们属性面板包含2种(实际项目中,根据需求设计): 图纸属性图元属性

我们通过鼠标点击的不同,切换不同的属性面板:

  • 点击画布空白地方:显示图纸属性;
  • 点击图元:显示图元属性;

2.4.1 组合式函数

这里,我们学习下非常有用的Vue知识和一些优雅的架构技巧:组合式函数、状态管理

什么是组合式函数

组合式函数(Composite function)是一种通过将多个独立的函数组合起来,来解决复合问题的函数。组合式函数的好处在于可以通过简单地组合多个函数来减少代码量,提高代码的可读性,并提高程序的灵活性和可扩展性。以下是组合式函数的一些主要优点:

  1. 代码重用:通过组合多个函数,可以减少代码量,提高代码的可读性和可维护性。在实际编程过程中,我们常常需要重复使用某些功能,组合式函数可以帮助我们更轻松地实现代码重用。
  2. 模块化:通过将函数组合在一起,可以实现程序的模块化,使得代码结构更清晰,模块之间的关系更明确。这有助于提高程序的可维护性和可读性。
  3. 提高代码的可读性:组合式函数将多个相关的函数组合在一起,有助于提高代码的可读性。通过这种方式,开发者可以更容易地理解函数的作用,以及各个函数之间的关系。
  4. 灵活性:组合式函数可以根据需要动态地调整各个函数的顺序、参数或调用方式,以便更好地满足问题的需求。这使得程序具有更高的灵活性和可扩展性。
  5. 复用逻辑:组合式函数可以将一些常用的逻辑代码封装起来,使得这些代码可以在程序的多个地方复用。这有助于减少重复代码,提高代码的质量。
  6. 可测试性:组合式函数更容易编写单元测试,因为每个函数都可以独立测试。这有助于提高程序的可测试性,降低调试成本。
  7. 易于维护和扩展:通过将函数组合在一起,开发者可以更容易地发现和解决程序中的问题,从而提高程序的维护和扩展能力。

总之,组合式函数具有代码重用、模块化、提高可读性、灵活性、复用逻辑、可测试性和易于维护和扩展等优点,可以帮助开发者编写更高效、更简洁的代码。

状态管理

【注意注意】【敲黑板】这里的状态管理不是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-inputv-model.number="options.gridRotate"@change="onChangeOptions"/></t-form-item><t-form-item label="网格颜色" name="gridColor"><t-color-pickerclass="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-pickerclass="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-pickerclass="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-pickerclass="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-pickerclass="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-sliderv-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-selectv-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

Gitee: meta2d.js: The meta2d.js is real-time data exchange and interactive web 2D engine. Developers are able to build Web SCADA, IoT, Digital twins and so on. Meta2d.js是一个实时数据响应和交互的2d引擎,可用于Web组态,物联网,数字孪生等场景。

本教程相关代码开源地址

https://github.com/le5le-com/meta2d.js/tree/main/examples/diagram-editor-vue3

开源不易,欢迎大家点星点赞支持

大家的热烈支持,是我们做的更好的动力:

Github Star地址:GitHub - le5le-com/meta2d.js: The meta2d.js is real-time data exchange and interactive web 2D engine. Developers are able to build Web SCADA, IoT, Digital twins and so on. Meta2d.js是一个实时数据响应和交互的2d引擎,可用于Web组态,物联网,数字孪生等场景。

五、其他

如果大家觉得实用、喜欢,欢迎转发点赞留言,共同学习!由于教程都是按照作者自己的视角写的,难免考虑不到所有细节,欢迎大家写一些自己的学习心得分享!

我们计划陆续推出一些系列文章,欢迎关注。

最后,开源不易,写作更不易,欢迎点星支持:https://github.com/le5le-com/meta2d.js


http://www.mrgr.cn/p/81588477

相关文章

spring6——容器

文章目录 容器&#xff1a;IocIoc容器控制反转&#xff08;Ioc&#xff09;依赖注入IoC容器在Spring的实现 基于XML管理Bean搭建环境获取bean依赖注入setter注入构造器注入特殊值处理字面量赋值null值xml实体CDATA节 特殊类型属性注入为对象类型属性赋值方式一&#xff1a;引入…

音频开发-小程序和H5

微信录音 1、引入sdk 2、录音操作 浏览器录音 参考文献&#xff1a;前端H5实现调用麦克风&#xff0c;录音功能_h5 录音_Darker丨峰神的博客-CSDN博客 function record() { window.navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 44100, // 采样率 channelCount…

【软件安装】MATLAB_R2021b for mac 安装

Mac matlab_r2021b 安装 下载链接&#xff1a;百度网盘 下载链接中所有文件备用。 我所使用的电脑配置&#xff1a; Macbook Pro M1 Pro 16512 系统 macOS 13.5 安装步骤 前置准备 无此选项者&#xff0c;自行百度 “mac 任何来源”。 1 下载好「MATLAB R2021b」安装文…

Leetcode-每日一题【剑指 Offer 56 - I. 数组中数字出现的次数】

题目 一个整型数组 nums 里除两个数字之外&#xff0c;其他数字都出现了两次。请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n)&#xff0c;空间复杂度是O(1)。 示例 1&#xff1a; 输入&#xff1a;nums [4,1,4,6]输出&#xff1a;[1,6] 或 [6,1] 示例 2&#x…

计算机网络——传输层

文章目录 **1 传输层提供的服务****1.1 传输层的功能****1.2 传输层的寻址与端口** **2 UDP协议****2.1 UDP数据报****2.2 UDP校验** **3 TCP协议****3.1 TCP协议的特点****3.2 TCP报文段****3.3 TCP连接管理****3.4 TCP可靠传输****3.5 TCP流量控制****3.6 TCP拥塞控制** 1 传…

Verilog语法学习——LV4_移位运算与乘法

LV4_移位运算与乘法 题目来源于牛客网 [牛客网在线编程_Verilog篇_Verilog快速入门 (nowcoder.com)](https://www.nowcoder.com/exam/oj?page1&tabVerilog篇&topicId301) 题目 题目描述&#xff1a; 已知d为一个8位数&#xff0c;请在每个时钟周期分别输出该数乘1/…

Spring Security 构建基于 JWT 的登录认证

一言以蔽之&#xff0c;JWT 可以携带非敏感信息&#xff0c;并具有不可篡改性。可以通过验证是否被篡改&#xff0c;以及读取信息内容&#xff0c;完成网络认证的三个问题&#xff1a;“你是谁”、“你有哪些权限”、“是不是冒充的”。 为了安全&#xff0c;使用它需要采用 …

HTTP、HTTPS协议详解

文章目录 HTTP是什么报文结构请求头部响应头部 工作原理用户点击一个URL链接后&#xff0c;浏览器和web服务器会执行什么http的版本持久连接和非持久连接无状态与有状态Cookie和Sessionhttp方法&#xff1a;get和post的区别 状态码 HTTPS是什么ssl如何搞到证书nginx中的部署 加…

2023 蓝桥杯真题B组 C/C++

https://www.dotcpp.com/oj/train/1089/ 题目 3150: 蓝桥杯2023年第十四届省赛真题-冶炼金属 题目描述 小蓝有一个神奇的炉子用于将普通金属 O 冶炼成为一种特殊金属 X。这个炉子有一个称作转换率的属性 V&#xff0c;V 是一个正整数&#xff0c;这意味着消耗 V 个普通金 属 O…

react-native 输入框 被软键盘遮挡 (KeyboardAvoidingView)

本组件用于解决一个常见的尴尬问题&#xff1a;手机上弹出的键盘常常会挡住当前的视图。本组件可以自动根据键盘的高度&#xff0c;调整自身的 height 或底部的 padding&#xff0c;以避免被遮挡。 <KeyboardAvoidingViewbehavior{Platform.OS ios ? padding : height}key…

【点云处理教程】02从 Python 中的深度图像估计点云

一、说明 这是“点云处理”教程的第二篇文章。“点云处理”教程对初学者友好&#xff0c;我们将在其中简单地介绍从数据准备到数据分割和分类的点云处理管道。在本教程中&#xff0c;我们将学习如何在不使用 Open3D 库的情况下从深度图像计算点云。我们还将展示如何优化代码以获…

【VUE】解决图片视频加载缓慢/首屏加载白屏的问题

1 问题描述 在 Vue3 项目中&#xff0c;有时候会出现图片视频加载缓慢、首屏加载白屏的问题 2 原因分析 通常是由以下原因导致的&#xff1a; 图片或视频格式不当&#xff1a;如果图片或视频格式选择不当&#xff0c;比如选择了无损压缩格式&#xff0c;可能会导致文件大小过大…

微信小程序交易体验分常见问题指引

小程序交易体验分是为保障小程序用户的交易体验&#xff0c;促进开发者向用户提供更好的服务&#xff0c;帮助开发者更好的评估自身服务水平的机制。平台将对开发者在其小程序的违规行为进行判定&#xff0c;根据违规行为的严重程度对该小程序扣减不同分值的交易体验分&#xf…

风靡朋友圈的妙鸭相机,到底用了哪些底层技术?

不知道大家近期的朋友圈有没有被和海马体、天真蓝如出一辙的AI写真刷屏&#xff01; 这些面若桃花、精致到头发丝、光影充满氛围感的写真都是一款叫“妙鸭相机”的小程序生成的&#xff01;只要9.9&#xff0c;就能体验999写真&#xff01; 虽然只要9.9&#xff0c;但生成的照片…

opencv-24 图像几何变换03-仿射-cv2.warpAffine()

什么是仿射&#xff1f; 仿射变换是指图像可以通过一系列的几何变换来实现平移、旋转等多种操作。该变换能够 保持图像的平直性和平行性。平直性是指图像经过仿射变换后&#xff0c;直线仍然是直线&#xff1b;平行性是指 图像在完成仿射变换后&#xff0c;平行线仍然是平行线。…

海康摄像头开发笔记(一):连接防爆摄像头、配置摄像头网段、设置rtsp码流、播放rtsp流、获取rtsp流、调优rtsp流播放延迟以及录像存储

文为原创文章&#xff0c;转载请注明原文出处 本文章博客地址&#xff1a;https://hpzwl.blog.csdn.net/article/details/131679108 红胖子(红模仿)的博文大全&#xff1a;开发技术集合&#xff08;包含Qt实用技术、树莓派、三维、OpenCV、OpenGL、ffmpeg、OSG、单片机、软硬结…

服务器 Docker Alist挂载到本地磁盘(Mac版)夸克网盘

1.服务器下载alist 默认有docker环境 docker pull xhofe/alist2.生成容器 -v /home/alist:/opt/alist/data 这段意思是alist中的数据映射到docker 主机的文件夹&#xff0c;/home/alist就是我主机的文件夹&#xff0c;这个文件夹必须先创建 docker run -d --restartalways…

【Python】数据分析+数据挖掘——探索Pandas中的数据筛选

1. 前言 当涉及数据处理和分析时&#xff0c;Pandas是Python编程语言中最强大、灵活且广泛使用的工具之一。Pandas提供了丰富的功能和方法&#xff0c;使得数据的选择、筛选和处理变得简单而高效。在本博客中&#xff0c;我们将重点介绍Pandas中数据筛选的关键知识点&#xff…

ChatGPT结合知识图谱构建医疗问答应用 (一) - 构建知识图谱

一、ChatGPT结合知识图谱 在本专栏的前面文章中构建 ChatGPT 本地知识库问答应用&#xff0c;都是基于词向量检索 Embedding 嵌入的方式实现的&#xff0c;在传统的问答领域中&#xff0c;一般知识源采用知识图谱来进行构建&#xff0c;但基于知识图谱的问答对于自然语言的处理…

Nginx配置WebSocket反向代理

1、WebSocket协议 ​ WebSocket协议相比较于HTTP协议成功握手后可以多次进行通讯&#xff0c;直到连接被关闭。但是WebSocket中的握手和HTTP中的握手兼容&#xff0c;它使用HTTP中的Upgrade协议头将连接从HTTP升级到WebSocket。这使得WebSocket程序可以更容易的使用现已存在的…