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

前言

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

你将收获

  • 免费好用、专属自己的绘图工具
  • 前端项目实战学习
  • 如何从0搭建一个前端项目等基础框架
  • 项目设计思路及优雅的架构技巧
  • 开源项目学习
  • 热门可视化引擎Meta2d.js等学习使用

技术栈

Meta2d.js - 国产开源免费好用的可视化引擎

Vue3 - 流行的简单易用等前端Web框架

Vite - 高效好用的前端热门构建工具

TDesign - 支持Vue3的前端UI组件库

需要提前掌握

  • 前端基础工具node.js安装(仅安装即可)
  • npm(pnpm、yarn)基本使用
  • package.json基本认识

以上基础知识可自行网上学习

一、 Vite + Vue3框架搭建

1.1 搭建vue3的vite项目

参考vite文档(开始 | Vite 官方中文文档)的pnpm的方式创建项目:

pnpm create vite

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

1.2 修改package.json

【注意】因为当前vite更新比较频繁,经常直接使用脚手架命令生成的框架运行会报错。可以尝试切换不同的包管理工具(pnpm、yarn、npm)试试;或看看vite、vue等是否有最新版本号,修改package.json升级。

当前,我们使用pnpm i安装依赖包后,发现运行错误。查看有新的vite@4.4.2,手动修改package.json升级。

另外,我个人习惯,把package.json中的dev重命名为start。

1.3 运行检查基础框架

// 安装依赖包
pnpm i
// 本地运行。脚手架默认命令为:pnpm dev
pnpm start 

根据命令行提示,在浏览器打开:http://127.0.0.1:5173/ 正常运行,基础框架完成。

1.4 丰富框架

  • 在package.json中添加meta2d.js、vue-router、tdesign、postcss等项目需要用的依赖包。

    {
    "name": "diagram-editor-vue3",
    "private": true,
    "version": "0.0.1",
    "scripts": {
    "start": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
    },
    "dependencies": {
    "@meta2d/activity-diagram": "^1.0.0",
    "@meta2d/chart-diagram": "^1.0.3",
    "@meta2d/class-diagram": "^1.0.0",
    "@meta2d/core": "^1.0.19",
    "@meta2d/flow-diagram": "^1.0.0",
    "@meta2d/form-diagram": "^1.0.3",
    "@meta2d/fta-diagram": "^1.0.0",
    "@meta2d/le5le-charts": "^1.0.2",
    "@meta2d/sequence-diagram": "^1.0.0",
    "@meta2d/svg": "^1.0.2",
    "tdesign-vue-next": "^1.3.10",
    "vue": "^3.3.4",
    "vue-router": "^4.2.4"
    },
    "devDependencies": {
    "@vitejs/plugin-vue": "^4.2.3",
    "autoprefixer": "^10.4.13",
    "postcss": "^8.4.6",
    "postcss-import": "^14.1.0",
    "postcss-nested": "^6.0.1",
    "typescript": "^5.0.2",
    "vite": "^4.4.2",
    "vue-tsc": "^1.8.3"
    }
    }

  • 添加postcss支持

    • 在package.json中删除:"type": "module"选项。

    • 添加postcss.config.js文件:

      module.exports = {
      plugins: {
      'postcss-import': {},
      'postcss-nested': {},
      autoprefixer: {},
      },
      };

1.5 修改index.html

修改index.html为符合项目描述内容

1.6 初始化css

修改style.css为符合项目的默认初始样式

1.7 添加router

新增src/router.ts文件:

import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  { path: '/', component: () => import('./views/Index.vue') },
  { path: '/preview', component: () => import('./views/Preview.vue') },
];

const router = createRouter({
  history: createWebHistory('/'),
  routes,
});

export default router;

其中:

'/' - 编辑器页面

'/preview' - 预览页面

1.8 加载vue-router、tdesign

在main.ts中加载vue-router、tdesign等基础服务。

import { createApp } from 'vue';
import './style.css';
import App from './App.vue';

import router from './router.ts';
import TDesign from 'tdesign-vue-next';

const app = createApp(App);

// 加载基础服务
app.use(router).use(TDesign);
// end

app.mount('#app');

1.9 设置路由

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

使用TDesignDropdown下拉菜单创建菜单栏

  <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.jsAPI帮助文档来实现

新建文件

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

// 打开默认空白文件
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.jsAPI帮助文档来实现

2.2.2 创建工具栏

画直线

设置html DOM元素属性,支持拖拽和点击

<t-tooltip content="直线">
  <span
    :draggable="true"
    @dragstart="onAddShape($event, 'line')"
    @click="onAddShape($event, 'line')"
  >
    <t-icon name="slash" />
  </span>
</t-tooltip>

设置图元数据

const onAddShape = (event: DragEvent | MouseEvent, name: string) => {
  event.stopPropagation();
  let data: any;
  if (name === 'text') {
    data = {
      text: 'text',
      width: 100,
      height: 20,
      name: 'text',
    };
  } else if (name === 'line') {
    data = {
      anchors: [
        { id: '0', x: 1, y: 0 },
        { id: '1', x: 0, y: 1 },
      ],
      width: 100,
      height: 100,
      name: 'line',
      lineName: 'line',
      type: 1,
    };
  }
  if (!(event as DragEvent).dataTransfer) {
    meta2d.canvas.addCaches = deepClone([data]);
  } else {
    (event as DragEvent).dataTransfer?.setData('Meta2d', JSON.stringify(data));
  }
};

添加文字

设置html DOM元素属性,支持拖拽和点击

 <t-tooltip content="文字">
  <span
    :draggable="true"
    @dragstart="onAddShape($event, 'text')"
    @click="onAddShape($event, 'text')"
  >
    <svg class="l-icon" aria-hidden="true">
      <use xlink:href="#l-text"></use>
    </svg>
  </span>
</t-tooltip>

设置图元数据

const onAddShape = (event: DragEvent | MouseEvent, name: string) => {
  event.stopPropagation();
  let data: any;
  if (name === 'text') {
    data = {
      text: 'text',
      width: 100,
      height: 20,
      name: 'text',
    };
  } else if (name === 'line') {
    data = {
      anchors: [
        { id: '0', x: 1, y: 0 },
        { id: '1', x: 0, y: 1 },
      ],
      width: 100,
      height: 100,
      name: 'line',
      lineName: 'line',
      type: 1,
    };
  }
  if (!(event as DragEvent).dataTransfer) {
    meta2d.canvas.addCaches = deepClone([data]);
  } else {
    (event as DragEvent).dataTransfer?.setData('Meta2d', JSON.stringify(data));
  }
};

连线

设置click事件

<t-tooltip content="连线">
  <svg
    width="1em"
    height="1em"
    viewBox="0 0 1024 1024"
    xmlns="http://www.w3.org/2000/svg"
    @click="drawLine"
    :style="{
      color: isDrawLine ? ' #1677ff' : '',
    }"
  >
    <path
      d="M192 64a128 128 0 0 1 123.968 96H384a160 160 0 0 1 159.68 149.504L544 320v384a96 96 0 0 0 86.784 95.552L640 800h68.032a128 128 0 1 1 0 64.064L640 864a160 160 0 0 1-159.68-149.504L480 704V320a96 96 0 0 0-86.784-95.552L384 224l-68.032 0.064A128 128 0 1 1 192 64z m640 704a64 64 0 1 0 0 128 64 64 0 0 0 0-128zM192 128a64 64 0 1 0 0 128 64 64 0 0 0 0-128z"
      fill="currentColor"
    ></path>
  </svg>
</t-tooltip>

实现连线

// 连线状态
const isDrawLine = ref<boolean>(false);

// 连线实现
const drawLine = () => {
  if (isDrawLine.value) {
    isDrawLine.value = false;
    meta2d.finishDrawLine();
    meta2d.drawLine();
    meta2d.store.options.disableAnchor = true;
  } else {
    isDrawLine.value = true;
    meta2d.drawLine(meta2d.store.options.drawingLineName);
    meta2d.store.options.disableAnchor = false;
  }
};

设置连线类型

设置html属性

 <t-dropdown
      :minColumnWidth="160"
      :maxHeight="560"
      overlayClassName="header-dropdown"
    >
      <a>
        <svg class="l-icon" aria-hidden="true">
          <use
            :xlink:href="
              lineTypes.find((item) => item.value === currentLineType)?.icon
            "
          ></use>
        </svg>
      </a>
      <t-dropdown-menu>
        <t-dropdown-item v-for="item in lineTypes">
          <div class="flex middle" @click="changeLineType(item.value)">
            {{ item.name }} <span class="flex-grow"></span>
            <svg class="l-icon" aria-hidden="true">
              <use :xlink:href="item.icon"></use>
            </svg>
          </div>
        </t-dropdown-item>
      </t-dropdown-menu>
    </t-dropdown>

连线类型设置

const lineTypes = reactive([
  { name: '曲线', icon: '#l-curve2', value: 'curve' },
  { name: '线段', icon: '#l-polyline', value: 'polyline' },
  { name: '直线', icon: '#l-line', value: 'line' },
  { name: '脑图曲线', icon: '#l-mind', value: 'mind' },
]);
const currentLineType = ref('curve');

const changeLineType = (value: string) => {
  currentLineType.value = value;
  if (meta2d) {
    meta2d.store.options.drawingLineName = value;
    meta2d.canvas.drawingLineName && (meta2d.canvas.drawingLineName = value);
    meta2d.store.active?.forEach((pen) => {
      meta2d.updateLineType(pen, value);
    });
  }
};

设置连线箭头

设置html属性

   <t-dropdown
      :minColumnWidth="160"
      :maxHeight="560"
      :delay2="[10, 150]"
      overlayClassName="header-dropdown"
    >
      <a>
        <svg class="l-icon" aria-hidden="true">
          <use
            :xlink:href="
              fromArrows.find((item) => item.value === fromArrow)?.icon
            "
          ></use>
        </svg>
      </a>
      <t-dropdown-menu>
        <t-dropdown-item v-for="item in fromArrows">
          <div
            class="flex middle"
            style="height: 30px"
            @click="changeFromArrow(item.value)"
          >
            <svg class="l-icon" aria-hidden="true">
              <use :xlink:href="item.icon"></use>
            </svg>
          </div>
        </t-dropdown-item>
      </t-dropdown-menu>
    </t-dropdown>
    <t-dropdown
      :minColumnWidth="160"
      :maxHeight="560"
      :delay2="[10, 150]"
      overlayClassName="header-dropdown"
    >
      <a>
        <svg class="l-icon" aria-hidden="true">
          <use
            :xlink:href="toArrows.find((item) => item.value === toArrow)?.icon"
          ></use>
        </svg>
      </a>
      <t-dropdown-menu>
        <t-dropdown-item v-for="item in toArrows">
          <div
            class="flex middle"
            style="height: 30px"
            @click="changeToArrow(item.value)"
          >
            <svg class="l-icon" aria-hidden="true">
              <use :xlink:href="item.icon"></use>
            </svg>
          </div>
        </t-dropdown-item>
      </t-dropdown-menu>
    </t-dropdown>

箭头设置

const fromArrow = ref('');
const fromArrows = [
  { icon: '#l-line', value: '' },
  { icon: '#l-from-triangle', value: 'triangle' },
  { icon: '#l-from-diamond', value: 'diamond' },
  { icon: '#l-from-circle', value: 'circle' },
  { icon: '#l-from-lineDown', value: 'lineDown' },
  { icon: '#l-from-lineUp', value: 'lineUp' },
  { icon: '#l-from-triangleSolid', value: 'triangleSolid' },
  { icon: '#l-from-diamondSolid', value: 'diamondSolid' },
  { icon: '#l-from-circleSolid', value: 'circleSolid' },
  { icon: '#l-from-line', value: 'line' },
];
const toArrow = ref('');
const toArrows = [
  { icon: '#l-line', value: '' },
  { icon: '#l-to-triangle', value: 'triangle' },
  { icon: '#l-to-diamond', value: 'diamond' },
  { icon: '#l-to-circle', value: 'circle' },
  { icon: '#l-to-lineDown', value: 'lineDown' },
  { icon: '#l-to-lineUp', value: 'lineUp' },
  { icon: '#l-to-triangleSolid', value: 'triangleSolid' },
  { icon: '#l-to-diamondSolid', value: 'diamondSolid' },
  { icon: '#l-to-circleSolid', value: 'circleSolid' },
  { icon: '#l-to-line', value: 'line' },
];

const changeFromArrow = (value: string) => {
  fromArrow.value = value;
  // 画布默认值
  meta2d.store.data.fromArrow = value;
  // 活动层的箭头都变化
  if (meta2d.store.active) {
    meta2d.store.active.forEach((pen: Pen) => {
      if (pen.type === PenType.Line) {
        pen.fromArrow = value;
        meta2d.setValue(
          {
            id: pen.id,
            fromArrow: pen.fromArrow,
          },
          {
            render: false,
          }
        );
      }
    });
    meta2d.render();
  }
};

const changeToArrow = (value: string) => {
  toArrow.value = value;
  // 画布默认值
  meta2d.store.data.toArrow = value;
  // 活动层的箭头都变化
  if (meta2d.store.active) {
    meta2d.store.active.forEach((pen: Pen) => {
      if (pen.type === PenType.Line) {
        pen.toArrow = value;
        meta2d.setValue(
          {
            id: pen.id,
            toArrow: pen.toArrow,
          },
          {
            render: false,
          }
        );
      }
    });
    meta2d.render();
  }
};

画布缩放

  • 监听当前画布比例

    onMounted(() => {
    const timer = setInterval(() => {
    if (meta2d) {
    clearInterval(timer);
    // 获取初始缩放比例
    scaleSubscriber(meta2d.store.data.scale);

        // 监听缩放
        // @ts-ignore
        meta2d.on('scale', scaleSubscriber);
      }
    }, 200);
    

    });

    const scaleSubscriber = (val: number) => {
    scale.value = Math.round(val * 100);
    };

  • 缩放到100%

    const onScaleDefault = () => {
    meta2d.scale(1);
    meta2d.centerView();
    };

  • 缩放到窗口大小

    const onScaleWindow = () => {
    meta2d.fitView();
    };

运行查看

这里由于是单机环境,数据保存在前本地存储。

无论是否单机环境,运行查看大致流程基本上是:保存数据(这里是前端本地存储)-> 跳转运行页面 -> 新页面读取加载数据。

  • 添加click事件

    <t-tooltip content="运行查看"> <t-icon name="play-circle-stroke" @click="onView" /> </t-tooltip>
  • 保存数据到本地存储

  • 跳转运行页面

    const onView = () => {
    // 先停止动画,避免数据波动
    meta2d.stopAnimate();

    // 本地存储
    const data: any = meta2d.data();
    localStorage.setItem('meta2d', JSON.stringify(data));
    
    // 跳转到预览页面
    router.push({
      path: '/preview',
      query: {
        r: Date.now() + '',
        id: data._id,
      },
    });
    

    };

  • 加载数据

Preview.vue

<template>
  <div class="app-page">
    <View />
  </div>
</template>

<script lang="ts" setup>
import { onMounted } from 'vue';
import View from '../components/View.vue';

onMounted(() => {
  // 读取本地存储
  let data: any = localStorage.getItem('meta2d');
  if (data) {
    data = JSON.parse(data);
    // 设置为预览模式
    data.locked = 1;
  }
  meta2d.open(data);
});
</script>

<style lang="postcss" scoped>
.app-page {
  height: 100vh;
}
</style>

返回编辑

返回编辑的基本流程是: 跳转编辑页面 -> 新页面读取加载数据。

这和运行查看有重复的逻辑(新页面读取加载数据),因此,我们可以把这部分放到公共的View.vue组件里面实现。

View.vue

...

onMounted(() => {
  // 创建实例
  new Meta2d('meta2d', meta2dOptions);

  // 按需注册图形库
  // 以下为自带基础图形库
  register(flowPens());
  registerAnchors(flowAnchors());
  register(activityDiagram());
  registerCanvasDraw(activityDiagramByCtx());
  register(classPens());
  register(sequencePens());
  registerCanvasDraw(sequencePensbyCtx());
  registerEcharts();
  registerCanvasDraw(formPens());
  registerCanvasDraw(chartsPens());
  register(ftaPens());
  registerCanvasDraw(ftaPensbyCtx());
  registerAnchors(ftaAnchors());

  // 注册其他自定义图形库
  // ...

  // 加载数据
  let data: any = localStorage.getItem('meta2d');
  if (data) {
    data = JSON.parse(data);

    // 判断是否为运行查看,是-设置为预览模式
    if (location.pathname === '/preview') {
      data.locked = 1;
    } else {
      data.locked = 0;
    }
    meta2d.open(data);
  }
});

...

自动保存

这里是单机环境,我们自动保存到前端本地存储。

  • 监听数据变化
  • 自动保存

Index.Vue

let timer: any;
function save() {
  if (timer) {
    clearTimeout(timer);
  }
  timer = setTimeout(() => {
    const data: any = meta2d.data();
    localStorage.setItem('meta2d', JSON.stringify(data));
    timer = undefined;
  }, 1000);
}

onMounted(() => {
  meta2d.on('scale', save);
  meta2d.on('add', save);
  meta2d.on('opened', save);
  meta2d.on('undo', save);
  meta2d.on('redo', save);
  meta2d.on('add', save);
  meta2d.on('delete', save);
  meta2d.on('rotatePens', save);
  meta2d.on('translatePens', save);
});

2.3 创建图形库Graphics

2.3.1 定义图元数据列表

因为是内置基础图元,我们暂时直接写死数组。实际项目中,可以通过API接口获取图元数据列表。

const graphicGroups = [
  {
    name: '基本形状',     // 分组名称
    list: [
      {
        name: '正方形',   // 图元显示名称
        icon: 'l-rect',  // 图元显示图标,这里用的是iconfont图标
        data: {          // Meta2d.js图元数据
          width: 100,
          height: 100,
          name: 'square',
        },
      },
    ]
  },
  {
    name: '脑图',
    list: [...]
  }
]

由于篇幅问题,这里仅展示数据结构示意,详细可参考文末教程相关代码。

上面数据结构列表包含2种数据:

  • "Meta2d.js图元数据"- Meta2d.js可视化引擎需要的数据,实际绘图数据
  • 其他 - Vue UI用的数据,编辑器显示用的数据

2.3.2 显示图元列表

这里我们使用折叠面板来实现图元列表显示。

<t-collapse :defaultExpandAll="true">
      <t-collapse-panel
        :header="item.name"
        v-for="item in graphicGroups"
        :key="item.name"
      >
        <template v-for="elem in item.list">
          <div
            class="graphic"
            :draggable="true"
            @dragstart="dragStart($event, elem)"
            @click.prevent="dragStart($event, elem)"
          >
            <svg class="l-icon" aria-hidden="true">
              <use :xlink:href="'#' + elem.icon"></use>
            </svg>
            <p :title="elem.name">{{ elem.name }}</p>
          </div>
        </template>
      </t-collapse-panel>
    </t-collapse>

2.3.3 图元拖拽

由于Meta2d.js已经内置接收拖拽数据的功能。这里,我们只用实现拖拽绑定数据过程即可,只需2步,简单方便。

const dragStart = (e: any, elem: any) => {
  if (!elem) {
    return;
  }
  e.stopPropagation();

  // 拖拽事件
  if (e instanceof DragEvent) {
    // 设置拖拽数据
    e.dataTransfer?.setData('Meta2d', JSON.stringify(elem.data));
  } else {
    // 支持单击添加图元。平板模式
    meta2d.canvas.addCaches = [elem.data];
  }
};

2.3.4 平板模式单击添加图元

Meta2d.js支持单击图元添加,方便触摸场景。

  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-input
          v-model.number="options.gridRotate"
          @change="onChangeOptions"
        />
      </t-form-item>
      <t-form-item label="网格颜色" name="gridColor">
        <t-color-picker
          class="w-full"
          v-model="options.gridColor"
          :show-primary-color-preview="false"
          format="CSS"
          :color-modes="['monochrome']"
          @change="onChangeOptions"
        />
      </t-form-item>

      <t-divider />

      <t-form-item label="标尺" name="rule">
        <t-switch v-model="options.rule" @change="onChangeOptions" />
      </t-form-item>

      <t-divider />

      <t-form-item label="背景颜色" name="background">
        <t-color-picker
          class="w-full"
          v-model="data.background"
          :show-primary-color-preview="false"
          format="CSS"
          :color-modes="['monochrome']"
          @change="onChangeData"
        />
      </t-form-item>
      <t-form-item label="图元默认颜色" name="color">
        <t-color-picker
          class="w-full"
          v-model="data.color"
          :show-primary-color-preview="false"
          format="CSS"
          :color-modes="['monochrome']"
          @change="onChangeData"
        />
      </t-form-item>
    </t-form>
  </div>
</template>

C. 设置图纸数据

const onChangeData = () => {
  Object.assign(meta2d.store.data, data);
  meta2d.store.patchFlagsBackground = true;
  meta2d.render();
};

因为涉及到背景,需要设置一个背景更新标志:meta2d.store.patchFlagsBackground = true;

D. 设置编辑器选项

const onChangeOptions = () => {
  meta2d.setOptions(options);
  meta2d.store.patchFlagsTop = true;
  meta2d.store.patchFlagsBackground = true;
  meta2d.render();
};

因为涉及到标尺,需要设置一个标尺图层更新标志:meta2d.store.patchFlagsTop = true;

2.4.5 图元属性面板

A. 定义图元数据

const pen = ref<any>();
// 位置数据。当前版本位置需要动态计算获取
const rect = ref<any>();

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

B. 获取选中图元数据

import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useSelection } from '@/services/selections';

const { selections } = useSelection();


onMounted(() => {
  getPen();
});

const getPen = () => {
  pen.value = selections.pen;
  if (pen.value.globalAlpha == undefined) {
    pen.value.globalAlpha = 1;
  }

  rect.value = meta2d.getPenRect(pen.value);
};

// 监听选中不同图元
// @ts-ignore
const watcher = watch(() => selections.pen.id, getPen);

onUnmounted(() => {
  watcher();
});

C. 编写UI

<template>
  <div class="props-panel">
    <t-form label-align="left" v-if="pen">
      <h5 class="mb-24">图元</h5>
      <t-form-item label="文本" name="text">
        <t-input v-model="pen.text" @change="changeValue('text')" />
      </t-form-item>
      <t-form-item label="颜色" name="color">
        <t-color-picker
          class="w-full"
          v-model="pen.color"
          :show-primary-color-preview="false"
          format="CSS"
          :color-modes="['monochrome']"
          @change="changeValue('color')"
        />
      </t-form-item>
      <t-form-item label="背景" name="background">
        <t-color-picker
          class="w-full"
          v-model="pen.background"
          :show-primary-color-preview="false"
          format="CSS"
          :color-modes="['monochrome']"
          @change="changeValue('background')"
        />
      </t-form-item>
      <t-form-item label="线条" name="dash">
        <t-select v-model="pen.dash" @change="changeValue('dash')">
          <t-option :key="0" :value="0" label="实线"></t-option>
          <t-option :key="1" :value="1" label="虚线"></t-option>
        </t-select>
      </t-form-item>
      <t-form-item label="圆角" name="borderRadius">
        <t-input-number
          :min="0"
          :max="1"
          :step="0.01"
          v-model="pen.borderRadius"
          @change="changeValue('borderRadius')"
        />
      </t-form-item>
      <t-form-item label="不透明度" name="globalAlpha">
        <t-slider
          v-model="pen.globalAlpha"
          :min="0"
          :max="1"
          :step="0.01"
          @change="changeValue('globalAlpha')"
        />
        <span class="ml-16" style="width: 50px; line-height: 30px">
          {{ pen.globalAlpha }}
        </span>
      </t-form-item>

      <t-divider />

      <t-form-item label="X" name="x">
        <t-input-number v-model="rect.x" @change="changeRect('x')" />
      </t-form-item>
      <t-form-item label="Y" name="y">
        <t-input-number v-model="rect.y" @change="changeRect('y')" />
      </t-form-item>
      <t-form-item label="宽" name="width">
        <t-input-number v-model="rect.width" @change="changeRect('width')" />
      </t-form-item>
      <t-form-item label="高" name="height">
        <t-input-number v-model="rect.height" @change="changeRect('height')" />
      </t-form-item>

      <t-divider />

      <t-form-item label="文字水平对齐" name="textAlign">
        <t-select v-model="pen.textAlign" @change="changeValue('textAlign')">
          <t-option key="left" value="left" label="左对齐"></t-option>
          <t-option key="center" value="center" label="居中"></t-option>
          <t-option key="right" value="right" label="右对齐"></t-option>
        </t-select>
      </t-form-item>
      <t-form-item label="文字垂直对齐" name="textBaseline">
        <t-select
          v-model="pen.textBaseline"
          @change="changeValue('textBaseline')"
        >
          <t-option key="top" value="top" label="顶部对齐"></t-option>
          <t-option key="middle" value="middle" label="居中"></t-option>
          <t-option key="bottom" value="bottom" label="底部对齐"></t-option>
        </t-select>
      </t-form-item>

      <t-divider />

      <t-space>
        <t-button @click="top">置顶</t-button>
        <t-button @click="bottom">置底</t-button>
        <t-button @click="up">上一层</t-button>
        <t-button @click="down">下一层</t-button>
      </t-space>
    </t-form>
  </div>
</template>

D. 设置图元数据

设置图元数据是调用meta2d.setValue实现。

当前需要注意的是:

const lineDashs = [undefined, [5, 5]];

const changeValue = (prop: string) => {
  const v: any = { id: pen.value.id };
  v[prop] = pen.value[prop];
  if (prop === 'dash') {
    v.lineDash = lineDashs[v[prop]];
  }
  meta2d.setValue(v, { render: true });
};

const changeRect = (prop: string) => {
  const v: any = { id: pen.value.id };
  v[prop] = rect.value[prop];
  meta2d.setValue(v, { render: true });
};

E. 设置图元层级

根据Meta2d.js 图元API文档,调用相关函数即可

const top = () => {
  meta2d.top();
  meta2d.render();
};
const bottom = () => {
  meta2d.bottom();
  meta2d.render();
};
const up = () => {
  meta2d.up();
  meta2d.render();
};
const down = () => {
  meta2d.down();
  meta2d.render();
};

2.4.6 更多图元属性

更多属性功能可参考Meta2d.js 引擎API文档图元API文档去编写

三、运行查看

因为前面结构规划清晰,所以运行查看比较简单,只需要加载View.vue子组件即可。整个页面只需短短几行代码即可:

<template>
  <div class="app-page">
    <View />
  </div>
</template>

<script lang="ts" setup>
import View from '../components/View.vue';
</script>

<style lang="postcss" scoped>
.app-page {
  height: 100vh;
}
</style>

四、开源与代码

Meta2d.js开源地址

Github:https://github.com/le5le-com/meta2d.js

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

相关推荐
桂月二二37 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579653 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me4 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者4 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架
qq_392794484 小时前
前端缓存策略:强缓存与协商缓存深度剖析
前端·缓存