从零打造一个类Visio的流程图拓扑图绘图工具

前言

大家好,本系列从 Web 前端实战的角度,给大家分享介绍如何从零打造一个自己专属的绘图工具,实现流程图、拓扑图、脑图等类 Visio 的绘图工具。

从零打造一个类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 文档(
https://cn.vitejs.dev/guide/)的pnpm的方式创建项目:

pnpm create vite

按照命令行提示,简单设置如下配置:

从零打造一个类Visio的流程图拓扑图绘图工具

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 设置路由

  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 并在浏览器打开:

从零打造一个类Visio的流程图拓扑图绘图工具

从零打造一个类Visio的流程图拓扑图绘图工具

至此,基础框架搭建完成。

二、创建编辑器

2.0 编辑器布局

拆分编辑器为:菜单工具栏(Header)、图形库(Graphics)、编辑器画布(View)、属性面板(Props)

从零打造一个类Visio的流程图拓扑图绘图工具

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 创建菜单栏

从零打造一个类Visio的流程图拓扑图绘图工具

使用TDesign的Dropdown 下拉菜单创建菜单栏

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}}\',
      `${meta2d.store.data.background}">`
    );
  } else {
    mySerializedSVG = mySerializedSVG.replace(\'{{bk}}\', \'\');
    mySerializedSVG = mySerializedSVG.replace(\'{{bkRect}}\', \'\');
  }


  mySerializedSVG = mySerializedSVG.replace(/--le5le--/g, \'\');


  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 创建工具栏

从零打造一个类Visio的流程图拓扑图绘图工具

画直线

设置 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(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

从零打造一个类Visio的流程图拓扑图绘图工具

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 步,简单方便。

从零打造一个类Visio的流程图拓扑图绘图工具

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. 设置单击事件
从零打造一个类Visio的流程图拓扑图绘图工具

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

  1. 绑定单击数据
从零打造一个类Visio的流程图拓扑图绘图工具

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-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();
// 位置数据。当前版本位置需要动态计算获取
const rect = ref();

这里由于图元位置需要动态计算,因此需要单独定义。

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

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

内容出处:,

声明:本网站所收集的部分公开资料来源于互联网,转载的目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。如果您发现网站上有侵犯您的知识产权的作品,请与我们取得联系,我们会及时修改或删除。文章链接:http://www.yixao.com/share/30789.html

发表评论

登录后才能评论