记录---实现一个支持@的输入框

news/2024/10/5 5:58:49/文章来源:https://www.cnblogs.com/smileZAZ/p/18274149

🧑‍💻 写在开头

点赞 + 收藏 === 学会🤣🤣🤣

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

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

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

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

 

 

封装之后使用:

<AtInputheight={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就如下:

 <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功能,那么代码如下:

    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该方法中主要做了以下逻辑:

  • 在此之前需要先了解 Selection的一些方法
  1. 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。
  2. 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。
 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 });
};
选择器弹出后,那么下面就到了选择用户之后的流程了,
 /*** @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]);// 选择用户后重新计算contentsetContent(document.getElementById('atInput')?.innerText as string);// 关闭弹框setVisible(false);// 输入框聚焦atRef.current.focus();};

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

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

最后

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

 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]);

完整组件代码

输入框主要逻辑代码:

let timer: NodeJS.Timeout | null = null;const AtInput = (props: AtInputProps) => {const { height = 300, onRequest, onChange, value, onBlur } = props;// 输入框的内容=innerTextconst [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]);// 选择用户后重新计算contentsetContent(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>);
};

选择器代码

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

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

本文转载于:https://juejin.cn/post/7357917741909819407

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/732982.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

前端异常监控平台Sentry安装配置使用及问题

一、Docker安装 这里我们使用Docker环境来安装Sentry,这里简述下载CentOS系统中安装Docker的过程。卸载旧版本的 Docker(如果有的话): 如果之前安装过 Docker,需要先卸载: sudo yum remove -y docker \ docker-client \ docker-client-latest \ docker-common \ docker-l…

VUE 调用电脑摄像头进行拍照并上传到服务器保存,附前后端代码

前端代码 <template><div id="picUpload"><div><el-form ref="uploadForm" :model="uploadForm" :rules="rules" label-width="120px"><el-row><el-form-item label="文件来源系统&q…

.net core如何读取appsettings的配置文件

假设建立一个.netcore mvc网站, 在appsettings.json添加如下代码: 如何读取这些配置的数值呢? 法一: 1. 先安装如下包: 2. 然后在HomeController.cs里添加如下代码: 测试结果如下: 法二: 1.先定义一个Model,如下: 2.然后去Startup.cs文件里,找到ConfigureServices方…

SUB-1G超低功耗收发芯片DP10RF001支持200MHz~960MHz(G)FSK/OOK调制无线抄表工业传感无线遥控

简介DP10RF001是一款工作于 200MHz~960MHz 范围内的低功耗、高性能、单片集成的(G)FSK/OOK无线收发芯片。内部集成完整的射频接收机、射频发射机、频率综合器、调制解调器,只需配备简单、低成本的外围器件就可以获得良好的收发性能。芯片支持灵活可设的数据包格式,支持自动应…

NCHU-软件学院-232019班-23201125-罗伊鑫-第三次Blog

前言 总结两次题目集的题目的知识点、题量、难度等情况 1.知识点 两次题目集都对于类的设计的提前规划好有着必要的需求,还有就是对于继承与多态的合理的使用。接着就是对于正则表达式的使用的检测,然后就是要有清晰的逻辑编程表达。 2.题量 总的来说,两次题目集的题量还是充…

未来教育新篇章:可视化大屏引领知识革命

在这个信息爆炸的时代,教育资源的获取和呈现方式正在经历一场前所未有的变革。想象一下,当所有的教育资源都以一种直观、生动的方式展现在你眼前,那会是一种怎样的体验?今天,就让我们一起走进这个充满无限可能的“教育资源可视化大屏”世界。传统的教育模式往往受限于教材…

ETL服务器连接GaussDB(DWS)集群客户端配置

问题描述:给ETL的服务器上安装gsql的工具,用来连接GaussDB(DWS)集群,做数据抽取用 DWS:GaussDB(DWS) 8.2.1-ESL 1.获取软件包 登录FusionInsight Manager系统,在“集群”下拉列表中单击需要操作的集群名称。选择“更多 >->下载客户端->下载集群客户端,不选择保存…

Win 安装WSL+Ubuntu

简要:Win安装WSL(Windows Subsystem for Linux)主要是在win系统可以运行linux系统,下面详解介绍如何安装WSL和Ubuntu以及Ubuntu一些简单配置一、安装WSL 可以参考官网内容->WSL相关介绍 1、使用图像界面 打开控制面板(win+r输入control)->程序和功能->打开或关闭…

Altair SimSolid无网格快速结构仿真软件

Altair SimSolid软件作为一款快速无网格划分工具,凭借其独特的算法和计算能力,简化了工程师和分析师在进行复杂结构分析时的操作。它不仅提高了分析效率,降低了出错的可能性,还为用户提供了丰富的分析功能和直观易用的操作体验。在未来,随着Altair SimSolid的不断升级和优…

Hive 元数据

元数据,即解释数据的数据。在Hive架构中,数据库/表内实际数据存放在HDFS之类的分布式存储系统中,但这些数据并不带有数据库/表/字段之类的描述信息,这些信息均由单独的关系型数据库维护(一般用MySQL)。 表信息 TBLS 记录数据表的信息,可以作为左表,去关联表的其他信息。…