最强富文本编辑器?TinyMCE系列文章【3】

1,543 阅读11分钟

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战

前言

在上一篇最强富文本编辑器?TinyMCE系列文章【2】文章中介绍了如何封装TinyMCE富文本编辑器组件,这次来聊一聊用了富文本组件编辑之后的图文信息文章和增加文件链接如何实现预览的功能需求,这与TinyMCE本身就没多大关系了,但是是由于它的使用引起的纷争。这就是一波未平一波又起,刚把需求实现完,这不此需求延伸出的其他需求又接踵而至,根本不给你喘息的时间。只要你休息,你就会影响老板买豪宅提豪车的时间,这罪过可就大了😛,生活就是如此不易。

需求背景

我就拿我之前提到的知识库的项目来聊聊,因为TinyMCE富文本编辑器组件在这上面使用比较丰富。以后我们都先上图,从图叙述需求是怎样的,便于看文章的小伙伴理解,这样不至于我分享了很多都不知道做这个需求是要做什么,解决什么问题。OK,见下图:

微信截图_20220120203637.png

这就是我们使用TinyMCE富文本编辑器编辑的图文信息,现在需求是需要做到:

1)点击图片可以进行预览
2)将附件的链接复制进编辑器,点击是需要判断文件类型然后进行直接预览或下载之后查看

思考?我相信有小伙伴遇到过富文本的内容需要点击图片进行预览,但是就是不知道你们是怎么做的?然后富文本编辑器的文件链接还能打开?更别说进行预览了;到此肯定会劝退很多小伙伴,其实这就是我们的日常,一旦发现没有做过或者找不到相关示例,就会打退堂鼓。我想说的是完全没有必要,当你有足够的求知欲你就会找到解决问题的方法,你看下面就是我们解决上述需求的效果:

微信截图_20220120204521.png 微信截图_20220120204603.png

开发

开发我们分为预览组件开发和附件组件开发,争取提供的代码和思路可以直接让小伙伴使用或借用。

图片预览组件

图片预览组件我们选用的是react-viewer,这个插件值得推荐,我们项目中图片预览的所有需求都是使用此插件,它提供的功能很强大也很灵活,根据你的项目需求可以配置所需功能。至于如何使用react-viewer插件你可以自己去学习,如果不清楚我可以编写一篇文章介绍react-viewer插件的使用方法。

image.png

安装react-viewer插件:

yarn add react-viewer

思考一个问题:富文本编辑器生成的内容在编辑时我们无法增加点击事件,那我们怎么做到点击图片可以触发预览组件呢?

其实这个问题在我之前这篇文章2022第一次更文:前端项目实战中的3种Progress进度条效果就提到过,可以使用React提供的特殊属性Children,把富文本编辑器内容作为图片预览的Children,相当于我们的封装的图片预览组件包裹富文本编辑器的内容,然后我们就可以为富文本内容添加事件,也就能实现可以点击图片的功能了。

先上图片预览组件的代码。创建ImageViewer.tsx文件:

import React, {ReactElement, useState} from "react";
import Viewer from "react-viewer";
import {filePreview} from "../attachment/Attachment";
import './style.less';


interface ImageViewerProps {
    className?: string; // 类名
    children?: ReactElement;
    disabled?: boolean; // 禁用预览
    attachmentDisabled?: boolean; // 附件预览禁用标识
}


/**
 * 图片预览组件
 * @param props
 * @constructor
 */
const ImageViewer = (props: ImageViewerProps) => {
    const { className, children, disabled = false, attachmentDisabled = false } = props;
    const [imgList, setImgList] = useState<any[]>([]);
    const [viewerVisible, setViewerVisible] = useState<boolean>(false);


    /**
     * 点击图片预览大图
     * @param e
     */
    const imgPreview = (e: any) => {
        if (disabled) {
            return;
        }
        e.stopPropagation();
        const { target } = e;
        // 自定义附件超链接
        if (!attachmentDisabled && target.getAttribute('data-editor-link') === 'true') {
            e.preventDefault();
            filePreview(target.getAttribute('data-editor-link-text'), target.getAttribute('href'));
            return;
        }
        if (target && target.tagName.toLowerCase() == 'img') {
            setImgList([{src: target.src}]);
            setViewerVisible(true);
        }
    }


    return (
        <>
            <div className={className} onClick={imgPreview}>
                {children}
            </div>
            <Viewer
                className="img-viewer-component"
                visible={viewerVisible}
                onClose={() => setViewerVisible(false) }
                images={imgList}
                zIndex={2000}
            />
        </>
    )
}


export default ImageViewer;

创建style.less样式文件:

.img-viewer-component {
    transition-duration: .01s !important;
}

其实样式文件可要可不要,我们项目需要设置过渡效果,所以需要创建一个样式文件进行设置。

上述代码就是图片预览组件的全部代码,如果细心的小伙伴可以看到,我们这个组件还依赖了一个Attachment组件中 的文件预览的filePreview方法,上面说需求就提到过,用户是可以将文件上传后的链接粘贴进了富文本编辑器的,以至于生成的富文本内容就会有文件超链接,而我们封装的预览组件又是包裹富文本编辑器内容的,所有文件超链接也就有点击事件,所以我们就要根据文件链接的url判断文件类型,如果符合我们允许的文件类型就可以进行后续的附件预览操作。

if (!attachmentDisabled && target.getAttribute('data-editor-link') === 'true') {
    e.preventDefault();
    filePreview(target.getAttribute('data-editor-link-text'), target.getAttribute('href'));
    return;
}

小伙伴也有可能不理解这段代码,其实它的意思是我们点击的这个<a>标签是不是我们附件设置的特有<a>标签,即<a>标签是否有data-editor-link属性,如果是则去直接新开浏览器进行预览,否则就提示无法预览。这部分逻辑和代码在下面的附件预览组件封装中可以看到,两个组件需要配合使用,但是如果不想配合使用,可以从上面代码看到我们也做了处理,就是加了一个attachmentDisabled属性,不需要附件预览的相关功能的就直接传入true就不会走附件相关的逻辑了。

我想图片预览这个组件应该是讲清楚了,既然封装好了,那就看下面的使用方法:

1)引入组件
import ImageViewer from "../../component/imageViewer/ImageViewer";

2)使用组件:
<ImageViewer className="bit-html-content">
    <div dangerouslySetInnerHTML={{__html: logContent}}></div>
</ImageViewer>

对,就是这么简单,引入使用就完事了,就是这么得劲👍👏

附件预览组件

继续趁热打铁,否则就走远了👻。直接上代码:

创建attachment.tsx文件:

import React, {useState} from "react";
import {ObjTest} from "../../util/common"; // 工具方法
import {Button, message, Modal, Tag} from "antd";
import {
    PaperClipOutlined,
    VerticalAlignBottomOutlined,
} from '@ant-design/icons';
import {fileDownload} from "../../util/request"; // 请求方法的封装
import {globalUploadAPI} from "../../config/api"; // 配置的API接口地址
import './style.less';



// 可以预览的文件类型
const previewFileTypes = ['txt', 'pdf', 'jpg', 'jpeg', 'png', 'gif'];


interface DataSource {
    name: string;   // 附件名称(带文件后缀)
    url: string;    // 附件下载地址
}


interface AttachmentProps {
    className?: string;
    contentClassName?: string;
    dataSource: DataSource[];
}


/**
 * 下载弹窗组件
 * @param name
 * @param url
 * @constructor
 */
const DownLoadModal = ({name, url, modal}) => {
    const [loading, setLoading] = useState<boolean>(false);


    /**
     * 下载文件
     * @param name
     * @param url
     */
    const download = (name: string, url: string) => {
        setLoading(true);
        fileDownload('/download',
            {fileName: name, fileUrlPath: url},
            {baseURL: globalUploadAPI}).then((res: any) => {
            modal.destroy();
            modal = null;
            message.success('下载成功');
        }).finally(() => {
            setLoading(false);
        })
    }


    return (
        <div className="flexic flexc mt30">
            <Button
                className="button-red"
                size="small"
                style={{width: 100}}
                icon={<VerticalAlignBottomOutlined />}
                loading={loading}
                onClick={() => download(name, url)}
            >下载</Button>
        </div>
    )
}



/**
 * 预览
 * @param url
 */
export const filePreview = (name: string, url: string) => {
    if (!url) {
        message.error('无效下载链接');
        return;
    }
    // 获取文件类型
    let fileType = url.split('.').slice(-1)[0];
    // txt、pdf文件可新开页面预览
    if (previewFileTypes.includes(fileType)) {
        window.open(url, '_blank')
        return;
    }
    let modal = Modal.confirm({});
    modal.update({
        className: 'attachment-download-modal',
        title: '该文件无法预览,请下载后查看',
        closable: true,
        centered: true,
        icon: null,
        content: <DownLoadModal name={name} url={url} modal={modal} />
    })
}


export default Attachment;

创建style.less样式文件:

.custom-attachment-uploader {
    .ant-upload-list-text-container {
        transition: none;
    }
    .link-btn {
        font-size: 14px;
        color: #0A89FF;
    }
    .close-btn {
        color: #ff4d4f;
    }
}
.attachment-download-modal {
    .ant-modal-confirm-btns {
        display: none;
    }
}
.attachement-group-list {
    .attachment-list-item {
        font-size: 14px;
        font-weight: 400;
        color: #545FD6;
        line-height: 30px;
        letter-spacing: 1px;
        span:hover {
            cursor: pointer;
            text-decoration: underline;
        }
    }
}

附件组件封装里面包含:DownLoadModal下载弹窗组件和一个filePreview文件预览方法。主要就是当文件链接不是允许的预览文件类型就使用DownLoadModal组件提示用户进行下载之后查看。

微信截图_20220121160155.png

至此使用TinyMCE富文本编辑器延伸出预览图片和文件链接需求的主要代码就是上面所呈现的,有需要的同学可以直接使用。至于代码中一些配置如果你是用心把文章从头读到尾的,那一定会进行相应的配置就能实现和我一样的效果,我是出于尽量让小伙伴们直接复制过去就能使用的初心写的这些文章,但如果想完全掌握那就得认真研读文章和代码,这样一定会有收获。

基础方法工具

上面文章中有一些方法我们是经过封装了的,所以小伙伴们看到的都是我们直接引用,为了避免小伙伴们直接复制过去不能使用,我还是决定把相应的代码贴出,让小伙伴们一劳永逸而不至于来骂我说不能使用,误人子弟。

上面提到设置特有带data-editor-link属性的<a>标签是以下代码,其实就是我们使用复制粘贴CopyToClipboard组件将文件链接设置为特有的<a>标签方便我们区分是文件链接还是富文本插件添加的超链接。

<CopyToClipboard
    text={`<a data-editor-link="true" data-editor-link-text="${v.name}" href="${v.url}" target="_blank">${v.name}</a>`}
    onCopy={() => message.success({content: '复制成功', duration: 1})}
>
    <span className="link-btn plr15 hcp">复制</span>
</CopyToClipboard>

其他工具代码:

/**
 * 判断变量类型
 * @param target
 * @param {objType} type
 * @returns {boolean}
 */
export const ObjTest = new class {
    private str: string = '';

    private getTargetStr(target) {
        this.str = Object.prototype.toString.call(target).toLowerCase();
    }

    // 对象
    isObj(target: any) {
        this.getTargetStr(target);
        return this.str === '[object object]';
    }
    // 函数
    isFunc(target: any) {
        this.getTargetStr(target);
        return this.str === '[object function]';
    }
    // 数组
    isArray(target: any) {
        this.getTargetStr(target);
        return this.str === '[object array]';
    }
    isNotEmptyArray(target: any) {
        this.getTargetStr(target);
        if (this.isArray(target) && target.length > 0) {
            return true;
        }
        return false;
    }
    // 字符串
    isStr(target: any) {
        this.getTargetStr(target);
        return this.str === '[object string]';
    }
    // 数字
    isNumber(target: any) {
        this.getTargetStr(target);
        return this.str === '[object number]';
    }
    // 未定义
    isUndefined(target: any) {
        this.getTargetStr(target);
        return this.str === '[object undefined]';
    }
    // 空指针
    isNull(target: any) {
        this.getTargetStr(target);
        return this.str === '[object null]';
    }
    // 无效数字
    isNan(target: any) {
        this.getTargetStr(target);
        return this.str === '[object number]' && isNaN(target);
    }
    // 有效json字符串
    isJSONStr(target: any) {
        if (this.isStr(target)) {
            try {
                let res = JSON.parse(target);
                if (this.isObj(res) || this.isArray(res)) {
                    return true;
                }
                return false;
            }catch (e) {
                return false;
            }
        }
        return false;
    }
    // 判断是否空对象,不是Object类型的均返回true
    isEmptyObject(target: any) {
        if (this.isObj(target) && Object.keys(target).length > 0) {
            return false;
        }
        return true;
    }
}
/**
 * 文件下载接口
 * @param file
 * @param key
 * @param fileName
 */
export function fileDownload(url: string, body: any, options: IRequestOptions = null) : Promise<any> {
    const { method = 'post', withToken = true } = options;
    return instance({
        url,
        data: (method.toLowerCase() == 'post' ? body : {}),
        params: (method.toLowerCase() == 'get' ? body : {}),
        method: 'post',
        baseURL: globalAPI,
        timeout: 200000,
        headers: withToken ? getHeaders() : '',
        responseType: 'blob',
        // 自定义参数序列化方法
        paramsSerializer: (params: any) => {
            return encodeGetParams(params);
        },
        ...options,
    })
        .then(res => {
            // 获取文件名
            const { filename } = res.headers;

            // 兼容IE和Edge
            if ("ActiveXObject" in window || navigator.userAgent.indexOf("Edge") > -1) {
                window.navigator.msSaveBlob(res.data, decodeURIComponent(filename));
                return res;
            }

            let reader = new FileReader()
            reader.readAsDataURL(res.data)
            reader.onload = (e: any) => {
                let a = document.createElement('a');
                a.download = decodeURIComponent(filename);
                a.href = e.target.result
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
            }
            return res;
        });
}

至此,TinyMCE编辑器的介绍,使用,封装和延伸出的预览需求都聊完了,我相信如果小伙伴们看完这个系列文章会有一定的收获,那我写这个系列文章的目的也就达到了。这个系列文章都是我实际工作中的真实需求做完上线之后累积的知识,对没有用过TinyMCE富文本编辑的小伙伴来说,在以后的工作中多了一种新选择。其实这里还有一个延伸的思考,但是我们需求没有要求这样去做,但是我觉得有扩展的意义,那就是如何做到点击图片把所有图片捡出来放进react-viewer插件里左右切换查看?

细心的小伙伴可以看到我们点击图片之后只为react-viewer插件设置了一张图片,也就是点一张看一张,所以我们在这里可以写个方法将内容中的img标签的图片地址全部获取到,设置images是一个图片地址集合就能达到点一张图片切换预览所有图片了。至于方法这里就不提供了,只提供思想。

微信截图_20220121163243.png

最后,感谢看完整个系列文章,3Q!后续将会推出工作中的其他组件封装方法,敬请期待。

往期精彩文章

后语

伙伴们,如果觉得本文对你有些许帮助,点个👍或者➕个关注在走呗^_^ 。另外如果本文章有问题或有不理解的部分,欢迎大家在评论区评论指出,我们一起讨论共勉。