赞
踩
大家好,本系列从 Web 前端实战的角度,给大家分享介绍如何从零打造一个自己专属的绘图工具,实现流程图、拓扑图、脑图等类 Visio 的绘图工具。
Meta2d.js - 国产开源免费好用的可视化引擎
Vue3 - 流行的简单易用等前端 Web 框架
Vite - 高效好用的前端热门构建工具
TDesign - 支持 Vue3 的前端 UI 组件库
以上基础知识可自行网上学习
参考 vite 文档(https://cn.vitejs.dev/guide/)的pnpm的方式创建项目:
pnpm create vite
按照命令行提示,简单设置如下配置:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4L22flll-1689562415566)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/279a3708723f4063a3a079ad9e40d31e~tplv-k3u1fbpfcp-zoom-1.image)]
【注意】因为当前 vite 更新比较频繁,经常直接使用脚手架命令生成的框架运行会报错。可以尝试切换不同的包管理工具(pnpm、yarn、npm)试试;或看看 vite、vue 等是否有最新版本号,修改 package.json 升级。
当前,我们使用 pnpm i 安装依赖包后,发现运行错误。查看有新的vite@4.4.2,手动修改 package.json 升级。
另外,我个人习惯,把 package.json 中的 dev 重命名为 start。
// 安装依赖包
pnpm i
// 本地运行。脚手架默认命令为:pnpm dev
pnpm start
根据命令行提示,在浏览器打开:http://127.0.0.1:5173/ 正常运行,基础框架完成。
{ "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" } }
module.exports = {
plugins: {
'postcss-import': {},
'postcss-nested': {},
autoprefixer: {},
},
};
修改 index.html 为符合项目描述内容
修改 style.css 为符合项目的默认初始样式
新增 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’ - 预览页面
在 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');
安装依赖库: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/'),
},
},
});
{
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
},
},
...
}
运行 pnpm start 并在浏览器打开:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E1odX31q-1689562415566)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/610d730e50ef48d1acd1f2c7c56d28fb~tplv-k3u1fbpfcp-zoom-1.image)]
至此,基础框架搭建完成。
拆分编辑器为:菜单工具栏(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>
Meta2d 画布实例必须挂载在 html 中 DOM 元素上
<div id="meta2d"></div>
import { Meta2d } from '@meta2d/core';
创建实例必须等挂载容器(DOM 元素)创建完成。因此我们一般在 onMounted 中创建实例。注意,如果挂载容器存在动画或其他原因导致挂载容器大小、位置不稳定时,需要等挂载容器样式稳定后在创建。
onMounted(() => {
const myMeta2d = new Meta2d('meta2d', meta2dOptions);
});
通过 new Meta2d 创建实例后,默认会把当前实例挂载到 global.meta2d 全局变量上。后续可以直接通过 meta2d 来操作画布。
根据需求,按需注册图形库。
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()); // 注册其他自定义图形库 // ... });
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-50PoybBg-1689562415568)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f436343a2aa04e958f093b776b4582db~tplv-k3u1fbpfcp-zoom-1.image)]
使用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(); };
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`
);
};
const downloadPng = () => {
let name = (meta2d.store.data as any).name;
if (name) {
name += '.png';
}
meta2d.downloadPng(name);
};
// 判断该画笔 是否是组合为状态中 展示的画笔 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 帮助文档来实现
设置 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); };
const onScaleDefault = () => {
meta2d.scale(1);
meta2d.centerView();
};
const onScaleWindow = () => {
meta2d.fitView();
};
这里由于是单机环境,数据保存在前本地存储。
无论是否单机环境,运行查看大致流程基本上是:保存数据(这里是前端本地存储)-> 跳转运行页面 -> 新页面读取加载数据。
<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); });
因为是内置基础图元,我们暂时直接写死数组。实际项目中,可以通过 API 接口获取图元数据列表。
const graphicGroups = [ { name: '基本形状', // 分组名称 list: [ { name: '正方形', // 图元显示名称 icon: 'l-rect', // 图元显示图标,这里用的是iconfont图标 data: { // Meta2d.js图元数据 width: 100, height: 100, name: 'square', }, }, ] }, { name: '脑图', list: [...] } ]
由于篇幅问题,这里仅展示数据结构示意,详细可参考文末教程相关代码。
上面数据结构列表包含 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>
由于 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];
}
};
Meta2d.js 支持单击图元添加,方便触摸场景。
这里为了方便,直接合并在拖拽函数里面了
这里,我们属性面板包含 2 种(实际项目中,根据需求设计): 图纸属性、图元属性。
我们通过鼠标点击的不同,切换不同的属性面板:
这里,我们学习下非常有用的 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, }; };
【注意注意】【敲黑板】优雅的架构技巧
方便实现状态管理
每次使用组合式函数希望拥有独立的数据拷贝,不与其他使用者冲突
监听画布的 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(); };
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>
这里暂时设置图纸属性有:图纸名称、网格、标尺、颜色等。
【注意注意注意】:
图纸名称、颜色属于图纸数据,参考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;
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(); };
更多属性功能可参考 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>
Github:https://github.com/le5le-com/meta2d.js
Gitee: https://gitee.com/le5le/meta2d.js
https://github.com/le5le-com/meta2d.js/tree/main/examples/diagram-editor-vue3
开源不易,欢迎大家点星点赞支持
大家的热烈支持,是我们做的更好的动力:
Github Star 地址:https://github.com/le5le-com/meta2d.js
如果大家觉得实用、喜欢,欢迎转发点赞留言,共同学习!由于教程都是按照作者自己的视角写的,难免考虑不到所有细节,欢迎大家写一些自己的学习心得分享!
我们计划陆续推出一些系列文章,欢迎关注。
最后,开源不易,写作更不易,欢迎点赞分享,更欢迎点星支持:https://github.com/le5le-com/meta2d.js
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。