如何实现 ChatGPT 的光标跟随 [2023-03-27]
光标跟随
效果
查看代码
vue
<template>
<div class="cursor-container">
<div
class="markdown-body"
v-html="markedContent"
ref="markdownBodyRef"
></div>
<div class="cursor" v-show="infos.showCursor"></div>
</div>
</template>
<script lang="ts" setup>
import 'github-markdown-css/github-markdown-dark.css'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import { marked } from 'marked'
import { computed, onMounted, onUpdated, reactive, ref } from 'vue'
import mdText from './md'
marked.setOptions({
highlight(code, lang) {
return lang && hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang }).value
: hljs.highlightAuto(code).value
}
})
const markdownBodyRef = ref()
const infos = reactive({
/** 原始内容 */
content: '',
/** 是否显示光标 */
showCursor: true,
/** 光标位置 */
position: { x: 0, y: 0, h: 0 }
})
/** marked转换后内容 */
const markedContent = computed(() => marked.parse(infos.content))
let index = 0
let timer = setInterval(() => {
if (index >= mdText.length) {
clearInterval(timer)
infos.showCursor = false
return
}
infos.content += mdText[index++]
}, 100)
/**
* 获取最后一个文本节点,如果没有不是文本节点就判断是否是元素节点,是的话递归,不是往同级的上一个找
* 由于marked插件转换会在每个标签后加一个\n,不处理的话找文本节点会找到这个\n,所以需要处理
* 非空判断处理之后,找到的文本节点后可能还是有\n,需要进一步处理
*/
function getLastTextNode(dom: Node) {
const childs = dom.childNodes
// 从后往前
for (let i = childs.length - 1; i >= 0; i--) {
const node = childs[i]
if (node.nodeType === Node.TEXT_NODE && /\S/.test(node.nodeValue)) {
node.nodeValue = node.nodeValue.replace(/\s+$/, '')
return node
} else if (node.nodeType === Node.ELEMENT_NODE) {
const last = getLastTextNode(node)
if (last) {
return last
}
}
}
return null
}
/**
* 更新光标位置
* 光标应该加载所有节点中的最后一个文本节点的末尾
*/
const handleUpdateCursor = () => {
const contentDom = markdownBodyRef.value as HTMLElement
const lastText = getLastTextNode(contentDom) as Node
const txtNode = document.createTextNode('\u200b') // 零宽字符,看不见
// 如果最后一个文本节点存在,加载文本节点后,如果不存在,加在整个节点末尾
if (lastText) {
lastText.parentElement.appendChild(txtNode)
} else {
contentDom.appendChild(txtNode)
}
const domRect = contentDom.getBoundingClientRect()
// 创建一个range范围对象,类似鼠标框选
const range = document.createRange()
// 设置框选范围,都是起始位置,虽然不框选任何范围,但可以使用 getBoundingClientRect获取框选范围的坐标
range.setStart(txtNode, 0)
range.setEnd(txtNode, 0)
const rect = range.getBoundingClientRect()
infos.position.x = rect.left - domRect.left
infos.position.y = rect.top - domRect.top
// 自己加的,光标的高度根据内容伸缩
infos.position.h = rect.bottom - rect.top
txtNode.remove()
}
onMounted(handleUpdateCursor) // 当进入界面,markdown渲染后显示光标
onUpdated(handleUpdateCursor) // 当内容不断变化是,更新光标
</script>
<style lang="less" scoped>
.cursor-container {
position: relative;
.markdown-body {
padding: 16px;
}
@keyframes toggle {
30% {
opacity: 1;
}
}
.cursor {
content: '';
position: absolute;
width: 8px;
background: #d5d9da;
opacity: 0;
animation: toggle 0.6s infinite;
left: calc(v-bind('infos.position.x') * 1px);
top: calc(v-bind('infos.position.y') * 1px);
height: calc(v-bind('infos.position.h') * 1px);
}
}
</style>ts
const base = `# 标题
这是一段简单的段落,其中包含一些*斜体*和**粗体**的文本。
## 列表
下面是一个无序列表:
- 项目1
- 项目2
下面是一个有序列表:
1. 项目1
2. 项目2
## 链接和图片
这是一个链接:[Google](https://www.google.com/)。
这是一张图片:

## 表格
| 列1 | 列2 | 列3 |
| --- | --- | --- |
| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 7 | 8 | 9 |
这是一个简单的表格。
## 代码块
\`\`\` javascript
function sayHello(name) {
console.log("Hello, " + name + "!");
}
sayHello("world");
`
const mdText = `
\`\`\` md
${base}
\`\`\`
${base}`
export default mdText文本框跟随文字内容自动伸缩高度
查看代码
vue
<template>
<div class="textarea-container">
<textarea
@input="handleTextAreaChange"
class="textarea"
rows="1"
placeholder="请输入内容"
/>
</div>
</template>
<script lang="ts" setup>
const handleTextAreaChange = e => {
const textarea = e.target
textarea.style.height = 'auto' // 先自适应,再得到目标的滚动高度
// 滚动的高度即为目标高度
textarea.style.height = textarea.scrollHeight + 'px'
}
</script>
<style lang="less" scoped>
.textarea-container {
padding: 0.75rem 0 0.75rem 1rem;
border: 1px rgba(0, 0, 0, 0.1);
border-radius: 0.375rem;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
}
.textarea {
height: 24px;
max-height: 200px;
width: 100%;
font-size: 1rem;
line-height: 1.5rem;
padding-right: 1.75rem;
overflow-y: hidden;
background-color: transparent;
color: inherit;
}
</style>