基于 highlightjs-line-numbers.js 插件二次开发
插件源码:
import type { HLJSApi } from "highlight.js";
interface LineNumbersMethods {
initLineNumbersOnLoad: (options?: any) => void;
lineNumbersBlock: (element: HTMLElement, options?: any) => void;
lineNumbersValue: (value: string, options?: any) => string;
copyCodeBlock: (element: HTMLElement | string) => boolean;
setLineHighlight: (
className: string,
startLine?: number,
endLine?: number
) => void; // 设置高亮行
clearLineHighlights: () => void; // 清除所有高亮配置
destroy: () => void;
}
export type ExtendedHLJS = HLJSApi & LineNumbersMethods;
/** 显示行号插件 */
export const hljsLineNumbers = (hljs: ExtendedHLJS) => {
// 定义用于生成 HTML 和 CSS 的常量
const TABLE_NAME = "hljs-ln", // 行号表格的 CSS 类名
LINE_NAME = "hljs-ln-line", // 每一行的 CSS 类名
CODE_BLOCK_NAME = "hljs-ln-code", // 代码单元格的 CSS 类名
NUMBERS_BLOCK_NAME = "hljs-ln-numbers", // 行号单元格的 CSS 类名
NUMBER_LINE_NAME = "hljs-ln-n", // 行号数字的 CSS 类名
DATA_ATTR_NAME = "data-line-number", // 存储行号的 data 属性名
BREAK_LINE_REGEXP = /\r\n|\r|\n/g; // 用于分割行的正则表达式,兼容不同操作系统的换行符
// 存储高亮行信息
const highlightedLines: {
start: number;
end: number;
className: string;
}[] = [];
/**
* 设置需要高亮的代码行
* @param startLine - 起始行号
* @param endLine - 结束行号(可选,默认等于起始行号)
* @param className - 要添加的CSS类名
*/
const setLineHighlight = (
className: string,
startLine?: number,
endLine: number = startLine || 0
): void => {
if (!startLine) return;
if (startLine > endLine) {
[startLine, endLine] = [endLine, startLine];
}
highlightedLines.push({ start: startLine, end: endLine, className });
};
/**
* 检查给定的 DOM 元素是否是代码区域(即带有 .hljs-ln-code 类名的元素)的后代
* @param domElt - 要检查的 DOM 元素
* @returns 如果是后代,则返回 true,否则返回 false
*/
const isHljsLnCodeDescendant = (domElt: any): boolean => {
let curElt = domElt;
while (curElt) {
if (curElt.className && curElt.className.indexOf("hljs-ln-code") !== -1) {
return true;
}
curElt = curElt.parentNode;
}
return false;
};
/**
* 从行号插件生成的任何子元素开始,向上查找并返回包含整个行号结构的 <table> 元素
* @param hljsLnDomElt - 行号结构内的任一 DOM 元素
* @returns 包含行号的 <table> 元素
*/
const getHljsLnTable = (hljsLnDomElt: any): HTMLElement => {
let curElt = hljsLnDomElt;
while (curElt.nodeName !== "TABLE") {
curElt = curElt.parentNode;
}
return curElt;
};
/**
* 修复 Microsoft Edge 浏览器复制代码时丢失换行符的问题
* 由于 hljs-ln 将代码行包装在 <table> 元素中,Edge 复制时不会包含换行符,
* 需要根据 DOM 结构重建真实的选中代码文本
* @param selection 当前的 Selection 对象
* @returns 真实选中的代码文本(包含换行符)
*/
const edgeGetSelectedCodeLines = (selection: any): string => {
// 获取当前选中的不含换行符的文本
const selectionText = selection.toString();
// 获取 selection 的起始节点(anchorNode)和结束节点(focusNode)所在的 <td> 元素
// selection 的起始和结束节点可能是文本节点,需要向上遍历 DOM 树找到其父级 <td>
let tdAnchor = selection.anchorNode;
while (tdAnchor.nodeName !== "TD") {
tdAnchor = tdAnchor.parentNode;
}
let tdFocus = selection.focusNode;
while (tdFocus.nodeName !== "TD") {
tdFocus = tdFocus.parentNode;
}
// 从 <td> 元素的 data-line-number 属性中提取行号
let firstLineNumber = parseInt(tdAnchor.dataset.lineNumber);
let lastLineNumber = parseInt(tdFocus.dataset.lineNumber);
// 如果复制了多行
if (firstLineNumber !== lastLineNumber) {
let firstLineText = tdAnchor.textContent;
let lastLineText = tdFocus.textContent;
// 如果用户是反向选择(例如从下往上),则交换起始和结束的行号及文本内容
if (firstLineNumber > lastLineNumber) {
let tmp = firstLineNumber;
firstLineNumber = lastLineNumber;
lastLineNumber = tmp;
tmp = firstLineText;
firstLineText = lastLineText;
lastLineText = tmp;
}
// 剔除第一行开头未被选中的部分
while (selectionText.indexOf(firstLineText) !== 0) {
firstLineText = firstLineText.slice(1);
}
// 剔除最后一行末尾未被选中的部分
while (selectionText.lastIndexOf(lastLineText) === -1) {
lastLineText = lastLineText.slice(0, -1);
}
// 重构并返回真实复制的文本
// 1. 从第一行中实际选中的文本开始
let selectedText = firstLineText;
// 2. 获取整个代码块的 table 元素
const hljsLnTable = getHljsLnTable(tdAnchor);
// 3. 遍历中间所有被完整选中的行
for (let i = firstLineNumber + 1; i < lastLineNumber; ++i) {
// 构造选择器,找到对应行号的代码单元格
const codeLineSel = format('.{0}[{1}="{2}"]', [
CODE_BLOCK_NAME,
DATA_ATTR_NAME,
i
]);
const codeLineElt = hljsLnTable.querySelector(codeLineSel);
// 拼接换行符和该行的全部内容
selectedText += "\n" + codeLineElt?.textContent;
}
// 4. 拼接最后一行中实际选中的文本
selectedText += "\n" + lastLineText;
return selectedText;
} else {
// 如果只复制了单行,直接返回 selection 的文本即可
return selectionText;
}
};
// 确保所有浏览器的代码复制/粘贴行为一致
// (相关 issue: https://github.com/wcoder/highlightjs-line-numbers.js/issues/51)
// 监听全局的 'copy' 事件
const copyEventHandler = (e: ClipboardEvent) => {
const selection = window.getSelection();
if (isHljsLnCodeDescendant(selection?.anchorNode)) {
let selectionText;
if (window.navigator.userAgent.indexOf("Edge") !== -1) {
selectionText = edgeGetSelectedCodeLines(selection);
} else {
selectionText = selection?.toString();
}
e.clipboardData?.setData("text/plain", selectionText!);
e.preventDefault();
}
};
window.removeEventListener("copy", copyEventHandler);
window.addEventListener("copy", copyEventHandler);
/**
* 在文档的 <head> 中动态创建一个 <style> 标签,并注入插件所需的 CSS 样式
*/
const addStyles = () => {
const css = document.createElement("style");
css.innerHTML = format(
// CSS 规则:
// 1. .hljs-ln { border-collapse: collapse; } -> 让表格边框合并
// 2. .hljs-ln td { padding: 0; } -> 移除单元格的内边距
// 3. .hljs-ln-n:before { content: attr(data-line-number); } -> 核心!使用 ::before 伪元素和 content 属性显示 data-line-number 的值作为行号
".{0}{border-collapse:collapse}" +
".{0} td{padding:0}" +
".{1}:before{content:attr({2})}",
[TABLE_NAME, NUMBER_LINE_NAME, DATA_ATTR_NAME]
);
document.getElementsByTagName("head")[0].appendChild(css);
};
/**
* 初始化函数,在页面加载时自动为所有代码块添加行号
* @param options - 配置选项
*/
const initLineNumbersOnLoad = (options: any) => {
// 检查文档是否已经加载完成
if (
document.readyState === "interactive" ||
document.readyState === "complete"
) {
documentReady(options);
} else {
// 如果尚未加载完成,则添加一个事件监听器,在 DOMContentLoaded 事件触发时执行
addEventListener("DOMContentLoaded", () => {
documentReady(options);
});
}
};
/**
* 当文档准备就绪时执行此函数
* 它会查找页面上所有需要添加行号的代码块并进行处理
* @param options - 配置选项
*/
const documentReady = (options: any) => {
try {
// 查找所有 `<code>` 标签,它们通常带有 `hljs` 或 `nohighlight` 类
const blocks = document.querySelectorAll("code.hljs,code.nohighlight");
for (const i in blocks) {
// 使用 hasOwnProperty 确保我们处理的是元素自身的属性,而不是原型链上的
if (Object.prototype.hasOwnProperty.call(blocks, i)) {
// 检查该代码块是否被显式禁用行号功能
if (!isPluginDisabledForBlock(blocks[i] as HTMLElement)) {
lineNumbersBlock(blocks[i] as HTMLElement, options);
}
}
}
} catch (e) {
console.error("LineNumbers error: ", e);
}
};
/**
* 检查一个代码块元素是否通过添加 'nohljsln' 类来禁用了行号插件
* @param element - 代码块元素
* @returns 如果被禁用则返回 true
*/
const isPluginDisabledForBlock = (element: HTMLElement): boolean => {
return element.classList.contains("nohljsln");
};
/**
* 对单个代码块元素应用行号
* @param element - 要处理的 `<code>` 元素
* @param options - 配置选项
*/
const lineNumbersBlock = (element: HTMLElement, options: any) => {
if (typeof element !== "object") return;
// 将处理过程推入事件队列,避免在处理大型代码块时阻塞 UI 线程
setTimeout(() => {
element.innerHTML = lineNumbersInternal(element, options);
}, 0);
};
/**
* 对一个代码字符串应用行号并返回结果
* @param value - 包含代码的字符串
* @param options - 配置选项
* @returns - 添加了行号的 HTML 字符串
*/
const lineNumbersValue = (value: string, options: any): string => {
if (typeof value !== "string") return "";
// 创建一个临时的 `<code>` 元素来容纳字符串,以便复用处理逻辑
const element = document.createElement("code");
element.innerHTML = value;
return lineNumbersInternal(element, options);
};
/**
* 行号处理的核心内部函数
* @param element - 包含代码的 `<code>` 元素
* @param options - 配置选项
* @returns - 添加了行号的 HTML 字符串
*/
const lineNumbersInternal = (element: HTMLElement, options: any): string => {
// 1. 合并和解析选项
const internalOptions = mapOptions(element, options);
// 2. 修复 highlight.js 可能产生的多行 span 问题
duplicateMultilineNodes(element);
// 3. 生成并返回带行号的 HTML
return addLineNumbersBlockFor(element.innerHTML, internalOptions);
};
/**
* 根据给定的 HTML 内容和选项,生成最终的行号表格 HTML
* @param inputHtml - 从 `<code>` 元素获取的 innerHTML
* @param options - 内部使用的配置选项
* @returns - 完整的 `<table>` HTML 或原始 HTML
*/
const addLineNumbersBlockFor = (
inputHtml: string,
options: { singleLine: boolean; startFrom: number }
): string => {
const lines = getLines(inputHtml);
// 如果最后一行是空的(通常是由于末尾的换行符导致),则移除它
if (lines.length > 0 && lines[lines.length - 1].trim() === "") {
lines.pop();
}
// 仅当代码有多于一行,或 `singleLine` 选项为 true 时,才添加行号
if (lines.length > 1 || options.singleLine) {
let html = "";
// 遍历每一行代码
for (let i = 0, l = lines.length; i < l; i++) {
const lineNumber = i + options.startFrom;
// 检查当前行是否需要高亮,并获取对应的类名
const lineClassArr: string[] = [];
// 遍历所有需要高亮的行配置
for (const highlight of highlightedLines) {
if (lineNumber >= highlight.start && lineNumber <= highlight.end) {
// 对类名去重
const classes = highlight.className.split(/\s+/).filter((i) => i);
for (const className of classes) {
if (!lineClassArr.includes(className)) {
lineClassArr.push(className);
}
}
}
}
// 额外添加的类名
const lineClass = lineClassArr.length
? `class="${lineClassArr.join(" ")}"`
: "";
// 为每一行生成一个 <tr>
html += format(
"<tr {7}>" +
// 左侧 <td>: 用于显示行号
'<td class="{0} {1}" {3}="{5}">' +
// 这个 div 的 :before 伪元素会显示行号
'<div class="{2}" {3}="{5}"></div>' +
"</td>" +
// 右侧 <td>: 用于显示代码
'<td class="{0} {4}" {3}="{5}">' +
// 如果行是空的,则插入一个空格以确保行高不会坍缩
"{6}" +
"</td>" +
"</tr>",
[
LINE_NAME,
NUMBERS_BLOCK_NAME,
NUMBER_LINE_NAME,
DATA_ATTR_NAME,
CODE_BLOCK_NAME,
i + options.startFrom, // 计算实际行号(考虑 startFrom 选项)
lines[i].length > 0 ? lines[i] : " ", // 代码内容
lineClass // 额外添加的类名
]
);
}
// 将所有行包装在一个 `<table>` 元素中
return format('<table class="{0}" data-total-lines={2}>{1}</table>', [
TABLE_NAME,
html,
lines.length
]);
}
// 如果不满足添加行号的条件,则返回原始 HTML
return inputHtml;
};
/**
* 将外部传入的选项和从元素 data 属性中读取的选项合并为内部使用的标准选项对象
* @param element 代码块
* @param options 外部 API 选项
* @returns 内部 API 选项
*/
const mapOptions = (
element: HTMLElement,
options: {}
): { singleLine: boolean; startFrom: number } => {
options = options || {};
return {
singleLine: getSingleLineOption(options as { singleLine: boolean }),
startFrom: getStartFromOption(element, options as { startFrom: number })
};
};
/**
* 获取 singleLine 选项的值
* @param options - 选项对象
* @returns
*/
const getSingleLineOption = (options: { singleLine: boolean }): boolean => {
const defaultValue = false;
return options.singleLine ? options.singleLine : defaultValue;
};
/**
* 获取起始行号
* 优先级:data 属性 > JS 选项 > 默认值
* @param element 代码块元素
* @param options 选项对象
* @returns 起始行号
*/
const getStartFromOption = (
element: HTMLElement,
options: { startFrom: number }
): number => {
const defaultValue = 1;
let startFrom = defaultValue;
// 首先检查 JS 选项
if (isFinite(options.startFrom)) {
startFrom = options.startFrom;
}
// 检查元素上的 data-ln-start-from 属性,如果存在,则覆盖 JS 选项
// 因为局部选项(HTML 属性)的优先级更高
const value = getAttribute(element, "data-ln-start-from");
if (value !== null) {
startFrom = toNumber(value, defaultValue);
}
return startFrom;
};
/**
* 修复 highlight.js 将多行内容(如多行注释、字符串)渲染在单个 <span> 内的问题
* 此函数递归地遍历 DOM 节点,找到并处理这些多行 <span>
* @param element - 要处理的元素
*/
const duplicateMultilineNodes = (element: { childNodes: any }) => {
const nodes = element.childNodes;
for (const node of nodes) {
// 检查节点内容是否包含换行符
if (getLinesCount(node.textContent) > 0) {
// 如果此节点还有子节点,则递归处理
if (node.childNodes.length > 0) {
duplicateMultilineNodes(node);
} else {
// 如果是叶子节点(没有子节点),则调用 duplicateMultilineNode 进行处理
duplicateMultilineNode(node.parentNode);
}
}
}
};
/**
* 将一个包含多行文本的节点(通常是 <span>),拆分成多个节点,
* 每个新节点包含一行文本,并继承原始节点的 CSS 类
* @param element - 包含多行文本的节点
*/
const duplicateMultilineNode = (element: HTMLElement) => {
const className = element.className;
// 如果节点没有 hljs- 相关的类,则不处理
if (!/hljs-/.test(className)) return;
// 获取节点内的所有行
const lines = getLines(element.innerHTML);
let result = "";
// 遍历每一行,用带有原始类的 <span> 包裹它
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i].length > 0 ? lines[i] : " ";
result += format('<span class="{0}">{1}</span>\n', [className, lineText]);
}
// 用生成的新 HTML 替换原始节点的 innerHTML
element.innerHTML = result.trim();
};
/**
* 将文本字符串按换行符分割成数组
* @param text - 输入文本
* @returns - 行数组
*/
const getLines = (text: string): string[] => {
if (text.length === 0) return [];
return text.split(BREAK_LINE_REGEXP);
};
/**
* 计算文本中的换行符数量
* @param text - 输入文本
* @returns - 换行符的数量
*/
const getLinesCount = (text: string): number => {
return (text.trim().match(BREAK_LINE_REGEXP) || []).length;
};
/**
* 一个简单的字符串格式化函数,类似于 C# 的 String.Format
* 用法: format("Hello {0}, {1}!", ["World", "Test"]) -> "Hello World, Test!"
* @param format - 格式化模板,使用 {0}, {1} ...作为占位符
* @param args - 替换占位符的参数数组
*/
const format = (format: string, args: any[]): string => {
return format.replace(/\{(\d+)\}/g, (m: any, n: string | number) => {
return args[n as number] !== undefined ? args[n as number] : m;
});
};
/**
* 安全地获取元素的 HTML 属性
* @param element 代码块
* @param attrName 属性名
* @returns 属性值,如果不存在则返回 null
*/
const getAttribute = (
element: HTMLElement,
attrName: string
): string | null => {
return element.hasAttribute(attrName)
? element.getAttribute(attrName)
: null;
};
/**
* 将字符串转换为数字,如果转换失败,则返回一个备用值
* @param str 源字符串
* @param fallback 备用值
* @returns 解析后的数字或备用值
*/
const toNumber = (str: string | null, fallback: number): number => {
if (!str) return fallback;
const number = Number(str);
return isFinite(number) ? number : fallback;
};
/**
* 复制代码块内容
* @param element 代码块元素或选择器
* @returns 是否复制成功
*/
const copyCodeBlock = (element: HTMLElement | string): boolean => {
try {
// 如果传入的是选择器字符串,则获取对应元素
const codeElement =
typeof element === "string"
? (document.querySelector(element) as HTMLElement)
: element;
if (!codeElement) return false;
// 创建选区并选中代码
const range = document.createRange();
range.selectNode(codeElement);
// 清除当前选择并添加新选区
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// 执行复制命令
document.execCommand("copy");
// 清除选择
selection?.removeAllRanges();
return true;
} catch (e) {
console.error("Failed to copy code block:", e);
return false;
}
};
// 检查全局环境中 hljs (highlight.js) 是否已加载
if (hljs) {
// 如果已加载,则将本插件的核心功能函数挂载到 hljs 对象上,以便外部调用
hljs.initLineNumbersOnLoad = initLineNumbersOnLoad;
hljs.lineNumbersBlock = lineNumbersBlock;
hljs.lineNumbersValue = lineNumbersValue; // 注入插件所需的 CSS 样式
hljs.copyCodeBlock = copyCodeBlock;
hljs.setLineHighlight = setLineHighlight;
hljs.clearLineHighlights = () => {
highlightedLines.length = 0;
};
hljs.destroy = () => {
window.removeEventListener("copy", copyEventHandler);
highlightedLines.length = 0;
// TODO: 是否还有其他需要清理的?
};
addStyles();
} else {
// 如果未检测到 highlight.js,就报错
console.error("highlight.js not detected!", hljs);
}
};
在vue3中使用:
<script setup lang="ts">
import "highlight.js/styles/atom-one-light.min.css";
import { useMutationObserver } from "@vueuse/core";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import plaintext from "highlight.js/lib/languages/plaintext";
import python from "highlight.js/lib/languages/python";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";
import { type ExtendedHLJS, hljsLineNumbers } from "@/utils";
// 按需引入语言
hljs.registerLanguage("plaintext", plaintext);
hljs.registerLanguage("python", python);
hljs.registerLanguage("bash", bash);
const props = defineProps<{
data: string;
showLineNumbers?: boolean;
setLineHighlight?: {
className: string;
startLine?: number;
endLine?: number;
}[];
}>();
// 标记代码块是否渲染完成
const codeRendered = ref(false);
// 标记markdown显示,防止行号插件的渲染导致代码块闪烁
const showMarked = computed(() => {
const show = !props.showLineNumbers ? true : codeRendered.value;
return show;
});
const marked = new Marked(
markedHighlight({
langPrefix: "hljs language-",
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : "plaintext";
return hljs.highlight(code, { language }).value;
}
})
);
// 解析markdown内容
const htmlContent = marked.parse(props.data);
// 获取代码块,后面需要添加行号
const contentRef = ref<HTMLDivElement | null>(null);
watch(
() => contentRef.value,
(el) => {
if (!el || !props.showLineNumbers) return;
// 拿到代码块之后为代码块添加行号
// 初始化行号
hljsLineNumbers(hljs as ExtendedHLJS);
let code = document.querySelectorAll("pre code");
// 为代码块指定区间的行添加样式
if (props.setLineHighlight && Array.isArray(props.setLineHighlight)) {
props.setLineHighlight.forEach((i) => {
(hljs as ExtendedHLJS).setLineHighlight(
i.className,
i.startLine,
i.endLine
);
});
}
// 为代码块添加行号
code.forEach((block) => {
(hljs as ExtendedHLJS).lineNumbersBlock(block as HTMLElement);
});
}
);
// 监听代码块内容变化,有hljs-ln代表行号添加完成
const observer = useMutationObserver(
contentRef,
(mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
// 检查新增节点是否为 table 且 class 包含 hljs-ln
mutation.addedNodes.forEach((node) => {
if (
node instanceof HTMLElement &&
node.tagName.toLowerCase() === "table" &&
node.classList.contains("hljs-ln")
) {
codeRendered.value = true;
// 获取代码块的行数 <table class="hljs-ln" data-total-lines="26">
const totalLines = node.getAttribute("data-total-lines");
if (totalLines) {
const digitNum = totalLines.length; // 位数
const width = digitNum * 17 + "px";
// 获取table下的.hljs-ln-numbers元素,并设置行号的宽度,防止行号元素在宽页面下变宽,设置第一个就好
const lnNumbers = node.querySelector(".hljs-ln-numbers");
if (lnNumbers && lnNumbers instanceof HTMLElement) {
lnNumbers.style.width = width;
}
}
}
});
}
}
},
{ childList: true, subtree: true }
);
// showLineNumbers为 false 不需要监听
computed(() => {
if (!props.showLineNumbers) {
observer.stop();
}
});
onUnmounted(() => {
// 目前主要卸载其中的监听
if (props.showLineNumbers) {
(hljs as ExtendedHLJS).destroy?.();
}
});
// 向外暴露一个方法,用于复制代码块,因为使用原先的复制组件复制会导致复制出被压缩的代码
const copyCodeBlock = () => {
if (!contentRef.value) return;
(hljs as ExtendedHLJS).copyCodeBlock(contentRef.value);
};
defineExpose({
copyCodeBlock
});
</script>
<template>
<div v-show="showMarked" ref="contentRef" v-html="htmlContent"></div>
</template>
<style scoped>
:deep(.hljs) {
background: #f9fbff !important;
}
:deep(.hljs-ln) {
width: 100%;
}
:deep(.hljs-ln-n) {
padding-right: 16px;
text-align: right;
color: #999;
}
</style>