header-bg.jpg
Vue 中使用 quill.js 封装富文本编辑器组件
发表于 2019-06-24 07:34
|
分类于 JavaScript
|
评论次数 1
|
阅读次数 350

近期在写富文本编辑器相关的内容,我在 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: '请输入正文',
    });
}

到这里一个富文本编辑器就可以使用了,应该是这个样子的:

QQ图片20190624071610.png

好了,本文结束,谢谢浏览。

46fb00014564ddb4904b.gif

哈哈哈,皮了一下,这样用肯定是远远不够的,下面开始正文。

扩展功能

字体大小选择

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 到了两个数组中,这两个数组就是用来做删除功能的。

发布评论
评论
共计 1条评论
最新评论
2019-07-09 17:45:301[河南省郑州市网友]
1
0
0
回复