实现一个支持@的输入框

近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如"xxx在xxx提及了您!"。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:

但是不难发现跟微信飞书对比下,有两个细节没有处理。

  1. @用户没有高亮
  2. 在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。

然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果

封装之后使用:

js 复制代码
<AtInput
    height={150}
    onRequest={async (searchStr) => {
        const { data } = await UserFindAll({ nickname: searchStr });
        return data?.list?.map((v) => ({
            id: v.uid,
            name: v.nickname,
            wechatAvatarUrl: v.wechatAvatarUrl,
        }));
    }}
    onChange={(content, selected) => {
        setAtUsers(selected);
    }}
/>

那么实现这么一个输入框大概有以下几个点:

  1. 高亮效果
  2. 删除/选中用户时需要整体删除
  3. 监听@的位置,复制给弹框的坐标,联动效果
  4. 最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交

大多数文本输入框我们会使用input,或者textarea,很明显以上1,2两点实现不了,antd也是使用的textarea,所以也是没有实现这两个效果。所以这块使用富文本编辑,设置contentEditable,将其变为可编辑去做。输入框以及选择器的dom就如下:

html 复制代码
 <div style={{ height, position: 'relative' }}>
       {/* 编辑器 */}
       <div 
           id="atInput" 
           ref={atRef} 
           className={'editorDiv'} 
           contentEditable 
           onInput={editorChange} 
           onClick={editorClick} 
       />
       {/* 选择用户框 */}
       <SelectUser 
           options={options} 
           visible={visible} 
           cursorPosition={cursorPosition} 
           onSelect={onSelect} 
       />
 </div>

实现思路:

  1. 监听输入@,唤起选择框。
  2. 截取@xxx的xxx作为搜素的关键字去查询接口
  3. 选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来
  4. 选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除
  5. 那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了

以上提到了监听@文本变化,通常绑定onChange事件就行,但是还有一种用户通过点击移动光标,这块需要绑定change,click两个时间,他们里边的逻辑基本一样,只需要额外处理点击选中输入框中用户时,整体选中g功能,那么代码如下:

js 复制代码
    const onObserveInput = () => {
        let cursorBeforeStr = '';
        const selection: any = window.getSelection();
        if (selection?.focusNode?.data) {
            cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
        }
        setFocusNode(selection.focusNode);
        const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
        setCurrentAtIdx(lastAtIndex);
        if (lastAtIndex !== -1) {
            getCursorPosition();
            const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
            if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
                setSearchStr(searchStr);
                fetchOptions(searchStr);
                setVisible(true);
            } else {
                setVisible(false);
                setSearchStr('');
            }
        } else {
            setVisible(false);
        }
    };

    const selectAtSpanTag = (target: Node) => {
        window.getSelection()?.getRangeAt(0).selectNode(target);
    };

    const editorClick = async (event) => {
        onObserveInput();
        // 判断当前标签名是否为span 是的话选中当做一个整体
        if (e.target.localName === 'span') {
            selectAtSpanTag(e.target);
        }
    };

    const editorChange = (event) => {
        const { innerText } = event.target;
        setContent(innerText);
        onObserveInput();
    };

每次点击或者文本改变时都会去调用onObserveInput,以上onObserveInput该方法中主要做了以下逻辑:

  1. 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个"@"符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。
  2. 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。
js 复制代码
 const getCursorPosition = () => {
        // 坐标相对浏览器的坐标
        const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
        // 获取编辑器的坐标
        const editorDom = window.document.querySelector('#atInput');
        const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
        // 光标所在位置
        setCursorPosition({ x: x - eX, y: y - eY });
};

选择器弹出后,那么下面就到了选择用户之后的流程了,

js 复制代码
 /**
     * @param id 唯一的id 可以uid
     * @param name 用户姓名
     * @param color 回显颜色
     * @returns
     */
    const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
        const ele = document.createElement('span');
        ele.className = 'at-span';
        ele.style.color = color;
        ele.id = id.toString();
        ele.contentEditable = 'false';
        ele.innerText = `@${name}`;
        return ele;
    };

    /**
     * 选择用户时回调
     */
    const onSelect = (item: Options) => {
        const selection = window.getSelection();
        const range = selection?.getRangeAt(0) as Range;
        // 选中输入的 @关键字  -> @郑
        range.setStart(focusNode as Node, currentAtIdx!);
        range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
        // 删除输入的 @关键字
        range.deleteContents();
        // 创建元素节点
        const atEle = createAtSpanTag(item.id, item.name);
        // 插入元素节点
        range.insertNode(atEle);
        // 光标移动到末尾
        range.collapse();
        // 缓存已选中的用户
        setSelected([...selected, item]);
        // 选择用户后重新计算content
        setContent(document.getElementById('atInput')?.innerText as string);
        // 关闭弹框
        setVisible(false);
        // 输入框聚焦
        atRef.current.focus();
    };

选择用户的时候需要做的以下以下几点:

  1. 删除之前的@xxx字符
  2. 插入不可编辑的span标签
  3. 将当前选择的用户缓存起来
  4. 重新获取输入框的内容
  5. 关闭选择器
  6. 将输入框重新聚焦

最后

在选择的用户或者内容发生改变时将数据抛给父组件

js 复制代码
 const getAttrIds = () => {
        const spans = document.querySelectorAll('.at-span');
        let ids = new Set();
        spans.forEach((span) => ids.add(span.id));
        return selected.filter((s) => ids.has(s.id));
    };

    /**  @的用户列表发生改变时,将最新值暴露给父组件 */
    useEffect(() => {
        const selectUsers = getAttrIds();
        onChange(content, selectUsers);
    }, [selected, content]);

完整组件代码

输入框主要逻辑代码:

js 复制代码
let timer: NodeJS.Timeout | null = null;

const AtInput = (props: AtInputProps) => {
    const { height = 300, onRequest, onChange, value, onBlur } = props;
    // 输入框的内容=innerText
    const [content, setContent] = useState<string>('');
    // 选择用户弹框
    const [visible, setVisible] = useState<boolean>(false);
    // 用户数据
    const [options, setOptions] = useState<Options[]>([]);
    // @的索引
    const [currentAtIdx, setCurrentAtIdx] = useState<number>();
    // 输入@之前的字符串
    const [focusNode, setFocusNode] = useState<Node | string>();
    // @后关键字 @郑 = 郑
    const [searchStr, setSearchStr] = useState<string>('');
    // 弹框的x,y轴的坐标
    const [cursorPosition, setCursorPosition] = useState<Position>({
        x: 0,
        y: 0,
    });
    // 选择的用户
    const [selected, setSelected] = useState<Options[]>([]);
    const atRef = useRef<any>();

    /** 获取选择器弹框坐标 */
    const getCursorPosition = () => {
        // 坐标相对浏览器的坐标
        const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
        // 获取编辑器的坐标
        const editorDom = window.document.querySelector('#atInput');
        const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
        // 光标所在位置
        setCursorPosition({ x: x - eX, y: y - eY });
    };

    /**获取用户下拉列表 */
    const fetchOptions = (key?: string) => {
        if (timer) {
            clearTimeout(timer);
            timer = null;
        }
        timer = setTimeout(async () => {
            const _options = await onRequest(key);
            setOptions(_options);
        }, 500);
    };

    useEffect(() => {
        fetchOptions();
        // if (value) {
        //     /** 判断value中是否有at用户 */
        //     const atUsers: any = StringTools.filterUsers(value);
        //     setSelected(atUsers);
        //     atRef.current.innerHTML = value;
        //     setContent(value.replace(/<\/?.+?\/?>/g, '')); //全局匹配内html标签)
        // }
    }, []);

    const onObserveInput = () => {
        let cursorBeforeStr = '';
        const selection: any = window.getSelection();
        if (selection?.focusNode?.data) {
            cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
        }
        setFocusNode(selection.focusNode);
        const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
        setCurrentAtIdx(lastAtIndex);
        if (lastAtIndex !== -1) {
            getCursorPosition();
            const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
            if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
                setSearchStr(searchStr);
                fetchOptions(searchStr);
                setVisible(true);
            } else {
                setVisible(false);
                setSearchStr('');
            }
        } else {
            setVisible(false);
        }
    };

    const selectAtSpanTag = (target: Node) => {
        window.getSelection()?.getRangeAt(0).selectNode(target);
    };

    const editorClick = async (e?: any) => {
        onObserveInput();
        // 判断当前标签名是否为span 是的话选中当做一个整体
        if (e.target.localName === 'span') {
            selectAtSpanTag(e.target);
        }
    };

    const editorChange = (event: any) => {
        const { innerText } = event.target;
        setContent(innerText);
        onObserveInput();
    };

    /**
     * @param id 唯一的id 可以uid
     * @param name 用户姓名
     * @param color 回显颜色
     * @returns
     */
    const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
        const ele = document.createElement('span');
        ele.className = 'at-span';
        ele.style.color = color;
        ele.id = id.toString();
        ele.contentEditable = 'false';
        ele.innerText = `@${name}`;
        return ele;
    };

    /**
     * 选择用户时回调
     */
    const onSelect = (item: Options) => {
        const selection = window.getSelection();
        const range = selection?.getRangeAt(0) as Range;
        // 选中输入的 @关键字  -> @郑
        range.setStart(focusNode as Node, currentAtIdx!);
        range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
        // 删除输入的 @关键字
        range.deleteContents();
        // 创建元素节点
        const atEle = createAtSpanTag(item.id, item.name);
        // 插入元素节点
        range.insertNode(atEle);
        // 光标移动到末尾
        range.collapse();
        // 缓存已选中的用户
        setSelected([...selected, item]);
        // 选择用户后重新计算content
        setContent(document.getElementById('atInput')?.innerText as string);
        // 关闭弹框
        setVisible(false);
        // 输入框聚焦
        atRef.current.focus();
    };

    const getAttrIds = () => {
        const spans = document.querySelectorAll('.at-span');
        let ids = new Set();
        spans.forEach((span) => ids.add(span.id));
        return selected.filter((s) => ids.has(s.id));
    };

    /**  @的用户列表发生改变时,将最新值暴露给父组件 */
    useEffect(() => {
        const selectUsers = getAttrIds();
        onChange(content, selectUsers);
    }, [selected, content]);

    return (
        <div style={{ height, position: 'relative' }}>
            {/* 编辑器 */}
            <div id="atInput" ref={atRef} className={'editorDiv'} contentEditable onInput={editorChange} onClick={editorClick} />
            {/* 选择用户框 */}
            <SelectUser options={options} visible={visible} cursorPosition={cursorPosition} onSelect={onSelect} />
        </div>
    );
};

选择器代码

js 复制代码
const SelectUser = React.memo((props: SelectComProps) => {
  const { options, visible, cursorPosition, onSelect } = props;

  const { x, y } = cursorPosition;

  return (
    <div
      className={'selectWrap'}
      style={{
        display: `${visible ? 'block' : 'none'}`,
        position: 'absolute',
        left: x,
        top: y + 20,
      }}
    >
      <ul>
        {options.map((item) => {
          return (
            <li
              key={item.id}
              onClick={() => {
                onSelect(item);
              }}
            >
              <img src={item.wechatAvatarUrl} alt="" />
              <span>{item.name}</span>
            </li>
          );
        })}
      </ul>
    </div>
  );
});
export default SelectUser;

以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等,未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。

相关推荐
Qrun7 分钟前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp8 分钟前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.1 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl3 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫5 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友5 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理6 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻7 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
mapbar_front7 小时前
在职场生存中如何做个不好惹的人
前端
牧杉-惊蛰8 小时前
纯flex布局来写瀑布流
前端·javascript·css