富文本编辑器Quill使用

前言

以下是在 Vue 项目中安装和配置 Quill 富文本编辑器的步骤:

安装依赖包

1
npm install vue-quill-editor@3.0.6 --save

依赖的版本是"quill": "^1.3.4"

目前不建议使用 `quill@2.x`版本,因为很多插件还不支持。

全局注册组件(可选)

打开 src/main.js 文件,添加以下内容:

1
2
3
4
5
6
7
8
9
import Vue from 'vue'
import VueQuillEditor from 'vue-quill-editor'

// 引入 Quill 样式
import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import 'quill/dist/quill.bubble.css'

Vue.use(VueQuillEditor)

在组件中使用

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
<template>
<div>
<quill-editor
v-model="content"
ref="myQuillEditor"
:options="editorOption"
></quill-editor>

<button @click="saveContent">保存内容</button>
</div>
</template>

<script>
export default {
data() {
return {
content: '',
editorOption: {
theme: 'snow',
modules: {
toolbar: [
['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'}],
[{'size': ['small', false, 'large', 'huge']}],
[{'header': [1, 2, 3, 4, 5, 6, false]}],
[{'color': []}, {'background': []}],
[{'font': []}],
[{'align': []}],
['clean'],
['image', 'video']
]
}
}
}
},
methods: {
saveContent() {
console.log('保存的内容:', this.content)
// 这里可以添加保存内容的逻辑
}
}
}
</script>

自定义配置(可选)

如果你需要自定义 Quill 功能,可以创建一个单独的文件来配置:

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
// src/plugins/quill.js
import Vue from 'vue'
import VueQuillEditor from 'vue-quill-editor'

// 引入 Quill 核心模块
import Quill from 'quill'
// 引入工具栏模块
import Toolbar from 'quill/modules/toolbar'
// 引入主题
import Snow from 'quill/themes/snow'

// 注册模块
Quill.register('modules/toolbar', Toolbar)
Quill.register('themes/snow', Snow)

// 自定义工具栏配置
const editorOption = {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{'header': 1}, {'header': 2}],
[{'list': 'ordered'}, {'list': 'bullet'}],
['clean']
]
}
}

// 注册 Quill 组件
Vue.use(VueQuillEditor, { editorOption })

然后在 main.js 中引入这个配置文件:

1
2
import Vue from 'vue'
import './plugins/quill'

以上就是在 Vue 项目中集成 Quill 富文本编辑器的完整步骤。你可以根据项目需求调整工具栏配置和样式。

自定义组件

InlineQuillEditor.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
<template>
<div class="z-container">
<quill-editor
ref="editorRef"
v-model="editorContent"
:options="editorOptions"
@blur="onEditorBlur($event)"
@focus="onEditorFocus($event)"
@ready="onEditorReady($event)"
@click="onEditorClick($event)"
/>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
</template>

<script>
import { quillEditor } from "vue-quill-editor";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";

export default {
name: "QuillEditorComponent",
components: {
quillEditor,
},
props: {
value: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "请输入内容...",
},
readOnly: {
type: Boolean,
default: false,
},
toolbarOptions: {
type: Array,
default: () => [
["bold", "italic", "underline", "strike", "image"],
[{ header: 1 }, { header: 2 }],
[{ list: "ordered" }, { list: "bullet" }],
[{ align: [] }],
["clean"],
],
},
},
data() {
return {
editorContent: this.value,
editorOptions: {
theme: "snow",
modules: {
toolbar: {
container: this.toolbarOptions,
handlers: {},
},
},
placeholder: this.placeholder,
readOnly: this.readOnly,
},
error: null,
z_timer: null,
};
},
watch: {
value(newVal) {
this.editorContent = newVal;
},
editorContent(newVal) {
this.$emit("input", newVal);
},
},
mounted() {
console.info(this.getEditor());
console.info(this.getContainer());
},
methods: {
onEditorClick() {
let container = this.getContainer();
container.classList.add("focused");
if (this.z_timer) {
clearTimeout(this.z_timer);
}
},
onEditorBlur() {
this.$emit("blur");
let container = this.getContainer();
this.z_timer = setTimeout(() => {
container.classList.remove("focused");
}, 3000);
},
onEditorFocus() {
this.$emit("focus");
let container = this.getContainer();
container.classList.add("focused");
},
onEditorReady() {
this.$emit("ready");
},
getEditor() {
return this.$refs.editorRef.quill;
},
getContainer() {
return this.$refs.editorRef.quill.container.closest(".z-container");
},
},
};
</script>

<style scoped>
.z-container {
border: 1px solid #ccc;
position: relative;
}

.quill-editor {
height: 100%;
}

.z-container /deep/ .ql-container {
border: none !important;
}

.z-container /deep/ .ql-toolbar {
opacity: 0;
position: absolute;
top: -42px;
pointer-events: none;
transition: opacity 0.2s ease;
}

.z-container.focused /deep/ .ql-toolbar {
opacity: 1;
pointer-events: auto;
}

.error-message {
color: #f5222d;
font-size: 12px;
margin-top: 8px;
}
</style>

插件

图片缩放插件

1
2
npm install quill-image-resize-module@3.0.0 -- save
npm install quill-image-drop-module@1.0.3 -- save

注意quill-image-drop-module是图片拖动到编辑器中的功能,不是支持图片的拖拽移动。

添加引用

1
2
3
4
5
6
7
8
9
10
import { quillEditor, Quill } from "vue-quill-editor";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";

import ImageResize from "quill-image-resize-module"; // 图片缩放组件引用

Quill.register("modules/imageResize", ImageResize); // 注册

import { ImageDrop } from "quill-image-drop-module"; // 图片拖动组件引用
Quill.register("modules/imageDrop", ImageDrop); // 注册

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
editorOptions: {
theme: "snow",
modules: {
toolbar: {
container: this.toolbarOptions,
handlers: {},
},
imageResize: {
//放大缩小
displayStyles: {
backgroundColor: "black",
border: "none",
color: "white",
},
modules: ["Resize", "DisplaySize", "Toolbar"],
},
imageDrop: true, //图片拖拽
},
placeholder: this.placeholder,
readOnly: this.readOnly,
},

vue.config.js

修改这个文件要重启项目才能生效

1
2
3
4
5
6
7
8
9
10
11
12
13
const { defineConfig } = require("@vue/cli-service");
const webpack = require("webpack");
module.exports = defineConfig({
transpileDependencies: true,
configureWebpack: {
plugins: [
new webpack.ProvidePlugin({
"window.Quill": "quill/dist/quill.js",
Quill: "quill/dist/quill.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
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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
<template>
<div class="z-container">
<quill-editor
ref="editorRef"
v-model="editorContent"
:options="editorOptions"
@blur="onEditorBlur($event)"
@focus="onEditorFocus($event)"
@ready="onEditorReady($event)"
@click="onEditorClick($event)"
/>
</div>
</template>

<script>
import { quillEditor, Quill } from "vue-quill-editor";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";

import ImageResize from "quill-image-resize-module"; // 图片缩放组件引用
Quill.register("modules/imageResize", ImageResize); // 注册

export default {
name: "QuillEditorComponent",
components: {
quillEditor,
},
props: {
value: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "请输入内容...",
},
readOnly: {
type: Boolean,
default: false,
},
toolbarOptions: {
type: Array,
default: () => [
["bold", "italic", "underline", "strike", "image"],
[{ header: 1 }, { header: 2 }],
[{ list: "ordered" }, { list: "bullet" }],
[{ align: [] }],
["clean"],
],
},
},
data() {
return {
editorContent: this.value,
editorOptions: {
theme: "snow",
modules: {
toolbar: {
container: this.toolbarOptions,
handlers: {},
},
imageResize: {
//放大缩小
displayStyles: {
backgroundColor: "black",
border: "none",
color: "white",
},
modules: ["Resize", "DisplaySize"],
},
},
placeholder: this.placeholder,
readOnly: this.readOnly,
},
z_timer: null,
};
},
watch: {
value(newVal) {
this.editorContent = newVal;
},
editorContent(newVal) {
this.$emit("input", newVal);
},
},
mounted() {
// console.info(this.getEditor());
// console.info(this.getContainer());
this.initImgEvent();
},
methods: {
initImgEvent() {
let container = this.getContainer();
let isDragging = false;
let startX, startY;
let imgStartX, imgStartY;
let imgNode;
container.addEventListener("mousedown", (e) => {
// console.info("mousedown", e.target);
if (e.target.tagName === "IMG") {
imgNode = e.target;
imgNode.style.position = "absolute";
imgNode.style.cursor = "move";

if (!imgNode.style.left) {
imgNode.style.left = "0px";
imgNode.style.top = "0px";
}
isDragging = true;
startX = e.clientX;
startY = e.clientY;
imgStartX = parseInt(imgNode.style.left) || 0;
imgStartY = parseInt(imgNode.style.top) || 0;
e.preventDefault();
}
});
container.addEventListener("mousemove", (e) => {
if (isDragging) {
imgNode.style.left = e.clientX - startX + imgStartX + "px";
imgNode.style.top = e.clientY - startY + imgStartY + "px";
}
});
container.addEventListener("mouseup", () => {
// console.info("mouseup");
isDragging = false;
});
},
onEditorClick() {
let container = this.getContainer();
container.classList.add("focused");
if (this.z_timer) {
clearTimeout(this.z_timer);
}
},
onEditorBlur() {
this.$emit("blur");
let container = this.getContainer();
this.z_timer = setTimeout(() => {
container.classList.remove("focused");
}, 3000);
},
onEditorFocus() {
this.$emit("focus");
let container = this.getContainer();
container.classList.add("focused");
},
onEditorReady() {
this.$emit("ready");
},
getEditor() {
return this.$refs.editorRef.quill;
},
getContainer() {
return this.$refs.editorRef.quill.container.closest(".z-container");
},
},
};
</script>

<style scoped>
.z-container {
border: 1px solid #ccc;
position: relative;
height: 100%;
}

.quill-editor {
height: 100%;
}

.z-container /deep/ .ql-container img {
position: absolute;
left: 0;
top: 0;
cursor: move;
}

.z-container /deep/ .ql-container {
border: none !important;
overflow: hidden;
}

.z-container /deep/ .ql-toolbar {
opacity: 0;
position: absolute;
top: -42px;
pointer-events: none;
transition: opacity 0.2s ease;
}

.z-container.focused /deep/ .ql-toolbar {
opacity: 1;
pointer-events: auto;
}
</style>

属性白名单

富文本编辑器会把我们的HTML中标签的属性都过滤掉

这里我们想保留img标签的style、width、height等属性

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
const BlockEmbed = Quill.import("blots/block/embed");

// 自定义Image格式,允许style属性
class CustomImage extends BlockEmbed {
static create(value) {
const node = super.create(value);
if (typeof value === "string") {
node.setAttribute("src", value);
} else {
// 处理包含style属性的情况
if (value.src) node.setAttribute("src", value.src);
if (value.style) node.setAttribute("style", value.style);
if (value.alt) node.setAttribute("alt", value.alt);
if (value.width) node.setAttribute("width", value.width);
if (value.height) node.setAttribute("height", value.height);
}
return node;
}

static value(domNode) {
return {
src: domNode.getAttribute("src"),
style: domNode.getAttribute("style"),
alt: domNode.getAttribute("alt"),
width: domNode.getAttribute("width"),
height: domNode.getAttribute("height"),
};
}
}

CustomImage.blotName = "image";
CustomImage.tagName = "IMG";

// 注册自定义Image格式
Quill.register({
"formats/image": CustomImage,
});

配置添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
editorOptions: {
theme: "snow",
modules: {
// 扩展白名单,允许img标签的style属性
clipboard: {
matchers: [
[
"img",
(node, delta) => {
// 保留img标签的style属性
const style = node.getAttribute("style");
if (style) {
delta.attributes = delta.attributes || {};
delta.attributes.style = style;
}
return delta;
},
],
],
},
},
},

行高设置插件

添加lineHeight.js

1
2
3
4
5
6
7
8
9
import { Quill } from "vue-quill-editor";
const Parchment = Quill.import("parchment");
class lineHeightAttributor extends Parchment.Attributor.Style {}
const lineHeightStyle = new lineHeightAttributor("lineHeight", "line-height", {
scope: Parchment.Scope.INLINE,
whitelist: ["1", "1.5", "1.75", "2", "3", "4", "5"],
});

export { lineHeightStyle };

引用

1
2
import { lineHeightStyle } from "@/components/QuillEditor/lineHeight";
Quill.register({ "formats/lineHeight": lineHeightStyle }, true);

添加lineHeight

1
2
3
4
5
6
7
8
9
10
toolbarOptions: {
type: Array,
default: () => [
["bold", "italic", "underline", "strike", "image"],
[{ header: 1 }, { header: 2 }],
[{ align: [] }],
[{ lineHeight: lineHeightStyle.whitelist }],
["clean"],
],
},

处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
editorOptions: {
theme: "snow",
modules: {
toolbar: {
container: this.toolbarOptions,
handlers: {
lineHeight: (value) => {
if (value) {
const quill = this.$refs.editorRef.quill;
quill.format("lineHeight", value);
}
},
},
},
}
}

添加样式

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
/deep/ .ql-snow .ql-picker.ql-lineHeight .ql-picker-label::before {
content: "行高";
}

/deep/
.ql-snow
.ql-picker.ql-lineHeight
.ql-picker-item[data-value="1"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="1"]::before {
content: "1";
}
/deep/
.ql-snow
.ql-picker.ql-lineHeight
.ql-picker-item[data-value="1.5"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="1.5"]::before {
content: "1.5";
}

/deep/
.ql-snow
.ql-picker.ql-lineHeight
.ql-picker-item[data-value="1.75"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="1.75"]::before {
content: "1.75";
}
/deep/
.ql-snow
.ql-picker.ql-lineHeight
.ql-picker-item[data-value="2"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="2"]::before {
content: "2";
}
/deep/.ql-snow .ql-picker.ql-lineHeight .ql-picker-item[data-value="3"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="3"]::before {
content: "3";
}
/deep/.ql-snow .ql-picker.ql-lineHeight .ql-picker-item[data-value="4"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="4"]::before {
content: "4";
}
/deep/.ql-snow .ql-picker.ql-lineHeight .ql-picker-item[data-value="5"]::before,
.ql-snow .ql-picker.ql-lineHeight .ql-picker-label[data-value="5"]::before {
content: "5";
}
/deep/.ql-snow .ql-picker.ql-lineHeight {
width: 60px;
}

下滑线插件

方式1

quill-underline-button.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
import { Quill } from "vue-quill-editor";
const Delta = Quill.import("delta");
// 创建自定义模块
export default class UnderlineSpaceButton {
constructor(quill, options) {
this.quill = quill;

// 获取工具栏并添加按钮
let toolbar = quill.getModule("toolbar");
toolbar.addHandler("underlineSpace", this.insertUnderlineSpace.bind(this));
}

insertUnderlineSpace() {
const quill = this.quill;
let bodyHTML = "&nbsp;&#8203;".repeat(20);
let value = quill.clipboard.convert(`<u>${bodyHTML}</u>`); //把html转换成delta格式
let range = quill.getSelection(true); //获取光标位置
const delta = new Delta()
.retain(range.index)
.delete(range.length)
.concat(value);
quill.updateContents(delta, Quill.sources.USER);
}
}

引用注册

1
2
import UnderlineSpaceButton from "./quill-underline-button";
Quill.register("modules/underlineSpaceButton", UnderlineSpaceButton);

添加进toolbar

1
2
3
4
5
6
toolbarOptions: {
type: Array,
default: () => [
["underlineSpace"],
],
},

配置插件及事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
editorOptions: {
theme: "snow",
modules: {
toolbar: {
container: this.toolbarOptions,
handlers: {
underlineSpace: function (value) {
// 这里可以添加额外的处理逻辑
console.log("underlineSpace button clicked", value);
// 默认会调用插件中的 insertUnderlineSpace 方法
}
}
},
underlineSpaceButton: {
// 这里可以传入插件的配置选项,如果有的话
},
}
}

这4个都不能少,否则插件不生效

  • 通过Quill.register('modules/underlineSpaceButton', UnderlineSpaceButton)注册插件
  • toolbarcontainer 配置中添加'underlineSpace'按钮
  • handlers 配置自定义按钮的行为
  • modules 配置中添加underlineSpaceButton选项

添加样式

1
2
3
/deep/ .ql-underlineSpace::before {
content: "__";
}

方式2

添加进toolbar

1
2
3
4
5
6
toolbarOptions: {
type: Array,
default: () => [
["underlineSpace"],
],
},

设置事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
editorOptions: {
theme: "snow",
modules: {
toolbar: {
container: this.toolbarOptions,
handlers: {
// 注册自定义按钮的处理函数
underlineSpace: () => {
// 这里不需要实现具体逻辑,因为插件内部已经处理了
let quill = this.getEditor();
let bodyHTML = "&nbsp;&#8203;".repeat(20);
let value = quill.clipboard.convert(`<u>${bodyHTML}</u>`); //把html转换成delta格式
console.info(value);
let range = quill.getSelection(true); //获取光标位置
const delta = new Delta()
.retain(range.index)
.delete(range.length)
.concat(value);
quill.updateContents(delta, Quill.sources.USER);
}
}
},
}
}

添加样式

1
2
3
/deep/ .ql-underlineSpace::before {
content: "__";
}

白名单

1
2
3
4
// 自定义白名单
const Parchment = Quill.import("parchment");
const Block = Parchment.query("block");
Block.tagName = ["P", "DIV", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "BR"]; // 添加 BR 标签