Hugo | Small Fix:为代码块添加复制按钮


2025-02-06
2025-03-18
5625 字
装修小支线:抄,抄出问题就改。改不明白就搜。顺便 Cactus 怎么回事,反省一下这个能把行号复制进去的问题!

我都可以解释

其实我真的很想一直写我的同人博客怎么装修的,但是因为工程太大了我就一直在拖拖拖,然后我想我要不先整点小的写,正好我有很多装修的东西没说。比如我的时间线装修。(每次装修完了都在想,这 F12 一下就都能看到的东西谁需要我单独写博客啊,但是想了想还是写一下吧,主要是记笔记。)

因为我太喜欢宫城良工程量 年轻不懂事,第一次做个人博客选主题的时候,看到了首页瀑布流就走不动道,直接抄了主题下来做博客了,完全没看到这个主题介绍里写的 one page portfolio,等我意识到这个博客字面意义上只有首页和 single 页的时候一切都来不及了于是我只能从头手搓很多功能。

加上朋友觉得我时间线页面做得还可以想要抄,我想,那肯定我要摆很多代码出来,那不如顺手给我的朴素的代码块加个,复制按钮,方便你我他。

还是老规矩,不想看折腾的流程直接右边的 TOC 目录到教程那里就行。以下大量唠唠叨叨,不嫌弃的人请用。

不就是抄吗,谁不会啊

因为同人博客的主题用的是 cactus,我注意到了那里面有代码块复制按钮,直接黏贴过来在 head.html 里引用一下不就完了,“啪”地一下很快啊,哪里会有问题?

预习一下,小热身

在简单的谷歌一下后,我意识到从 0 手搓复制按钮需要这么几步:

  1. 在程序认识到这是代码块之后给代码块的框体加上按钮;这一步网上看到有在 hugo 渲染代码块的时候直接改样式的,我觉得麻烦,看过 cactus 代码以后发现它直接在 javascript 里面用的 jquery 库去选网页元素,选定元素之后直接加按钮,比较简洁;
  2. 定义按钮样式;没啥好说的;
  3. 点击按钮后复制代码块的内容;这一步都是 javascript 没跑;
  4. 引入复制代码块的 javascript。

动手!

于是我美滋滋地打开 themes/cactus/static/js/code-copy.js,原地复制粘贴到自己的个人博客的 js 文件夹里,又抄了 css 过来,最后引入,我已经抄得十分熟练。

那么接下来,就在自己的博客试一试吧!打开了我的上一篇搞 Umami 的博文,复制,粘贴结果!

SQL
11DATABASE_URL=postgresql://username:mypassword@localhost:5432/mydb
22
33DATABASE_URL=mysql://username:mypassword@localhost:3306/mydb

……啊咧咧?

其实刚放过来的时候我还没觉得有啥问题,直到我看到结果:这个行号怎么也一起进来了??

于是我再次打开了谷歌。最开始,我找到的答案是来自这篇文章:Hugo 代码增加显示行号功能 | 黄忠德的博客。这篇文章里提到了“使用复制功能的时候,会连行号一起复制”的现象,给出的解决方案是在 css 里把行号的 user-select 属性设置为 none

但这篇文章写的时间是 2020 年,感觉不一定准,我直接 F12 了一下,发现按照我当下 Hugo 的版本,代码块渲染行号的时候是这么渲染的:

html
1<span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#55595f"> 1</span>

所以其实在我当前的 Hugo 版本里,所有的行号已经自动带上了 user-select:none 的样式。所以这个思路也不对。后来我找到了原来的代码问题所在:

javascript
 1(() => {
 2
 3  function createCopyButton(codeNode) {
 4    const copyBtn = document.createElement('button');
 5    copyBtn.className = 'code-copy-btn';
 6    copyBtn.type = 'button';
 7    copyBtn.innerText = 'copy';
 8
 9    let resetTimer;
10    copyBtn.addEventListener('click', () => {    
11      navigator.clipboard.writeText(codeNode.innerText).then(() => {
12        copyBtn.innerText = 'copied!';
13      }).then(() => {
14        clearTimeout(resetTimer)
15        resetTimer = setTimeout(() => {
16          copyBtn.innerText = 'copy';
17        }, 1000)
18      })
19    })
20
21    return copyBtn;
22  }
23
24  document.querySelectorAll('pre > code')
25  .forEach((codeNode) => {
26    const copyBtn = createCopyButton(codeNode);
27    const preNode = codeNode.parentNode;
28    codeNode.parentNode.insertBefore(copyBtn, codeNode);
29  })  
30
31  document.querySelectorAll('.highlight table > tbody > tr > td:first-child .code-copy-btn')
32  .forEach((btn) => {
33    btn.remove();
34  })  
35
36})()

主要的问题就是 codeNode.innerText 这一个元素,根据搜索结果,innerText 会获取元素及其子元素的可见文本内容,因此,我推测,只要不是 display:none 的元素,哪怕设置了 user-select 设置为 noneinnerText 仍然会包含这些文本内容。

我修!

第一条通罗马的大路

问题找到了,我试图解决。第一个思路是,能不能定义一串字符串,字符串通过某种选择器的使用,最后只录入 user-select 属性不为 none 的元素的文本,最后把这一串字符串写入粘贴板。

首先比较棘手的是,因为行号是用内联样式写的,所以没法直接通过类选择器去选定具体的容器;当然,理论上也可以改,但我猜测这会需要修改 Hugo 默认的代码块渲染的逻辑,我并无兴趣看那块的信息(以及非常逃避翻 Hugo 文档),所以这个小思路放弃了。

仔细观察,因为我目前版本的 Hugo 渲染代码块样式的时候,会把代码块渲染成多层的结构嵌套。现在代码块每行是一个 <span style="display:flex;"> 的 flex 容器,每个容器下面含有两个 <span>,第一个是上面给过的、用来放行号的内联样式的 <span>,第二个是代码行本身的命令;而命令行里面,因为涉及语法的渲染,会时常有内联了字体颜色的语法高亮 <span> 的渲染,而在这些语法高亮之间,会夹杂着没有任何高亮的,属于上一层 <span> 的字符串。

这会导致,如果我的代码逻辑是遍历 <span> 并跳过 user-select:none 的元素的话,我会无法避免地遍历命令行里的多层 <span> ,而导致一些内容被重复复制多次;如果我进一步只提取最里层的 <span> 内容拼接,可能会丢掉一些 html 元素必须的空格格式,并且不小心弄丢夹在两个语法高亮中间的、套在上一层 <span> 里但是没包在这一层 <span> 里的字符。

因此,如果我想要用这个思路解决问题,我的代码需要,

  • 每次拼接时,需要选中 <span style="display:flex;"> 容器;
    • 如果想要跳过行号,必须从行号上一层做初步筛选;
    • 命令行的文本需要全部录入最后复制过去的字符串;
  • 跳过行号元素(只能用 user-select 的属性来做判定);
  • 录入命令行:
    • 保证不重复录入高亮元素;只录入最外层选择器的内容;
    • 需要保留必要的空格和 html 标签的闭合部分(也就是说,提取的时候需要把提取的内容作为文本录入而非直接只录入元素);

明确了需求反复调试后,最终 AI 给了我这样的代码,自己实际测试后有效:

javascript
 1(() => {
 2
 3  function createCopyButton(codeNode) {
 4    const copyBtn = document.createElement('button');
 5    copyBtn.className = 'code-copy-btn';
 6    copyBtn.type = 'button';
 7    copyBtn.innerText = 'Copy';  
 8
 9    let resetTimer;
10    copyBtn.addEventListener('click', () => {
11      // 提取代码部分的文本内容,排除 user-select: none 的元素
12      let codeText = '';
13      codeNode.querySelectorAll('span[style*="display:flex"]').forEach(line => {
14        // 找到最外层的代码部分(排除行号)
15        const codePart = line.querySelector('span:not([style*="user-select:none"])');
16        if (codePart) {
17          // 提取最外层的文本内容,保留空格和标签结构
18          codeText += extractTextContent(codePart);
19        }
20      });  
21
22      // 去除多余的空行和空白字符
23      codeText = codeText.trim();  
24
25      // 复制到剪贴板
26      navigator.clipboard.writeText(codeText).then(() => {
27        copyBtn.innerText = 'Copied!';
28        clearTimeout(resetTimer);
29        resetTimer = setTimeout(() => {
30          copyBtn.innerText = 'Copy';
31        }, 1000);
32      });
33    });  
34
35    return copyBtn;
36  }  
37
38  // 递归提取元素的文本内容,保留空格和标签结构
39  function extractTextContent(node) {
40    let text = '';
41    node.childNodes.forEach(child => {
42      if (child.nodeType === Node.TEXT_NODE) {
43        text += child.textContent; // 提取文本节点内容
44      } else if (child.nodeType === Node.ELEMENT_NODE) {
45        text += extractTextContent(child); // 递归处理子元素
46      }
47    });
48    return text;
49  }  
50
51  // 确保每个代码块只绑定一次复制按钮
52  document.querySelectorAll('pre > code').forEach((codeNode) => {
53    // 检查是否已经存在复制按钮
54    if (!codeNode.parentNode.querySelector('.code-copy-btn')) {
55      const copyBtn = createCopyButton(codeNode);
56      codeNode.parentNode.insertBefore(copyBtn, codeNode);
57    }
58  });  
59
60  // 移除多余的复制按钮(如果有)
61  document.querySelectorAll('.highlight table > tbody > tr > td:first-child .code-copy-btn').forEach((btn) => {
62    btn.remove();
63  });  
64
65})();

第二条通罗马的大路

其实上个思路我写到一半的时候曾经半途而废,一个是因为失败次数太多,一个是因为需要非常精准地去思考嵌套结构和递归的处理,以及需要思考各种节点属性之类的都超出我现有知识储备量的内容,所以我当时拐弯想了第二个解决方式。

首先,根据 stackoverflow 上的这个问题的答案 How do I copy to the clipboard in JavaScript?,我们是有两种方式能够把内容复制到粘贴板上的。

第一个方法就是 cactus 和我上面那个代码里用到的 navigator.clipboard.writeText 功能,可以通过变量写入到粘贴板,另一个是 document.execCommand('copy') 的命令行,直接从 DOM 读取写入。

我又注意到,user-select:none 这一属性本身的意思就是不可被选中。因此,是否有一种思路是,先让鼠标自动选中代码块里所有允许选中的内容,随后把这些内容复制到粘贴板。中间有点小波折,因为我没有意识到 navigator.clipboard.writeText 是提前写入的,不等前面的逻辑,而 document.execCommand('copy') 是一个需要走流程的代码(?(使用我本当下脚的中文比比划划.jpg)),然后很快就做出来了。

javascript
 1(() => {
 2
 3  function createCopyButton(codeNode) {
 4    const copyBtn = document.createElement('button');
 5    copyBtn.className = 'code-copy-btn';
 6    copyBtn.type = 'button';
 7    copyBtn.innerText = 'Copy';
 8
 9    let resetTimer;
10    copyBtn.addEventListener('click', () => {
11      // 创建一个临时的选区
12      const range = document.createRange();
13      range.selectNodeContents(codeNode); // 选中 codeNode 的内容
14
15      // 获取当前的选区对象
16      var selection = window.getSelection();
17      selection.removeAllRanges(); // 清除现有的选区
18      selection.addRange(range); // 添加新创建的范围
19
20      // 复制到剪贴板      
21        document.execCommand('copy');//执行复制操作
22        copyBtn.innerText = 'Copied!';//修改短时间图标
23        selection.removeAllRanges(); // 清除现有的选区
24        clearTimeout(resetTimer);
25        resetTimer = setTimeout(() => {
26          copyBtn.innerText = 'Copy';
27        }, 1000);
28    });
29
30    return copyBtn;
31  }
32
33  document.querySelectorAll('pre > code')
34  .forEach((codeNode) => {
35    const copyBtn = createCopyButton(codeNode);
36    const preNode = codeNode.parentNode;
37    codeNode.parentNode.insertBefore(copyBtn, codeNode);
38  })
39
40  document.querySelectorAll('.highlight table > tbody > tr > td:first-child .code-copy-btn')
41  .forEach((btn) => {
42    btn.remove();
43  })
44
45})();

这个也符合了我的要求!

方案对比

clipboard.writeText
  • 异步的,提前阅读和写入,不会耽误网站使得加载速度变慢;
  • 嵌套结构层次逻辑复杂,不太能确定所有情况下复制结果符合预期;
  • 可以写入变量,定义更自由(但是在这个具体情境下区别不大吧?)
execCommand(‘copy’)
  • 同步的,速度会稍慢;(怀疑在少量运行时区别不大);或许可以用 async 来调整,但懒得写了;
  • 基础逻辑简单清晰明了,保证复制内容不会出问题;
  • 虽然目前主流浏览器都支持该命令,将来似乎可能有兼容性问题;

总之都各有各的好处吧,我因为对自己的代码嵌套理解不自信会倾向于用 execCommand('copy'),如果大家伙比较有信心在这个代码基础上改的话,可能从效率和速度之类的角度考虑,clipboard.writeText 是更好的选择。

教程

那么难点攻克结束,我们整理一下写最后的过程吧:给自己的代码块添加复制按钮!

#1 添加复制功能 | JavaScript

在本地的 /static/js 文件夹里创建你的 javascript 文件,这里我一开始直接粘贴的 cactus 的所以沿用了文件名 code-copy.js

创建成功之后粘贴代码。上文提到了两种方式,代码都可行,我在这里放 execCommand('copy') 的版本,因为我对它的逻辑更有信心。下面的代码加了一点注释,这样如果有人想改或者想知道在干嘛可以看一眼。

javascript
 1(() => {
 2
 3  function createCopyButton(codeNode) {
 4    const copyBtn = document.createElement('button');// 创建按钮元素
 5    copyBtn.className = 'code-copy-btn';// 定义按钮的类名为code-copy-btn
 6    copyBtn.type = 'button';// 元素类型为按钮
 7    copyBtn.innerText = 'Copy';// 按钮内文字为“Copy”
 8
 9    let resetTimer; //创建计时器
10
11	//点击按钮时执行操作
12    copyBtn.addEventListener('click', () => {
13      
14      const range = document.createRange();// 创建一个临时的选区
15      range.selectNodeContents(codeNode); // 选中 codeNode 的内容
16
17      // 获取当前的选区对象
18      var selection = window.getSelection();
19      selection.removeAllRanges(); // 清除现有的选区
20      selection.addRange(range); // 添加新创建的范围到当前选取
21
22      // 复制到剪贴板      
23        document.execCommand('copy');// 执行复制操作
24        copyBtn.innerText = 'Copied!';//修改按钮内文为“Copied”,提示用户代码已复制
25        selection.removeAllRanges(); // 清除现有的选区
26        clearTimeout(resetTimer); // 开始计时
27        resetTimer = setTimeout(() => {
28          copyBtn.innerText = 'Copy';
29        }, 1000); // 1000毫秒(1秒)后按钮内的文件恢复为“Copy”
30    });
31
32    return copyBtn;
33  }
34
35  // 给每一个代码块添加复制按钮
36  document.querySelectorAll('pre > code')
37  .forEach((codeNode) => {
38    const copyBtn = createCopyButton(codeNode);
39    const preNode = codeNode.parentNode;
40    codeNode.parentNode.insertBefore(copyBtn, codeNode);
41  })
42
43// 删除多余的按钮
44  document.querySelectorAll('.highlight table > tbody > tr > td:first-child .code-copy-btn')
45  .forEach((btn) => {
46    btn.remove();
47  })
48
49})();

代码添加完成以后需要在页面合适的位置引用一下。我自己的主题是有一个单独的 partials/scripts.html 专门用来引用各种本地的 javascripts 的,所以放在那个里面了。一般的主题可能放在 head.html 还是 footer.html 都没有问题,实在不确定,可以打开自己的 layouts/_default/baseof.html 看一眼有哪些 partials 是自己的主题的每个网页都会用到的,理论上,只要是在 </body> 的标签之前,哪个 partials 的网页里加上都可以。

引用的代码如下:

html
1<script src="{{ "js/code-copy.js" | absURL }}"></script>

当然,如果你的 javscript 文件名字不是 code-copy.js 记得改成你的文件名。

#2 增加按钮的样式 | CSS

因为是关于按钮样式的内容,每个主题应该都不一样,建议复制下来自己改一改。CSS 如下:

css
 1 pre { // 整体代码框
 2    overflow-x: auto; // 显示自动换行,我讨厌代码出框
 3    font-size: 14px; // 字体大小设置
 4    line-height: 22px;
 5    position: relative; // 配合按钮的 position: absolute 一起用,让按钮位置和代码框的位置保持相对静止
 6    overflow: visible; // 因为讨厌按钮悬浮在代码框上面挡住代码,把按钮放在了代码框右上角,出框了,所以这里需要允许显示溢出的内容
 7
 8    .code-copy-btn { // 按钮样式
 9      position: absolute; // 不跟着代码框里面的代码的移动而移动,相对代码框静止
10      top: -1.8em; // 按钮在代码框外方的上面,而非覆盖在代码框上面
11      right: 0; // 右对齐
12      border: 0; // 按钮没有边界
13      border-radius: 5px 5px 0 0; // 按钮左右上角圆角,下面垂直贴边
14      font-size: 0.9em; // 按钮字体大小
15      line-height: 1.7; // 按钮行高
16      color: #fff; // 按钮字体颜色
17      background-color: #282c34; // 按钮背景颜色,我是为了保证和代码块一个颜色所以选的,如果你的代码块样式背景颜色和我的不一样记得改。
18      min-width: 60px; // 按钮宽度
19      text-align: center; // 按钮文字居中
20      cursor: pointer; // 鼠标在按钮范围内显示pointer(一般是小手指?)
21    }
22
23    .code-copy-btn:hover {
24      background-color: #62396f; // 悬浮在按钮上方时按钮的背景颜色改为主题的主题色,建议自定义改一下
25    }
26
27    code {
28      padding: 0 10px; // 左右留点空
29      max-height: 300px; // 代码块最大高度
30      overflow: auto; // 比高度多出来的时候上滚轮
31    }
32  }

引入的话,因为我是习惯直接在 css 文件夹里加了一个 custom.css 堆所有的自定义样式,所以没有额外的引入环节。如果你不喜欢样式代码堆一块喜欢一个功能一个文件记得后续引入样式 <link href="{{ "css/custom.css" | absURL }}" rel="stylesheet">custom.css 改成你自己的文件名。

OK! 最后 check 一下 | config

其实这一步不做也行,感觉 Hugo 现在的大多数版本都自己有默认设置,但是要是闲的没事干对渲染的结构有更多好奇,可以看看 config.toml 里的一些参数。

toml
 1[markup]
 2  [markup.highlight]
 3    anchorLineNos = false
 4    codeFences = true
 5    guessSyntax = false
 6    hl_Lines = ''
 7    hl_inline = false
 8    lineAnchors = ''
 9    lineNoStart = 1
10    lineNos = false
11    lineNumbersInTable = true
12    noClasses = true
13    style = 'monokai'
14    tabWidth = 4
15    wrapperClass = 'highlight'

我自己的 config 是 toml 格式的,我就放个 toml 了,yaml 和 json 可以参考 Hugo 自己关于渲染的文档 Configure markup | Hugo 来看参数。

题外话

在研究渲染的时候发现了,在 config.toml 里关于代码渲染的参数 noClasses 是默认为 true 的(具体格式如下)

toml
1[markup]
2  [markup.highlight]
3    lineNos = true
4    lineNumbersInTable = false
5    noClasses = true

那么渲染的时候就可以给每个代码块和行的语句增加类名,按照设置后渲染结果可以看一下。我按把上面代码块前两行的渲染结果对比给大家摆出来,左侧/上方的是 noClasses = trueHugo 默认)配置的渲染结果,右侧/下方的是 noClasses = false 配置下的渲染结果。

html
 1<div class="highlight">
 2  <pre tabindex="0" style="color:#abb2bf;background-color:#282c34;-moz-tab-size:2;-o-tab-size:2;tab-size:2;display:grid;">
 3    <button class="code-copy-btn" type="button">Copy</button>
 4    <code class="language-toml" data-lang="toml">
 5      <span style="display:flex;">
 6        <span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#55595f">1</span>
 7        <span>[<span style="color:#e06c75">markup</span>]</span>
 8      </span>
 9      <span style="display:flex;">
10        <span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#55595f">2</span>
11        <span>  [<span style="color:#e06c75">markup</span>.<span style="color:#e06c75">highlight</span>]</span>
12      </span>
13    </code>
14  </pre>
15</div>
html
 1<div class="highlight">
 2  <pre tabindex="0" class="chroma">
 3    <button class="code-copy-btn" type="button">Copy</button>
 4    <code class="language-toml" data-lang="toml">
 5      <span class="line">
 6        <span class="ln">1</span>
 7        <span class="cl">
 8          <span class="p">[</span><span class="nx">markup</span><span class="p">]</span>
 9        </span>
10    </span>
11      <span class="line">
12        <span class="ln">2</span>
13        <span class="cl">
14          <span class="p">[</span><span class="nx">markup</span><span class="p">.</span><span class="nx">highlight</span><span class="p">]</span>
15        </span>
16      </span>
17    </code>
18  </pre>
19</div>

可以发现,当设置 noClasses = false 的时候,所有的代码块渲染出来的元素都有类名,如果要写复制功能,可以很方便地跳过行号的类只写入代码命令的类里包裹的 text;但根据我本地渲染出来的结果,这些所有的类都没有设置 css 样式,所以全是空白代码的样子,没有办法直接调用代码渲染已有的样式库里面定义的样式。尤其很多的 style 针对不同的代码语言有不同的渲染颜色和风格;固然也可以找一下 css 样式库粘贴下来自定义代码渲染样式然后自己改改,但是我懒。

但这仍然是解决复制的时候会把代码行复制进去的问题的一个方法。如果有人有兴趣的话也可以从这里下手。

不知道为啥我对支线任务这么认真,感觉我做正事有这个心思早就财富自由了。