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

3,832 阅读11分钟

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

前言

在上一篇最强富文本编辑器?TinyMCE系列文章【1】介绍了最强富文本编辑器TinyMCE的配置和使用方法,这次我来聊聊在项目中怎么去封装TinyMCE编辑器成一个公用的富文本组件,并提供给组内小伙伴在不同页面调用富文本编辑器来实现各自需求的。其实TinyMCE本身就是别人封装好的插件,只是在我们实际使用中,感觉它的封装显得不那么友好,在我们代码中也显得冗余繁琐,故需要我们二次封装,使其能更好的满足实际需求。

需求背景

给你们看看设计图,一下就能明白我们的需求是怎样的:

微信截图_20220119195503.png

如上图所示,我们需要做一个记录项目日志的表格,其中日志内容、备注和自定义列都需要支持图文编辑,链接添加,表格样式,特殊字符等,并且日志内容的编辑器需要现实工具栏等必须插件功能,备注和自定义列不需要工具栏,但要支持图文编辑和链接输入。(其实这个项目日志需求还有根据滚动到顶获取该行日志时间改变表头日期;往下滚动触底自动加载数据;选择日期需定位到顶等不简单的需求,但是这里不做延伸,只阐述富文本相关的需求,后续文章进行拆分叙述。)

关于富文本的需求就是:一个表里需要两套富文本编辑器,一种带工具栏一种无工具栏。这不就是TinyMCE支持的三种模式中的其中两种么:无工具栏即内联模式,带工具栏即基础模式

好,需求理解清楚我们就开始来搞一搞,看看TinyMCE到底能不能行。

开发

封装富文本编辑器时我也是把它搞成的是受控组件。因为在我们项目中编辑表格行都是在表格外面包了一层Form表单的,这样我们在提交数据时就能像表单一样提交,最主要的是使用表单是由于它是同步的,这样我们就不用考虑数据异步的问题了。

那先来搞一搞无工具栏的,先把简单的搞定,有工具栏不就分分钟得事么,开整!

内联模式封装

创建SimpleEditor.tsx文件:

import React from "react";
import {Editor} from '@tinymce/tinymce-react';
import {IAllProps} from "@tinymce/tinymce-react/lib/es2015/main/ts";



/**
 * 无工具栏富文本编辑器组件
 * @param props
 * @constructor
 */
const SimpleEditor = (props: any) => {
    const { onChange = (content: string) => {} } = props;


    /**
     * 内容变更
     * @param content
     * @param editor
     */
    const editorChange = (content: string, editor: any) => {
        onChange(content);
    }

    
    /**
     * 获取属性
     */
    const getProps = (): Omit<IAllProps, 'onChange'> => {
        let newProps = {...props};
        delete newProps.onChange;
        return newProps;
    }


    return (
        <Editor
            {...getProps()}
            init={{
                language: 'zh_CN',
                plugins: [
                    'autolink',
                    'powerpaste',
                ],
                default_link_target: '_blank',
                statusbar: false,
                menubar: false,
                toolbar: '',
                font_formats: '宋体;仿宋;微软雅黑;黑体;仿宋_GB2312;楷体;隶书;幼圆',
                content_css: '/editor/content.css',
                body_class: 'bit-tinymce-content',
            }}
            onEditorChange={editorChange}
        />
    )
}


export default SimpleEditor;

public文件创建editor文件夹,并创建content.css文件作为编辑区的基础样式:

body {
    font-family: arial, helvetica, sans-serif;
    font-size: 12px;
    font-size-adjust: none;
    font-stretch: normal;
    font-style: normal;
    font-variant: normal;
    font-weight: normal;
    color: #313C42;
    overflow: auto !important;
}

body, ul, ol, dl, dd, h1, h2, h3, h4, h5, h6, p, form, fieldset, legend, input, textarea, select, button, th, td {
    margin: 0;
    padding: 0;
}

h1, h2, h3, h4, h5, h6 {
    font-size: 100%;
    font-weight: normal;
}

table {
    font-size: inherit;
}

input, select {
    font-family: arial, helvetica, clean, sans-serif;
    font-size: 100%;
    font-size-adjust: none;
    font-stretch: normal;
    font-style: normal;
    font-variant: normal;
    font-weight: normal;
    line-height: normal;
}

button {
    overflow: visible;
}

li {
    list-style-image: none;
    list-style-position: outside;
    list-style-type: none;
}

img, fieldset {
    border: 0 none;
}

ins {
    text-decoration: none;
}

body::-webkit-scrollbar {
    width: 3px;
    height: 3px;
}

body::-webkit-scrollbar-track {
    background: #D8D8D8;
    border-radius: 2px;
}

body::-webkit-scrollbar-thumb {
    background: #ccc;
    border-radius: 2px;
}

body::-webkit-scrollbar-thumb:hover {
    background: #ccc;
}

body::-webkit-scrollbar-corner {
    background: #ccc;
}

从样式文件可以看到,我把滚动条的样式也改变了,这主要是我们需求需要改变滚动条样式,若你的需求没做要求可以不添加改变滚动条的样式代码。

使用方法:

1)导入组件:
import SimpleEditor from "../../component/simpleEditor/SimpleEditor";

2)使用组件:
<Form form={form}>
    <Form.Item
        className="m0"
        name={[pkid, 'logRemark']}
        rules={[
            {
                validator: (rule: any, value: any) => {
                    if (ObjTest.isStr(removeHTML(value)) && removeHTML(value).trim().length > 500) {
                        return Promise.reject(`内容不可超过500字`);
                    }
                    return Promise.resolve();
                }
            },
        ]}
    >
        <SimpleEditor />
    </Form.Item>
</Form>

无工具栏的富文本组件封装就是上述代码,其实现在来整理这个系列文章时才发现多此一举了,TinyMCE本身提供的内联模式就可以实现无工具栏的效果,但是我们也还需二次封装一下才去使用,毕竟我们目的是要使用它并让它成为受控组件配合Form表单组件一起使用,所以还是不能直接拿来使用,这下小伙伴们可以复制我这个代码直接去项目中使用了。

基础模式封装

有了上面内联模式封装的经验,那基础模式封装无非就是增加一些必要的插件和处理图片上传和文件这些限制条件了。

创建TinymceEditor.tsx文件:

import React, {useEffect, useRef, useState} from "react";
import {message} from "antd";
import {fileUpload} from "../../util/request";
import { Editor } from '@tinymce/tinymce-react';
import {IAllProps} from "@tinymce/tinymce-react/lib/es2015/main/ts";
import './editor.less';


// 许可图片地址前缀
const VALID_IMG_SRC = [    'http://xxx.xxx.local',    'http://xxx-test.xxx.local',];


interface TinymceEditorProps extends Omit<IAllProps, 'onChange'> {
    onChange?: (content: string) => void;
}


/**
 * tinymce富文本编辑器组件
 * 注意: content_css指向的样式文件(content.css)需在index.html页面引入, 以便编辑状态和浏览状态呈现相同样式
 * @param props
 * @constructor
 */
const TinymceEditor = (props: TinymceEditorProps) => {
    const { init = {}, onChange } = props;


    /**
     * 图片上传
     * @param data
     */
    const imgUpload = (data: any) => {
        const { type } = data;
        const fileTypes = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif'];
        if (!fileTypes.includes(type)) {
            const msg = '只允许上传jpg,jpeg,png,gif格式的图片';
            message.error(msg);
            return Promise.reject(msg);
        }
        return fileUpload('/upload/image/sample', data, 'file');
    }


    /**
     * 检查图片地址是否是外链
     * @param args
     */
    const validateImgSrc = (node: any): boolean => {
        let imgs = node.querySelectorAll('img') || [];
        let src = [];
        imgs.forEach((v: any) => {
            src.push(v.src);
        })
        // 只验证以http:// 或 https:// 开头的地址, 有任意一个地址不在许可图片地址内的,则验证失败
        let res = src.filter((v: any) => v.indexOf('http://') === 0 || v.indexOf('https://') === 0).some((v: any) => {
            return !VALID_IMG_SRC.some((item: any) => v.includes(item));
        });
        if (res) {
            return false;
        }
        return true;
    }


    /**
     * 移除无法通过验证的图片结点
     * @param node
     */
    const removeImgNode = (node: any) => {
        let imgs = node.querySelectorAll('img');
        imgs.forEach((img: any) => {
            if (VALID_IMG_SRC.some((v: any) => img.src.includes(v))) {
                return;
            }
            img.remove();
        })
    }


    /**
     * 内容变更
     * @param content
     * @param editor
     */
    const handleEditorChange = (content, editor) => {
        onChange(content);
    }


    const getProps = (): Omit<IAllProps, 'onChange'> => {
        let newProps = {...props};
        delete newProps.onChange;
        return newProps;
    }


    return (
        <Editor
            {...getProps()}
            init={
                {
                    language: 'zh_CN',
                    plugins: [
                        'advlist autolink lists link charmap print preview anchor',
                        'searchreplace visualblocks code fullscreen',
                        'insertdatetime media table code help wordcount image imagetools codesample',
                        'quickbars autoresize',
                        'powerpaste', // plugins中,用powerpaste替换原来的paste
                    ],
                    powerpaste_word_import: 'propmt',// 参数可以是propmt, merge, clear,效果自行切换对比
                    powerpaste_html_import: 'propmt',// propmt, merge, clear
                    powerpaste_allow_local_images: true,
                    paste_data_images: true,
                    file_picker_callback: (callback, value, meta) => {
                        let input = document.createElement('input');
                        input.setAttribute('type', 'file');
                        input.onchange = (e) => {
                            imgUpload(input.files[0]).then(res => {
                                callback(res.httpUrl, {alt: res.httpUrl});
                            })
                        }
                        input.click();
                    },
                    images_upload_handler: function (blobInfo, success, failure) {
                        imgUpload(blobInfo.blob()).then(res => {
                            success(res.httpUrl);
                        })
                    },
                    paste_postprocess: (plugin: any, args: any) => {
                        const {node} = args;
                        const {innerText = ''} = node;

                        // 验证图片地址
                        if (!validateImgSrc(node)) {
                            removeImgNode(node);
                            message.warning('无法粘贴带有外链的图片(因链接失效或被屏蔽可能导致图片无法正常显示),请先把网页上的图片保存到本地后再上传', 3);
                            return;
                        }

                        // 当检测到粘贴到编辑器的字符串带有特定标识时,将该字符串转换为超链接插入
                        if (innerText.includes('data-editor-link="true"')) {
                            node.innerHTML = node.innerText;
                        }
                    },
                    min_height: 500,
                    statusbar: false,
                    toolbar_mode: 'wrap',
                    content_css: '/editor/content.css', // 可从style.less编译获得
                    body_class: 'tinymce-content',
                    menubar: false,
                    image_caption: false,
                    image_title: true,
                    image_dimensions: false,
                    target_list: false,
                    default_link_target: '_blank',
                    quickbars_image_toolbar: 'alignleft aligncenter alignright | imageoptions',
                    block_formats: 'Paragraph=p; Header 1=h1; Header 2=h2; Header 3=h3; Header 4=h4; Header 5=h5; Header 6=h6',
                    fontsize_formats: '8px 10px 14px 16px 18px 20px 22px 24px 26px 28px 32px 48px',
                    font_formats: '宋体;仿宋;微软雅黑;黑体;仿宋_GB2312;楷体;隶书;幼圆',
                    toolbar:
                        'formatselect | \ \
                         bold italic underline strikethrough forecolor backcolor removeformat | \
                         bullist numlist | \
                         outdent indent | \
                         image table | \
                         alignleft aligncenter alignright alignjustify | \
                         link charmap | \
                         undo redo preview',
                    quickbars_selection_toolbar: 'bold italic | formatselect | blockquote',
                    ...init,
                }
            }
            onEditorChange={handleEditorChange}
        />
    )
}


export default TinymceEditor;

创建editor.less文件:

使用position: sticky !important固定编辑器表头,编辑区样式还是使用public文件中的content.css文件。

.tox.tox-tinymce {
    overflow: visible;
    .tox-editor-container {
        overflow: visible;
    }
    .tox-editor-header {
        position: sticky !important;
        top: 0;
        z-index: 10;
    }
}

使用方法:

1)导入组件:
import TinymceEditor from '../../component/tinymceEditor/TinymceEditor';

2)使用组件:
<Form form={form}>
    <Form.Item
        className="h80 m0"
        name={[pkid, 'logContent']}
        rules={[
            {required: loggerType !== 1 ? true : false, message: `请输入日志内容`},
            {
                validator: (rule: any, value: any) => {
                    if (ObjTest.isStr(removeHTML(value)) && removeHTML(value).trim().length > 2000) {
                        return Promise.reject(`内容不可超过2000字`);
                    }
                    return Promise.resolve();
                }
            },
        ]}
    >
        <TinymceEditor disabled={!editable} />
    </Form.Item>
</Form>

最终效果:

微信截图_20220120151218.png

可以看到经过我们的封装,项目中可以随便引用TinymceEditor组件进行使用。在基础模式的封装中,我们限制了图片须为设置的域名才能被粘贴进编辑器中;还限制了图片的上传格式;代码中还体现了富文本编辑是可以通过disabled进行禁用,我们需求就是:超过三天之后就不能编辑日志内容,但是可以编辑备注等列内容,所以就需要通过disabled进行控制;后续代码中将会贴出完整的TinymceEditor组件代码,其中的上传图片方法代码的封装也会贴出,争取让小伙伴们拿去就只需要更改特殊配置就能使用。

微信截图_20220120160853.png

在项目中使用的时候,因为我们是将页面用iframe嵌入另一个平台的,然后我们上线之后发现用户输入百度官网http://www.baidu.com的链接地址并选择当前页面打开时,页面显示拒绝链接请求;于是我们就去查,发现是因为百度官网不允许其它网站以iframe形式嵌套它,所以我们为了避免用户再次输入不允许嵌套的链接地址并选择当前页面打开造成拒绝链接请求的现象,需要增加以下一句代码进行配置,把当前页面打开选项隐藏掉,这样就能解决上述问题了。

target_list: false,

TinyMCE最终封装

TinymceEditor.tsx文件:

import React, {useRef, useState} from "react";
import {fileUpload} from "../../util/request";
import {message} from "antd";
import './editor.less';
import {Editor} from '@tinymce/tinymce-react';
import {IAllProps} from "@tinymce/tinymce-react/lib/es2015/main/ts";
import {globalBigFileUploadURL} from "../../config/api";


// 许可图片地址前缀
const VALID_IMG_SRC = [
    'http://xxx.xxx.local',
    'http://xxx-test.xxx.local',
];


interface TinymceEditorProps extends Omit<IAllProps, 'onChange'> {
    imageTypes?: string[];  // 图片格式
    videoTypes?: string[];  // 音视频格式
    imageMaxSize?: number;  // 上传图片大小上限(MB)
    videoMaxSize?: number; // 上传视频大小上限(MB)
    onChange?: (content: string) => void;
}


/**
 * tinymce富文本编辑器组件
 * 注意: content_css指向的样式文件(content.css)需在index.html页面引入, 以便编辑状态和浏览状态呈现相同样式
 * @param props
 * @constructor
 */
const TinymceEditor = (props: TinymceEditorProps) => {
    const {
        init = {},
        onChange,
        imageMaxSize = 20,
        videoMaxSize = 200,
        imageTypes = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif'],
        videoTypes = ['video/mp4', 'video/ogg', 'video/webm', 'video/swf'],
    } = props;
    const [loading, setLoading] = useState<boolean>(false);
    const editorRef = useRef<any>(null);


    /**
     * 自定义错误提示
     * @param content
     */
    const msgError = (content: string) => {
        if (!editorRef.current) {
           return;
        }
        editorRef.current.notificationManager.open({
            text: content,
            type: 'error'
        });
    }


    /**
     * 设置loading状态
     * @param loading
     */
    const updateLoading = (state: boolean) => {
        if (editorRef.current) {
            editorRef.current.setProgressState(state);
        }
        setLoading(state);
    }


    /**
     * 图片上传
     * @param data
     */
    const imgUpload = (data: any, needMsg: boolean = true): any => {
        const { type, size } = data;
        // 校验图片格式、大小
        if (imageTypes.includes(type)) {
            if (size > imageMaxSize * 1024 * 1024) {
                let msg = `图片大小不能超过${imageMaxSize}MB`;
                if (needMsg) {
                    msgError(msg);
                }
                return Promise.reject(msg);
            }
            updateLoading(true);
            return fileUpload('/upload/image/sample', data, 'file').finally(() => {
                updateLoading(false);
            });
        }
        // 校验音视频格式、大小
        if (videoTypes.includes(type)) {
            if (size > videoMaxSize * 1024 * 1024) {
                let msg = `视频大小不能超过${videoMaxSize}MB`;
                if (needMsg) {
                    msgError(msg);
                }
                return Promise.reject(msg);
            }
            updateLoading(true);
            return fileUpload('/Upload/SingleFile', data, 'file', {baseURL: globalBigFileUploadURL}).then((res: any) => {
                return {httpUrl: res.data}
            }).finally(() => {
                updateLoading(false);
            });
        }

        const msg = '只允许上传jpg,jpeg,png,gif格式的图片或mp4,ogg,webm,swf格式的视频';
        if (needMsg) {
            msgError(msg);
        }
        return Promise.reject(msg);
    }


    /**
     * 检查图片地址是否是外链
     * @param args
     */
    const validateImgSrc = (node: any): boolean => {
        let imgs = node.querySelectorAll('img') || [];
        let src = [];
        imgs.forEach((v: any) => {
            src.push(v.src);
        })
        // 只验证以http:// 或 https:// 开头的地址, 有任意一个地址不在许可图片地址内的,则验证失败
        let res = src.filter((v: any) => v.indexOf('http://') === 0 || v.indexOf('https://') === 0).some((v: any) => {
            return !VALID_IMG_SRC.some((item: any) => v.includes(item));
        });
        if (res) {
            return false;
        }
        return true;
    }


    /**
     * 移除无法通过验证的图片结点
     * @param node
     */
    const removeImgNode = (node: any) => {
        let imgs = node.querySelectorAll('img');
        imgs.forEach((img: any) => {
            if (VALID_IMG_SRC.some((v: any) => img.src.includes(v))) {
                return;
            }
            img.remove();
        })
    }


    /**
     * 内容变更
     * @param content
     * @param editor
     */
    const handleEditorChange = (content, editor) => {
        onChange(content);
    }


    /**
     * 过滤html文本
     */
    const filterHtml = (html: string) => {
        // 替换强制换行样式
        let content = html.replace(/white-space: nowrap;/g, '');
        return content;
    }


    const getProps = (): Omit<IAllProps, 'onChange'> => {
        let newProps = {...props};
        delete newProps.onChange;
        return newProps;
    }


    /**
     * 选择文件
     * @param callback
     * @param value
     * @param meta
     */
    const filePickerCallback = (callback: any, value: any, meta: any) => {
        const { filetype } = meta;
        let accept = '';
        if (filetype === 'media') {
            accept = videoTypes.join(',');
        }
        if (filetype === 'image') {
            accept = imageTypes.join(',');
        }
        let input = document.createElement('input');
        input.setAttribute('type', 'file');
        input.setAttribute('accept', accept);
        input.onchange = (e) => {
            imgUpload(input.files[0]).then(res => {
                callback(res.httpUrl, {alt: res.httpUrl});
            })
        }
        input.click();
    }


    /**
     * 图片上传
     * @param blobInfo
     * @param success
     * @param failure
     */
    const imagesUploadHandler = (blobInfo: any, success: any, failure: any) => {
        imgUpload(blobInfo.blob(), false).then(res => {
            success(res.httpUrl);
        }).catch((msg: any) => {
            failure(msg, {remove: true});
        })
    }


    /**
     * 粘贴预处理
     * @param pluginApi
     * @param data
     */
    const pastePreprocess = (pluginApi: any, data: any) => {
        const content = data.content;
        const newContent = filterHtml(content);
        data.content = newContent;
    }


    /**
     * 粘贴后处理
     * @param plugin
     * @param args
     */
    const pastePostprocess = (plugin: any, args: any) => {
        const {node} = args;
        const {innerText = ''} = node;

        // 验证图片地址
        if (!validateImgSrc(node)) {
            removeImgNode(node);
            message.warning('无法粘贴带有外链的图片(因链接失效或被屏蔽可能导致图片无法正常显示),请先把网页上的图片保存到本地后再上传', 3);
            return;
        }

        // 当检测到粘贴到编辑器的字符串带有特定标识时,将该字符串转换为超链接插入
        if (innerText.includes('data-editor-link="true"')) {
            node.innerHTML = node.innerText;
        }
    }


    return (
        <Editor
            {...getProps()}
            init={
                {
                    language: 'zh_CN',
                    plugins: [
                        'advlist autolink lists link charmap print preview anchor',
                        'searchreplace visualblocks code fullscreen',
                        'insertdatetime media table code help wordcount image imagetools codesample',
                        'quickbars autoresize',
                        'powerpaste', // plugins中,用powerpaste替换原来的paste
                    ],
                    powerpaste_word_import: 'propmt',// 参数可以是propmt, merge, clear,效果自行切换对比
                    powerpaste_html_import: 'propmt',// propmt, merge, clear
                    powerpaste_allow_local_images: true,
                    paste_data_images: true,
                    file_picker_callback: filePickerCallback,
                    images_upload_handler: imagesUploadHandler,
                    paste_preprocess: pastePreprocess,
                    paste_postprocess: pastePostprocess,
                    min_height: 500,
                    statusbar: false,
                    toolbar_mode: 'wrap',
                    content_css: '/editor/content.css', // 可从style.less编译获得
                    body_class: 'tinymce-content',
                    menubar: false,
                    image_caption: true,
                    image_title: true,
                    default_link_target: '_blank',
                    target_list: false,
                    quickbars_image_toolbar: 'alignleft aligncenter alignright | imageoptions',
                    block_formats: 'Paragraph=p; Header 1=h1; Header 2=h2; Header 3=h3; Header 4=h4; Header 5=h5; Header 6=h6',
                    fontsize_formats: '8px 10px 14px 16px 18px 20px 22px 24px 26px 28px 32px 48px',
                    font_formats: '宋体;仿宋;微软雅黑;黑体;仿宋_GB2312;楷体;隶书;幼圆',
                    toolbar:
                        'formatselect | \ \
                         bold italic underline strikethrough forecolor backcolor removeformat | \
                         bullist numlist | \
                         outdent indent | \
                         image media table | \
                         alignleft aligncenter alignright alignjustify | \
                         link charmap | \
                         undo redo fullscreen preview code',
                    quickbars_selection_toolbar: 'bold italic | formatselect | blockquote',
                    video_template_callback: (data) => {
                        return '<video width="' + '100%' + '" height="' + '500' + '"' + (data.poster ? ' poster="' + data.poster + '"' : '') + ' controls="controls">\n' + '<source src="' + data.source + '"' + (data.sourcemime ? ' type="' + data.sourcemime + '"' : '') + ' />\n' + (data.altsource ? '<source src="' + data.altsource + '"' + (data.altsourcemime ? ' type="' + data.altsourcemime + '"' : '') + ' />\n' : '') + '</video>';
                    },
                    init_instance_callback : (editor) => {
                        editorRef.current = editor;
                    },
                    ...init,
                }
            }
            disabled={loading}
            onEditorChange={handleEditorChange}
        />
    )
}


export default TinymceEditor;

文件上传方法:

/**
 * 文件上传接口
 * @param file
 * @param key
 * @param fileName
 */
export function fileUpload(url: string, file: any, key: string, options: IRequestOptions = {}) : Promise<any> {
    const { withToken = true } = options;

    let formData = new FormData();
    formData.append(key, file);

    return instance({
        url,
        data: formData,
        method: 'post',
        baseURL: globalUploadAPI,
        timeout: 200000,
        headers: withToken ? {...getHeaders(), 'Content-Type':'multipart/form-data'} : {'Content-Type':'multipart/form-data'},
        // 自定义参数序列化方法
        paramsSerializer: (params: any) => {
            return encodeGetParams(params);
        },
        ...options,
    }).then(res => {
        const { success, info, status } = res.data;
        if (success === false) {
            message.error(info);
            return Promise.reject(info);
        }
        // 检查接口返回状态(兼容java输出模型)
        if (status !== null && status !== undefined) {
            return codeCheck(res.data, {});
        }
        return res.data;
    })
}

往期精彩文章

后语

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