(共556篇)
全部分类

quill-editor二次开发全记录
[ Case ] 

背景

Quill包原生功能及 从网上找的quill相关插件也都不能满足公司实际产品需求, 现对quill二次开发, 取名为vary-quill, vary-quill包作为公司内部富文本专用npm包已发布到私有npm库中.

问题

  1. 支持的字号太少, 只有3种字号
  2. 文字对齐方式, 默认是类名模式, 需要改为行内模式
  3. 缺少定义行高的模块
  4. 从截屏中粘贴的图片, quill自动转成了Base64格式, 如果截图区域更大, 会导致生成的base64码以M(兆)为单位, 虽然数据库中没有限制字段长度, 但已严重影响产品流程
  5. 从Word中粘贴的混合图文内容, 由于浏览器的限制, word中复制的图片是访问不了的, quil在更新内容的时候, 没有筛选此类节点, 导致编辑器中出现大量的图片占位符(带边框的空白区域),

想要的结果

  1. 字号要支持12~32px范围的切换
  2. 文字对齐方式, 从类名模式改为行内模式
  3. 增加行高模块, 支持用户给文字设置1~5倍的行矩
  4. 从截屏中粘贴的图片, 要上传到阿里云OSS库, 再更新到编辑器中
  5. 从word中粘贴的图文混合内容: 要过滤掉混合内容中的图片节点; 要给用户相应的提示, 免得用户不知道图片哪里去了(为什么没有粘贴进编辑器)

模式使用原则

模式分为两种,:

  1. 是通过类名设置样式 ,该模式中的样式适用于"当CSS属性值不存在可扩展性(就那么几个固定的值)“的情况, 比如align-center等
  2. 通过内联style设置样式, 该模式适用于"当CSS属性值可能会后期扩展(非固定值)“的情况, 比如font-size line-height等

安装

1
yarn add vary-quill

字号

字号范围主要在 formats/size 文件中指定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import Parchment from "parchment";

let SizeClass = new Parchment.Attributor.Class("size", "ql-size", {
  scope: Parchment.Scope.INLINE,
  whitelist: Array.from({ length: 21 }, (v, k) => k * 1 + 12 + "px"),
});
let SizeStyle = new Parchment.Attributor.Style("size", "font-size", {
  scope: Parchment.Scope.INLINE,
  whitelist: Array.from({ length: 21 }, (v, k) => k * 1 + 12 + "px"),
});

export { SizeClass, SizeStyle };

这里添加了从12到32的字号范围, 虽然这里暴露了类名模式和内联模式, 注册该组件的是, 使用的是内联模式 Quill.register({“formats/size”: SizeStyle,}, true)

行高

文字对齐方式主要在 formats/lineheight 文件中指定

 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
import Parchment from "parchment";

const config = {
  scope: Parchment.Scope.INLINE,
  whitelist: [
    "1em",
    "1.5em",
    "2em",
    "2.5em",
    "3em",
    "3.5em",
    "4em",
    "4.5em",
    "5em",
  ],
};
let LineheightClass = new Parchment.Attributor.Class(
  "lineheight",
  "ql-lineheight",
  config
);
let LineheightStyle = new Parchment.Attributor.Style(
  "lineheight",
  "line-height",
  config
);

export { LineheightClass, LineheightStyle };

同样的, 注册该组件的时候, 使用的内联模式 `` Quill.register({“formats/align”: AlignStyle,}, true)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

截屏粘贴
-
对于粘贴板事件, Quill在 modles/clipboard 的 onPaste 方法中处理相应数据, 一般来说, 如果想单独处理这些数据, 可以在 new Quill() 时, 通过以下方式就可以自行处理粘贴板数据
```js
new Quill(selector, {
  modules:{
    clipboard:{
      matchers:[
        // 处理图片
      ]
    }
  }
})

但是Quill在这里没有支持异步操作,所以干脆在Quill处理完所有的数据后, 统一对 Delta 数据做一次筛选

 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
onPaste(e) {
    if (e.defaultPrevented || !this.quill.isEnabled()) return;
    let range = this.quill.getSelection();
    let delta = new Delta().retain(range.index);
    let scrollTop = this.quill.scrollingContainer.scrollTop;
    this.container.focus();
    this.quill.selection.update(Quill.sources.SILENT);
    setTimeout(() => {
      delta = delta.concat(this.convert()).delete(range.length);
      /* 
          这里的delta就是 Quill在粘贴板事件发生后, 以及经过各种matcher处理后,返回的节点数据
        在这里对delta中的数据类型做判断和筛选是最合适的, 处理的时候要注意几点:
        1. 由于图片上传是异步的, 所以这里的处理方式要支持异步
        2. 不同项目中的具体处理方法可能不相同(接口不同或者是命名方式不同), 所以具体的处理方法要从外部传参进来
        3. 本来只需要处理Base64数据的, 但是考虑到以后可能会处理更多类型的数据, 这里干脆不做数据类型的区分, 
            全部交给外部参数处理
      */
      this.quill.updateContents(delta, Quill.sources.USER);
      this.quill.setSelection(
        delta.length() - range.length,
        Quill.sources.SILENT
      );
      this.quill.scrollingContainer.scrollTop = scrollTop;
      this.quill.focus();
    })
}

方案

  1. 实例化Quill的时候, 给clipboard 模块新增一个参数: imgResolver
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
imgResolver中, 接收由 promiseMatcher 传递进来的单个delta信息,
new Quill(selector, {
  modules:{
    clipboard:{
      imgResolver: function(ops, next){
        /*
         ops: 节点信息, 
             Base64类型的ops :{image:'data:image/...;base64...'}
            Fill类型的ops: {image:'fill://...'}
         next: promiseMatcher中的resolve, ops信息修改之后, 放进next中作为返回值
         
         
        if(是base64类型的信息){
          上传文件
          拿到上传文件的路径
          const url = OSS前缀 + 文件路径
          ops.image = url
          next(ops)
        }
        */
      }
    }
  }
})
  1. 在onPatse前面, 添加一个函数 promiseMatcher, 这个函数主要处理两件事
 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
promiseMatcher(ops) {
 return new Promise((resolve) => {
   const { imgResolver } = this.options;
   if (!imgResolver) {
     resolve(ops);
     return;
   }
   if (
     ops.insert &&
     ops.insert.image &&
     Object.prototype.toString
     .call(imgResolver)
     .toLowerCase()
     .slice(-9, -1) === "function"
   ) {
     imgResolver(ops, resolve);
   } else {
     resolve(ops);
   }
 });
}

onPaste(){
 // ...onPaste
}
  1. 修改 onPaste 方法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 其他代码省略    
setTimeout(() => {
    delta = delta.concat(this.convert()).delete(range.length);
      /* 
        这里把delta中的ops信息,通过遍历交给promiseMatcher处理,理论上, 图片上传过程中, 是不允许用户修改富文本内容的
      这一点imgResolver中要注意一下. 
    */
    Promise.all(delta.ops.map((item) => this.promiseMatcher(item))).then(
      (ops) => {
        delta.ops = ops;

        this.quill.updateContents(delta, Quill.sources.USER);
        this.quill.setSelection(
          delta.length() - range.length,
          Quill.sources.SILENT
        );
        this.quill.scrollingContainer.scrollTop = scrollTop;
        this.quill.focus();
      }
    );
  }, 1);

Word内容粘贴

word内容中如果包含图片, 浏览器是识别不了的, 但是 Quill 同样在富文本中插入了一个图片占位符. 这个完全是没有意义的, 与粘贴板内容相同, 在所有的节点处理之后, 对从word内容粘贴过来的图片节点做一次筛选, 去掉即可.

方案

  1. 在imgResolver中添加对File内容的处理
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
new Quill(selector, {
  modules:{
    clipboard:{
      imgResolver: function(ops, next){
        /*
        if(是file://类型的信息){
          ops = {
              discard: true
          }
          next(ops)
        }
        */
      }
    }
  }
})
  1. 在 promiseMatcher完成之后 ,对所有被标记为{discard : true}的节点做一次过滤
 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
// 其他代码省略    
setTimeout(() => {
    delta = delta.concat(this.convert()).delete(range.length);
  
    Promise.all(delta.ops.map((item) => this.promiseMatcher(item))).then(
      (ops) => {
        
        ops = ops.filter((item) => {
          if (item.discard) {
            return false;
          }
          return true;
        });
        delta.ops = ops;

        this.quill.updateContents(delta, Quill.sources.USER);
        this.quill.setSelection(
          delta.length() - range.length,
          Quill.sources.SILENT
        );
        this.quill.scrollingContainer.scrollTop = scrollTop;
        this.quill.focus();
      }
    );
  }, 1);

以上都是二次开发的思路及修改要点, 看看就行, 项目里应用的时候都不需要改动

完整示例

下面是一个vue文件的案例模板, 各项目可以根据需求自行更改

  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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
<template>
  <div>
    <div class="uploader_row">
      <var-upload
        ref="imageUploader"
        accept=".jpg,.jpeg,.png,.gif"
        multiple
        max-length="10"
        @success="imageUpladSucess"
      ></var-upload>
    </div>
    <div ref="scene" class="editor_scene"></div>
  </div>
</template>

<script>
const dataURLtoBlob = function (dataurl) {
  const arr = dataurl.split(',');
  const mime = arr[0].match(/:(.*?);/)[1];
  const bstr = atob(arr[1]);
  let n = bstr.length;
  const u8arr = new Uint8Array(n);
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  return new Blob([u8arr], { type: mime });
};

// 将blob转换为file
const blobToFile = function (theBlob, fileName) {
  theBlob.lastModifiedDate = new Date();
  theBlob.name = fileName;
  return theBlob;
};

const defaultOptions = {
  theme: 'snow',
  boundary: document.body,
  placeholder: 'Insert text here ...',
  modules: {
    toolbar: {
      container: [
        ['bold', 'italic', 'underline', 'strike'],
        ['blockquote', 'code-block'],
        [{ header: 1 }, { header: 2 }],
        [{ list: 'ordered' }, { list: 'bullet' }],
        [{ script: 'sub' }, { script: 'super' }],
        [{ indent: '-1' }, { indent: '+1' }],
        [{ direction: 'rtl' }],
        [{ header: [1, 2, 3, 4, 5, 6, false] }],
        [{ color: [] }, { background: [] }],
        [{ align: [] }],
        [{ size: Array.from({ length: 11 }, (v, k) => k * 2 + 12 + 'px') }],
        [{ lineheight: ['1em', '1.5em', '2em', '2.5em', '3em', '3.5em'] }],
        ['clean'],
        ['link', 'image'],
      ],
      handlers: {},
    },
    imageResize: {},
    clipboard: {
      imgResolver: ()=>{},
    },
  },
};
import oss from 'vary-ui/utils/oss';
import { nanoid } from 'nanoid';

import VaryQuill from 'vary-quill';
import 'vary-quill/dist/quill.core.css';
import 'vary-quill/dist/quill.snow.css';

import ImageResize from 'quill-image-resize-module';
VaryQuill.register('modules/imageResize', ImageResize);

let editor = null;

export default {
  name: 'VaryQuillEditor',
  props: {
    options: {
      type: Object,
      default() {
        return {};
      },
    },
    enable: {
      type: Boolean,
      default: true,
    },
    content: {
      type: String,
      default: '',
    },
  },
  data() {
    return {
      html: '',
    };
  },
  watch: {
    enable(v) {
      if (v) {
        editor.enable();
      } else {
        editor.disable();
      }
    },
    content(v) {
      if (v) {
        if (v !== this.html) {
          editor.pasteHTML(v);
        }
      } else {
        editor.setText('');
      }
    },
  },
  mounted() {
    this.init();
  },
  methods: {
    init() {
      // 为什么要在这里修改选项? 因为这里的函数需要用到Vue实例 this
      defaultOptions.modules.toolbar.handlers.image = () => {
        this.$refs.imageUploader.trigger();
      };
      defaultOptions.modules.clipboard.imgResolver = (ops, next) => {
        if (/^data:.*?base64/.test(ops.insert.image)) {
          // 对base64节点, 先转为Blob,再转为File , 再上传到OSS, 再返回给promiseMatcher
          const blobData = dataURLtoBlob(ops.insert.image);
          const file = blobToFile(blobData);
          oss
            .put(`/demo/${nanoid()}.jpg`, file, 'admalltech-public')
            .then((res) => {
              console.log('upload', res);
              ops.insert.image = res.url;
              next(ops);
            });
        } else if (/^file:\/\//.test(ops.insert.image)) {
          // 对于word中的图片, 直接舍弃
          this.$message.success('本地或者Word中的图片请通过工具栏上传');
          ops = { discard: true };
          next(ops);
        } else {
          next(ops);
        }
      }
      editor = new VaryQuill(this.$refs.scene, defaultOptions);
      editor.on('text-change', () => {
        // 内容发生变化事件
        const html = editor.root.innerHTML;
        if (html !== this.content) {
          this.html = html;
          this.$emit('change', html);
        }
      });
      this.$emit('ready');
      editor.pasteHTML(this.content);
    },
    
    imageUpladSucess(res) {
      // 工具栏上的图片上传
      res.forEach((item) => {
        editor.insertEmbed(
          editor.selection.savedRange.index,
          'image',
          process.env.OSS_URL + item.fileName,
        );
      });
    },
    
  },
};
</script>
1
2
3
4
5
6
7
8
9
<style lang="scss" scoped>
.uploader_row {
  display: none;
}
.editor_scene {
  background-color: white;
  height: 400px;
}
</style>