仿chatGpt光标跟随(简易版本)

749 阅读3分钟

一、效果图

仿chatgpt光标跟随.gif

二、实现思路

光标始终跟随最后一个文字,并且不受任何类似块级元素的影响,光标也不会出现换行。那么,找到最后一个文本节点,向该文本节点插入这个光标。

三、代码实现

  1. 找到最后一个文本节点(因为需要在这个文本节点后插入光标)
/** 获取最后一个文本!!!节点 */
function getLastTextNode(node: Node| null):Node | null {
    if (!node) return null;
    if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
      return node;
    }
    for (let i = node.childNodes.length-1;i>=0;i--) {
      const childNode = node.childNodes[i];
      const textNode = getLastTextNode(childNode);
      if (textNode) {
        return textNode
      }
    }
    return null;
  }
  1. 创建临时文本并把它放在最后一个文本节点后(主要是用这个临时文本节点来获取当前所在的位置信息,方便后续放入光标在这里)
    let tempText = document.createTextNode("\u200b"); // 零宽字符
    if (lastTextNode) {
      lastTextNode.parentNode && lastTextNode.parentNode.appendChild(tempText);
    } else {
      textRef.value && textRef.value.appendChild(tempText);
    }
  1. 通过创建的临时文本开始获取位置距离信息等,并将光标放到对应位置
   // 获取临时文本节点距离父节点的距离(x,y)
      const range = document.createRange(); // 设置范围
      range.setStart(tempText, 0);
      range.setEnd(tempText, 0);
      const rect = range.getBoundingClientRect(); // 获取距离信息
   // 获取当前文本容器距离视图的距离(x,y)
     const textRect = contentRef.value && contentRef.value.getBoundingClientRect();
   // 获取到当前文本节点的位置,并将光标的位置插入到相应位置
    if (textRect) {
      const x = rect.left - textRect.left;
      const y = rect.top - textRect.top - 7.5; // 7.5 是光标高度的一半,为了居中显示光标
      // 将光标放到对应位置(需要父组件有相对定位哦,不然可能会出现偏移)
      cursorRef.value!.style.transform = `translate(${x}px,${y}px)`;
    }
  1. 移除临时文本节点(很重要)
tempText.remove()

四、完整代码(复制到本地即可使用)

<script lang="ts" setup>
import {onMounted, ref} from "vue";
function delay(time: number) {
  return new Promise((resolve) => setTimeout(resolve, time));
}

const mockData =  `
  <p>1. 近日,一份全球数学竞赛决赛名单引起广泛关注。其中,学服装设计的姜萍,以93分的高分名列第12位。天才少女姜萍的故事在全网引发热议。总台记者对江苏涟水中专党委书记进行了专访,揭秘姜萍选择涟水中专的原因。</p>
  <p>2. 江苏涟水中专党委书记介绍,姜萍中考621分,能够达到当地普通高中的录取分数线,之所以选择涟水中专,据姜萍自己讲,原因之一是当时她的姐姐以及两个要好的同学都在这所学校就读。另外,就是姜萍对服装专业比较感兴趣,认为这里对自己的兴趣、爱好发展发挥更有利。</p>
`
/** 模拟请求 */
async function mockResponse  () {
  cursorRef.value!.style.display = 'block';
  for (let i = 0; i < mockData.length; i++) {
    let text = mockData.slice(0, i);
    textRef.value!.innerHTML = text;
    updateCursor();
    await delay(100);
  }
  cursorRef.value!.style.display = 'none';
}

  /** 获取最后一个文本节点 */
  function getLastTextNode(node: Node| null):Node | null {
    if (!node) return null;
    if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
      return node;
    }
    for (let i = node.childNodes.length-1;i>=0;i--) {
      const childNode = node.childNodes[i];
      const textNode = getLastTextNode(childNode);
      if (textNode) {
        return textNode
      }
    }
    return null;
  }
  /** 光标跟随逻辑 */
  const contentRef = ref<HTMLElement | null>(null); // 包含text和光标的容器节点
  const textRef = ref<HTMLElement | null>(null); // text节点
  const cursorRef = ref<HTMLElement | null>(null); // 光标节点
  function updateCursor() {
    // 1. 找到最后一个文本节点
    let lastTextNode = getLastTextNode(textRef.value)
    // 2. 创建一个临时文本节点
    let tempText = document.createTextNode("\u200b"); // 零宽字符
    // 3. 将临时文本节点放在最后一个文本节点之后
    if (lastTextNode) {
      lastTextNode.parentNode && lastTextNode.parentNode.appendChild(tempText);
    } else {
      textRef.value && textRef.value.appendChild(tempText);
    }
    // 4. 获取临时文本节点距离父节点的距离(x,y)
      const range = document.createRange(); // 设置范围
      range.setStart(tempText, 0);
      range.setEnd(tempText, 0);
      const rect = range.getBoundingClientRect(); // 获取距离信息
    // 5. 获取当前文本容器距离视图的距离(x,y)
     const textRect = contentRef.value && contentRef.value.getBoundingClientRect();
    // 6. 获取到当前文本节点的位置,并将光标的位置插入到相应位置
    if (textRect) {
      const x = rect.left - textRect.left;
      const y = rect.top - textRect.top - 7.5; // 7.5 是光标高度的一半,为了居中显示光标
      cursorRef.value!.style.transform = `translate(${x}px,${y}px)`;
    }
    // 7. 移除临时文本节点
    tempText.remove();
  }

onMounted(()=> {
  mockResponse()
})
</script>

<template>
  <div class="container" ref="contentRef">
    <div class="text" ref="textRef"></div>
    <div class="cursor" ref="cursorRef"></div>
  </div>
</template>

<style lang="scss" scoped>
.container {
  position: relative;
  width: 500px;
  height: 300px;
  margin: 100px auto;
  background-color: #eee;
  border-radius: 5px;
  padding: 20px;

  .text {
    color: #666;
  }

  .cursor {
    display: none;
    position: absolute;
    left: 10px;
    top: 10px;
    width: 15px;
    height: 15px;
    background-color: #000;
    border-radius: 10px;
    animation: cursorAnimate 0.5s infinite;
  }

  @keyframes cursorAnimate {
    0% {
      opacity: 0;
    }

    50% {
      opacity: 1;
    }

    100% {
      opacity: 0;
    }
  }
}
</style>