用 Svg 手撸一个流程图

简介

近期接触 Svg 比较多, 想着 Svg 能做些啥。看到 Process On 是一个应用的场景, 而且平时我们多少都会接触一些。使用人群比较多,就这样一拍脑袋决定做一个流程图的程序。

首先我们需要知道流程图需要哪些东西。页面主要分为物料区域、画布区域、操作栏。最终的产物就是在画布上绘制一些图形。

所以我们的问题变成如何在画布上绘制出矩形、菱形、直线。这个时候就是 Svg 出场的时候了。

最近我接触到了Svg(可伸缩矢量图形)的技术,开始思考Svg能用来做些什么有趣的事情。我注意到流程图是一个常见的应用场景,我们在日常生活中经常会遇到一些流程图。因为流程图的使用人群众多,就这样一拍脑袋决定做一个流程图的程序。

首先,我们需要明确流程图软件需要哪些基本功能。主要的页面可以分为物料区域、画布区域和操作栏。而最终的目标就是在画布上绘制出各种形状的图形。

因此,我们面临的问题就变成了如何在画布上绘制矩形、菱形和直线等基本图形。这时候,Svg技术就派上用场了。

Svg

Svg(Scalable Vector Graphics)是一种基于XML的矢量图形格式,在前端开发中被广泛使用。与位图图像相比,Svg图像可以无损放大和缩小,适应不同的屏幕分辨率。

在前端开发中,我们可以使用Svg来创建各种形状和图标,以及实现动画效果。通过直接在HTML文件中插入Svg标签,我们可以定义并绘制出矢量图形。

Svg提供了丰富的元素和属性,例如rect(矩形)、circle(圆形)、path(路径)等,可以用于绘制不同的图形。我们可以通过设置这些元素的属性,如位置、大小、颜色,来控制图形的外观。

此外,Svg还支持CSS样式和JavaScript交互。我们可以为Svg元素应用CSS样式,使其更具吸引力和可定制性。而通过JavaScript,我们可以实现对Svg图形的动态操作和交互,如点击、拖拽、变形等。

我们这里使用 Svg 主要原因是它可以生成各种我们需要的形状。

画布上添加图形

从图中可以看出在画布上画出一个 rect 图形。

  1. 首先有一个相对坐标轴,可能这个坐标轴的位置是相对浏览器左上角有一定偏移的。
  2. 需要知道这个图形的坐标位置
  3. 需要知道这个图形的宽高

矩形

TS 复制代码
// 通过 Bounds 来描述图形位置和大小
class Bounds  {
  constructor(
    public width = 0,
    public height = 0,
    // 画布绝对位置
    public absX = 0,
    public absY = 0,
  ) { }
}
html 复制代码
   <!-- 绘制 rect 图形代码  -->
    <rect :rx="radiusValue" :ry="radiusValue" :width="shape.bounds.width" :height="shape.bounds.height"
      :x="shape.bounds.absX" :y="shape.bounds.absY" :fill="shape.style.fill" :stroke="shape.style.stroke" :stroke-width="shape.style.width" />

绘制一个矩形不仅仅需要 Bounds 信息,我们还需要定义一个 Shape 类来描述一个通用的图形。可以是任何形状的图形。

ts 复制代码
// 目前我们用到这两个属性,后面需要更多属性再加
export class Shape {
    bounds: Bounds;
    style: StyleObj;
}

直线

首先来看一条最简单的一条直线, 涉及点 (0,0)、(0,6) 两个点,绘制一条线跟矩形不同,他可能涉及多个点,无法通过 Bounds 进行描述。可以通过 Point 描述点

html 复制代码
<path d="M0,0 L0,6 " fill="#000" />
ts 复制代码
interface Point {
  x: number;
  y: number;
}

再来更新下 Shape

ts 复制代码
export class Shape {
    id: string; // 图形唯一id
    bounds: Bounds; // 此处的 (absX,absY) 和 waypoint[0] 位置相同
    style: StyleObj;
    waypoint: Point[] // 一条线至少有两个点,可以是多个点,如果多个点不在一条直线上就是折线
}

绘制线的代码

html 复制代码
      <!-- 展示线 -->
  <path :d="computedData.svgPath" :stroke="computedData.style.strokeColor" :stroke-dasharray="computedData.style.strokeDasharray ||
    (computedData.style.dashed ? '10 8' : '')
    " :stroke-width="computedData.style.strokeWidth" fill="none" stroke-linejoin="round" />

其他图形

如圆形、菱形、多边形等就不在赘述,也是相同的方法。像这几个图形都可以用 Bounds 来描述。知道了坐标,可以通过计算得到各个点,将它们绘制出来。接下来来看看如何将它们联动起来。

VUE 复制代码
<script setup lang="ts">
// 以菱形为例:计算菱形的四个点,就是取矩形四条线的中点,将它们连接起来
const points = computed(() => {
    const { absX, absY, width, height } = props.shape.bounds
    return `${absX + width / 2},${absY}  ${absX + width},${absY + height / 2} ${absX + width / 2}, ${absY + height} ${absX}, ${absY + height / 2}`
})
</script>
<!-- 菱形 -->
<polygon :points="points" fill="#fff" stroke="#000" stroke-width="2" />

交互

连线关系

如图所示,当我们将线拖到图形上,需要与图形建立关系。当我们建立关系后效果,拖动图形,线也会跟随移动。

继续给 shape 添加 sourceId、targetId 来表示关联元素

ts 复制代码
interface Shape {
    id: string; // 图形唯一id
    bounds: Bounds; 
    style: StyleObj;
    waypoint: Point[]; 
    sourceId: string; // 关联的源端图形
    targetId: string; // 关联的目标端图形
}    

流程如下

  1. 点击线 edgeShape 的端点如起始端点:source (起始点),进行移动
  2. 再释放鼠标的时候,如果此时在另一个图形上,就将 edgeShape 的 sourceId 设置为这个图形 id
  3. 移动线 edgeShape 的末端点同理,更新的是 targetId

现在考虑如何判断我们在释放鼠标的时候是否在图形上。

vue 复制代码
<template>
    <!-- 画布顶层,监听整个画布事件 -->
    <svg version="1.1" xmlns="http://www.w3.org/2000/svg" transform-origin="0 0" @mousemove="handleMousemove">
        <!-- 渲染所有图形 -->
        <DiagramShape :graph="graph" :shape="graph.rootShape" />
    </svg>
</template>
vue 复制代码
<!-- 每个图形单独设置事件监听,并阻止冒泡 -->
<script>
    const mousemove = (event: MouseEvent) => {
      graph.emitter.emit(EventType.SHAPE_MOUSE_MOVE, event, props.shape);
    }
</script>
<template>
  <g @click.stop @mousedown.stop @mouseup.stop @mousemove.stop @dragenter.stop dragenter stop @dragleave.stop @drop.stop
    @dragover.stop @mousemove="mousemove">
    <rect :rx="radiusValue" :ry="radiusValue" :width="shape.bounds.width" :height="shape.bounds.height"
      :x="shape.bounds.absX" :y="shape.bounds.absY" fill="#fff" stroke="#000" stroke-width="2">
    </rect>
  </g>
</template>

事件处理

JS 复制代码
export class EdgePointMoveModel {
    // 点击线的控制点
    startMoveEdgePoint() {
        const onMouseMove = this.onMouseMove.bind(this);
        const onMouseUp = () => {
            this.graph.emitter.off(EventType.SHAPE_MOUSE_MOVE, onMouseMove);
            this.graph.emitter.off(EventType.SHAPE_MOUSE_UP, onMouseUp);
            window.removeEventListener('mouseup', onMouseUp); // 如果移动到了画布或窗口之外
        
            this.endMove();
        };
        // 监听移动,此函数处理关联图形高亮
        this.graph.emitter.on(EventType.SHAPE_MOUSE_MOVE, onMouseMove);
        // 此处监听鼠标释放,更新图形关系
        this.graph.emitter.on(EventType.SHAPE_MOUSE_UP, onMouseUp);
        window.addEventListener('mouseup', onMouseUp);
    }
    endMove() {
        /** 更新 edgeShape 的sourceId、targetId */
        if (this.movingShape.subShapeType === SubShapeType.CommonEdge) {
            // 如果有连接源图形,这里之所以通过 newVal 包一层是为了redo 和 undo 做的,这里可以忽略
            if (this.isSourcePoint) {
                if (this.sourceShape) {
                    newVal.sourceId = this.sourceShape.id
                } else {
                    newVal.sourceId = undefined
                }
                newVal.bounds = {
                    ...this.movingShape.bounds,
                    absX: newVal.waypoint[0].x,
                    absY: newVal.waypoint[0].y
                }
            }
            // 如果有连接 target 图形
            if (this.isTargetPoint) {
                if (this.targetShape) {
                    newVal.targetId = this.targetShape.id
                } else {
                    newVal.targetId = undefined
                }
            }
        }
    }
}

其他场景处理

  • 快速创建,上图中快速创建图形需要更新对应 sourceId,targetId 。
  • 删除图形时,比如删除目标的矩形,需要更新关联线的关系。
  • 拖动时关联关系变动,从图形1拖动到图形2时需要更新对应的变更关系

联动处理

当图形有了关联后,移动图形后需要更改关联线。初始状态如下,将矩形下移时,对应的线也要需要更新 waypoint 。

由于有关联,移动矩形效果如下:

其他场景,不仅仅是移动,如果我们 resize 大小,也是需要同步相关线。效果如下

代码实现

如果移动的是 source 端,移动的变更 (dx,dy)。我们需要将整个变更应用到线的第一个端点

js 复制代码
/**
 * 伪代码实现,更新关联线逻辑,这里都是直线,没有涉及折线算法
 * @param {number} dx - 水平方向上的位移量。
 * @param {number} dy - 垂直方向上的位移量。
 * @param {Shape} edgeShape 移动的线
 * @param {'source' | 'target'} movePointType 移动时起点还是端点
 */
const endMove = (dx,dy,edgeShape,movePointType) => {
    // 获取绘制线的点
    const waypoint = edgeShape.waypoint
    const firstPoint = waypoint[0]
    const lastPoint = waypoint[waypoint.length - 1]
    if (movePointType === 'source') {
        firstPoint.x += dx
        firstPoint.y += dy
    } else if (movePointType === 'target') {
        lastPoint.x += dx
        lastPoint.y += dy
    }
}

输入文字

接下来看看如何在矩形或者线上进行文字输入。输入我们常用的时 input、textarea、div contenteditable

我们可以通过 Svg foreignObject 标签嵌入HTML内容。我们这里选择 div contenteditable。它支持多行,并且样式自定义比较简单和纯粹。

多边形上的输入框

实现输入的效果比较简单,就是在原来的图形上盖一层输入框。需要注意的是输入框的大小要比原来的图形大一圈,这样不会出现盖住原来图形边的情况。

html 复制代码
<template>
    <!-- 图形 -->
    <rect :rx="radiusValue" :ry="radiusValue" :width="shape.bounds.width" :height="shape.bounds.height"
      :x="shape.bounds.absX" :y="shape.bounds.absY">
    </rect>
    <foreignObject :width="shape.bounds.width - 4" :height="shape.bounds.height - 4" :x="shape.bounds.absX + 2"
      :y="shape.bounds.absY + 2">
      <div class="textarea" ref="input" :contenteditable="editable">
        {{ shape.modelName }}
      </div>
    </foreignObject>
<template>

线上的输入框

线上的输入框实现和多边形一样,多了一点是线需要确定当前输入框相对于线的相对位置。

更新 Bounds 定义

TS 复制代码
class Bounds  {
  constructor(
    public width = 0,
    public height = 0,
    public absX = 0,
    public absY = 0,
    public offsetX = 0.5, // 代表 x 轴相对位置百分比 50%
    public offsetY = 0.5, // 代表 y 轴相对位置百分比 50%
  ) { }
VUE 复制代码
<script >
const labelStyle = computed(() => {
  const { waypoint } = props.shape;
  // 名称的宽度,宽度是根据 str 算出来的   
  const { width, height, offsetX, offsetY } = props.shape.nameBounds;
  const firstPoint = waypoint[0];
  const lastPoint = waypoint[waypoint.length - 1];
  return {
      width: width + paddingWidth,
      height: Math.max(height, 30),
      absX: firstPoint.x + (lastPoint.x - firstPoint.x) * offsetX - width / 2,
      absY: firstPoint.y + (lastPoint.y - firstPoint.y) * offsetY - height / 2,
    };
})
</script>
<template>
<foreignObject :width="labelStyle.width" :height="labelStyle.height" :x="labelStyle.absX" :y="labelStyle.absY">
    <div :contenteditable="editable">
    {{ shape.modelName }}
    </div>
</foreignObject>
</template>

redo/undo

最后来看下 redo/undo 是如何实现的?

规则

首先来说下规则:

  • undo、redo 操作中未进行更新、新增操作,可以任意在已有步骤里进行 undo、redo。
  • 假设现在执行了四步,undo 了两步,然后执行了一个新的操作 newStep。此时之前四步中的后两步会被丢弃,所有的步骤中只包含前两步和最新的newStep。

可以在这三步中自由的 undo/redo。

思路

思路如下:

  1. 首先我们得有记录,创建步骤记录对象 Step
  2. 然后需要一个队列存储所有的 Step
  3. 当我们点击 undo/redo 时候本质是更新 CurrentStep 指针指向队列中的 Step 。所以我们需要创建一个 CurrentStep 来维护指针指向
  4. 当我们 undo 之后做了更新操作或者创建操作,需要将 CurrentStep 之后的 Step 都进行删除,然后再向队列中添加新的 Step

代码

TS 复制代码
/** Step 相关 */

// 更新类型
export enum ChangeType {
    INSERT = 1, // 插入对象
    UPDATE = 2, // 更新某个或多个字段
    DELETE = 3, // 删除对象
}

/**
 * 一条更新记录,一次更新可能存在多条更新记录
 * @param {ChangeType} type 更新类型
 * @param {string} shapeId 图形id
 */
export class Change {
    constructor(public type: ChangeType, public shapeId: string) {
    }
    // 更新前的旧值,与 newValue 对象中的 key 保持一致,未发生变更不列入
    oldValue?: UpdateShapeValue;
    // 更新后对应的值
    newValue?: UpdateShapeValue;
}
/**
 * 一条更新记录
 * @param {string} stepId 
 * @param {number} index 第几条记录
 * @param {Change[]} changes 一次更新记录包含多个更新,如:移动一个矩形,需要更新矩形的位置,也需要更新关联线的位置
 */
export class Step {
    constructor(public stepId: string, public index: number /**序号 */, public changes: Change[]) {
    }
}

/*** CurrentStep */
class CurrentStep {
  hasPrev = false; // 是否有上一步
  hasNext = false; // 是否有下一步
  stepId = ""; // 指针指向 step 记录
  stepSize = 0; // 总的 step 数,用于判断是否有 next
  nextStepIndex = 0; /** 用于判断是否有 prev */
  // ...
}

维护 Step 队列,这里用的是 IndexDb, 出于将所有操作都放在前端的考虑采用前端存储的方案。

js 复制代码
const dbName = 'history';
const version = 3;
const storeName = 'stack';

const dbPromise = new Promise((resolve, reject) => {
    // 打开或创建数据库,指定数据库名和版本号
    let request = indexedDB.open(dbName, version);
    // ...
    request.onsuccess = (event: any) => {
        resolve(event.target.result);
    };
})
/**
 * 在IndexedDB中,db.transaction方法是进行数据操作的核心接口。它用于开始一个新的数据库事务,通过事务可以确保一系列读写操作的原子性和一致性。
 */
const dbOperation = (transactionMode, operation) => {
    return dbPromise.then((db: any) => {
        // db.transaction 方法开启一个事务
        // 参数1:要操作的对象存储数组(Object Store)名称列表
        // 参数2:事务模式,可选值包括 'readonly'、'readwrite' 和 'versionchange'
        const transaction = db.transaction(storeName, transactionMode);
        // 通过事务对象获取或创建对象存储(Object Store)
        // 在IndexedDB中,数据操作必须通过事务对象进行。
        const objectStore = transaction.objectStore(storeName);
        // 将事务对象和事务暴露给调用者使用
        return operation(objectStore, transaction);
    });
};

export const stepManager = {
    add(data: Step) {
        // 声明是读写操作,拿到事务对象,的通过 objectStore.add 进行数据添加
        // 在 onsuccess 中表示添加成功将结果 resolve 出去
        return dbOperation('readwrite', (objectStore) => {
            return new Promise((resolve, reject) => {
                const request = objectStore.add(data);
                request.onsuccess = () => {
                    console.log('Data added successfully');
                    resolve(request.result);
                };
                request.onerror = reject;
            });
        });
    },
    // findPre、findNext、deleteAfterIndex、clear 等等操作
}

未完待续

剩下还有一些问题点,限于文章篇幅原因放到后面再讲,可以先把问题抛出来,有兴趣的同学可以自己尝试一下。

  • 连接裁剪,如下图,将多余线进行裁剪,连接到最近的点。
  • 连线折线,连接两个图形是自动生成折线
  • 自动布局,点击调整画布整体排版
  • 画布缩略图,当画布比较大,需要缩略图来快速调整画布位置,脑图中可能比较场景。
相关推荐
昨天;明天。今天。1 小时前
案例-表白墙简单实现
前端·javascript·css
数云界1 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd1 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常1 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer1 小时前
Vite:为什么选 Vite
前端
小御姐@stella1 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing1 小时前
【React】增量传输与渲染
前端·javascript·面试
eHackyd2 小时前
前端知识汇总(持续更新)
前端
万叶学编程5 小时前
Day02-JavaScript-Vue
前端·javascript·vue.js