点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
背景:
银河集团聚焦香港身份服务 17 年,目前服务项目涉及香港优才计划,留学进修,专才,香港教育,房产等。当客户购买对应产品时,我们需要与之签订对应的合同。由于合同内容会随各种因素以及根据客户签约的产品而有所调整。例如公司的 logo 更换了,那之前所有的合同上的 logo 都要去更换一遍,或者一些合同条款和协议更新了,所有的合同也需要相应去更新。如果靠人去手动维护这些合同需要很大的人力成本。所以我们决定打造一款能够自由编辑合同编辑器。这篇文章主要分享一些功能点的前端技术实现方式。
功能特性
1. 组件自由拖拽
编辑器内置了很多制作合同常用的组件,例如页眉页脚,分割线等,这相比于使用 WPS 来制作合同要高效得多。
实现拖拽,这里利用了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))
}
拖拽结束时我们要处理组件放置在哪个容器区域,在这个区域内渲染指定类型的组件,这有点类似于低代码方式,一般这样的应用都会具有一些通用的功能,例如:
组件渲染在鼠标松开的位置,并且能够在容器内自由拖动,实时更新位置。
组件可以通过拖放来实现宽高和大小的调节。
组件需要限制在容器内移动,不能超出容器。
组件移动过程中,可以展示辅助线以及吸附功能,方便组件与组件之间快速对齐。
实现这些通用的逻辑,我们可以不必从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)
})
},
})
效果图:
4.文本选择
富文本内文本有时候文本需要复制,当使用鼠标聚焦文字并拖拽选择时,整个富文本区域会移动,造成文本无法被选中。
优化前效果:
解决思路:
我们可以在富文本区域上方覆盖一层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
}
优化后的效果:
这里有一点比较关键,就是当用户点击其他区域时,要将编辑器恢复成初始状态,所以这里我使用element-plus提供的指令:vClickOutside
import { ClickOutside as vClickOutside } from 'element-plus'
5. 编辑区与操作栏分离
富文本的操作栏功能众多,如果嵌入一个可以自由拖拽的容器内,会显得页面比较拥挤,不利于展示主要元素,所以业界编辑通用做法时操作区与编辑区分离,操作栏一般位于编辑器顶部或者右侧
实现这个很简单,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 是后端程序自动进行的。所以这种方式是不可
第一版方案采用的是前端保存时,自动生成每一页合同的图片,将图片上传至阿里云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」即可。
“分享、点赞、在看” 支持一波👍