VitePress 中复制功能的使用

2024.09.22

在 VitePress 生成 Markdown 文档中代码块时,会在最终生成的代码块中添加复制代码的按钮。在检查这个按钮的时候发现页面中的仅仅是一个按钮,并没有添加什么点击事件,在翻阅了 VitePress 的代码之后,发现在 src\client\app\composables\copyCode.ts 文件中有该功能的详细实现。

代码地址

src\client\app\composables\copyCode.ts

typescript
import { inBrowser } from 'vitepress'

export function useCopyCode() {
   if (inBrowser) {
       const timeoutIdMap: WeakMap<HTMLElement, NodeJS.Timeout> = new WeakMap()
       window.addEventListener('click', (e) => {
          const el = e.target as HTMLElement
          if (el.matches('div[class*="language-"] > button.copy')) {
            const parent = el.parentElement
            const sibling = el.nextElementSibling?.nextElementSibling
            if (!parent || !sibling) {
              return
            }

            const isShell = /language-(shellscript|shell|bash|sh|zsh)/.test(
              parent.className
            )

            let text = ''

            sibling
              .querySelectorAll('span.line:not(.diff.remove)')
              .forEach((node) => (text += (node.textContent || '') + '\n'))
            text = text.slice(0, -1)

            if (isShell) {
              text = text.replace(/^ *(\$|>) /gm, '').trim()
            }

            copyToClipboard(text).then(() => {
              el.classList.add('copied')
              clearTimeout(timeoutIdMap.get(el))
              const timeoutId = setTimeout(() => {
                el.classList.remove('copied')
                el.blur()
                timeoutIdMap.delete(el)
              }, 2000)
              timeoutIdMap.set(el, timeoutId)
            })
          }
       })
   }
}

async function copyToClipboard(text: string) {
   try {
       return navigator.clipboard.writeText(text)
   } catch {
       const element = document.createElement('textarea')
       const previouslyFocusedElement = document.activeElement

       element.value = text

       // Prevent keyboard from showing on mobile
       element.setAttribute('readonly', '')

       element.style.contain = 'strict'
       element.style.position = 'absolute'
       element.style.left = '-9999px'
       element.style.fontSize = '12pt' // Prevent zooming on iOS

       const selection = document.getSelection()
       const originalRange = selection
          ? selection.rangeCount > 0 && selection.getRangeAt(0)
          : null

       document.body.appendChild(element)
       element.select()

       // Explicit selection workaround for iOS
       element.selectionStart = 0
       element.selectionEnd = text.length

       document.execCommand('copy')
       document.body.removeChild(element)

       if (originalRange) {
          selection!.removeAllRanges() // originalRange can't be truthy when selection is falsy
          selection!.addRange(originalRange)
       }

       // Get the focus back on the previously focused element, if any
       if (previouslyFocusedElement) {
          ;(previouslyFocusedElement as HTMLElement).focus()
       }
   }
}

那么问题来了,如何把这个功能拿来自己使用?

仔细阅读一下源码,首先 useCopyCode 对页面中的 click 事件进行了监听,并且判断是否是复制代码的按钮,如果判断正确,就获取父元素和兄弟元素,父元素是用来判断语言类型,而兄弟元素用来获得所有的代码文本。

从 html 元素中获得文本的代码如下,首先获取 span 元素中类为 line 的元素并排除 .diff.remove 并获取元素的文本,如果文本为 nullundefined 则替换为空文本,并在每行的末尾添加换行符。

typescript
sibling.querySelectorAll('span.line:not(.diff.remove)')
       .forEach((node) => (text += (node.textContent || '') + '\n'))

以下这段代码则是去除 shell 代码中的 $> 以及文本前后的空行:

typescript
text = text.replace(/^ *(\$|>) /gm, '').trim()

以下这段代码是更改复制按钮的样式:

typescript
copyToClipboard(text).then(() => {
  el.classList.add('copied') // 为按钮添加 `copied` 类
  clearTimeout(timeoutIdMap.get(el)) // 清除 weakmap 中的计时器
  const timeoutId = setTimeout(() => {
    el.classList.remove('copied') // 去除 `copied` 类
    el.blur() // 移除元素的聚焦
    timeoutIdMap.delete(el)
  }, 2000)
  timeoutIdMap.set(el, timeoutId)
})

copyToClipboard 则是将文本复制到系统的剪贴板中。

如果想要自己使用,则只需要在 useCopyCode 代码中按照自己的要求修改即可。

对于复制按钮的 css 样式如下所示:

css
button.copy{
  --size: 28px;
  height: var(--size);
  width: var(--size);
  background: none;
  background-image: url('Base64 编码图像,此处省略');
  background-repeat: no-repeat;
  border: 1px solid #000;
  border-radius: 2px;
  background-position: 50%;
  background-size: 20px;
  padding: 5px;
  box-sizing: border-box;
}

button.copied{
  background-image: url('Base64 编码图像,此处省略');
}