quill-editor 2.0 集成 vue

在 vue 项目中使用 quill-editor 2.0

更多精彩

写在前面的话

  1. 之前做过 vue-quill-editor 富文本框编辑器 ,这个版本是基于 Github 上一个已经集成好的组件做的二次开发
  2. 这个组件是基于 quill@1.3.6 ,但是现在需要编辑器能支持插入表格,这个需求 quill@1.3.6 做不到
  3. 但是 quill@2.0.0-dev.3 支持在编辑器中插入表格,不过这不是正式版,而是开发版
  4. 而原版的 vue-quill-editor 两年前就没更新了,所以 quill 的版本一直停留在 1.x
  5. 那么要实现新需求,就只能重新集成一个新的了

相关网址

  1. 官网关于 2.x 中已经失效的 API 介绍
  2. 官方文档
  3. GitHub 源码

基础功能集成

  1. 最初的实现引导来自 在Vue中使用富文本编辑器Quill - SegmentFault 思否

项目引入 quill@2.x

  1. Releases · quilljs/quill · GitHub 可以看到当前官方的正式版是 1.3.7
  2. 不过直接进入 GitHub - quilljs/quill 的首页会发现项目的默认分支已经切换到了开发版,所以 2.x 版本虽然是开发版,但实际用起来不会有什么问题,完全可以放心使用
  3. 通过 npm view quill 可以看到当前的开发版本的具体版本号是 2.0.0-dev.3 ,所以直接在项目根目录使用 npm install quill@2.0.0-dev.3 --save 安装即可,如下图

创建编辑器组件

  1. 真正会被渲染成编辑器的 DIV 是 in-editor ,其外层的 in-editor-wrapper 只是作为父级包裹一层
    • 因为当编辑器初始化后,其结构是如下图
    • 所以如果没有父级 DIV 进行包裹,初始化的时候会抛出没有容器的错误
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
<template>
  <div class="in-editor-wrapper">
    <div class="in-editor"></div>
  </div>
</template>

<script>
  // 引入原始组件
  import Quill from 'quill'

  // 引入核心样式和主题样式
  import 'quill/dist/quill.core.css'
  import 'quill/dist/quill.snow.css'

  export default {
    name: 'inEditor',
    props: {
      // 用于双向绑定
      value: String
    },
    data () {
      return {
        // 待初始化的编辑器
        editor: null,
        // 配置参数
        options: {
          theme: 'snow',
          modules: {
            // 工具栏的具体配置
            toolbar: {
              container: [
                ['bold', 'italic', 'underline', 'strike'],
                ['blockquote', 'code-block'],
                [{'list': 'ordered'}, {'list': 'bullet'}],
                [{'script': 'super'}],
                [{'indent': '-1'}, {'indent': '+1'}],
                [{'size': ['small', false, 'large', 'huge']}],
                [{'header': [1, 2, 3, 4, 5, 6, false]}],
                [{'color': []}, {'background': []}],
                [{'align': []}],
                ['link', 'image']
              ]
            }
          },
          placeholder: '请输入内容 ...'
        }
      }
    },
    watch: {
      // 监听外部值的传入,用于将值赋予编辑器
      'value' (val) {
        // 如果编辑器没有初始化,则停止赋值
        if (!this.editor) {
          return
        }

        // 获取编辑器当前内容
        let content = this.editor.root.innerHTML

        // 外部传入了新值,而且与当前编辑器的内容不一致
        if (val && val !== content) {
          // 将外部传入的HTML内容转换成编辑器识别的delta对象
          let delta = this.editor.clipboard.convert({
            html: val
          })

          // 编辑器的内容需要接收delta对象
          this.editor.setContents(delta)
        }
      }
    },
    mounted () {
      // 初始化编辑器
      this._initEditor()
    },
    methods: {
      // 初始化编辑器
      _initEditor () {
        // 获取编辑器的DOM容器
        let editorDom = this.$el.querySelector('.in-editor')

        // 初始化编辑器
        this.editor = new Quill(editorDom, this.options)

        // 双向绑定
        this.editor.on('text-change', () => {
          this.$emit('input', this.editor.root.innerHTML)
        })
      }
    }
  }
</script>

<style lang="stylus" type="text/stylus">
  .in-editor-wrapper
    flex-grow 1
    display flex
    flex-direction column
    overflow hidden
    .ql-toolbar
      .ql-formats
        .ql-picker-label
          &::before
            position relative
            top -5px
        button
          i.icon
            font-size 14px
    .ql-container
      flex-grow 1
      height 0
      overflow hidden
</style>

使用编辑器组件

  1. 按正常的组件引入方式即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
  <in-editor v-model="description"></in-editor>
</template>

<script type="text/ecmascript-6">
import inEditor from "components/in-editor"

export default {
  name: "inEditorForm",
  data() {
    return {
      description: 'Hello World'
    };
  },
  components: {
    inEditor
  }
};
</script>

<style lang="stylus" type="text/stylus"></style>

启用表格功能

配置工具栏

  1. 在 2.x 中原始版本就已经支持插入表格,只需要按照如下代码的方式,在 toolbar 中配置对应按钮即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
options: {
  theme: 'snow',
  modules: {
    // 启用表格功能
    table: true,
    toolbar: {
      container: [
        // 为了减少代码内容,这里被省略了,可以参考上述代码
        ...
        [
          {'table': 'TD'},
          {'table-insert-row': 'TIR'},
          {'table-insert-column': 'TIC'},
          {'table-delete-row': 'TDR'},
          {'table-delete-column': 'TDC'}
        ]
      ]
    }
  },
  placeholder: this.placeholder
}

渲染表格按钮的图标

  1. 按照上述代码对工具栏进行配置后,只能在工具栏上看到 1 个按钮,后续 4 个按钮都看不到,如下图
  2. 其实在上图的第 1 个按钮后面还有 4 个按钮,查看代码结构可知,如下图
    • 可以很清楚的看到,一共生成了 5 个按钮,但只有第一个按钮中有 SVG 格式的图标,后续 4 个按钮都是空的
  3. 为什么后续 4 个按钮没有图标,难道因为开发版的原因,表格功能的按钮图标还没有完整提供?
  4. 其实并不是,查看 2.x 的源码目录,在 /assets/icons 目录下可以看到表格的图标非常完整,如下图
  5. 那么为什么有图标,但是却不显示?
  6. 继续查看 2.x 的源码目录,在 /ui/icons.js 中找到如下代码
    • 可以看到关于表格的按钮,就只引入了第 1 个
    • 后续 4 个按钮虽然有提供 SVG 文件,但并没有引入
1
2
3
4
5
6
7
8
9
...
import tableIcon from '../assets/icons/table.svg';
...

export default {
  ...
  table: tableIcon,
  ...
};
  1. 既然按钮图标的 SVG 文件是有的,那么只需要扩充一下 /ui/icons.js 即可
    • let icons = Quill.import('ui/icons') 就是调用 quill 的原生图标库,从 Issue #1099 · quilljs/quill · GitHub 学来的
    • 之后通过 Lodash 遍历准备好的自定义图标库,将其逐个插入到 /ui/icons.js 中即可
    • 注意,是在编辑器组件初始化之前将自定义图标库插入原生图标库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script>
  import Quill from 'quill'
  import _ from 'lodash'
  import { ICON_SVGS } from 'components/in-editor/ui/icon'

  export default {
    ...
    mounted () {
      this._initCustomToolbarIcon()
      this._initEditor()
    },
    methods: {
      _initCustomToolbarIcon () {
        // 获取quill的原生图标库
        let icons = Quill.import('ui/icons')

        // 从自定义图标SVG列表中找到对应的图标填入到原生图标库中
        _.forEach(ICON_SVGS, (iconValue, iconName) => {
          icons[iconName] = iconValue
        })
      }
      ...
    }
  }
</script>
  1. ICON_SVGS 所在文件内容如下
    • 这里其实是一个败笔,本来是想通过和 /ui/icons.js 一样的方式把 SVG 文件通过 import 方式引入
    • 但不管怎么尝试获得的都是文本内容,最后不得以只能采用如下方式
    • 后期找到解决方案会再次优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export const ICON_SVGS = {
  'table-insert-row': `<svg viewbox="0 0 18 18">
    <g class="ql-fill ql-stroke ql-thin ql-transparent">
      <rect height="3" rx="0.5" ry="0.5" width="7" x="4.5" y="2.5"></rect>
      <rect height="3" rx="0.5" ry="0.5" width="7" x="4.5" y="12.5"></rect>
    </g>
    <rect class="ql-fill ql-stroke ql-thin" height="3" rx="0.5" ry="0.5" width="7" x="8.5" y="7.5"></rect>
    <polygon class="ql-fill ql-stroke ql-thin" points="4.5 11 2.5 9 4.5 7 4.5 11"></polygon>
    <line class="ql-stroke" x1="6" x2="4" y1="9" y2="9"></line>
  </svg>`,
  'table-insert-column': `<svg viewbox="0 0 18 18">
    <g class="ql-fill ql-transparent">
      <rect height="10" rx="1" ry="1" width="4" x="12" y="2"></rect>
      <rect height="10" rx="1" ry="1" width="4" x="2" y="2"></rect>
    </g>
    <path class="ql-fill" d="M11.354,4.146l-2-2a0.5,0.5,0,0,0-.707,0l-2,2A0.5,0.5,0,0,0,7,5H8V6a1,1,0,0,0,2,0V5h1A0.5,0.5,0,0,0,11.354,4.146Z"></path>
    <rect class="ql-fill" height="8" rx="1" ry="1" width="4" x="7" y="8"></rect>
  </svg>`,
  'table-delete-row': `<svg viewbox="0 0 18 18">
    <g class="ql-fill ql-stroke ql-thin ql-transparent">
      <rect height="3" rx="0.5" ry="0.5" width="7" x="4.5" y="2.5"></rect>
      <rect height="3" rx="0.5" ry="0.5" width="7" x="4.5" y="12.5"></rect>
    </g>
    <rect class="ql-fill ql-stroke ql-thin" height="3" rx="0.5" ry="0.5" width="7" x="8.5" y="7.5"></rect>
    <line class="ql-stroke ql-thin" x1="6.5" x2="3.5" y1="7.5" y2="10.5"></line>
    <line class="ql-stroke ql-thin" x1="3.5" x2="6.5" y1="7.5" y2="10.5"></line>
  </svg>`,
  'table-delete-column': `<svg viewbox="0 0 18 18">
    <g class="ql-fill ql-transparent">
      <rect height="10" rx="1" ry="1" width="4" x="2" y="6"></rect>
      <rect height="10" rx="1" ry="1" width="4" x="12" y="6"></rect>
    </g>
    <rect class="ql-fill" height="8" rx="1" ry="1" width="4" x="7" y="2"></rect>
    <path class="ql-fill" d="M9.707,13l1.146-1.146a0.5,0.5,0,0,0-.707-0.707L9,12.293,7.854,11.146a0.5,0.5,0,0,0-.707.707L8.293,13,7.146,14.146a0.5,0.5,0,1,0,.707.707L9,13.707l1.146,1.146a0.5,0.5,0,0,0,.707-0.707Z"></path>
  </svg>`
}
  1. 按照上述方式渲染完成后的表格图标如下图

扩展表格按钮的功能

  1. 表格按钮的图标渲染完成后,点击这几个表格按钮会发现,只有第 1 个按钮有效果,后续 4 个按钮没有任何反应
  2. 这其实很容易想通,毕竟后续 4 个按钮默认连图标都没有渲染,所以功能自然也是需要自定义的
  3. 实现方式同样参考自 在Vue中使用富文本编辑器Quill - SegmentFault 思否
  4. 和上文中稍有区别的位置在于我把按钮的触发事件声明在 methods 中,而不是直接声明在 options.modules.toolbar.handlers
    • 因为按照上文直接声明在配置中,会出现编辑器还没有初始化导致 this.editor = undefined 的情况
  5. 按照如下代码配置后,编辑器的表格插入功能就大功告成了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<script>
  ...
  export default {
    ...
    data () {
      return {
        ...
        options: {
          modules: {
            toolbar: {
              container: [
                ...
              ],
              handlers: {
                'table': this._tableHandler,
                'table-insert-row': this._tableInsertRowHandler,
                'table-insert-column': this._tableInsertColumnHandler,
                'table-delete-row': this._tableDeleteRowHandler,
                'table-delete-column': this._tableDeleteColumnHandler
              }
            }
          }
        }
      }
    },
    methods: {
      ...
      _tableHandler () {
        this.editor.getModule('table').insertTable(2, 3)
      },
      _tableInsertRowHandler () {
        this.editor.getModule('table').insertRowBelow()
      },
      _tableInsertColumnHandler () {
        this.editor.getModule('table').insertColumnRight()
      },
      _tableDeleteRowHandler () {
        this.editor.getModule('table').deleteRow()
      },
      _tableDeleteColumnHandler () {
        this.editor.getModule('table').deleteColumn()
      }
    }
  }
</script>

重写图片上传功能

  1. quill 的原生图片上传是通过将待上传的图片文件转义成 BASE64 格式后直接插入到文本中
  2. 图片会作为文本的一部分被直接传入后端,进行持久化操作
  3. BASE64 格式的图片非常冗长,这不是个好的解决方案,所以需要优化

引入图片上传模块

  1. GitHub - NextBoy/quill-image-extend-module 是在 quill@1.3.6 阶段就非常好用的图片上传模块
  2. 实测发现在 quill@2.x 中也能正常使用,在项目根目录执行 npm install quill-image-extend-module --save-dev 安装即可
    • 不过该模块的作者已经在 README 中表示不再维护了,所以有时间的话,我可能会自己参考着重写一份,方便后期维护
  3. 接下来在组件按照如下方式中引入模块
    • quill-image-extend-module 中引入了两个模块,分别是 ImageExtendQuillWatch
    • ImageExtend 用于进行图片上传功能的重写,因为是自定义的 modules ,所以放在 options.modules 中,和 toolbar 同级
    • QuillWatch 用于监听图片上传的操作,监听操作需要放置在 options.modules.toolbar.handles.image 中,表示是监听图片按钮的点击操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script>
  import Quill from 'quill'
  import { ImageExtend, QuillWatch } from 'quill-image-extend-module'

  Quill.register('modules/ImageExtend', ImageExtend)

  export default {
    ...
    data () {
      return {
        options: {
          modules: {
            toolbar: {
              container: [
                ...
                ['link', 'image']
              ],
              handlers: {
                ...
                'image': this._imageHandler
              }
            },
            ImageExtend: {
              loading: true,
              name: 'image',
              size: 2,
              action: `/api/file/upload/image`,
              response: (res) => {
                return res.data
              }
            }
          }
        }
      }
    },
    methods: {
      _imageHandler () {
        QuillWatch.emit(this.quill.id)
      }
    }
  }
</script>
  1. 实际上是通过异步的表单上传方式将图片上传到了服务端,如果服务端代码正好是 Java ,又正好是 SpringBoot ,可以参考以下代码作为服务端的图片接收接口
1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/api/file/upload")
public class FileUploadController {

    @PostMapping("")
    public ResponseData fileUpload(@RequestParam MultipartFile file) {
        ...
    }
}

设置图片大小

  1. 原生的图片上传功能,不仅图片是作为 BASE64 格式进行保存,而且上传的图片无法修改大小
  2. 就算将图片的上传方式修改后,图片依旧是无法直接修改大小的,这块的需求也需要手动实现
  3. quill@1.x 版本中,GitHub - kensnyder/quill-image-resize-module 是一个非常好用的调整图片大小的扩展模块
  4. 在之前使用的 vue-quill-editor/04-example.vue · GitHub 都有提供集成案例
  5. 但是当我准备把这个模块引入到 2.x 中的时候,安装过程中提示这个模块引入的依赖包都太老了,疯狂报错,所以最后只能罢休
  6. 要自己实现一个功能如此强大的模块,肯定是有点来不及,所以我做了一个极简版,实际操作效果如下图

实现代码

  1. 在初始化编辑器时,通过 this.editor.root.addEventListener 监听编辑器内容的双击事件
  2. 如果双击的对象是图片,则弹出一个对话框,this.$prompt() 是 ElementUI 的全局函数,用于弹出对话框
  3. 在对话框中输入准备修改的图片宽度,即可按比例调整图片的大小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<script>
  ...

  export default {
    ...
    methods: {
      _initEditor () {
        let editorDom = this.$el.querySelector('.in-editor')

        this.editor = new Quill(editorDom, this.options)

        // 监听图片点击
        this.editor.root.addEventListener('dblclick', this._initImageResize, false)

        // 双向绑定
        this.editor.on('text-change', () => {
          this.$emit('input', this.editor.root.innerHTML)
        })
      },
      _initImageResize (event) {
        let currentTarget = event.target

        // 判断当前点击的是不是图片
        if (currentTarget && currentTarget.tagName && currentTarget.tagName.toUpperCase() === 'IMG') {
          this.$prompt('请输入宽度', '提示', {
            inputValue: currentTarget.width,
            confirmButtonText: '确定',
            cancelButtonText: '取消'
          }).then(({value}) => {
            // 赋值新宽度
            currentTarget.width = value
          }).catch(() => {})
        }
      }
    }
  }
</script>

实现编辑器的全屏扩展

  1. 有时候受制于表单页的布局,编辑器的可编辑区域会比较拘谨
  2. 所以需要在工具栏上提供一个按钮,可以让编辑器实现浏览器范围内的全屏放大
  3. 这个需求没有使用现成的扩展组件,而是结合** ElementUI** 的 el-dialog 组件实现了效果

在编辑器组件中引入 el-dialog

  1. el-dialog 是 ElementUI 的模态窗口,具体用法参考 Element - component/dialog
    • fullscreen 表示窗口打开时是直接全屏展现的
  2. el-dialog 中增加了一个 DIV ,标记为 in-full-editor ,用于在窗口展现时初始化一个用于全屏显示的编辑器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
  <div class="in-editor-wrapper">
    <div class="in-editor"></div>
    <el-dialog 
      modal
      append-to-body
      fullscreen 
      custom-class="in-editor-modal"
      :visible.sync="fullEditorShow" 
      :title="title">
      <div class="in-full-editor"></div>
    </el-dialog>
  </div>
</template>

在工具栏上增加一个全屏按钮

  1. 从如下代码中可以看到,在工具栏上新增了一个 expand 按钮,显示效果如下图
  2. 图标内容同样直接存放在之前的 import { ICON_SVGS } from 'components/in-editor/ui/icon' 中,内容如下
    • 之前的表格按钮内容被省略了
    • 至于初始化按钮的函数中不需要做任何更改
1
2
3
4
5
6
export const ICON_SVGS = {
  ...
  'expand': `<svg viewBox="0 0 18 18">
    <path d="M5.797 9.76a.6.6 0 1 1 .849.848L2.253 15h3.379a.6.6 0 0 1 .592.503l.008.097a.6.6 0 0 1-.6.6H.8a.612.612 0 0 1-.162-.022A.6.6 0 0 1 .2 15.6v-4.832a.6.6 0 0 1 1.2 0l-.001 3.389zM15.588.2a.61.61 0 0 1 .176.025l.041.016a.373.373 0 0 1 .053.021c.007.006.015.01.022.014a.599.599 0 0 1 .31.588l-.002 4.768a.6.6 0 0 1-1.2 0V2.254L10.6 6.642a.6.6 0 0 1-.765.07l-.083-.07a.6.6 0 0 1 0-.848L14.144 1.4h-3.388a.6.6 0 0 1-.592-.503L10.156.8a.6.6 0 0 1 .6-.6z"/>
   </svg>`
}

实现两个编辑器之间的数据交互

  1. 从如下代码中可以看到,fullEditor 的初始化并不在 mounted() 函数中,而是通过监听 fullEditorShow 的显示来对 fullEditor 进行初始化
    • 因为当全屏窗口没有展现之前,全屏编辑器自然也是不需要初始化的
  2. expand 按钮点击后触发的 _expandHandler() 函数会修改 fullEditorShow = true ,全屏窗口就会展现
  3. _initFullEditor() 函数中,对全屏编辑器中了初始化以及赋值操作
    • 首先要判断全屏编辑器是否已经初始化,防止重复初始化,在 quill@2.x 中已经移除了 destory() 函数,所以需要通过这种方式判断
    • 然后初始化操作需要放置在 $nextTick() 函数中,因为初始化需要在 DOM 元素加载完毕后才能进行
    • 最后赋值操作则是直接从 this.editor 中获取,但需要通过 setTimeout(() => {}, 20) 做一个小小的延迟,防止编辑器的初始化还没有完成
  4. 仔细对比 _initFullEditor()_initEditor() 函数可以发现,在全屏编辑器的初始化函数中,没有对数据进行双向绑定
    • 因为全屏编辑器在打开的情况下,当前浏览器窗口就只能处理编辑器的数据,要想处理其他操作,则需要先退出全屏编辑器
    • 所以只需要监听全屏窗口的打开和关闭状态,在打开时将原始编辑器的内容赋值给全屏编辑器,在关闭时将全屏编辑器的内容赋值给原始编辑器即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<script>
  ...

  export default {
    ...
    data () {
      return {
        title: '请输入内容',
        editor: null,
        fullEditor: null,
        fullEditorShow: false,
        options: {
          modules: {
            toolbar: {
              container: [
                ...
                ['expand']
              ],
              handlers: {
                ...
                'expand': this._expandHandler
              }
            }
          }
        }
      }
    },
    watch: {
      ...
      'fullEditorShow' (val) {
        if (val) {
          this._initFullEditor()
        } else {
          this.editor.setContents(this.fullEditor.getContents())
        }
      }
    },
    methods: {
      ...
      _initFullEditor () {
        // 全屏编辑器不存在,则初始化
        if (!this.fullEditor) {
          this.$nextTick(() => {
            let fullEditorDom = document.querySelector('.in-full-editor')
            this.fullEditor = new Quill(fullEditorDom, this.options)
            this.fullEditor.root.addEventListener('dblclick', this._initImageResize, false)
          })
        }

        // 将当前编辑器的内容赋值给全屏编辑器
        setTimeout(() => {
          this.fullEditor.setContents(this.editor.getContents())
        }, 20)
      },
      ...
      _expandHandler () {
        this.fullEditorShow = !this.fullEditorShow
      }
    }
  }
</script>

全屏编辑器在全屏窗口中的样式参考

  1. 只提供参考,样式这种东西,根据实际情况灵活调整即可
.in-editor-modal
  &.is-fullscreen
    display flex
    flex-direction column
    .el-dialog__header
      flex 0 0 24px
    .el-dialog__body
      flex-grow 1
      padding 0
      display flex
      flex-direction column
      overflow hidden