头图

功能描述

实现在 Braft Editor 中添加和显示自定义的 emoji 图标(给出的是一系列的图片地址,形如:https: xxx/emoji1.png)。主要功能点包括:点击对应的 emoji 后在编辑器中渲染对应的 emoji 图标,以及支持将发送的消息“重新编辑”回填到编辑框中。

image.png

开发过程

初始尝试

最初,我试图使用自定义的 EmojiSelect 组件来实现这个功能。但这种方法只能向编辑框插入类似 [emoji1] 这样的 emoji 描述文本,而不能显示为实际的 emoji 图标。

const EmojiSelect = ({ onEmojiClick }) => {
    const [visibleObj, setVisibleObj] = useState({});

    const handleClickEmoji = item => {
        onEmojiClick(item);
        // 其他操作如关闭弹窗
    };
    return (
        <Popover
            overlayClassName={styles['custom-emoji-popover']}  
            {
              ...其他配置
            }
            value={replyEditorState}
            content={
                <div className={styles['icons-container']}>
                    <div>默认表情</div>
                    <div className={styles['icon-list']}>
                        {sortEmoji.map((item, index) => {
                            return (
                                <img
                                    key={index}
                                    onClick={() => handleClickEmoji(item)}
                                    src={item.url}
                                    alt="emoji"
                                />
                            );
                        })}
                    </div>
                </div>
            }
        >
            😄
        </Popover>
    );
};

BraftEditor组件处以下面的方式使用:

<BraftEditor
    {
       ...其他配置
    }
    controls={[
        {
            key: 'custom-emoji', // 使用key来指定控件类型
            title: '表情', // 自定义控件title
            text: (
                <EmojiSelect
                    onEmojiClick={emoji => {
                        const newEditorState = ContentUtils.insertText(
                            replyEditorState,
                            `[${emoji.name}]`
                        );
                        setReplyEditorState(newEditorState);
                    }}
                />
            ),
            type: 'button' // ,
        },
        'bold',
        'text-color',
        'link'
    ]} 
/>

尝试使用装饰器

为了让插入的 emoji 描述文本能在编辑器里显示为 emoji 图标,我试图使用装饰器来实现。但这种方法遇到了一些问题,例如无法选中 emoji、不能删除 emoji、只能在 emoji 前面输入文本、光标位置错误等,踩坑记录和这位哥们相似: Draft.js实现微信emoji功能。因此,我放弃了这种方法。

const Emoji = (props) => {
    const {decoratedText } = props;
    const name = decoratedText.substring(1, decoratedText.length - 1);

    const emoji = sortEmoji.find(item => item.name === name);
    return <img src={emoji.url} width={20} height={20}/>;
};

// 找到所有的 "[emoji1]" 字符串
const emojiStrategy = (contentBlock, callback, contentState) => {
    const text = contentBlock.getText();
    let matchArr, start, end;
    const regExp = /\[emoji\d+\]/g;
    while ((matchArr = regExp.exec(text)) !== null) {
        start = matchArr.index;
        end = start + matchArr[0].length;
        callback(start, end);
    }
};

// 创建装饰器
const emojiDecorator = new CompositeDecorator([
    {
        strategy: emojiStrategy,
        component: Emoji,
    },
]);

其他方法尝试

参考了下面的一些实现方法,但效果均不太想理想(亦或是我自己实现方法不佳),遂未采用。

项目实践,一文秒懂Braft Editor编辑器扩展自定义Block

基于Draftjs实现的Electron富文本聊天输入框(三) —— Emoji

draft-js-plugins

使用 Braft Editor 的表情包插件

最后,我找到了 Braft Editor 的表情包插件 https://braft.margox.cn/demos/emoticond ,并根据插件的源码 https://github.com/margox/braft-extensions/blob/master/src/emoticon/index.jsx,自定义了我的 EmojiSelect/index.js 文件。

// EmojiSelect/index.js文件

import React from 'react';
import { ContentUtils } from 'braft-utils';
import './index.less';

import { emoticonsArr } from '@/constants/emoji';


const insertEmoticon = (editor, editorState, src) => {
    editor.setValue(
        ContentUtils.insertText(editorState, ' ', null, {
            type: 'EMOTICON',
            mutability: 'IMMUTABLE',
            data: { src }
        })
    );
};

let controlRef = null;
const bindControlRef = ref => (controlRef = ref);

export default options => {
    options = {
        emoticons: emoticonsArr,
        closeOnSelect: true,
        closeOnBlur: true,
        ...options
    };

    const {
        emoticons,
        closeOnSelect,
        closeOnBlur,
        includeEditors = ['demo-editor-with-emoticon'],
        excludeEditors
    } = options;

    const genericEmoticonsList = (emoticonsList, props) => {
        return emoticonsList.map((item, index) => (
            <img
                onClick={() => {
                    insertEmoticon(props.editor, props.editorState, item);
                    closeOnSelect && controlRef && controlRef.hide();
                }}
                key={index}
                src={item}
            />
        ));
    };

    return {
        type: 'entity',
        includeEditors,
        excludeEditors,
        name: 'EMOTICON',
        control: props => ({
            key: 'EMOTICON',
            replace: 'emoji',
            type: 'dropdown',
            text: <i className="bfi-emoji"></i>,
            showArrow: false,
            ref: bindControlRef,
            autoHide: closeOnBlur,
            component: (
                <div className="braft-emoticon-picker">
                    <div className="braft-emoticon-title">默认表情</div>
                    <div className="braft-emoticons-list">
                        {genericEmoticonsList(emoticons, props)}
                    </div>
                </div>
            )
        }),
        mutability: 'IMMUTABLE',
        component: props => {
            const entity = props.contentState.getEntity(props.entityKey);
            const { src } = entity.getData();
            return (
                <span className="braft-emoticon-in-editor">
                    <img src={src} />
                    {props.children}
                </span>
            );
        },
        importer: (nodeName, node) => {
            if (
                nodeName.toLowerCase() === 'span' &&
                node.classList &&
                node.classList.contains('braft-emoticon-wrap')
            ) {
                const imgNode = node.querySelector('img');
                const src = imgNode.getAttribute('src');
                // 移除img节点以避免生成atomic block
                node.removeChild(imgNode);
                return {
                    type: 'EMOTICON',
                    mutability: 'IMMUTABLE',
                    data: { src }
                };
            }
        },
        // 调用toHTML方法时,表情可转化为[emoji]格式
        exporter: entityObject => {
            const { src } = entityObject.data;
            const emojiName = src
                .split('/')
                .pop()
                .replace('.png', '');
            return `[${emojiName}]`;
        }
    };
};

BraftEditor组件处以下面的方式使用:

import createEmoticonPlugin from '../../../../../components/EmojiSelect';
import BraftEditor from 'braft-editor';
import 'braft-editor/dist/index.css';

import { emoticonsArr } from '@/constants/emoji';
BraftEditor.use(
    createEmoticonPlugin({
        includeEditors: ['demo-editor-with-emoticon'],
        emoticons: emoticonsArr,
        closeOnBlur: true,
        closeOnSelect: true
    })
);

<BraftEditor               
   id="demo-editor-with-emoticon"
   value={replyEditorState}
   onChange={editOnChange}
   controls={['emoji', 'bold', 'text-color', 'link']}                 
/>

回显问题的解决

我还解决了一个问题,即如何将 <p>[emoji38][emoji25]文本[emoji27][emoji36]</p> 这样的格式(其中可能会有其他的a标签之类的),解析出其中的emoji表情并在富文本编辑器中以正常的 emoji 显示。我通过解析 HTML,将其中的 emoji 文本转换为实际的 emoji 图片,利用ContentUtils.insertText 在原来的editorState进行修改,从而实现了这个功能。

附:

// 这个方法主要是识别节点类型,利用ContentUtils.insertText 在原来的editorState进行修改
const convertNodeToEditorState = (node, editorState) => {
    node.childNodes.forEach(childNode => {
        if (childNode.nodeName === 'P') {
            if (childNode.textContent.trim() === '') {
                // 如果 <p> 标签为空,添加换行
                editorState = ContentUtils.insertText(editorState, '\n');
            } else {
                // 如果 <p> 标签包含文本或其他元素,递归处理
                editorState = convertNodeToEditorState(childNode, editorState);
            }
            editorState = ContentUtils.insertText(editorState, '\n');
        } else if (
            childNode.nodeName === 'SPAN' &&
            childNode.classList.contains('braft-emoticon-wrap')
        ) {
            // 如果节点是一个表情符号,添加对应的表情符号标记
            const imgNode = childNode.querySelector('img');
            const src = imgNode.getAttribute('src');
            const emojiName = src
                .split('/')
                .pop()
                .replace('.png', '');
            editorState = ContentUtils.insertText(
                editorState,
                `[${emojiName}]`
            );
        }
        // 如果节点是a标签
        else if (childNode.nodeName === 'A') {
            editorState = ContentUtils.insertText(
                editorState,
                childNode.textContent,
                '',
                {
                    type: 'LINK',
                    data: { href: childNode.getAttribute('href') }
                }
            );
        } else if (childNode.nodeName === '#text') {
            // 如果节点是文本节点,则对文本进行切割:
            // 如 '你好[emoji1]再见[emoji2]了' 处理为 ['你好', '[emoji1]', '再见', '[emoji2]', '了']
            // 使用正则表达式匹配和分割字符串
            const str = childNode.textContent;
            const regex = /((?:\[[^\]]*\])|(?:[^\[]+))/g;
            const result = str.match(regex).map(item => {
                // 识别是否 [emoji + 数字(1~300)+] 的格式
                const isEmoji = /\[emoji(300|[1-2]?\d{1,2})\]/.test(item);
                return {
                    str: isEmoji ? item.replace(/\[|\]/g, '') : item,
                    isEmoji: isEmoji
                };
            });
            result.forEach(temp => {
                if (temp.isEmoji) {
                    editorState = ContentUtils.insertText(
                        editorState,
                        ' ',
                        null,
                        {
                            type: 'EMOTICON',
                            mutability: 'IMMUTABLE',
                            data: {
                                src: `${emojiImgUrl}${temp.str.replace(
                                    /\[|\]/g,
                                    ''
                                )}.png`
                            }
                        }
                    );
                } else {
                    editorState = ContentUtils.insertText(
                        editorState,
                        temp.str
                    );
                }
            });
        } else if (childNode.nodeName === 'BR') {
            editorState = ContentUtils.insertText(editorState, '\n');
        } else {
            // 如果节点是其他类型,递归处理
            editorState = convertNodeToEditorState(childNode, editorState);
        }
    });

    return editorState;
};

const convertHtmlToEditorState = (html, editorState) => {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    return convertNodeToEditorState(doc.body, editorState);
};

// 只能用当前的editorState去setValue,不然无法再次插入emoji
const editorState = ContentUtils.clear(braftEditorRef.current.getValue());
const text = convertHtmlToEditorState(val, editorState);
text && braftEditorRef.current.setValue(text);

样式处理

.bf-container {
      padding-bottom: 100px;
      .bf-controlbar {
        box-shadow: unset;
        .bf-dropdown:first-child {
          .dropdown-content {
            .dropdown-arrow {
              display: none;
            }
            top: -900%;
            border: 1px solid #d9d9d9;
            .dropdown-content-inner {
              background-color: white;
              .braft-emoticon-picker::before {
                background-image: none;
              }
              .braft-emoticon-picker::after {
                background-image: none;
              }
              .braft-emoticon-picker {
                width: 420px;
                overflow: auto;
                height: 300px;
                padding: 8px 8px 0 8px;
                .braft-emoticon-title {
                  margin: 4px 8px;
                }
                .braft-emoticon-list-common {
                  max-height: 74px;
                  min-height: 44px;
                  height: max-content !important;
                  overflow: hidden !important;
                }
                .braft-emoticons-list {
                  padding: 0;
                  width: 410px;
                  overflow: visible;
                  height: 200px;
                  img {
                    cursor: pointer;
                  }
                }
              }
            }
          }
        }
      }
      .bf-content {
        height: 100%;
        .public-DraftEditor-content {
          margin-top: -15px;
        }
        .public-DraftEditor-content > div {
          padding-bottom: 5px;
        }
      }
      .customer-button {
        margin: 5px 0;
        padding: 0 5px;
        font-size: 13px;
        .ant-upload {
          width: 34px;
        }
      }
      .customer-button-disabled {
        cursor: not-allowed;
      }
      .bf-media {
        display: contents;
      }
    }

猫子
17 声望0 粉丝