灏天阁

Textarea 光标实践

· Yin灏

最近在项目开发中,遇到这样一个需求,在 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 来获取所有的信息。注意,正则在书写时,进行包裹,所以我们可以通过正则+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);
};

到这基本要求算是达到了。

总结:虽然这个功能比较简单,但是其中需要考虑的点也是比较多的。其实我在开发中也并不是一下子就可以考虑到所有的点,也是在实践的过程中不断发现完善的。其实这个过程也是我们开发工作的缩影吧。明确需求,拆分需求,边界问题,逐一解决,逐一完善。

- Book Lists -