要实现的主要功能:
-
可输入文字
-
可输入任何自定义话题,话题类型为:#话题#
-
可以在输入文字的任意位置加话题,如:#话题#我是测试、我是#话题#测试、我是测试#话题#
-
当输入的#为单数时,打开话题列表,如图1。当动态输入的话题能够模糊搜索到时,显示话题列表,搜索不到时,该话题将不会在向后端请求查询。例如#test没有搜索到,那么我们继续输入,当前话题变成#test1将不会向后端发起查询请求。
-
话题列表随输入的位置移动显示,包括换行。
图1
大的难点有:
-
如何获取当前输入的位置。
-
如何确定话题列表的显示列表。
-
如何将选中的话题插入到输入字符串中。
-
如何确定当前输入之前的#为单数。
-
换行时如何确定话题列表的位置。
一、解决
1、获取当前输入的位置
当前输入位置有三种情况,鼠标在当前输入字符串的首部、中间、末尾。
使用textarea或者input输入时,我们并不能获取到当前输入的位置,所以我们要使用textarea+div的形式来获取当前输入的位置。
a、textarea和div同步显示输入内容
这里textarea我使用的是antd的Input.Textarea,它存在一个问题就是输入中文时,输入一个字符时,触发一次onChange,整体代码如下:
<div className="textare-comp">
<div ref={inputRef}>
<Input.TextArea
value={value}
placeholder={placeholder}
onChange={onChangeText}
className={inputCls}
/>
</div>
<div className="text-warp" ref={inputWrapRef} style={{ display: 'inline-block' }}>
{hiddenValue}
</div>
</div>
onChangeText函数:
const onChangeText = e => {
const value = e?.target?.value;
value?.length <= maxLen && onChange(value);
let index = getMousePos();
const start = value.lastIndexOf('\n') > -1 ? value.lastIndexOf('\n') : 0;
// 匹配回车
if (value?.[value?.length - 1]?.charCodeAt() === 10) {
index = value?.length - 1; }
setHiddenValue(value.slice(start, index));
changeTopicsList();
};
-
maxLen为我们输入的最大字符数,因为我们这里有字数限制。
-
getMousePos函数即可获取当前鼠标位置,返回的index为鼠标在字符串中的索引值。
-
start值的判断逻辑是当输入的有换行符,则取最后一个换行符的index,否则start为0。
-
如果最后一个换行符刚好是字符串的末尾,index为输入框内容的长度。
-
如果不是末尾,则将start~index位置的字符串作为div的内容。
此时div中的内容即为鼠标之前的内容了,接下来我们就可以插入话题了。
b、获取鼠标的位置
// 获取鼠标在输入框中的像素位置
const getMousePosPx = () => {
const inputWidth = inputRef.current.offsetWidth;
const wrapBox = inputWrapRef.current.getBoundingClientRect();
const wrapHeight = wrapBox.width / (inputWidth - 22);
const width = wrapBox.width % inputWidth || 0;
let height = parseInt(wrapHeight) * wrapBox.height;
const lineFeedNum = value?.match(/\n/gi)?.length || 0;
height = lineFeedNum > 0 ? (lineFeedNum + 1) * wrapBox.height : height;
if (height > 90) {
height = 90;
}
return { width, height };
};
getMousePos即为获取鼠标位置的函数,inputRef为Input.Textarea的引用,target为Input.Textarea的DOM节点。
非IE浏览器使用target?.selectionStart可以获取到当前鼠标的位置,但是最换行和空格不起作用。
IE浏览器可以通过创建一个range的方法获取鼠标的位置。
c、获取鼠标之前的字符
当用户输入时,我们要将鼠标之前的字符获取到,设置为div的内容,由此获取div的宽度和高度确定话题列表的位置。
代码:
// 获取输入框中鼠标的位置
const getMousePos = () => {
const target = inputRef.current?.children?.[0];
let position = -1;
// 非IE浏览器
if (target?.selectionStart) {
position = target.selectionStart;
} else {
// IE
const range = document?.selection?.createRange();
range?.moveStart('character', -target.value.length);
position = range?.text?.length;
}
return position;
};
d、插入话题
插入话题的方式有两种:
-
输入自定义话题。
-
插入话题列表的话题。
输入自定义话题:
输入自定义话题插入话题的触发条件是hiddenValue的更改。当hiddenValue更改时,触发changeTopicsList函数。
changeTopicsList函数中做的事情有:
- 判断鼠标之前的#是单数还是双数。
- 如果是单数,则获取字符串开始到当前输入位置的宽度和高度,以及内容、话题。并通过onClickTopics函数显示话题列表。
- 如果是双数,话题列表是打开的,并且字符串开始到当前输入位置之间没有#,则关闭话题列表。
代码:
// 获取当前的位置,更新话题列表的位置
const changeTopicsList = () => {
const value = inputRef.current?.children?.[0]?.value;
const num = value?.match(/#/gi)?.length || 0;
const index = getMousePos();
const { width = 90, height = 130 } = getMousePosPx(index);
const lastIndex = value.slice(0, index).lastIndexOf('#');
if (num % 2 !== 0) {
// 记录要查找的内容
searchTopicRef.current = value.slice(lastIndex + 1, index);
onClickTopics({ top: height + 60, left: width + 68 });
} else {
clickTopicBtn && onClickTopics({ top: height + 60, left: width + 68 });
showTopics && setShowTopics(false);
}
};
getMousePosPx函数所做的就是根据div获取话题列表要显示的像素位置。
代码:
// 获取鼠标在输入框中的像素位置
const getMousePosPx = () => {
const inputWidth = inputRef.current.offsetWidth;
const wrapBox = inputWrapRef.current.getBoundingClientRect();
const wrapHeight = wrapBox.width / (inputWidth - 22);
const width = wrapBox.width % inputWidth || 0;
let height = parseInt(wrapHeight) * wrapBox.height;
if (height > 90) {
height = 90;
}
return { width, height };
};
注意:
-
inputWrapRef是div的引用。
-
inputRef.current.offsetWidth获取输入框的大小,为width+padding+border。
-
getBoundingClientRect返回的是一个DOMRect对象,可以通过这个对象获取到div的宽度。
-
由于输入框中的换行在div中是一起作用的,输入的内容在div中一行显示,所以需要使用wrapBox.width / (inputWidth - 22)计算行数,并用parseInt(wrapHeight) * wrapBox.height计算出对应的像素数,wrapBox.width % (inputWidth - 23) || 0计算宽度。因为inputWidth可能会不能容纳整数的字符,所以后面才会减22或者23。
-
判读height是否大于90,是因为输入框的高度就是90。
插入话题列表的话题:
插入话题列表的话题是当输入一个单数的#时,话题列表展开,点击话题,将话题插入到鼠标坐在位置。所以触发条件是话题的点击。
inserTopics函数中要做的事情是:获取当前输入之前的内容、之后的内容,将其与话题进行拼接,并更改textarea和div中的显示。
代码:
const inserTopics = text => {
const index = getMousePos();
let firstStr = value?.slice(0, index);
const len = value?.length;
const num = value?.match(/#/gi)?.length || 0;
// 双数
let resultStr = index > 0 ? value?.slice(index, len) : '';
let lastStr = '';
// 如果有单数的#,并插入时,替换光标之前最后一个# 和 光标之间的内容
if (num % 2 !== 0) {
const lastIndex = value.slice(0, index).lastIndexOf('#');
firstStr = value.slice(0, lastIndex);
lastStr = value.slice(index, value.length);
}
lastStr = index > 0 ? value?.slice(index, len) : '';
resultStr = `${firstStr}#${text}#${lastStr}`;
onChange(resultStr);
};
这里我还判断单数和双数的#,是因为在输入框下方还有一个插入话题的按钮。
完整代码:
export default function TextArea({ onChange, maxLen = 0, value = '', selectData = '', searchTopicRef, showTopics, clickTopicBtn, onClickTopics = () => {}, setShowTopics = () => {}, placeholder }) {
const [hiddenValue, setHiddenValue] = useState('');
const inputRef = useRef();
const inputWrapRef = useRef();
// 获取鼠标在输入框中的像素位置
const getMousePosPx = () => {
// const value = inputRef.current?.children?.[0]?.value;
const inputWidth = inputRef.current.offsetWidth;
const wrapBox = inputWrapRef.current.getBoundingClientRect();
const wrapHeight = wrapBox.width / (inputWidth - 22);
const width = wrapBox.width % inputWidth || 0;
let height = parseInt(wrapHeight) * wrapBox.height;
if (height > 90) {
height = 90;
}
return { width, height };
};
// 获取当前的位置,更新话题列表的位置
const changeTopicsList = () => {
const value = inputRef.current?.children?.[0]?.value;
const num = value?.match(/#/gi)?.length || 0;
const { width = 90, height = 130 } = getMousePosPx();
const index = getMousePos();
const lastIndex = value.slice(0, index).lastIndexOf('#');
if (num % 2 !== 0) {
// 记录要查找的内容
searchTopicRef.current = value.slice(lastIndex + 1, index);
onClickTopics({ top: height + 60, left: width + 68 });
} else {
clickTopicBtn && onClickTopics({ top: height + 60, left: width + 68 });
showTopics && setShowTopics(false);
}
};
const onChangeText = e => {
const value = e?.target?.value;
value?.length <= maxLen && onChange(value);
let index = getMousePos();
const start = value.lastIndexOf('\n') > -1 ? value.lastIndexOf('\n') : 0;
// 匹配回车
if (value?.[value?.length - 1]?.charCodeAt() === 10) {
index = value?.length - 1;
setShowTopics(false);
return false;
}
setHiddenValue(value.slice(start, index));
};
// 获取输入框中鼠标的位置
const getMousePos = () => {
const target = inputRef.current?.children?.[0];
let position = -1;
// 非IE浏览器
if (target?.selectionStart) {
position = target.selectionStart;
} else {
// IE
const range = document?.selection?.createRange();
range?.moveStart('character', -target.value.length);
position = range?.text?.length;
}
return position;
};
const inserTopics = text => {
const index = getMousePos();
let firstStr = value?.slice(0, index);
const len = value?.length;
const num = value?.match(/#/gi)?.length || 0;
// 双数
let resultStr = index > 0 ? value?.slice(index, len) : '';
let lastStr = '';
// 如果有单数的#,并插入时,替换光标之前最后一个# 和 光标之间的内容
if (num % 2 !== 0) {
const lastIndex = value.slice(0, index).lastIndexOf('#');
firstStr = value.slice(0, lastIndex);
lastStr = value.slice(index, value.length);
}
lastStr = index > 0 ? value?.slice(index, len) : '';
resultStr = `${firstStr}#${text}#${lastStr}`;
onChange(resultStr);
};
useEffect(() => {
selectData && inserTopics(selectData);
}, [selectData]);
useEffect(() => {
changeTopicsList();
}, [hiddenValue]);
const inputCls = cx('input text-overflow-nowrap');
return (
<div className="textare-comp">
<div ref={inputRef}>
<Input.TextArea value={value} placeholder={placeholder} onChange={onChangeText} className={inputCls} />
</div>
<div className="text-warp" ref={inputWrapRef} style={{ display: 'inline-block' }}>
{hiddenValue}
</div>
</div>
);
}