阅读 2927

基于React+Topology构建在线绘图工具

前言

本文将会带着大家基于React搭建一套属于自己的绘图工具。

项目的地址我已经托管到github上, 欢迎各位大佬批评指教!后面陆续会将完成的功能, 同步更新到我的github博客上。

环境搭建

  • 初始化项目

create-react-app project

  • 安装依赖

    "@topology/activity-diagram": "^0.2.24",
    "@topology/chart-diagram": "^0.3.0",
    "@topology/class-diagram": "^0.2.24",
    "@topology/core": "^0.3.1",
    "@topology/flow-diagram": "^0.2.24",
    "@topology/layout": "^0.3.0",
    "@topology/sequence-diagram": "^0.2.24",
    "antd": "^3.26.7",
    复制代码

    至于基础布局的代码, 大家可以自由发挥, 本文就不赘述了。如果想直接上手基础功能的话, 可以直接clone已有的仓库~

    ok! 完成项目基本环境的搭建后, 就可以开始逐个完成以下功能点了。

功能介绍

自定义图片示例

主页面左侧的图形渲染区域, 可以自定义渲染。

const Layout = ({ Tools, onDrag }) => {
  return Tools.map((item, index) => (
    <div key={index}>
      <div className="title">{item.group}</div>
      <div className="button">
        {item.children.map((item, idx) => {
          // eslint-disable-next-line jsx-a11y/anchor-is-valid
          return (
            <a
              key={idx}
              title={item.name}
              draggable
              href="/#"
              onDragStart={(ev) => onDrag(ev, item)}
            >
              <i className={'iconfont ' + item.icon} style={{ fontSize: 13 }}></i>
            </a>
          );
        })}
      </div>
    </div>
  ));
};
复制代码

自定义配置项数据源, icon: 'icon-image' 指的是左侧显示的小图标。data数据name属性的值即代表image, topology通过此属性来判断渲染的是否是图片。 image属性的值即是图片的地址。

{
  group: '自定义图片',
  children: [
    {
      name: 'image',
      icon: 'icon-image',
      data: {
        text: '',
        rect: {
          width: 100,
          height: 100
        },
        name: 'image',
        image: require('./machine.jpg')
      }
    },
  ]
}
复制代码

支持在线图片添加的功能

如果觉得使用本地图片麻烦, 我们可以换成在线的图片。

首先我们根据图片的url得出base64.

function getBase64(url, callback) {
  var Img = new Image(),
    dataURL = '';
  Img.src = url + '?v=' + Math.random();
  Img.setAttribute('crossOrigin', 'Anonymous');
  Img.onload = function () {
    var canvas = document.createElement('canvas'),
      width = Img.width,
      height = Img.height;
    canvas.width = width;
    canvas.height = height;
    canvas.getContext('2d').drawImage(Img, 0, 0, width, height);
    dataURL = canvas.toDataURL('image/jpeg');
    return callback ? callback(dataURL) : null;
  };
}
复制代码

最后通过onDrag 方法将在线的图片拖到画布上即可。

const onDrag = (event, image) => {
  event.dataTransfer.setData(
    'Text',
    JSON.stringify({
      name: 'image',
      rect: {
        width: 100,
        height: 100
      },
      image
    })
  );
};
复制代码

支持新建文件, 打开文件, 导出json, 保存png与svg

  • 新建一个空的画板

      canvas.open({ nodes: [], lines: [] });
    复制代码
  • 打开已有图形的文件

    const onHandleImportJson = () => {
      const input = document.createElement('input');
      input.type = 'file';
      input.onchange = event => {
        const elem = event.srcElement || event.target;
        if (elem.files && elem.files[0]) {
          const reader = new FileReader();
          reader.onload = e => {
            const text = e.target.result + '';
            try {
              const data = JSON.parse(text);
              canvas.open(data);
            } catch (e) {
              return false;
            } finally {
    
            }
          };
          reader.readAsText(elem.files[0]);
        }
      };
      input.click();
    }
    复制代码
  • 将画好的图保存为json文件

    import * as FileSaver from 'file-saver';
    
    FileSaver.saveAs(
      new Blob([JSON.stringify(canvas.data)], { type: 'text/plain;charset=utf-8' }),
      `le5le.topology.json`
    );
    复制代码
  • 保存为png文件

    canvas.saveAsImage('le5le.topology.png');
    复制代码
  • 保存为SVG文件

    const onHandleSaveToSvg = () => {
      const C2S = window.C2S;
      const ctx = new C2S(canvas.canvas.width + 200, canvas.canvas.height + 200);
      if (canvas.data.pens) {
        for (const item of canvas.data.pens) {
          item.render(ctx);
        }
      }
      let mySerializedSVG = ctx.getSerializedSvg();
      mySerializedSVG = mySerializedSVG.replace(
        '<defs/>',
        `<defs>
      <style type="text/css">
        @font-face {
          font-family: 'topology';
          src: url('http://at.alicdn.com/t/font_1331132_h688rvffmbc.ttf?t=1569311680797') format('truetype');
        }
      </style>
    </defs>`
      );
      mySerializedSVG = mySerializedSVG.replace(/--le5le--/g, '&#x');
      const urlObject = window.URL || window;
      const export_blob = new Blob([mySerializedSVG]);
      const url = urlObject.createObjectURL(export_blob);
      const a = document.createElement('a');
      a.setAttribute('download', 'le5le.topology.svg');
      a.setAttribute('href', url);
      const evt = document.createEvent('MouseEvents');
      evt.initEvent('click', true, true);
      a.dispatchEvent(evt);
    }
    复制代码
  • 撤销、恢复、复制、剪切、粘贴

      canvas.undo(); // 撤销
      canvas.redo(); // 恢复
      canvas.copy();  // 复制
      canvas.cut(); // 剪切
      canvas.paste(); // 粘贴
    复制代码

支持节点外观属性(位置大小边距, 边框样式, 字体样式)的设置

  • 节点的位置和大小
const renderForm = useMemo(() => {
  return <Form>
    <Row>
      <Col span={12}>
        <Form.Item label="X(px)">
          {getFieldDecorator('x', {
            initialValue: x
          })(<InputNumber />)}
        </Form.Item>
      </Col>
      <Col span={12}>
        <Form.Item label="Y(px)" name="y">
          {getFieldDecorator('y', {
            initialValue: y
          })(<InputNumber />)}
        </Form.Item>
      </Col>
      <Col span={12}>
        <Form.Item label="宽(px)" name="width">
          {getFieldDecorator('width', {
            initialValue: width
          })(<InputNumber />)}
        </Form.Item>
      </Col>
      <Col span={12}>
        <Form.Item label="高(px)" name="height">
          {getFieldDecorator('height', {
            initialValue: height
          })(<InputNumber />)}
        </Form.Item>
      </Col>
      <Col span={12}>
        <Form.Item label="角度(deg)" name="rotate">
          {getFieldDecorator('rotate', {
            initialValue: rotate
          })(<InputNumber />)}
        </Form.Item>
      </Col>
    </Row>
  </Form>
}, [x, y, width, height, rotate, getFieldDecorator]);
复制代码
  • 边框样式
const renderStyleForm = useMemo(() => {
  return <Form>
    <Row>
      <Col span={24}>
        <Form.Item label="线条颜色">
          {getFieldDecorator('strokeStyle', {
            initialValue: strokeStyle
          })(<Input type="color" />)}
        </Form.Item>
      </Col>
      <Col span={12}>
        <Form.Item label="线条样式">
          {getFieldDecorator('dash', {
            initialValue: dash
          })(
            <Select style={{ width: '95%' }}>
              <Option value={0}>_________</Option>
              <Option value={1}>---------</Option>
              <Option value={2}>_ _ _ _ _</Option>
              <Option value={3}>- . - . - .</Option>
            </Select>
          )}
        </Form.Item>
      </Col>
      <Col span={12}>
        <Form.Item label="线条宽度">
          {getFieldDecorator('lineWidth', {
            initialValue: lineWidth
          })(<InputNumber style={{ width: '100%' }} />)}
        </Form.Item>
      </Col>
    </Row>
  </Form>
}, [lineWidth, strokeStyle, dash, getFieldDecorator]);
复制代码
  • 字体设置
const renderFontForm = useMemo(() => {
  return <Form>
    <Col span={24}>
      <Form.Item label="字体颜色">
        {getFieldDecorator('color', {
          initialValue: color
        })(<Input type="color" />)}
      </Form.Item>
    </Col>
    <Col span={12}>
      <Form.Item label="字体类型">
        {getFieldDecorator('fontFamily', {
          initialValue: fontFamily
        })(<Input />)}
      </Form.Item>
    </Col>
    <Col span={11} offset={1}>
      <Form.Item label="字体大小">
        {getFieldDecorator('fontSize', {
          initialValue: fontSize
        })(<InputNumber />)}
      </Form.Item>
    </Col>
    <Col span={24}>
      <Form.Item label="内容">
        {getFieldDecorator('text', {
          initialValue: text
        })(<TextArea />)}
      </Form.Item>
    </Col>
  </Form>
}, [color, fontFamily, fontSize, text, getFieldDecorator])
复制代码

当我们对表单里面的每一项都进行修改时, 都会调用onFormValueChange 方法去改变对应节点的属性。最后将修改后的节点, 更新到画布上。

if (changedValues.node) {
  // 遍历查找修改的属性,赋值给原始Node
  for (const key in changedValues.node) {
    if (Array.isArray(changedValues.node[key])) {
    } else if (typeof changedValues.node[key] === 'object') {
      for (const k in changedValues.node[key]) {
        selected.node[key][k] = changedValues.node[key][k];
      }
    } else {
      selected.node[key] = changedValues.node[key];
    }
  }
}
canvas.updateProps(selected.node);
复制代码

支持节点的数据属性

在实际的业务开发中, 难免会出现默认Node节点上的属性不够用的情况, 或者节点上有特定的业务数据。那么这个时候, 我们可以将这些特殊的数据存在节点的自定义数据字段。

const renderExtraDataForm = useMemo(() => {
  return <Form >
    <Col>
      <Form.Item label="自定义数据字段">
        {getFieldDecorator('data', {
          initialValue: JSON.stringify(extraFields) 
        })(<TextArea rows={10} />)}
      </Form.Item>
    </Col>
  </Form>
}, [extraFields, getFieldDecorator])
复制代码

注意: Le5leTopology.Node节点默认是没有data这个属性的.

支持节点自定义事件的功能

由上图可知, 节点的事件分为事件类型和事件行为两部分。事件类型可以分为: 1.单击事件 2.双击事件 3.websocket事件 4.mqtt事件。事件行为可以分为: 1.跳转链接 2.执行动画 3.执行函数 4.执行window下的全局函数 5.更新属性数据。

Node节点中自定events属性, 因此根据文档中各个属性的枚举值, 我们可以很简单的绘制出各个事件类型与事件行为的对应关系。具体的代码由于篇幅限制, 就不粘贴了。有兴趣的同学可以阅读对应的源码.接下来, 我来演示一下如何对节点进行单击事件与websocket事件的绑定。

  • 单击执行自定义函数

查看上图动画, 我们可以发现, 每次点击图形, 都会输出我是自定义函数。那么在我们的编辑器上, 该如何配置呢?

  • 接收来自于websocket的值

我们通过websocket往服务器发送一个信号, 同时将会接收对应的值。首先我们需要事先连接好ws服务器。

canvas.openSocket('ws://123.207.136.134:9010/ajaxchattest');
复制代码

这一步很关键, 否则之后的流程都将会报错。

然后我们新增一个拥有点击事件的节点, 模拟信号的发起。

最后我们定义一个节点用于接收websocket返回的值。

接下来, 我们可以点击预览按钮, 测试我们配置的代码对不对。

支持线条的样式修改

目前只支持上图几种属性的设置。线条的更新与节点的更新类似, 我们直接修改线条的属性, 然后通过updateProps 更新对应线条的样式。

const onHandleLineFormValueChange = useCallback(
  (value) => {
    const { dash, lineWidth, strokeStyle, name, fromArrow, toArrow, ...other } = value;
    const changedValues = {
      line: { rect: other, lineWidth, dash, strokeStyle, name, fromArrow, toArrow }
    };
    if (changedValues.line) {
      // 遍历查找修改的属性,赋值给原始line
      for (const key in changedValues.line) {
        if (Array.isArray(changedValues.line[key])) {
        } else if (typeof changedValues.line[key] === 'object') {
          for (const k in changedValues.line[key]) {
            selected.line[key][k] = changedValues.line[key][k];
          }
        } else {
          selected.line[key] = changedValues.line[key];
        }
      }
    }
    canvas.updateProps(selected.line);
  },
  [selected]
);
复制代码

支持预览功能

当我们编辑完图形后, 需要预览。那么我们可以将画布上的数据通过路由传参(state)传递到新的页面, 最后通过new Topology重新生成一块画布, 将图形渲染上去。

  let reader = new FileReader();
  const result = new Blob([JSON.stringify(canvas.data)], { type: 'text/plain;charset=utf-8' });
  reader.readAsText(result, 'text/plain;charset=utf-8');
  reader.onload = (e) => {
    history.push({ pathname: '/preview', state: { data: JSON.parse(reader.result) } });
  }
复制代码

支持锁定,设置全局线的起始和终止箭头

  • 锁定
canvas.lock(2)
复制代码
  • 解锁
canvas.lock(0)
复制代码
  • 设置默认的连线类型
const onHandleSelectMenu = data => {
  setLineStyle(data.item.props.children);
  canvas.data.lineName = data.key;
  canvas.render();
}
复制代码
  • 设置默认的连线起始箭头
const onHandleSelectMenu1 = data => {
  setFromArrowType(data.item.props.children);
  canvas.data.fromArrowType = data.key;
  canvas.render();
}
复制代码
  • 设置默认的连线终止箭头
const onHandleSelectMenu2 = data => {
  setToArrowType(data.item.props.children);
  canvas.data.toArrowType = data.key;
  canvas.render();
}
复制代码

支持自动排版功能

如果画出的图形比较乱, 那么可以使用自动居中的功能。首先我们通过 rect.calcCenter(); 获取当前图形的中心点, 然后我们计算出画布中心点与当前图形的中心点的差值, 最后通过调用 canvas.translate(x, y) 方法对图形进行平移。

  const onHandleFit = () => {
    const rect = canvas.getRect();
    rect.calcCenter();
    x = document.body.clientWidth / 2 - rect.center.x;
    y = (document.body.clientHeight - 66) / 2 - rect.center.y;
    canvas.translate(x, y);
  };
复制代码

结尾

虽然Topology的官网有各个API的详细说明, 但是从API转化到实际业务中, 还是需要耗费蛮多时间。其次官方的React版本的数据流比较复杂且对于新手上手的成本比较高, 因此就萌生了想要写一版简单的topology-react 帮助大家快速上手。

最后的最后, 感谢Alsmile开源的绘图引擎。如果对你有帮助, 别忘了给一个小小的star哦, 谢谢啦~