React + wangEditor 实现@提及

背景

最近一个朋友有一个需求需要在wangEditor中做一个提及的效果交互,她自己找了个Demo,但是在Vue中实现的,因为她那边一直用的是React,想把Vue那一版转为React的,但对Vue不太了解,找我帮忙,我自然是欢快的答应了,经过三个小时的文档查阅及编码最终实现

这个呢有需要的朋友也可以直接 copy index.js 和 ModalList 组件

注意: ModalList 跟随光标出现而出现 位置可以自己微调下, 这个位置我这边用自己电脑和公司电脑展示的不一样,大家使用时候微调下即可

代码更改这里即可:

js 复制代码
const topPosition = `${selectionRect.top - containerRect.top + window.scrollY + 60}px`; 
const leftPosition = `${selectionRect.left - containerRect.left + window.scrollX + 5}px`;

这边查阅的文档大概是 王福朋老师 做的开源wangEditor及开源插件

因为是要用React做,这边直接贴一个对应React的文档

  1. 文档 React-wangEditor
  2. 插件集

(一)、面试中遇到的笔试题

(二)、面试中遇到的读代码题

(三)、Typescript基础篇

(四)、TS核心语法+各种实战应用(上)

(五)、TS核心语法+各种实战应用(下)

1. @提及实现

1.1 效果

1.2 实现

index.js
js 复制代码
import "@wangeditor/editor/dist/css/style.css"; // 引入 css
import React, { useState, useEffect } from "react";
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import { Boot, DomEditor, i18nChangeLanguage } from "@wangeditor/editor";
import mentionModule from "@wangeditor/plugin-mention";
import markdownModule from '@wangeditor/plugin-md';
import attachmentModule from '@wangeditor/plugin-upload-attachment';
import ctrlEnterModule from '@wangeditor/plugin-ctrl-enter'

import ModalList from "./ModalList";

// 国际化  默认中文  使用引文请将 zh-CN 更改为 en
i18nChangeLanguage('zh-CN');

// 注册。要在创建编辑器之前注册,且只能注册一次,不可重复注册。
Boot.registerModule(mentionModule); // 提及
Boot.registerModule(markdownModule); // markdown
Boot.registerModule(attachmentModule); // 附件
Boot.registerModule(ctrlEnterModule); // ctrl+enter 换行 

function MyEditor() {
    const [editor, setEditor] = useState(null); // editor 实例
    const [html, setHtml] = useState(""); // 编辑器内容
    const [isModalVisible, setIsModalVisible] = useState(false); // Modal 显示隐藏
    const [topPosition, setTopPosition] = useState('');
    const [leftPosition, setLeftPosition] = useState('');

    // 当前菜单排序和分组
    const toolbar = DomEditor.getToolbar(editor);
    const curToolbarConfig = toolbar?.getConfig();
    console.log(curToolbarConfig?.toolbarKeys);

    // 关于工具栏配置
    const toolbarConfig = {
        // 插入哪些菜单
        insertKeys: {
            index: 9, // 自定义插入的位置
            keys: ['uploadAttachment'], // "上传附件"菜单
        },

    };

    // 编辑器配置
    const editorConfig = {
        placeholder: "请输入内容...",
        EXTEND_CONF: {
            mentionConfig: {
                showModal, // 必须有
                hideModal, // 必须有
            },
        },

        // 在编辑器中,点击选中"附件"节点时,要弹出的菜单
        hoverbarKeys: {
            attachment: {
            menuKeys: ['downloadAttachment'], // "下载附件"菜单
            },
        },

        MENU_CONF: {
            // "上传附件"菜单的配置
            uploadAttachment: {
              server: '/api/upload', // 服务端地址
              timeout: 5 * 1000,     // 5s

              fieldName: 'custom-fileName',
              meta: { token: 'xxx', a: 100 }, // 请求时附加的数据
              metaWithUrl: true,              // meta 拼接到 url 上
              headers: { Accept: 'text/x-json' },

              maxFileSize: 10 * 1024 * 1024,  // 10M

            //   onBeforeUpload(file) {
            //     console.log('onBeforeUpload', file)
            //     return file // 上传 file 文件
            //     // return false // 会阻止上传
            //   },

            //   // 上传进度
            //   onProgress(progress) {
            //     console.log('onProgress', progress)
            //   },

            //   // 成功之后
            //   onSuccess(file, res) {
            //     console.log('onSuccess', file, res)
            //   },

            //   onFailed(file, res) {
            //     alert(res.message)
            //     console.log('onFailed', file, res)
            //   },

            //   // 错误信息
            //   onError(file, err, res) {
            //     alert(err.message)
            //     console.error('onError', file, err, res)
            //   },

            //   // 上传成功后,用户自定义插入文件
            //   customInsert(res, file, insertFn) {
            //     console.log('customInsert', res)
            //     const { url } = res.data || {}
            //     if (!url) throw new Error(`url is empty`)

            //     // 插入附件到编辑器
            //     insertFn(`customInsert-${file.name}`, url)
            //   },

            //   // 用户自定义上传
            //   customUpload(file, insertFn) {
            //     console.log('customUpload', file)

            //     return new Promise(resolve => {
            //       // 插入一个文件,模拟异步
            //       setTimeout(() => {
            //         const src = `https://www.w3school.com.cn/i/movie.ogg`
            //         insertFn(`customUpload-${file.name}`, src)
            //         resolve('ok')
            //       }, 500)
            //     })
            //   },

            //   // 自定义选择
            //   customBrowseAndUpload(insertFn) {
            //     alert('自定义选择文件,如弹出图床')
            //     // 自己上传文件
            //     // 上传之后用 insertFn(fileName, link) 插入到编辑器
            //   },

            //   // 插入到编辑器后的回调
            //   onInsertedAttachment(elem) {
            //     console.log('inserted attachment', elem)
            //   },
            }
        }
    };

    // 隐藏弹窗
    function hideModal() {
        setIsModalVisible(false); // 隐藏 modal
    }

    // 显示弹框
    function showModal(editor) {
        // 获取光标位置,定位 modal
        const domSelection = document.getSelection();
        const domRange = domSelection.getRangeAt(0);
        if (domRange == null) return;
        const selectionRect = domRange.getBoundingClientRect();

        // 获取编辑区域 DOM 节点的位置,以辅助定位
        const containerRect = editor.getEditableContainer().getBoundingClientRect();

        const topPosition = `${selectionRect.top - containerRect.top + window.scrollY + 60}px`;
        const leftPosition = `${selectionRect.left - containerRect.left + window.scrollX + 5}px`;

        // 显示 modal 弹框,并定位
        // PS:modal 需要自定义,如 <div> 或 Vue React 组件
        setIsModalVisible(true);

        // 当触发某事件(如点击一个按钮)时,插入 mention 节点
        console.log(selectionRect, containerRect, "展示");

        // 更新弹框位置
        setTopPosition(topPosition);
        setLeftPosition(leftPosition);
    }

    // @功能 as 提及 当触发某事件(如点击一个按钮)时,插入 mention 节点
    function insertMention(id, name) {
        const mentionNode = {
            type: 'mention', // 必须是 'mention'
            value: name, // 文本
            info: { id }, // 其他信息,自定义
            children: [{ text: '' }], // 必须有一个空 text 作为 children
        }

        editor.restoreSelection()          // 恢复选区
        editor.deleteBackward('character') // 删除 '@'
        editor.insertNode(mentionNode)     // 插入 mention
        editor.move(1)                     // 移动光标
    }

    // 编辑器中输入内容
    const onChangeEditor = (editor) => {
        setHtml(editor.getHtml())
    }

    // 及时销毁 editor ,重要!
    useEffect(() => {
        return () => {
            if (editor == null) return
            editor.destroy()
            setEditor(null)
        }
    }, [editor])

    return (
        <div style={{ border: '1px solid #ccc', zIndex: 100}}>
            <Toolbar
                editor={editor}
                defaultConfig={toolbarConfig}
                mode="default"
                style={{ borderBottom: '1px solid #ccc' }}
            />
            <Editor
                defaultConfig={editorConfig}
                value={html}
                onCreated={setEditor}
                onChange={onChangeEditor}
                mode="default"
                style={{ height: '500px', overflowY: 'hidden' }}
            />
            {
                isModalVisible && (
                    <ModalList 
                        insertMention={insertMention} 
                        hideModal={hideModal} 
                        topPosition={topPosition} 
                        leftPosition={leftPosition} />
                    ) 
            }
        </div>
    )
}

export default MyEditor
1. ModalList.js
js 复制代码
import React, { useEffect, useRef, useState } from 'react';
import { Select } from 'antd';
const { Option } = Select;

export default function ModalList(props) {
    const { topPosition, leftPosition } = props;
    const selectRef = useRef();
    const [personList, setPersonList] = useState([]);

    useEffect(() => {
        // TODO: 这里可以发起请求  一个List
        selectRef.current.focus();
        setPersonList([
            { id: 1, name: 'John' },
            { id: 2, name: 'Tom' },
            { id: 3, name: 'Jack' },
            { id: 4, name: 'Marry' },
        ])

    }, []);


    const onChangeSelect = (e) => {
        let name = personList.find(item => item.id === e);
        props.insertMention(e, name.name);
        props.hideModal();
    };

    return (
        <Select
            ref={selectRef}
            showSearch
            allowClear
            placeholder="要@的人"
            style={{ width: '150px', position: 'absolute', top: topPosition, left: leftPosition }}
            optionFilterProp="children"
            filterOption={(input, option) => option?.children?.toLowerCase().indexOf(input?.toLowerCase()) >= 0}
            filterSort={(optionA, optionB) => {
                return optionA?.children?.toLowerCase().localeCompare(optionB?.children?.toLowerCase());
            }}
            onChange={onChangeSelect}
        >
            {
                personList.map(item => {
                    return (
                        <Option key={item.id} value={item.id}>
                            {item.name}
                        </Option>
                    );
                })
            }
        </Select>
    );
}

2. markdown语法

2.1. 效果

2.2. 实现

typescript 复制代码
安装  
    tnpm i @wangeditor/plugin-md

导入  
    import markdownModule from '@wangeditor/plugin-md'

注册  
    Boot.registerModule(markdownModule); // markdown

2.3. md使用语法

1.标题

js 复制代码
#  一级标题 输入# 按一个空格  输入标题 
##
###
####
#####

2.列表

js 复制代码
 - + *

3.引用

js 复制代码
>

4.分割线

js 复制代码
---

5.代码块

js 复制代码
``` 按下回车

3. 附件

3.1. 效果

3.2. 实现

js 复制代码
安装  tnpm i @wangeditor/plugin-md
导入  import markdownModule from '@wangeditor/plugin-upload-attachment'
注册  Boot.registerModule(attachmentModule); // 附件

// 在编辑器中配置
const editorConfig = {
      // 在编辑器中,点击选中"附件"节点时,要弹出的菜单
      hoverbarKeys: {
          attachment: {
            menuKeys: ['downloadAttachment'], // "下载附件"菜单
          },
      },

      MENU_CONF: {
          // "上传附件"菜单的配置
          uploadAttachment: {
            server: '/api/upload', // 服务端地址
            timeout: 5 * 1000,     // 5s

            fieldName: 'custom-fileName',
            meta: { token: 'xxx', a: 100 }, // 请求时附加的数据
            metaWithUrl: true,              // meta 拼接到 url 上
            headers: { Accept: 'text/x-json' },

            maxFileSize: 10 * 1024 * 1024,  // 10M

            onBeforeUpload(file) {
              console.log('onBeforeUpload', file)
              return file // 上传 file 文件
              // return false // 会阻止上传
            },

            // 上传进度
            onProgress(progress) {
              console.log('onProgress', progress)
            },

            // 成功之后
            onSuccess(file, res) {
              console.log('onSuccess', file, res)
            },

            onFailed(file, res) {
              alert(res.message)
              console.log('onFailed', file, res)
            },

            // 错误信息
            onError(file, err, res) {
              alert(err.message)
              console.error('onError', file, err, res)
            },

            // 上传成功后,用户自定义插入文件
            customInsert(res, file, insertFn) {
              console.log('customInsert', res)
              const { url } = res.data || {}
              if (!url) throw new Error(`url is empty`)

              // 插入附件到编辑器
              insertFn(`customInsert-${file.name}`, url)
            },

            // 用户自定义上传
            customUpload(file, insertFn) {
              console.log('customUpload', file)

              return new Promise(resolve => {
                // 插入一个文件,模拟异步
                setTimeout(() => {
                  const src = `https://www.w3school.com.cn/i/movie.ogg`
                  insertFn(`customUpload-${file.name}`, src)
                  resolve('ok')
                }, 500)
              })
            },

            // 自定义选择
            customBrowseAndUpload(insertFn) {
              alert('自定义选择文件,如弹出图床')
              // 自己上传文件
              // 上传之后用 insertFn(fileName, link) 插入到编辑器
            },

            // 插入到编辑器后的回调
            onInsertedAttachment(elem) {
              console.log('inserted attachment', elem)
            },
          }
      }
};

4. 国际化

4.1. 实现

js 复制代码
安装  
    tnpm i @wangeditor/plugin-md

导入  
    import { i18nChangeLanguage } from "@wangeditor/editor";

使用  
    i18nChangeLanguage('zh-CN');

5. ctrl/command + enter换行

5.1. 实现

js 复制代码
 1.安装  
    tnpm i @wangeditor/plugin-ctrl-enter

2.导入  
    import ctrlEnterModule from '@wangeditor/plugin-ctrl-enter'

3.注册  
    Boot.registerModule(ctrlEnterModule); // ctrl+enter 换行 
相关推荐
老码沉思录7 分钟前
React Native 全栈开发实战班 - 状态管理入门(Context API)
javascript·react native·react.js
文军的烹饪实验室1 小时前
ValueError: Circular reference detected
开发语言·前端·javascript
Martin -Tang2 小时前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发2 小时前
解锁微前端的优秀库
前端
王解3 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录3 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
老码沉思录3 小时前
React Native 全栈开发实战班 - 第四部分:用户界面进阶之动画效果实现
react native·react.js·ui
我不当帕鲁谁当帕鲁3 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂3 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐4 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架