从0到1实现一款在线合同编辑器

点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群

背景:

银河集团聚焦香港身份服务 17 年,目前服务项目涉及香港优才计划,留学进修,专才,香港教育,房产等。当客户购买对应产品时,我们需要与之签订对应的合同。由于合同内容会随各种因素以及根据客户签约的产品而有所调整。例如公司的 logo 更换了,那之前所有的合同上的 logo 都要去更换一遍,或者一些合同条款和协议更新了,所有的合同也需要相应去更新。如果靠人去手动维护这些合同需要很大的人力成本。所以我们决定打造一款能够自由编辑合同编辑器。这篇文章主要分享一些功能点的前端技术实现方式。

功能特性

1. 组件自由拖拽

编辑器内置了很多制作合同常用的组件,例如页眉页脚,分割线等,这相比于使用 WPS 来制作合同要高效得多。

d5859060e4f93134f2e6b1c730752c30.png

实现拖拽,这里利用了Html5 的拖放API来实现,首先需要将需要拖拽的元素设置属性draggable="true",并且监听dragstart事件

主要代码如下:

<div
  v-for="(item, index) in list"
  :key="index"
  :draggable="true"
  class="component-item"
  @dragstart="dragStart(item, $event)"
>
  <img :src="item.src">
  <span>{{ item.title }}</span>
</div>

拖拽开始事件处理

const dragStart = (item: any, event: DragEvent) => {
  // 绑定这个元素对应的业务数据,方便在 drop 时候获取
  event.dataTransfer?.setData('text/plain', JSON.stringify(item))
}

拖拽结束时我们要处理组件放置在哪个容器区域,在这个区域内渲染指定类型的组件,这有点类似于低代码方式,一般这样的应用都会具有一些通用的功能,例如:

  • 组件渲染在鼠标松开的位置,并且能够在容器内自由拖动,实时更新位置。

  • 组件可以通过拖放来实现宽高和大小的调节。

  • 组件需要限制在容器内移动,不能超出容器。

  • 组件移动过程中,可以展示辅助线以及吸附功能,方便组件与组件之间快速对齐。

3b4994fd1af9ca1dd9eaef083e8d7087.gif

实现这些通用的逻辑,我们可以不必从0开始造轮子,这里我引入一个第三方的 vue 组件:

vue3-dragable-resize

使用方式:

npm i vue3-draggable-resizable --save

引入:

import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
import Vue3DraggableResizable, { DraggableContainer } from 'vue3-draggable-resizable'


<DraggableContainer 
  reference-line-color="red"  
  @drop="(event) => drop(page.id, event)"   
  @dragover.prevent>
  <Vue3DraggableResizable
        v-for="item in componentList"
        :key="item.id"
        :initW="110"
        :initH="120"
        v-model:x="item.x"
        v-model:y="item.y"
        v-model:w="item.w"
        v-model:h="item.h"
        v-model:active="item.active"
        :draggable="true"
        :resizable="true"
        @drag-end="(e)=>handleDragEnd(item,e)"
        @resize-end="(e)=>handleResizeEnd(item,e)"
      >
        <component
          :is="item.name"
          v-bind="component.props"
        />
      </Vue3DraggableResizable>
</DraggableContainer>

DraggableContainer 属于容器组件,所有拖拽元素在都其内部进行。Vue3DraggableResizable组件则提供了拖拽手柄进行拖放以及改变大小操作,组件内部则承载着不同类型的组件。这里使用了vue.js 提供的内置的动态组件component来进行渲染,并通过v-bind 指令绑定组件对应的配置。

2. 富文本编辑

编写合同离不开富文本的支持,例如对字体进行加粗,下划线,插入表格,图片等,这里我使用了在其他项目也使用的开源富文本编辑器 tinymce,这款编辑器特定是比较轻量,功能强大,具有丰富的插件,也支持开发者自定义插件,在 github 上已经有 14.7k 的 Star。

tinymce地址

使用:

npm install tinymce@5.10.3 -S

社区也有封装好的 vue.js 版本 tinymce-vue,关于富文本配置大家可以参考官方文档,这里我主要讲述一下做合同编辑功能会用到的一些有用的配置。

3. 使用变量

合同内容有一些需要动态替换的部分,例如公司名称或者电话,当后面假如需要更改公司名称电话时候,我们只需要在后台配置更改即可,这样所有合同模板引用了这个变量的地方都会被替换。变量标记我们约定固定格式,例如输入$公司名称$,再在渲染时候使用正则表达式进行替换即可。

配置

editor.ui.registry.addAutocompleter('specialchars', {
  ch: '$', // 设置$为触发符
  trigger: '$',
  minChars: 0, // 最小的触发字符
  columns: 1, // 待选区域展示列数
  onAction(autocompleteApi, rng, value) {
    editor.selection.setRng(rng)
    editor.insertContent(`<span class='var-item'>${value}</span>`)
    autocompleteApi.hide()
  },
  fetch(pattern) {
    return new Promise((resolve) => {
      const results = varibleList.value.filter((char) => {
        return char.name.includes(pattern)
      }).map((v) => {
        return {
          value: v.name,
          text: v.name,
        }
      })
      resolve(results)
    })
  },
})

效果图:

cf22ab112e903ce6960e6b5d04727ead.gif


4.文本选择

富文本内文本有时候文本需要复制,当使用鼠标聚焦文字并拖拽选择时,整个富文本区域会移动,造成文本无法被选中。

优化前效果:

e7944e3ac3e33486370b74af307a8e9b.gif

解决思路:

我们可以在富文本区域上方覆盖一层div,当组件需要移动调整位置大小时,鼠标点击并拖拽事件会被div捕获,这样div下方的富文本就不会被影响了,当我们需要编辑文本或者选择文本时,我们可以使用双击鼠标。第一次点击鼠标我们需要暂时将上方的div隐藏,第二次点击就能选中文本了。

<div v-click-outside="onClickOutside" class="rich-wrap">
    <div @mousedown.stop>
      <GEditor
        v-model:content="myText"
        fixed-toolbar-container="#mytoolbar"
        :max-count="20000"
        :inline="true"
        :disabled="props.disabled"
        style="height: 100%;width: 100%;"
      />
    </div>
    <div v-if="isShow && props.mode === 'editor'" class="move-icon" @click="handleClick" />
  </div>


const isShow = ref(true)
const handleClick = () => {
  setActive(props.componentId)
  isShow.value = false
}
const onClickOutside = () => {
  isShow.value = true
}

优化后的效果:

ab0a23efcbaef63e301807ed201d741c.gif

这里有一点比较关键,就是当用户点击其他区域时,要将编辑器恢复成初始状态,所以这里我使用element-plus提供的指令:vClickOutside

import { ClickOutside as vClickOutside } from 'element-plus'

5. 编辑区与操作栏分离

富文本的操作栏功能众多,如果嵌入一个可以自由拖拽的容器内,会显得页面比较拥挤,不利于展示主要元素,所以业界编辑通用做法时操作区与编辑区分离,操作栏一般位于编辑器顶部或者右侧

3f5416fed2a00d9075cbafebee6dc363.gif

实现这个很简单,tinymce已经考虑到了这点,在配置里只需要设置 inline 以及 fixed-toolbar-container

<GEditor
    v-model:content="myText"
    fixed-toolbar-container="#mytoolbar"
    :inline="true"
    :disabled="props.disabled"
    style="height: 100%;width: 100%;"/>

将 inline 设置为 true,操作区功能将会渲染在 fixed-toolbar-container 设置的对应区域内。

6. 预览功能

编辑器另外一个重要的功能就是预览。预览可以帮助用户看到真实的最终效果,而最终效果用户是需要pdf格式那样的效果。最简单的就是点击预览按钮,直接呼出浏览器的打印功能,因为打印功能自带有pdf预览效果,用户也可以选择另存为pdf.调用打印功能只需要执行浏览器自带的方法:

window.print()

这个方法会打印整个window下body里面内容,而我们只需要打印指定的文档区域,最简单做法就是使用css3 媒体查询来控制打印样式,在打印时候隐藏一些无关元素。

@media print {
  @page {
    size: a4;
    margin: 0 !important;
    padding: 0 !important;
  }


  .editor-header,
  .CanvasTop,
  .editor-aside-left,
  .editor-aside-right {
    display: none !important;
  }
  .editor .el-container.is-vertical {
    display: none !important;
  }
  .multiple-pages {
    margin: 0 !important;
    height: 297mm !important;
    page-break-after: always !important;
  }
  .multiple-pages:last-child {
    page-break-after: auto !important;
  }
}
7. 生成PDF

业务方需要将制作好的合同下载成 pdf 再进行打印,其实浏览器打印时候就有另存为pdf选项,但是这样需要用户手动操作,再加上签单调用 pdf 是后端程序自动进行的。所以这种方式是不可

376d99a333efa07a9ee021d9b50ba202.png

第一版方案采用的是前端保存时,自动生成每一页合同的图片,将图片上传至阿里云OSS,再将地址上传给后端,这样后端在需要pdf时候去获取图片,将整个图片写入pdf.实现前端将内容生成图片,目前业界通用做法就是两种:

  • 基于 canvas 技术的开源库:html2canvas

  • 基于 svg 技术的开源库:dom-to-image

html2canvas 库原理就是根据html dom 结构以及样式,重新使用 canvas 提供的api来进行绘制,相当于重新写了一遍排版算法,这个库虽然使用人数多,但是官方已经很久都不维护了,一些很简单的 bug 都没人去修复了。在使用过程我也发现一些场景下很多问题,例如黑边问题等。

dom-to-image借助svg来实现dom转成图片,具体原理大家可以参考张鑫旭大佬的这篇文章

借助SVG forginObject实现DOM转图片(https://www.zhangxinxu.com/study/201708/svg-foreignobject-dom-to-image.html)

dom-to-image 省略了 html2canvas 自行排版的过程,但是最终生成生成图片这一步还是使用dom 绘制入 canvas 中再生成图片的。合同编辑器采用的方案就是这个。

事情到这本来应该结束了,但是由于上面方法生成的合同在打印时某些字体会有一些瑕疵。一向追求完美的我们于是又有了后续的技术接入。

为了追求完美,经过调研测试。最终方案决定使用 nodejs + puppeteer 来实现。

puppeteer

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome,通俗来说就是我们说的无头浏览器,借助浏览器原生生成pdf的能力来实现。

原理就是通过nodejs启动一个服务,Puppeteer 通过在后台启动一个浏览器,浏览器去访问合同编辑器,访问成功之后直接调用 Puppeteer 提供的生成pdf API,关键代码如下:

const browser = await puppeteer.launch({
  ignoreHTTPSErrors: true,
  headless: true, // 启用无头模式
  args: ['--no-sandbox', '--disable-setuid-sandbox'] // 禁用沙盒模式
})
const page = await browser.newPage()
// 访问指定页面
await page.goto('http://example.com/id=1', {
  waitUntil: 'networkidle0'
})
// 生成pdf方法,可以配置一些参数,控制pdf
await page.pdf({
  path: `./element.${Date.now()}.pdf`,
  format: 'A4'
})
await browser.close()

关于 puppeteer 更多用法,大家可以参考puppeteer API

在使用过程中有几点需要注意:

  • 部署到 linux 服务器会有一些字体问题,所以需要 linux 服务器提前准备好相应字体。

  • 生成 pdf 需要等待网页所有内容以及接口调用完成之后再去操作,避免生成内容缺失。

8.总结

通过开发合同编辑器,可以实现后续合同的完全线上化,变量替换功能也极大地方便后续合同内容的变更,对于降低维护成本有很大的帮助。在后续迭代过程中,银河前端团队也会积极倾听使用者的反馈,继续优化用户的使用体验感和推出一些新的特性。(完)

Node 社群

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

   “分享、点赞、在看” 支持一波👍
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值