“This is the fourth day of my participation in the First Challenge 2022. For details: First Challenge 2022”

preface

The most powerful rich text editor in the last post? TinyMCE series articles [1] introduced the configuration and use of TinyMCE, the strongest rich text editor. This time, I will talk about how to encapsulate TinyMCE editor into a common rich text component in the project, and provide partners in the group to call the rich text editor in different pages to achieve their own needs. In fact, TinyMCE itself is a plug-in packaged by others, but in our actual use, we feel that its packaging is not so friendly, and it also seems redundant and tedious in our code, so we need to secondary packaging, so that it can better meet the actual needs.

Demand background

Let me show you the design to give you an idea of our requirements.

As shown in the above, we need to do a project log record form, including the log content, note and custom columns are needed to support the graphic editor, add links, form style, special characters, etc., and the log content editor need reality must plugin functions such as toolbar, note and custom columns do not need the toolbar, but to support input graphic editor and links. (In fact, the project log requirement also changes the table header date based on the scroll to the top of the row to fetch the log; Scroll down to bottom automatically load data; Select the date to locate to the top and other not simple requirements, but do not extend here, only expounds the rich text related requirements, the subsequent article will be divided into narrative.)

The requirement for rich text is that you need two sets of rich text editors in a table, one with a toolbar and one without. These are two of the three modes that TinyMCE supports: inline without a toolbar and basic with a toolbar

All right, so once the requirements are understood we’re going to start playing around and see if TinyMCE works.

The development of

I also packaged a rich text editor as a controlled component. The main reason for using the Form is that it’s synchronous, so we don’t have to worry about asynchronous data.

That first to do a do not have a toolbar, first to do the simple, with a toolbar is not a minute to do things, open the whole!

Inline mode encapsulation

Create the simpleEditor. TSX file:

import React from "react"; import {Editor} from '@tinymce/tinymce-react'; import {IAllProps} from "@tinymce/tinymce-react/lib/es2015/main/ts"; /** * SimpleEditor = (props: any) => {const {onChange = (content: string) => {} } = props; /** * @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: '.png; Imitation song dynasty style typeface. Microsoft Yahei; Black body; Imitation song dynasty style typeface _GB2312; Regular script. Official script. Content_css: '/editor/content.css', body_class: 'bit-tinymce-content', }} onEditorChange={editorChange} /> ) } export default SimpleEditor;Copy the code

Create the editor folder in the public file and create the Content.css file as the base style for the editing area:

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; } th, em, strong, b, address, cite { font-style: normal; font-weight: normal; } 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; }Copy the code

As can be seen from the style file, I changed the style of the scrollbar. This is mainly because we need to change the style of the scrollbar. If your requirements do not require, you can not add the style code to change the scrollbar.

Usage:

1) Import SimpleEditor from ".. /.. /component/simpleEditor/SimpleEditor"; <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 (' no more than 500 words');} return promise.resolve ();}},]} > <SimpleEditor /> </ form.item > </Form>Copy the code

The toolbarless rich text component wrapper is the code described above, but it is unnecessary to wrap this series of articles. TinyMCE provides inline mode to achieve the toolbarless effect, but we need to wrap it again. After all, we want to use it as a controlled component with the Form component, so we can’t use it directly, so you can copy my code and use it directly in your project.

Base mode encapsulation

With the experience of inline mode encapsulation above, basic mode encapsulation is nothing more than adding the necessary plug-ins and handling image uploads and files.

Create tinymceEditor.tsx file:

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 rich text editor component * Note: Content_css points to a style file (content.css) that is introduced in the index.html page, * @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); } 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', Paste], powerpaste_word_import: 'propmt',// the parameters can be propmt, merge, clear, and the effect switches automatically compared to 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; // Verify the image address if (! validateImgSrc(node)) { removeImgNode(node); Warning (' Unable to paste images with external links (images may not display properly due to link failure or blocking), please save the images on the webpage to local before uploading ', 3); return; } // When a string pasted into the editor is detected with a specific identifier, Convert the string to a hyperlink and insert if (innertext.includes ('data-editor-link="true"')) {node.innerhtml = node.innertext; } }, min_height: 500, statusbar: false, toolbar_mode: 'wrap', content_css: '/editor/content.css', // 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: '宋体; Imitation song dynasty style typeface. Microsoft Yahei; Black body; Imitation song dynasty style typeface _GB2312; Regular script. Official script. End ', the 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;Copy the code

Create the editor.less file:

Use Position: sticky! Important Fix the editor header and use the content-css file in the public file for the edit area style.

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

Usage:

Import TinymceEditor from '.. /.. /component/tinymceEditor/TinymceEditor'; <Form Form ={Form}> < form. Item className=" h80m0 "name={[pKID, 'logContent']} rules={[{required: LoggerType!== 1? True: false, message: 'Please enter log content'}, {validator: (rule: any, value: any) => { if (ObjTest.isStr(removeHTML(value)) && removeHTML(value).trim().length > 2000) { return Promise.reject(' content cannot exceed 2000 words');} return promise.resolve ();}},]} > <TinymceEditor disabled={! editable} /> </Form.Item> </Form>Copy the code

End result:

As you can see, after our encapsulation, the TinymceEditor component can be used in any project. In the base mode wrapper, we restrict the domain name of the image to be pasted into the editor. It also limits the format in which images can be uploaded; The code also shows that rich text editing can be disabled by disabled. Our requirement is that the log content cannot be edited after more than three days, but the contents of remarks and other columns can be edited, so the control needs to be disabled. The complete TinymceEditor component code will be posted in the following code, as well as the package of the upload image method code. You only need to change the special configuration to use it.

When we used it in the project, because we embedded the page with iframe on another platform, and then we found that when the user entered the link address of baidu official website http://www.baidu.com and selected the current page to open, the page showed rejection of the link request. Then we will go to check, found because baidu website does not allow other website in the form of the iframe nested it, so we in order to avoid the user input does not allow nested link again and choose the current page open, refused to link request phenomenon need to add the following code is configured, the current page open the option to hide away, This will solve the above problems.

target_list: false,
Copy the code

TinyMCE final package

TinymceEditor. TSX file:

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;
Copy the code

File uploading method:

/** * 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'}, // Custom parameter serializer: (params: any) => { return encodeGetParams(params); },... options, }).then(res => { const { success, info, status } = res.data; if (success === false) { message.error(info); return Promise.reject(info); } // Check the interface return status (compatible with Java output models) if (status! == null && status ! == undefined) { return codeCheck(res.data, {}); } return res.data; })}Copy the code

Past wonderful articles

  • The most powerful rich text editor? TinyMCE Series of articles [1]
  • React project to implement a Checkbox style effect component
  • 2022 First update: 3 Progress bar effects in front end projects
  • Front-end Tech Trends 2022: You don’t come in and see how to earn it a small target?
  • If ancient have programmers writing summaries, probably is such | 2021 year-end summary
  • Front-end development specifications in front end team practice
  • Javascript high-level constructors
  • Javascript high-level inheritance
  • Recursion and closure

After the language

Guys, if you find this article helpful, click 👍 or ➕. In addition, if there are questions or do not understand the part of this article, you are welcome to comment on the comment section, we discuss together.