quill 编辑器图像处理

quill 编辑器图像处理

如何找到对应位置的源码。 目前我是无法直接找到的,
我是通过搜索网上的答案或者 debugger 才能够对应源码,
我做不到短时间内了解一个库的完全流程,所以看我文章的人也不必焦虑。慢慢debugger
了解部分源码就行

quill 为什么粘贴图片时为

base64

项目使用的版本是1.3.7

quilljs/quill
at 1.3.7 (github.com)

https://github.com/quilljs/quill/blob/0148738cb22d52808f35873adb620ca56b1ae061/modules/clipboard.js#L60

1
this.quill.root.addEventListener('paste', this.onPaste.bind(this));

https://github.com/quilljs/quill/blob/0148738cb22d52808f35873adb620ca56b1ae061/modules/clipboard.js#L108

1
2
3
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(); //(会将图片粘贴到剪贴板元素, 参考下面代码#1) this.quill.selection.update(Quill.sources.SILENT); setTimeout(() => {
delta = delta.concat(this.convert()).delete(range.length); this.quill.updateContents(delta, Quill.sources.USER); // range.length contributes to delta.length() this.quill.setSelection(delta.length() - range.length, Quill.sources.SILENT); this.quill.scrollingContainer.scrollTop = scrollTop; this.quill.focus(); }, 1); }

粘贴的时候核心代码在这上面。

#1

1
2
3
4
5
<div class="container">        <div class="editor" contenteditable="true" style="width: 200px;height: 200px;"></div>        <div class="clipboard" contenteditable="true"></div>    </div>    <script>        const editorEl = document.querySelector('.editor');        const clipboardEl = document.querySelector('.clipboard')
editorEl.addEventListener('paste', () => {
clipboardEl.focus()
})
</script>

image-20230312114008717

匹配到了这段代码

1
2
3
4
5
6
7
function matchBlot(node, delta) {
var match = _parchment2.default.query(node); if (match == null) return delta; if (match.prototype instanceof _parchment2.default.Embed) {
var embed = {}; var value = match.value(node); if (value != null) {
embed[match.blotName] = value; delta = new _quillDelta2.default().insert(embed, match.formats(node)); }
} else if (typeof match.formats === 'function') {
delta = applyFormat(delta, match.blotName, match.formats(node)); }
return delta;}

获取剪贴板里面的元素转换为 delta。然后将剪贴板的元素情况。 更新quill
里面的内容。就将图片插入进去了。

回到为什么是 base64图片。 原因就在于浏览器粘贴的时候用的就是
base64.🥳

是不是有点废话。这一段分析是为了下面处理图片做铺垫的~

粘贴图片上传图片

Clipboard
Module - Quill Rich Text Editor (quilljs.com)

quill 已经提供了接口。 所以我们只需要配置一下就可以了, 在 quill 的
option 里面做如下配置。 不只是粘贴图片。 quill的 toolbar
也有一个上传图片的功能。可以通过 options 里的handles
配置,这里不讨论。

1
2
3
clipboard: {
matchers: [
['img', this.noPastingPictures], ],},

noPastingPictures(上传 base64图片) 部分代码

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
// 黏贴图片base64改为上传urlnoPastingPictures(node, delta) {
const urlReg = /^https?:\/\/(([a-zA-Z0-9_-])+(\.)?)*(:\d+)?(\/((\.)?(\?)?=?&?[a-zA-Z0-9_-](\?)?)*)*$/i;
if (delta && delta.ops) {
delta.ops.forEach(item => {
if (item.insert && item.insert.image) {
if (item.insert.image.includes('data:image')) { // base64 类型的图片,上传替换 this.handleBase64Image(item); } else if (!urlReg.test(item.insert.image)) { // 不是网址类型的图片。直接报错 this.imageError(); }
}
});
}
return delta;
}, handleBase64Image(data) {
const {
image
} = data.insert;
try {
const file = this.dataURItoBlob(image);
file.name = new Date().getTime();
this.copyPicture(image, file);
} catch {
this.imageError();
}
}, async copyPicture(image, file) {
this.loadingCount++;
try {
const fileUrl = await (new FileUpload())
.uploadStart([file]);
await this.loadImage(fileUrl[0].url);
this.content = this.content.replace(image, fileUrl[0].url);
} catch {
this.$message.error('图片上传失败');
} finally {
this.loadingCount--;
}
}, imageError() {
this.$message.error('粘贴图片失败。请单独选中原图复制粘贴');
}, loadImage(url) {
const image = new Image();
return new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = url;
});
},

粘贴图片的时候你会发现 windows
从资源管理器里面粘贴的图片不显示。macos11从资源管理器里面粘贴的图片是一个文件图标,但是
macos12正常。
这就是系统上面的处理方法不同的。如果需要兼容从资源管理器里面粘贴图片。
可以自定义 quill 的 paste 事件。 读取对应的 paste
数据。转成图片。我选择的是通过拖动图片进行处理

拖动图片上传

1
2
3
4
5
6
7
8
bindDropEvent() {
const quillEl = this.$refs.quill.quill.root; quillEl.addEventListener('drop', this.dropHandle, false); this.$once('hook:beforeDestroy', () => {
quillEl.removeEventListener('drop', this.dropHandle); }); }, // 处理拖动上传 dropHandle(e) {
e.preventDefault(); if (!e.dataTransfer.files.length) {
return; }
const file = e.dataTransfer.files[0]; const type = file.type.split('/')[0]; if (type !== 'image') {
return; }
this.copyPicture(file); },

是从 vue项目代码里面拷贝过来的,将就看一下。
大概流程是拖动图片获取对应的数据, 上传然后插入。这里有个
bug,就是编辑器里面没有内容的时候,
需要focusc才能插入。而且会插入一个空行。

第三方解决方案

EthanYan6/quill-image-super-solution-module: [quill,image,upload,paste,drop,extend…]功能最全!实现最完美!体积最小!解决同类型插件的所有bug!此模块为富文本编辑器
vue-quill-editor
的专用插件,为其提供了自定义上传图片到服务器、粘贴图片上传至服务器、拖拽图片上传至服务器的功能。支持与其他模块一起使用。
(github.com)

这是我在 github 上面找的。

我觉得这个库有一点可以优化, 就是自定义上传函数。
而不是通过库去请求上传地址。

vue3 图片上传

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
196
197
198
199
200
201
202
203
204
205
<template>
<div class="quill-editor-wrapper">
<div ref="editorRef"></div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import { useGetUploadSign } from '@/composables/common';
import { UPLOAD_SCENES } from '@/constant/api';
import axios from 'axios';
import { ElMessage } from 'element-plus';

const BlockEmbed = Quill.import('blots/block/embed');

class ImageBlot extends BlockEmbed {
static blotName = 'customImage';
static tagName = 'div';
static className = 'custom-image-wrapper';

static create(value: any) {
const node = super.create(value) as HTMLElement;

node.setAttribute('data-url', value.url || '');
node.setAttribute('data-width', value.width || '');
node.setAttribute('data-height', value.height || '');
node.setAttribute('data-size', value.size || '');
node.setAttribute('data-name', value.name || '');
node.setAttribute('contenteditable', 'false');

const img = document.createElement('img');
img.setAttribute('src', value.url);
if (value.width) img.style.width = value.width + 'px';
if (value.height) img.style.height = value.height + 'px';

node.appendChild(img);
return node;
}

static value(node: HTMLElement) {
return {
url: node.getAttribute('data-url') || '',
width: node.getAttribute('data-width') || '',
height: node.getAttribute('data-height') || '',
size: node.getAttribute('data-size') || '',
name: node.getAttribute('data-name') || ''
};
}
}

Quill.register(ImageBlot);

const editorRef = ref<HTMLElement>();
let quill: Quill | null = null;

const PLACEHOLDER_IMAGE = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="300" height="200"%3E%3Crect width="300" height="200" fill="%23f0f0f0"/%3E%3Ctext x="150" y="100" text-anchor="middle" dy=".3em" fill="%23999" font-size="16"%3E上传中...%3C/text%3E%3C/svg%3E';

const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
img.src = URL.createObjectURL(file);
});
};

const imageHandler = async () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();

input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;

if (!file.type.startsWith('image/')) {
ElMessage.warning('请选择图片文件');
return;
}

if (!quill) return;

const range = quill.getSelection(true);

const placeholderId = `placeholder-${Date.now()}`;
quill.insertEmbed(range.index, 'customImage', {
url: PLACEHOLDER_IMAGE,
width: 300,
height: 200,
size: '',
name: placeholderId
});
quill.setSelection(range.index + 1, 0);

try {
const dimensions = await getImageDimensions(file);
const { sign, formData } = await useGetUploadSign(file, UPLOAD_SCENES.ACTIVITY_ENTRY);
await axios.post(sign.host, formData);

const editor = quill.root;
const placeholderNode = editor.querySelector(`[data-name="${placeholderId}"]`) as HTMLElement;

if (placeholderNode) {
placeholderNode.setAttribute('data-url', sign.url);
placeholderNode.setAttribute('data-width', dimensions.width.toString());
placeholderNode.setAttribute('data-height', dimensions.height.toString());
placeholderNode.setAttribute('data-size', file.size.toString());
placeholderNode.setAttribute('data-name', file.name);

const img = placeholderNode.querySelector('img');
if (img) {
img.setAttribute('src', sign.url);
img.style.width = '';
img.style.height = '';
}
}

ElMessage.success('图片上传成功');
} catch (error) {
ElMessage.error('图片上传失败');
console.error(error);
}
};
};

onMounted(() => {
if (!editorRef.value) return;

quill = new Quill(editorRef.value, {
theme: 'snow',
modules: {
toolbar: {
container: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
['link', 'image'],
['clean']
],
handlers: {
image: imageHandler
}
}
},
placeholder: '请输入内容...'
});
});

onBeforeUnmount(() => {
// @ts-ignore
if (quill && quill.destroy) {
//@ts-ignore
quill.destroy();
}
quill = null;
});

const getHtml = (): string => {
return quill?.root.innerHTML || '';
};

const setHtml = (html: string) => {
if (quill) {
quill.root.innerHTML = html;
}
};

defineExpose({
getHtml,
setHtml
});
</script>

<style scoped>
.quill-editor-wrapper {
min-height: 400px;
}

.quill-editor-wrapper :deep(.ql-container) {
min-height: 360px;
}

.quill-editor-wrapper :deep(.custom-image-wrapper) {
display: block;
margin: 10px 0;
text-align: center;
}

.quill-editor-wrapper :deep(.custom-image-wrapper img) {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
</style>