Skip to content

如何实现 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/)。

这是一张图片:

![图片](https://picsum.photos/200/300)


## 表格

| 列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>