JointJS是以SVG为基础,依赖Backbone、jQuery和Lodash开发的一款实现流程图绘制的开源前端框架,属于比较冷门的技术,网上文档甚少,基本上只能靠英文官网学习,以下是某大神汇总的部分知识点值得推荐:JointJS官方API的个人整理,望大家学习路上不要太辛苦。

我在项目开发时部分功能是参照官网的这个demo去做,demo是依赖Rappid开发的,Rappid是JointJS的一个付费版本,提供了更强大的功能,也很实用,但是付费,所以项目上没法使用,为了实现出与demo相近的功能下了一番苦功终于有些成果,在此分享两个功能的具体实现。

一、画布拖拽及缩放

1.画布拖拽

画布拖拽其实更多的是依赖jQuery对DOM的基础操作,这也是我在研究demo实现拖拽时发现的。先看下我的HTML标签结构:

<!-- 展现窗口 -->
<div id="wrap">
  <!-- 画板 -->
  <div>
  	<!-- 画布 -->
    <div id="paper"></div>
  </div>
</div>

来看一张图片
在这里插入图片描述
上图中的灰色区域是画板区域,也可以被去掉,只不过有这么一块区域存在后当画布拖动到边缘时会使画布和窗口之间存在距离而不是紧贴在一起,这样的视觉感受会更好一些。当鼠标在画布上点击不放开始进行拖拽时,其实就是鼠标在窗口中移动,通过mousemove事件的来修改窗口的scrollLeft和scrollTop在视觉上让人感觉是拖着画布在移动,但其实代码主要去控制的是窗口而非画布或者画板,明白了这个原理之后再来看下代码。

const [$WRAP, $PAPER] = [$(WRAP_DIV), $('#paper')];
const JOINT_GRAPH = new joint.dia.Graph();
const JOINT_PAPER = new joint.dia.Paper({
  el: $PAPER.get(0),
  model: JOINT_GRAPH,
  width: 3000,
  height: 3000,
  gridSize: 10, //栅格点间距,元素拖动会捕捉栅格点
  drawGrid: {
    name: 'doubleMesh',
    args: [
      {
        color: '#dedede', //栅格点颜色
        scaleFactor: 1,
        thickness: 1 //栅格点大小
      },
      {
        color: '#dedede', //栅格点颜色
        scaleFactor: 10,
        thickness: 2 //栅格点大小
      }
    ]
  },
  background: {
    color: '#fff'
  },
  snapLinks: true,
  restrictTranslate: true //设定元素不能拖出画布
});

以上是初始化一个绘制区域的代码,比较基础了,官网上都有,不明白的去官网或者前面推荐的博文里看,这里就不普及基础知识了,直接看以下部分:

let [_x, _y] = [0, 0];
//这里的on方法是JointJS中绑定事件的方法
JOINT_PAPER.on('blank:pointerdown', (evt, x, y) => {
  [_x, _y] = [evt.offsetX, evt.offsetY];
  $PAPER.css('cursor', 'grabbing');
  //这里的on方法是jQuery中绑定事件的方法
  $WRAP.on('mousemove', evt => {
    let scrollLeft = $WRAP.scrollLeft() - (evt.offsetX - _x);
    let scrollTop = $WRAP.scrollTop() - (evt.offsetY - _y);
    scrollLeft = 0 > scrollLeft ? 0 : scrollLeft;
    scrollTop = 0 > scrollTop ? 0 : scrollTop;
    $WRAP.scrollLeft(scrollLeft).scrollTop(scrollTop);
  });
});

JOINT_PAPER.on('blank:pointerup', (evt, x, y) => {
  $PAPER.css('cursor', 'grab');
  $WRAP.off('mousemove');
});

这里做了什么?首先给JointJS的画布元素JOINT_PAPER绑定鼠标按下事件,此时获取到鼠标按下时事件点在画布上的x轴和y轴坐标记录为_x和_y,然后开始给窗口容器绑定鼠标移动事件,注意看下图是mousemove回调中evt.target的指向。
在这里插入图片描述
这里它指向了一个svg标签,这个标签其实就是画布JOINT_PAPER对象的svg属性值。
在这里插入图片描述
知道这个之后,那么就好理解了。如果鼠标向左移动,由于_x是不变的,而evt.offsetX在不断减小,那么差值一定是负数,并且越来越小,由于$WRAP.scrollLeft() - (evt.offsetX - _x),所以最后重新设置的scrollLeft的值在不断增加,画布被移动到了最右端。同时不管最后这个值大到多少,设定scrollLeft时也无所谓,不会超出滚动条的最大宽度。相反,鼠标向右移动,evt.offsetX在不断增加,那么差值一定是正数,并且越来越大,所以最后重新设置的scrollLeft的值在不断减小,画布被移动到了最左端,如果最后计算出来的值是小于0的值则scrollLeft就设定为0。理解了左右拖拽的原理,那么上下拖拽也就可以理解了,最后拖拽结束后分别解绑事件就可以了。其实代码很简单,重要的是要搞清楚实现的思路,这种实现方式其实也可应用在其他地方。

2.画布缩放
function changeZoom(isIn) {
  const { sx: SX, sy: SY } = JOINT_PAPER.scale();
  const OPTIONS = JOINT_PAPER.options;
  let [x, y] = [0, 0];
  if (isIn) {
    [x, y] = [SX + 0.1, SY + 0.1];
    changeHolderSize(isIn, 0.1);
  } else {
    [x, y] = [SX - 0.1, SY - 0.1];
    //取小数点后一位防止浮点运算bug
    if (+x.toFixed(1) === 0.1 || +y.toFixed(1) === 0.1) return;
    changeHolderSize(isIn, 0.1);
  }
  const [N, R] = [x / SX, y / SY];
  JOINT_PAPER.scale(x, y);
  JOINT_PAPER.setOrigin(OPTIONS.origin.x * N, OPTIONS.origin.y * R);
  JOINT_PAPER.setDimensions(OPTIONS.width * N, OPTIONS.height * R);
}

function changeHolderSize(plus, cardinal) {
  const [CURRENT_H, CURRENT_W] = [
    +$PAPER.parent().css('height').replace(/[px]/g, ''),
    +$PAPER.parent().css('width').replace(/[px]/g, '')
  ];
  $PAPER.parent().css({
    height: plus ? CURRENT_H + 3000 * cardinal : CURRENT_H - 3000 * cardinal,
    width: plus ? CURRENT_W + 3000 * cardinal : CURRENT_W - 3000 * cardinal
  });
}

其实画布缩放本身没什么特别要说的,框架本身提供了scale接口,直接就可以缩放画布。changeHolderSize方法在这里主要是为了结合画布拖拽而写的,画布缩放时要及时的去调整画布的大小,使外层窗口的滚动条宽度能够适应当前的缩放等级。需要特别注意的是其中的这几行代码:

const [N, R] = [x / SX, y / SY];
JOINT_PAPER.scale(x, y);
JOINT_PAPER.setOrigin(OPTIONS.origin.x * N, OPTIONS.origin.y * R);
JOINT_PAPER.setDimensions(OPTIONS.width * N, OPTIONS.height * R);

为什么要计算一个N和R,然后再去setOrigin和setDimensions。如果注掉这几行直接去scale(x, y)会出现下图的问题。
在这里插入图片描述
注意到了么?缩放后画布上的栅格会出现这样显示不完整的效果,本身3000*3000的画布10像素一小格,100像素一个大格,不可能出现这样的情况,不信可以在初始话页面时拖动到最下面或者最右面看下,应该都是对的很整齐。这几行代码就可以完美解决这个问题,不要问我为什么这样算,我也不知道,因为这是我发现问题后再去研究demo时找到的代码照抄过来的,如果有朋友看懂了计算原理也麻烦留言讲一讲,真的虚心请教。

二、还原与重做

这是一个在各种软件中都很常见的功能,那么像这样的前端流程绘制项目自然不能缺少这样的功能,否则易用性将大打折扣。实现的方式就是利用一个栈来保存每一步绘制发生之前的状态,这样当我们需要还原操作时就可以去这个栈中获取上一步的状态进行还原,还原的同时将当前的最新状态保存到另一个栈中去用作重做操作。JointJS为代码中的JOINT_GRAPH对象提供了一个名为toJSON的接口,它可以将目前绘制出来的流程图转化成一段JointJS可识别的JSON串,这个JSON串可以用作后台保存数据,同时也可以用来保存我们每一步绘制后的不同状态。保存过的状态可以通过另一个fromJSON接口刷新到画布上。来看下代码:

JOINT_GRAPH.on('add', cacheGraph);
JOINT_GRAPH.on('change ', _.debounce(cacheGraph, 300));
JOINT_GRAPH.on('remove', cacheGraph);

这里我们给JOINT_GRAPH对象绑定了3个事件,下图是官网有关Graph的事件描述
在这里插入图片描述
change事件在我们拖动元素或者连线时都会被出发,并且跟浏览器的scroll事件一样在一次操作中触发很多次,所以这里用Lodash的_.debounce做了一个事件防抖,每次只缓存最后的变化,以下是cacheGraph方法对应的代码片段:

const [STACK_REDUCE, STACK_REDO] = [new Stack(), new Stack()]; //缓存绘图操作用到的栈
let permitCache = true; //标记是否允许缓存操作

/**
 * @description 缓存用户绘制过程
 * @returns
 */
function cacheGraph() {
  if (!permitCache) return; //为false表示此时的变化是由还原或重做引发不做缓存
  const GRAPH_JSON_OBJECT = JOINT_GRAPH.toJSON();
  STACK_REDUCE.push(GRAPH_JSON_OBJECT);
  STACK_REDO.clear();
}

先不去管对permitCache这个变量的判断,先看下面几行。STACK_REDUCE是用来缓存还原操作所需状态的栈,STACK_REDO是用来缓存重做操作所需状态的栈。在每次缓存状态时我们先获取当前的JSON串,然后将它保存到STACK_REDUCE中去,同时清空STACK_REDO,因为我们在绘制时的每一步操作都被视为最新状态,自然就不会有重做状态可用。好了,既然这里我们已经保存了还原时要用的状态,那么下面我们可以完成还原和重做的操作了

$(document).on('keydown', e => {
  if (e.ctrlKey === true && e.keyCode === 89) {
    /* Ctrl + Y 重做 */
    redoGraph();
  } else if (e.ctrlKey === true && e.keyCode === 90) {
    /* Ctrl + Z 还原 */
    reduceGraph();
  }
});

在说明reduceGraph和redoGraph这两个方法之前先说一下permitCache,它是作为一个标记在这里存在。因为当我们还原或者重做时都会触发JOINT_GRAPH上绑定的3个事件,但是我们不能在还原和重做时去保存新的状态,而是应该让之前保存在STACK_REDUCE中的状态依照先进后出的原则在STACK_REDUCE和STACK_REDO两个栈中移动,从STACK_REDUCE出来立即存入STACK_REDO,从STACK_REDO出来立即存入STACK_REDUCE,这样才能让还原与重做功能完美实现。所以在redoGraph和reduceGraph两个方法中都会先将permit设置为false,等操作结束后再将它改为true这样就不会影响到用户再次绘制时保存新的状态到STACK_REDUCE中去了。

/**
 * @description 还原操作的执行方法
 * @returns
 */
function reduceGraph() {
  permitCache = false;
  if (STACK_REDO.size() === 0) {
    const GRAPH_JSON_OBJECT = STACK_REDUCE.pop();
    STACK_REDO.push(GRAPH_JSON_OBJECT);
    const REDUCE = STACK_REDUCE.pop();
    if (REDUCE) {
      STACK_REDO.push(REDUCE);
      JOINT_GRAPH.fromJSON(REDUCE);
    } else {
      JOINT_GRAPH.clear();
    }
  } else {
    const REDUCE = STACK_REDUCE.pop();
    if (!REDUCE) {
      JOINT_GRAPH.clear();
      setTimeout(() => {
        permitCache = true;
      }, 100);
      return;
    }
    STACK_REDO.push(REDUCE);
    JOINT_GRAPH.fromJSON(REDUCE);
  }
  setTimeout(() => {
    permitCache = true;
  }, 100);
}

明白了permitCache在这里的作用我们先来说reduceGraph方法,这个方法用来实现还原操作。如果STACK_REDO栈的size是0,那么可以认定用户第一次点击ctrl+z进行还原操作,此时STACK_REDUCE栈顶保存的状态是画布的当前状态,这时我们不需要用它来刷新画布,直接把它push到STACK_REDO里去用作未来可能出现的重做操作,然后再从STACK_REDUCE栈中取出一次状态,如果取出的是一个JSON串,那么调用JOINT_GRAPH对象fromJSON接口刷新画布还原上一步操作后的结果,同时将状态push到STACK_REDO里。如果取出的不是JSON串那么就一定是undefined,此时STACK_REDUCE栈已为空,没有状态可用来还原,那么清空画布,不再做其他处理,修改permitCache为true就行了。如果STACK_REDO栈的size不是0,那么直接从STACK_REDUCE取出之前一步的状态进行判断,如果是undefined就清空画布,修改permitCache。如果不是undefined就调用JOINT_GRAPH对象fromJSON接口刷新画布还原上一步操作后的结果,将状态push到STACK_REDO里。

/**
 * @description 重做操作的执行方法
 * @returns
 */
function redoGraph() {
  const [REDO, CURRENT] = [STACK_REDO.pop(), JOINT_GRAPH.toJSON()];
  if (!REDO) return;
  STACK_REDUCE.push(REDO);
  if (_.isEqual(REDO, CURRENT)) {
    const GRAPH_JSON_OBJECT = STACK_REDO.pop();
    if (!GRAPH_JSON_OBJECT) return;
    permitCache = false;
    STACK_REDUCE.push(GRAPH_JSON_OBJECT);
    JOINT_GRAPH.fromJSON(GRAPH_JSON_OBJECT);
  } else {
    permitCache = false;
    JOINT_GRAPH.fromJSON(REDO);
  }
  setTimeout(() => {
    permitCache = true;
  }, 100);
}

最后说说这个redoGraph,就是点及ctrl+y时的重做操作。为什么要判断_.isEqual(REDO, CURRENT)?_.isEqual方法时Lodash中一个判断两个对象属性是否相同的一个方法,非常好用,这里用它来判断REDO和 CURRENT,一个是STACK_REDO栈顶保存的状态第一个状态,一个是当前画布显示的状态。判断它俩相等其实就是在判断用户是否是在点击完ctrl+z后点击ctrl+y,因为最后一步还原后STACK_REDO栈顶保存的状态和画布当前状态是相同的(这里也可以都转换成JSON串去比较字符串)。这就向做还原操作时判断STACK_REDO栈的size是否为0一样。判断完这个就可以基本上和还原时的逻辑大同小异了,只是这里不能清空画布,STACK_REDO栈中保存的状态为undefined时跳出方法就好。

相关代码查看请点击这里,代码中只实现了画布中添加元素的简单功能已验证以上涉及的内容。

Logo

瓜分20万奖金 获得内推名额 丰厚实物奖励 易参与易上手

更多推荐