本篇文章实现一个简单的提及功能,在输入 @ 后会弹出动作面板选择人员,选择后显示在正文里,并在最后获取正文和提及人员信息。最终效果如下:
目前暂不包含删除提及人的部分字符后直接删除完整的提及人@的功能,有思路的欢迎交流。
需求分析
这个功能最让人头疼的地方在于小程序文本域的输入回调提供的信息过少,没办法直接判断输入位置,在 Taro 文档中提到 TextArea 的 onInput 事件会返回文本内容 value、光标位置 cursor、按键类型 keyCode 三个值。但是在我的实际测试中,只拿到了 value。
这是什么人间疾苦。
所以说,我们需要先搞一个算法来找到当前新输入了什么以及在哪输入的。
之后的问题就好办了,如果输入的是 @,就拉起动作面板,选择某个人后触发回调,在正文里插入这个人的名字,然后把对应的信息存到另一个 state 方便后续操作就可以了。
组件实现
这里先贴出来完整代码,有需要的可以直接拿去用,组件库是 taro-vant:
MentionTextarea\index.tsx
import { useReady } from "@tarojs/taro";
import { FC, forwardRef, useImperativeHandle, useRef, Ref, useState } from "react";
import { View, Textarea } from "@tarojs/components";
import { ActionSheet, Search, Cell, Notify } from "@antmjs/vantui";
import { debounce, DebouncedFunc } from 'lodash';
import { getQuestionUser } from "@/services/engineerCarbonCopyProblem";
export interface Props {
defaultContent?: string
className?: string
ref?: Ref<MentionTextareaRef>
}
/**
* 抄送问题人员
*/
export interface QuestionUser {
id: number
originId: number
name: string
tel: string
/**
* 岗位名
*/
type: string
}
export interface MentionTextareaRef {
/**
* 获取填写的内容
* @returns [正文内容,抄送人员列表]
*/
getContent: () => [string, QuestionUser[]]
/**
* 设置填写的内容
*/
setContent: (content: string) => void
}
/**
* 找到新旧字符串的变化
*
* @param oldStr 老字符串
* @param newStr 新字符串
* @returns [变更的字符(删除则为空), 变更的位置]
*/
const findDiffChar = (oldStr: string, newStr: string): [string, number] => {
const arr1 = oldStr.split("");
const arr2 = newStr.split("");
// 用较长的内容进行遍历
const mapStr = arr1.length > arr2.length ? arr1 : arr2;
for (let i = 0; i < mapStr.length; i++) {
if (arr1[i] === arr2[i]) continue;
// 如果原始字符串长的话,说明是删除了字符
if (oldStr.length > newStr.length) return ['', i];
// 否则(原始字符串短)说明是插入了字符
return [mapStr[i], i];
}
return ['', -1];
};
/**
* 附带提及功能的文本域
* 非受控组件,通过 ref 来获取文本域的内容
* 用法见 src\pages\engineerReply\index.tsx
*/
export const MentionTextarea: FC<Props> = forwardRef((props, ref) => {
const { className, defaultContent= '' } = props;
// 输入框引用
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 上一个输入的内容
const lastContentRef = useRef<string>("");
// 当前编辑的位置
const editInfoRef = useRef<[string, number]>(['', 0]);
// 是否展示动作面板
const [showActionPanel, setShowActionPanel] = useState(false);
// 抄送人待选列表
const [userList, setUserList] = useState<QuestionUser[]>([]);
// 当前选中的所有抄送人
const selectedUsersRef = useRef<QuestionUser[]>([]);
// 正文内容
const [content, setContent] = useState<string>(defaultContent);
// 搜索防抖
const searchDebounce = useRef<DebouncedFunc<(keywords: any) => Promise<void>>>();
// 页面就绪后注册搜索防抖
useReady(() => {
const onSearch = async (keywords) => {
const resp = await getQuestionUser(keywords)
if (!resp?.success) {
Notify.show({ type: 'danger', message: resp?.message || '无法加载抄送人列表' });
return;
}
setUserList(resp?.data || []);
}
searchDebounce.current = debounce(onSearch, 300)
})
// 内容变更时触发的回调
// 会检查有没有输入 @
const onFieldInput = async (e) => {
const [diffChar, diffIndex] = findDiffChar(lastContentRef.current, e.detail.value);
// 输入了 @ 就打开抄送人面板
if (diffChar === '@') {
searchDebounce.current?.('');
setShowActionPanel(true);
}
setContent(e.detail.value)
lastContentRef.current = e.detail.value
editInfoRef.current = [diffChar, diffIndex]
}
// 在抄送人面板里选择抄送人
const onSelectUser = (user: QuestionUser) => {
console.log(editInfoRef.current)
// 把抄送人插入到当前光标位置
setContent(oldContent => {
const diffIndex = editInfoRef.current[1];
const newContent = oldContent.slice(0, diffIndex + 1) + user.name + oldContent.slice(diffIndex + 1);
// 插入抄送人后要及时更新内容,防止下次输入内容时输入位置定位错误
lastContentRef.current = newContent;
return newContent;
});
// 把抄送人添加到选中列表
selectedUsersRef.current = [...new Set([...selectedUsersRef.current, user])];
setShowActionPanel(false);
(textareaRef.current?.firstChild as HTMLTextAreaElement).focus()
}
// 获取抄送信息
const getContent = (): [string, QuestionUser[]] => {
const selectedUser = selectedUsersRef.current.filter(user => content.includes(`@${user.name}`));
return [content, selectedUser]
}
useImperativeHandle(ref, () => ({ setContent, getContent }))
// 渲染抄送人项目
const renderUser = (item: QuestionUser) => {
return (
<Cell
key={item.id + item.type + item.name}
onClick={() => onSelectUser(item)}
title={item.name}
value={item.type}
/>
)
}
return (
<View className={className}>
<Textarea
ref={textareaRef}
value={content}
placeholder='请输入回复内容,输入 @ 来选择抄送人'
onInput={onFieldInput}
autoFocus
autoHeight
maxlength={-1}
/>
<ActionSheet
show={showActionPanel}
onClose={() => setShowActionPanel(false)}
>
<View>
<Search
onChange={e => searchDebounce.current?.(e.detail)}
shape='round'
placeholder='请输入岗位/姓名'
/>
{userList.map(renderUser)}
</View>
</ActionSheet>
</View>
);
});
用法也很简单,组件通过 ref 暴露了 setContent 和 getContent 两个 api。setContent 可以直接修改正文内容,而 getContent 则返回一个元组,元素分别为正文内容和选择的被提及人数组。
具体类型可以看上面代码里的 MentionTextareaRef 接口。
下面是个调用示例:
import { FC, useRef } from "react";
import { View } from "@tarojs/components";
import { Button, Dialog } from "@antmjs/vantui";
import { MentionTextarea, MentionTextareaRef } from "@/components/MentionTextarea";
const MentionExample: FC = () => {
const MentionRef = useRef<MentionTextareaRef>(null);
const onSubmit = async () => {
const [content = '', atUsers = []] = MentionRef.current?.getContent() || [];
console.log('内容', content, '被@人', atUsers)
Dialog.alert({
title: '@人员信息',
message: atUsers.map(user => `${user.name} ID: ${user.id}`).join('\n')
})
return;
}
return (
<View>
<Dialog id='vanDialog' />
<MentionTextarea ref={MentionRef} />
<Button type='primary' block color='#00D3A3' onClick={onSubmit}>
确定
</Button>
</View>
);
};
export default MentionExample;
实现细节
下面的篇幅我们来讲一下在开发这个组件里遇到的一些问题、修复办法以及优化。
1、找到新输入字符及其位置
上面代码中的 findDiffChar 方法,是个纯函数可以直接拿走用。
原理也很简单,从头开始对比,找到第一个不相同的字符,然后判断下是新增还是删除就可以了。
2、搜索防抖
使用 lodash 的 debounce 来减少搜索请求次数
// 搜索防抖
const searchDebounce = useRef<DebouncedFunc<(keywords: any) => Promise<void>>>();
// 页面就绪后注册搜索防抖
useReady(() => {
const onSearch = async (keywords) => {
// ...
}
searchDebounce.current = debounce(onSearch, 300)
})
3、及时更新最后输入内容
组件里使用了 useRef 来保存最后输入的内容,用于和本次内容进行比较来得出新输入的字符和输入位置:
const lastContentRef = useRef<string>("");
// 输入内容回调
const onFieldInput = async (e) => {
// 使用 lastContentRef 来确定新输入字符
const [diffChar, diffIndex] = findDiffChar(lastContentRef.current, e.detail.value);
// ...
lastContentRef.current = e.detail.value
}
// 在抄送人面板里选择抄送人
const onSelectUser = (user: QuestionUser) => {
// 把抄送人插入到当前光标位置
setContent(oldContent => {
const diffIndex = editInfoRef.current[1];
const newContent = oldContent.slice(0, diffIndex + 1)
+ user.name
+ oldContent.slice(diffIndex + 1);
// 插入抄送人后要及时更新内容,防止下次输入内容时输入位置定位错误
lastContentRef.current = newContent;
return newContent;
});
}
这里需要尤其注意第三部分,选择抄送人后会更新正文内容,这里同样需要更新 lastContentRef,不然会导致下次输入的内容没法正确定位出哪个是新字符。如果这时候输入 @ 就会发现人员选择不会正常弹起。
4、重新获取焦点
Taro TextArea 和微信小程序一样,都是通过设置 focus prop 来重新获取焦点,但是我在实际使用中发现这么做不行。由于这个项目是在 h5 上开发的,于是就直接通过 ref 调用对应元素上的 focus 方法:
// 在抄送人面板里选择抄送人
const onSelectUser = (user: QuestionUser) => {
// ...
(textareaRef.current?.firstChild as HTMLTextAreaElement).focus()
}
5、获取内容前被@人二次确认
由于我们这里是简单的保存正文 string,用户可以随意的删除被 @ 人的部分名字,所以我们在最后要对被 @ 人进行一下确认,确保其名字是在内容里完整存在的:
// 获取抄送信息
const getContent = (): [string, QuestionUser[]] => {
// 剔除掉所有名字不完整的抄送人
const selectedUser = selectedUsersRef.current.filter(user => content.includes(`@${user.name}`));
return [content, selectedUser]
}
实际上这个功能一开始是设计成受控组件,但是后来发现如果是受控组件就需要在每次用户输入时跑一遍这个 filter 确认,为了减少计算量,遂改为调用 ref api 的形式。