一、效果图
二、实现思路
光标始终跟随最后一个文字,并且不受任何类似块级元素的影响,光标也不会出现换行。那么,找到最后一个文本节点,向该文本节点插入这个光标。
三、代码实现
- 找到最后一个文本节点(因为需要在这个文本节点后插入光标)
/** 获取最后一个文本!!!节点 */
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;
}
- 创建临时文本并把它放在最后一个文本节点后(主要是用这个临时文本节点来获取当前所在的位置信息,方便后续放入光标在这里)
let tempText = document.createTextNode("\u200b"); // 零宽字符
if (lastTextNode) {
lastTextNode.parentNode && lastTextNode.parentNode.appendChild(tempText);
} else {
textRef.value && textRef.value.appendChild(tempText);
}
- 通过创建的临时文本开始获取位置距离信息等,并将光标放到对应位置
// 获取临时文本节点距离父节点的距离(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)`;
}
- 移除临时文本节点(很重要)
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>