我最近在 Docker 论坛上尝试为换行和滚动内容添加行号。目前功能尚未上线,但我想听听大家的意见。先附上一些截图:
换行内容
如上所示,行号有助于识别一行何时结束、下一行何时开始。当然,还有其他渲染方式,但本主题聚焦于行号。![]()
滚动内容
在内容被隐藏的一侧或两侧设置较小的内边距,以提示滚动后可显示更多内容:
或使用阴影来指示水平方向上被隐藏的内容:
收到的部分反馈指出,较小的阴影可能与滚动条混淆,因此可能需要不同的样式设计。
在客户端添加行号
当使用水平滚动时,每一行内容在屏幕上都会占据一行。理论上,可以使用 JavaScript 注入一个包含行号范围的垂直 <div>,并将其定位在滚动内容旁边。但在换行模式下,单个行号可能对应多行内容,此时使用 CSS 的 span::before 配合 content: counter(...) 似乎更简便。本文采用的就是这种方法。
要使用 ::before,预格式化代码块中的每一行首先需要一个父元素,例如通过 api.decorateCookedElement 将其包裹在 <span> 中。这需要该函数在 highlight.js 运行之前执行,因为有时 highlight.js 不会在同一行内闭合它打开的标签。例如,当它认为某段代码是 Mustache/Handlebars 时:
docker image ls --format '{{slice (printf "%s:%s" .Repository .Tag) 0 11}}'
上述代码会在下一行输出闭合的 </span> 标签:
<code class="lang-handlebars hljs"><span class="xml">docker image ls --format '</span><span class="hljs-template-variable">{{<span class="hljs-name">slice</span> (<span class="hljs-name">printf</span> <span class="hljs-string">"%s:%s"</span> .Repository .Tag) <span class="hljs-number">0</span> <span class="hljs-number">11</span>}}</span><span class="xml">'
</span></code>
当然,这可能是一个临时性 Bug,我或许应该上报。但无论如何,谨慎为上。好消息是:目前看来,api.decorateCookedElement(...) 确实会在 highlight.js 之前执行。
接下来的 JavaScript 代码会将每一行包裹在 <span> 中,并添加 CSS 类 lines-scroll 或 lines-wrap 以指示所需的样式(甚至可以允许用户切换)。同时添加 lines-shadow 类以启用滚动阴影。此外,还会为父级 <pre> 和 <code> 添加一些 CSS 类,以支持样式设置,并确保装饰操作只执行一次。
主题 JavaScript 装饰器
以下代码还启用了编辑器预览中的行号及其他样式。修改最后一行,添加 onlyStream: true 即可禁用该功能:
api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines', onlyStream: true});
将以下内容全部添加到 /admin/customize/themes/ 中组件的「Common, Head」部分:
<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
try {
// 组合 'number', 'wrap', 'scroll' 和/或 'shadow'
const classes = ['decorator', 'number', 'scroll', 'shadow'].map(c => `lines-${c}`);
const split = /^(.*)$/mg;
const elems = post.querySelectorAll('pre code:not(.lines-decorator)');
elems.forEach(elem => {
const count = elem.innerHTML.trim().match(split).length;
// Discourse 使用 `<aside>` 表示其他论坛帖子的引用
const quote = elem.closest('aside') ? ['lines-in-quote'] : []
elem.parentElement.classList.add(`lines-count-${count}`, ...classes, ...quote);
elem.classList.add(`lines-count-${count}`, ...classes, ...quote);
const lineClass = ['lines-line', ...quote].join(' ');
// 假设行上没有事件监听器,
// 并修剪以隐藏末尾的空行
elem.innerHTML = elem.innerHTML.trim().replace(/^(.*)$/mg, `<span class="${lineClass}">$1</span>`);
});
} catch (e) {
console.error(e);
}
}
api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines'});
</script>
Sass SCSS 样式
以下样式支持带或不带行号的换行,以及带或不带行号和/或阴影的滚动。同时,当代码块仅有一行时,会抑制行号显示。
该样式不会为其他帖子的引用添加行号,因为如果引用只是部分内容,行号仍会从 1 开始。若要为引用添加行号,请删除下方(在 SCSS 中多次出现)的 :not(.lines-in-quote):
&.lines-number:not(.lines-count-1):not(.lines-in-quote) {
上述代码也是抑制单行代码块行号显示的位置。
主题 Sass 样式
将以下内容添加到 /admin/customize/themes/ 中组件的「Common, CSS」部分:
pre.lines-decorator code.lines-decorator span.lines-line {
&::before {
box-sizing: content-box;
}
}
pre.lines-decorator {
--lines-bgcolor: var(--hljs-bg);
--lines-number-width: 2em;
--lines-number-padding-to-right-border: .5em;
--lines-shadow-width: .8em;
--lines-shadow-color: rgba(0, 0, 0, .15);
// 外层 PRE 无内边距:
// - 滚动条不覆盖 CODE,但靠近最外侧边缘
// - 当有隐藏内容时,文本靠近边缘
// - 阴影同理
padding: 0;
// `code.less` 中的 #f5f5f5
background-color: var(--lines-bgcolor);
}
code.lines-decorator {
padding: 1.5em;
&.lines-wrap {
white-space: pre-wrap;
&:not(.lines-count-1) {
span.lines-line {
display: inline-block;
position: relative;
padding-left: 3em;
}
}
}
&.lines-scroll {
white-space: pre;
span.lines-line {
padding-right: 1.5em;
&::before {
position: sticky;
left: 0;
}
}
}
&.lines-wrap span.lines-line {
// 最小高度以确保空行的边框和背景可见(Firefox
// 仅需为此设置 ::before,但 Chrome 也需要为 span 设置)
min-height: 1.5em;
&::before {
position: absolute;
left: 0;
}
}
&.lines-shadow {
background-image:
// 前两个覆盖层随内容滚动,因此在无法滚动时出现在阴影前方。
// 这些覆盖层前 25% 与背景色匹配,随后渐变为完全透明
//(假设覆盖层宽度是阴影宽度的 4 倍)。
linear-gradient(to right, var(--lines-bgcolor) 25%, transparent),
linear-gradient(to left, var(--lines-bgcolor) 25%, transparent),
// 其后是两个固定位置的阴影,用于指示可滚动但被隐藏的内容(若未被覆盖)。
linear-gradient(to right, var(--lines-shadow-color), transparent),
linear-gradient(to left, var(--lines-shadow-color), transparent);
// 或:
// radial-gradient(farthest-side at 0px 50%, var(--lines-shadow-color), transparent),
// radial-gradient(farthest-side at 100% 50%, var(--lines-shadow-color), transparent);
background-size:
// 将覆盖层设置为实际阴影宽度的 4 倍,以匹配覆盖层渐变中的 25% 停止点
calc(4 * var(--lines-shadow-width)) 100%,
calc(4 * var(--lines-shadow-width)) 100%,
var(--lines-shadow-width) 100%,
var(--lines-shadow-width) 100%;
--shadow-left: 0;
&.lines-number:not(.lines-count-1):not(.lines-in-quote) {
// 如果行号可见,则需要移动阴影位置
--shadow-left: calc(var(--lines-number-width) + var(--lines-number-padding-to-right-border));
}
background-position: var(--shadow-left) center, right center, var(--shadow-left) center, right center;
background-attachment: local, local, scroll, scroll;
background-repeat: no-repeat;
}
&.lines-number:not(.lines-count-1):not(.lines-in-quote) {
padding-left: 0;
counter-reset: line-numbering;
span.lines-line::before {
display: inline-block;
content: counter(line-numbering);
counter-increment: line-numbering;
border-right: 1px solid #ccc;
width: var(--lines-number-width);
padding-right: var(--lines-number-padding-to-right-border);
margin-right: .5em;
height: 100%;
text-align: right;
font-family: var(--font-family);
color: var(--primary-medium);
background-color: var(--lines-bgcolor);
}
}
}
开发
开发时我使用了以下代码,允许设置 URL 查询参数,例如 ?avbPreview 以启用 JavaScript 装饰器钩子,或 ?avbPreview&avbClasses=number,scroll 来测试带滚动但无阴影的行号。由于所有 CSS 均为作用域内,测试期间不会影响其他用户。
开发/调试版本
<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
try {
const params = new URL(document.location.href).searchParams;
const preview = params.has('avbPreview');
if (!preview) {
return;
}
const classes = ['decorator'].concat(
(params.get('avbClasses') || 'number,scroll,shadow').split(',')).map(c => `lines-${c}`);
const debug = params.has('avbDebug');
const split = /^(.*)$/mg;
const elems = post.querySelectorAll('pre code:not(.lines-decorator)');
elems.forEach(elem => {
const count = elem.innerHTML.trim().match(split).length;
// Discourse 使用 `<aside>` 表示其他论坛帖子的引用
const quote = elem.closest('aside') ? ['lines-in-quote'] : []
elem.parentElement.classList.add(`lines-count-${count}`, ...classes, ...quote);
elem.classList.add(`lines-count-${count}`, ...classes, ...quote);
const lineClass = ['lines-line', ...quote].join(' ');
if (debug) {
console.log('===== 重写;源\n' + elem.innerHTML.trim());
console.log('===== 重写;结果\n' + elem.innerHTML.trim().replace(split, `<span class="${lineClass}">$1</span>`));
}
// 假设行上没有事件监听器,
// 并修剪以隐藏末尾的空行
elem.innerHTML = elem.innerHTML.trim().replace(/^(.*)$/mg, `<span class="${lineClass}">$1</span>`);
});
} catch (e) {
console.error(e);
}
}
api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines'});
</script>




