最近在项目开发中,遇到这样一个需求,在textarea中(这里我们用的ant-design)在光标处插入目标字符串,字符串类似于模板字符串格式,通过${}包裹。
对目标字符串有几点要求:
1、不能在已有的目标字符串中再插入,鼠标点击或者说按键操作(上下左右)使光标在目标字符串范围内,光标要自动定位到目标字符串尾部;
2、删除目标字符串时只能整体删除,例如删除目标字符串中一个字符,需要将整个目标字符串删除,通过鼠标进行范围选取时,要将有交集的目标字符串都删除。
接下来,我们就开始处理。
首先,在光标处进行内容插入的话,得知道光标的位置如何获取。 下面我是用ref的形式,获取到标签元素,再得到标签的selectionStart,selectionEnd这两个属性,分别代表光标的开始结束位置,在不进行鼠标批量选择时,这两个通常在一个位置。
/** 获取光标的位置 */
const getInsertIndex = () => {
selectionStartIndex.value = (textareaRef.value?.$el as HTMLTextAreaElement)?.selectionStart || 0;
selectionEndIndex.value = (textareaRef.value?.$el as HTMLTextAreaElement)?.selectionEnd || 0;
};
在获取到了光标的位置后,插入操作也就简单了,通过模板字符串的形式,进行拼接。
对于2个要求。这个该如何实现呢?
1、首先,得确定目标字符串在整个字符串中下标的范围,我们知道目标字符串有一个特点,就是用${}进行包裹,所以我们可以通过正则+matchAll来获取所有的信息。注意,正则在书写时,$ { }这三个字符需要转义。防止正则贪婪特性在匹配时造成匹配失败问题,需要做一个限制,${和}中间的字符需要排除这三个字符。因为matchAll得到的是伪数组,所以需要转成真正的数组来进行遍历操作。这里我们就得到了所有的信息。(type,key,url是我其它业务逻辑所需参数,可以不关注)
/** 找出光标范围内的变量和短链 */
const findKeyIndex = (target: string) => {
// eslint-disable-next-line no-useless-escape
const reg = /\$\{([^\$\{\}])+\}/g;
const str = target;
const result = str.matchAll(reg);
return Array.from(result).map((item) => {
const { type, key, url } = checkUrlOrLabel(item[0]);
return {
start: item.index as number,
end: item.index as number + item[0].length,
type, // 短链还是变量
key, // 短链为uid, 变量为名称
url,
};
});
};
这时得到了textarea中目标字符串的下标信息,根据下标信息处理光标。
监听keydown事件,监听各种按键操作
const keyList = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
const handleDeleteKey = (val: KeyboardEvent) => {
// 需要处理backspace,delete,上,下,左,右这几个按键
if (!keyList.includes(val.key)) { // 当不是目标键时,直接放过
return;
}
getInsertIndex();
// 删除时,若是删除变量和短链
// 判断删除的内容是否与变量或者短链有交集
const list = findKeyIndex(props.value);
if (val.key === 'Backspace') {
keyBackspace(list);
} else if (val.key === 'Delete') {
keyDelete(list);
} else {
keyDirection();
}
};
2、处理鼠标点击
这个我们直接在组件上绑定click事件来处理,首先,得到光标开始结束下标。看是否光标落在目标字符串范围内,在的话就将光标结束位置设置为目标字符串的结束位置;不在则不处理
/** 点击时获取光标的位置,若在变量或者短链范围内 */
const handleClick = () => {
getInsertIndex();
const list = findKeyIndex(props.value);
// eslint-disable-next-line consistent-return
list.forEach((item) => {
if (item.start === selectionStartIndex.value) {
setStartIndex(item.start);
setEndIndex(item.start);
return false;
}
if (item.start < selectionStartIndex.value && selectionStartIndex.value <= item.end) {
// 移动光标到变量或者短链结尾处
setStartIndex(item.end);
setEndIndex(item.end);
return false;
}
});
};
3、处理Backspace键操作
删除操作的话我们只需要设置光标的开始结束位置,不用额外操作字符串,浏览器会自动删除字符串。这里的逻辑为找到光标范围内有交集的目标字符串集合,再根据集合确定删除内容的范围:
(1)若是result的长度为0,说明没有交集,直接删除
(2)若是result有数据,则取result第一项的start和最后一项的end与光标开始位置和光标结束位置进行大小比较,若是start小于光标开始位置,则将光标开始位置赋值start;同理end也需要处理。(其实这里需要注意result只有一项和有多项的问题,但是一项和多项最后都要回归到start和end的判断处理,所以不用分开处理)
const keyBackspace = (list: Result[]) => {
const result = list.filter((item) => (
selectionStartIndex.value >= item.start && selectionStartIndex.value <= item.end)
|| (selectionEndIndex.value >= item.start && selectionEndIndex.value <= item.end)
|| (selectionStartIndex.value <= item.start && selectionEndIndex.value >= item.end));
if (result.length) { // 说明与短链或者变量有交集
// 根据光标的开始和结束返回,判断有哪些
const { start } = result[0];
const { end } = result[result.length - 1]; // 移动光标,删除相关内容
if (start < selectionStartIndex.value) {
setStartIndex(start);
}
if (end > selectionEndIndex.value) {
setEndIndex(end);
}
}
};
4、处理Delete键,Delete键有两种情况:
(1)光标开始位置和结束位置一致,此时删除操作为向后删除一个字符
(2)光标开始位置结束位置不一致,此时代表批量选择了,删除操作就是将光标范围内的数据删除。此时的操作就和Baskspace键一致。
所以代码逻辑就要进行判断
const keyDelete = (list: Result[]) => {
if (selectionStartIndex.value === selectionEndIndex.value) { // 没有进行范围选择,判断光标后一个字符是否在目标范围内
const nextIndex = selectionStartIndex.value + 1;
const target = list.find((item) => item.start <= nextIndex && nextIndex <= item.end);
// 设置光标
if (target) {
setEndIndex(target.end);
}
} else {
// 若是范围的话,就和Backsapce按键操作一致
keyBackspace(list);
}
};
5、处理上下左右键('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown')
这个操作的话,就是判断光标移动之后的位置是否在目标字符串范围内。此时的操作就和点击事件一致了。注意点:若是在keydown监听事件直接调用的话,光标位置还没有变化,此时处理就不生效,所以我这加了一个setTimeout,一开始我是使用async await nextTick()方式,但是并不生效,因为组件根本没有更新
const keyDirection = () => {
setTimeout(() => handleClick(), 0);
};
到这基本要求算是达到了。
总结:虽然这个功能比较简单,但是其中需要考虑的点也是比较多的。其实我在开发中也并不是一下子就可以考虑到所有的点,也是在实践的过程中不断发现完善的。其实这个过程也是我们开发工作的缩影吧。明确需求,拆分需求,边界问题,逐一解决,逐一完善。