解决 contenteditable 删除非法字符导致光标跳到开头的问题

在前端项目中,有时候我们会在 contenteditable 元素中处理用户输入,比如想自动清理非法字符(例如本文中我需要清理的 | 符号),同时保持光标位置不变。但实际操作中,如果你直接修改 el.textContentinnerText,会导致光标丢失或跳到开头。

本文将记录我如何解决这个问题,完整复原用户输入时的光标偏移。


🧩 需求场景

富文本输入框,当用户输入的内容中包含 | 字符时,需删除该字符,并保留当前光标位置。

场景一:直接输入 | 字符;

场景二:通过输入法输入时,包含了 | 字符。


🧪 效果展示

  • 用户输入包含 | 字符;

  • 按下空格后自动清除 |

  • 光标依旧保持在正确位置,不会跳到最前或末尾;

  • 支持中文输入法,避免 composition 阶段误触发。


💥 问题来源

  • window.getSelection() 获取的 range.endContainer 失效;

  • range.endOffset 变成 0

  • 光标跳到开头了!


🧠 补充说明:中文输入法兼容性

在中文等复合输入法(IME)场景下,用户输入字符会经历一个“合成阶段(composition)”,这个阶段字符尚未最终确认。如果你在 inputkeyup 阶段直接操作 DOM(比如清除非法字符),可能会不小心删除用户正在输入的拼音或候选词。

为避免这种情况,我们需要借助 compositionstartcompositionend 来识别这个阶段:

<span
  contenteditable="true"
  @keydown="editKeydown"
  @keyup="editKeyup"
  @compositionstart="onCompositionStart"
  @compositionend="onCompositionEnd"
>
</span>
data() {
  return {
    isComposing: false, // 标记是否处于输入法合成中
  };
},
methods: {
  onCompositionStart() {
    this.isComposing = true;
  },
  onCompositionEnd() {
    this.isComposing = false;
  },
}

✅ 解决方案

设定 span 元素 contenteditable="true"

场景一:

通过绑定的 @keydown 事件直接阻断输入:

if (e.key === "|" && e.ctrlKey === false && e.metaKey === false) {
   e.preventDefault(); // 阻止默认的 `|` 输入
}

场景二

1. 捕获更新前的光标偏移

getCaretCharacterOffsetWithin(element) {
  const selection = window.getSelection();
  let caretOffset = 0;

  // 如果没有选区,直接返回 0
  if (!selection || selection.rangeCount === 0) return 0;

  // 获取当前的选区(range 表示光标范围)
  const range = selection.getRangeAt(0);
  // 克隆一份 range 以免影响真实的选区
  const preCaretRange = range.cloneRange();
  // 将 range 折叠为一个点(光标所在位置)
  preCaretRange.collapse(false); // false 表示折叠到末尾,即光标处
  // 设置起点为 element 的起始位置
  preCaretRange.selectNodeContents(element);
  // 结束点为光标位置
  preCaretRange.setEnd(range.endContainer, range.endOffset);
  // 选中的文本即为从头到光标的所有字符,取长度即可
  caretOffset = preCaretRange.toString().length;

  return caretOffset;
}

2. DOM 更新后重新设置光标

setCaretPosition(element, offset) {
  const range = document.createRange();           // 创建一个新的光标范围
  const selection = window.getSelection();        // 获取当前选区

  let currentOffset = 0;                          // 用于累计字符数
  let nodeStack = [element];                      // 使用队列实现 DFS
  let found = false;

  // 遍历所有子节点(按顺序查找 offset 对应的文本节点)
  while (nodeStack.length && !found) {
    const node = nodeStack.shift();

    if (node.nodeType === Node.TEXT_NODE) {
      const nextOffset = currentOffset + node.textContent.length;

      // 判断目标 offset 是否落在当前文本节点内
      if (offset <= nextOffset) {
        // 设置光标起始位置(从当前文本节点起始偏移)
        range.setStart(node, offset - currentOffset);
        found = true;
        break;
      }

      currentOffset = nextOffset;
    } else {
      // 非文本节点,继续往下遍历
      for (let i = 0; i < node.childNodes.length; i++) {
        nodeStack.push(node.childNodes[i]);
      }
    }
  }

  // 如果找到了目标位置,则设置光标
  if (found) {
    range.collapse(true); // 折叠为光标位置
    selection.removeAllRanges(); // 清除旧光标
    selection.addRange(range);   // 应用新光标位置
  }
}

3. @keyup 处理逻辑整合

// 将更新后的纯净内容同步到数据源中(如 v-model 或 props)
this.test.value = newText;

// 更新 DOM 内容(注意:此操作早于获取偏移量时,会导致光标跳转到开头)
el.textContent = newText;

  注:需更换数据源为所绑定的字段。

我所遇到的光标调到开头的问题是由过早更新textContent所导致的获取偏移量时的异常(恒为0)

editKeyup(e) {
  // 避免在中文输入中误触发
  if (this.isComposing) return;

  if (e.code === "Space") {
    const el = e.currentTarget;
    // 获取原始文本内容(包含临时插入的分隔符 '|')
    let inputText = el.textContent;
    // 去除 '|' 字符,得到新内容
    let newText = inputText.replace(/\|/g, "");

    if (newText !== inputText) {
      // 将更新后的纯净内容同步到数据源中(如 v-model 或 props)
      this.test.value = newText;

      // DOM 更新后,恢复光标位置
      this.$nextTick(() => {
        // 获取当前光标在元素中的字符偏移
        const preCaretOffset = this.getCaretCharacterOffsetWithin(el);
        // 计算被删除的 '|' 数量
        const removedCount = (inputText.match(/\|/g) || []).length;
        // 更新 DOM 内容(注意:此操作会让光标丢失)
        el.textContent = newText;
        // 重新计算光标位置,减去被删掉的字符数量
        const newOffset = Math.max(0, preCaretOffset - removedCount);
        // 再次使用 nextTick,确保内容渲染后再设置光标
        this.$nextTick(() => {
          this.setCaretPosition(el, newOffset);
        });
      });
    }
  }
},

🧠 小结

这个方案的核心在于:

  • 先记录偏移,再改 DOM,再恢复位置

  • 使用 RangeSelection 精准控制;

  • contenteditable 场景下比 v-model 更灵活但也更容易踩坑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值