近期在写富文本编辑器相关的内容,我在 Vue 的项目中使用了 Quill.js。Quill 是一个现代化强大的富文本编辑器,它与其它富文本编辑器(例如 UEditor)不同的地方在于,过去的编辑器操作的数据和展现给用户的视图层是同一份 HTML/DOM,HTML 是树状结构,显然树状结构不如线性结构好处理,而 Quill 内部就是通过使用线性结构的方式使操作富文本编辑器变得简单,而且数据层和视图层是分离的,这让 Quill 对现在很流行的 React、Vue 或者 Angular 都能很好的支持。下面我简单介绍下Quill的使用:
快速上手
使用 npm 安装:
npm install quill
新建一个 editor 组件,模板内容如下:
<template>
<div ref="editor"></div>
</template>
在 script 标签中引入 quill:
<script>
import 'quill/dist/quill.snow.css'
import Quill from 'quill'
</script>
data 中挂载数据:
data: () => ({
toolbar: [
['bold', 'italic'],
['link', 'blockquote', 'code-block', 'image'],
[{ list: 'ordered' }, { list: 'bullet' }]
],
quill: null,
}),
在 mounted 钩子中初始化 quill
mounted() {
this.quill = new Quill(this.$refs.editor, {
theme: 'snow',
modules: {
toolbar: this.toolbar
},
placeholder: '请输入正文',
});
}
到这里一个富文本编辑器就可以使用了,应该是这个样子的:
好了,本文结束,谢谢浏览。
哈哈哈,皮了一下,这样用肯定是远远不够的,下面开始正文。
扩展功能
字体大小选择
quill 自带的字体大小选择有如下 2 种:
[{ 'size': ['small', false, 'large', 'huge'] }]
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
很显然,放在实际应用中显得很不人性化,所以我们需要将字体大小改成 以 px
为单位,引入如下 css:
.ql-snow .ql-picker.ql-size .ql-picker-label::before, .ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: '15px' !important;
font-size: 15px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before {
content: '12px' !important;
font-size: 12px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
content: '14px' !important;
font-size: 14px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="15px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="15px"]::before {
content: '15px' !important;
font-size: 15px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
content: '16px' !important;
font-size: 16px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="17px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="17px"]::before {
content: '17px' !important;
font-size: 17px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
content: '18px' !important;
font-size: 18px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
content: '20px' !important;
font-size: 20px !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
content: '24px' !important;
font-size: 24px !important;
}
然后在初始化 quill 之前,加入如下代码即可:
const Size = Quill.import('attributors/style/size');
Size.whitelist = ['12px', '14px', '15px', '16px', '17px', '18px', '20px', '24px'];
Quill.register(Size, true);
// this.quill = new Quill()...
文字居中方式
quill 默认是使用 class 来设置例如 text-align: center
的样式,class 的方式是比行内要好,但是如果要在前端显示出效果,必须引入他的 css 文件,这是非常麻烦的,比如你还有小程序,移动端都要显示文章,那么每个地方都要引入 css,这肯定不太可能,所以还是直接使用行内 style 的方式是最方便的。
和控制字体大小一样的方式,更改样式的渲染方式也非常简单:
const Align = Quill.import('attributors/style/align');
Align.whitelist = ['right', 'center', 'justify'];
Quill.register(Align, true);
// this.quill = new Quill()...
工具栏 icon 提示文字
quill 工具栏的每个 icon 默认是没有任何提示的,实际应用中这个是必要的功能,例如 GitHub、知乎、微信公众号的富文本编辑器都有这个功能。
这种轮子 Github 上随便找就能找出一堆,我找了一个非常强大的一个轮子:popper.js
popper
是一个 JS 定位引擎,例如下拉菜单、select 表单模拟、提示工具等等,总之一切弹出定位的 UI 组件都可以通过这个引擎实现,而 tooltip
就是 popper
的具体应用。
所以我们需要在工具栏上提示文字,直接使用使用 tooltip
即可。
安装:
npm install tooltip.js --save
引入:
import Tooltip from 'tooltip.js'
在 data 上挂载数据:
data: () => ({
toolbarTips: {
size: '字体大小 (ctrl+B)',
bold: '粗体 (ctrl+B)',
italic: '斜体 (ctrl+I)',
underline: '下划线 (ctrl+U)',
strike: '删除线',
color: '字体颜色',
background: '背景颜色',
link: '插入链接',
blockquote: '引用块',
'code-block': '插入代码',
list: {
ordered: '有序列表',
bullet: '无序列表'
},
image: '上传图片',
video: '上传视频',
align: '对齐方式',
clean: '清除格式',
},
}),
如上,我们可以看到以 key-value 的形式描述了每个 icon 的作用,接着在 mounted 钩子中初始化即可,需要注意的是必须先初始化 quill 再初始化 tooltip:
mounted() {
// this.quill = new Quill()...
let buttons = document.querySelectorAll('.ql-formats>button, .ql-formats>span'),
title = '';
for (let el of buttons) {
title = this.toolbarTips[el.className.split(' ')[0].replace('ql-', '')];
title && new Tooltip(el, {
placement: 'top',
title: '' + title === title ? title : title[el.value]
});
}
}
图片、视频上传功能
因为涉及到文件上传与删除两个部分,所以文件上传功能稍微复杂一点,我的项目是上传至 OSS 中,所以下面介绍如何将图片与视频上传至OSS,并且在编辑器中删除文件后,同步从 OSS 中删除。
首先在 template 中加入,两个 input
<template>
<section>
<div ref="editor" class="editor-box" @drop="drop"></div>
<input ref="image" @change="chooseFile($refs.image.files[0], 'image')" style="display:none" type="file" accept="image/*">
<input ref="video" @change="chooseFile($refs.video.files[0], 'video')" style="display:none" type="file" accept="video/*">
</section>
</template>
接着在初始化后 quill 后,拦截工具栏的 image 和 video 图标的点击事件,每次点击后直接触发相应 input 的 click 事件
mounted() {
// this.quill = new Quill()...
this.quill.getModule('toolbar').addHandler('image', () => {
this.$refs.image.value = '';
this.$refs.image.click();
});
this.quill.getModule('toolbar').addHandler('video', () => {
this.$refs.video.value = '';
this.$refs.video.click();
});
}
在用户选择完视频和图片文件后,触发 change 事件,将相应的 file 对象传递给 chooseFile 方法。
methods: {
chooseFile(file, fileType) {
if (file.type.split('/')[0] !== fileType) {
return this.$message.error('不支持您选择的文件类型,请更换其他文件');
}
this.upload(file);
},
},
如上,虽然定义了 accept 属性,但是为了严谨起见,还是先判断文件的类型,再允许上传文件
上传方法:
methods: {
async upload(file) {
let range = this.quill.getSelection(),
{api} = this,
now = (new Date).toGMTString(),
error = false,
suffix = file.type.split('/')[0],
name = suffix === 'image' ? '图片' : '视频',
msg = this.$message({
showClose: true,
message: `正在上传${name}`,
duration: 0,
}),
auth = await api.get('oss/auth/put', {now, type: file.type, dir: this.dir}).catch(e => {
error = true;
setTimeout(() => msg.close(), 1000);
this.$message({
showClose: true,
message: `获取上传权限失败,${e.message}`,
duration: 0,
type: 'error',
});
});
if (error) return;
const {Authorization, key} = auth,
host = suffix === 'image' ? 'imgHost' : 'videoHost',
cdn = suffix === 'image' ? 'imgCdn' : 'videoCdn';
await api.putFile(api[host] + key, Authorization, now, file).catch(e => {
error = true;
setTimeout(() => msg.close(), 1000);
this.$message({
showClose: true,
message: `${name}上传失败,${e}`,
duration: 0,
type: 'error',
});
});
if (error) return;
let url = api[cdn] + key;
range = range ? range.index : 0;
this.quill.insertEmbed(range, suffix, suffix === 'image' ? url : {
url,
controls: 'controls',
width: '100%',
});
this.quill.setSelection(++range);
this.media.push(url);// 将完整地址保存至数组中,保存文章时对比图片、视频的src,然后在相应的 mediaKeys中取出key,进行删除
this.mediaKeys.push(key);// 将 key 保存至数组中 保存文章时对比 media 元素 删除多余 media
setTimeout(() => msg.close(), 1000);
this.$message.success(`${name}上传成功`);
},
},
上面的代码中,我请求了 2 个 api, 第一个 api 是后台接口,用来获取 oss 的 put 权限,该接口返回 Authorization 和 key 两个参数,这两个参数在上传文件时,传递给 阿里云 oss 的接口,用于权限验证。
PHP 获取 PUT 权限接口示例:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use OSS\OssClient;
use OSS\Core\OssException;
class OssController extends Controller
{
private static $id = 'LTA******';//Access Key ID
private static $key = '5QD******';//Access Key Secret
private static $imgBucket = '******';
private static $videoBucketRaw = '******';
private static $videoBucketOut = '******';
private static $endpoint = 'oss-cn-beijing.aliyuncs.com';
// 用户通过PUT上传图片 在header里设置Authorization字段
public function putAuth(Request $r)
{
$time = time();
$type = $r->get('type');
$dir = $r->get('dir');
!$dir && self::error('请设置上传文件夹');
$now = $r->get('now');
!$dir && self::error('请设置上传时间');
if (strpos($type,'image') === 0) {
$bucket = self::$imgBucket;
} else if (strpos($type,'video') === 0) {
$bucket = self::$videoBucketRaw;
} else {
self::error('文件类型不支持');
}
$suffix = explode('/', $type)[1];
$object = "admin/{$dir}/" . date('Y', $time) . '/' . date('m', $time) . '/' . date('d', $time) . "/{$time}.{$suffix}";
$data = 'PUT' . "\n"
. "\n"
. $type . "\n"
. $now . "\n"
. "x-oss-date:{$now}" . "\n"
. "/{$bucket}/{$object}";
$signature = base64_encode(hash_hmac('sha1', $data, self::$key, true));
$id = self::$id;
self::go([
'Authorization' => "OSS {$id}:{$signature}",
'key' => $object
]);
}
}
JS 上传文件至 OSS 的 putFile
方法:
// OSS文件上传
putFile(host, token, now, blob, progress = false) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest;
xhr.responseType = 'json';
if (progress) {
xhr.upload.onprogress = e => e.lengthComputable && progress(e);// 侦听方法
}
xhr.onload = () => xhr.status === 200 ? resolve() : reject(xhr.response);
xhr.open('PUT', host, true);// 启动PUT请求
xhr.setRequestHeader('Authorization', token);// 设置Auth头部信息 用于验证授权
xhr.setRequestHeader('x-oss-date', now);// 因为无法设置Date头部信息 所以使用x-oss设置请求时间 GMT格式
xhr.send(blob);// 发送图片文件
});
},
putFile 方法接受的第一个参数 host 是 你 OSS 的访问地址,例如 https://xx.oss-cn-beijing.aliyuncs.com/
第二个是时间戳,我在 upload
方法中写出了如何生成该参数。
第三个参数即 file 对象 (blob文件),最后一个是缺省参数,需要传递一个方法,上传进度发生改变时,触发该方法。
图片、视频删除功能
在上传功能 upload 方法的末尾,可以看到我将完整路径和对应的 key 分别 push 到了两个数组中,这两个数组就是用来做删除功能的。