电脑使用 - 博客园折叠框等洛谷新版显示功能配置详解
序
为了在博客园上渲染洛谷的折叠块,epigraph 和 align 功能,我使用 AI 工具的辅助编写了一个插件,并将其改写成了 Html,使其能在 SimpleMemory 这样的高度配置化且深度重构 Markdown 的主题下运行。
使用方法非常简便。你可以在我本人的博客园博客上查看渲染效果,里面的 GF 学习笔记应该是较为复杂的情况,其通过了测试。
目前插件版本 / 主要内容版本为 v3.6,Powered By Arena AI。插件不会访问洛谷上的一切内容,基于博客园内部配置,不会产生访问频率问题。
::::warning[关于 AI 使用说明]{open}
由于本人过于菜,这里的一切 Html 代码都是由 AI 生成的。我已经确认过其的本地数据安全性,保证不会产生数据泄露的问题。本插件(或博客园内部配置)一切数据都存储于本地。
文章内容(除代码)不含任何 AI 生成内容。
::::
配置方法简述
你可以选用两种方法:
- 使用博客园内置的页脚 Html 功能来渲染。
- 使用油猴插件渲染。
博客园内的配置要求:开通 JS 功能,且使用 SimpleMemory 的主题优化配置,打开 MathJax3。这是所需的全部已知配置。
页脚 Html 配置代码
::::success[代码内容]
<!-- 作者:Exfish | 版本:3.5 页脚版
将本文件全部内容追加到博客园「设置 → 页脚 HTML 代码」。
需要博客 JS 权限;请停用同功能油猴脚本,保留现有主题和 MathJax 配置。
-->
<!-- 作者:Exfish | 版本:3.6 页脚版
将本文件全部内容放入博客园「设置 → 页脚 HTML 代码」。
更新时请替换旧版渲染器,不要重复追加;保留其他主题代码。
需要博客 JS 权限;请停用同功能油猴脚本,保留现有 MathJax 配置。
-->
<script>
// 博客园洛谷语法渲染器 — 页脚版 3.6
// 作者:Exfish
// 使用页面已有 MathJax,不重复加载数学引擎。
(function() {
'use strict';
// 同一页面重复粘贴或加载脚本时,避免重复绑定事件和观察器。
const installKey = '__exfishLuoguRenderer36Installed';
if (window[installKey]) return;
window[installKey] = true;
// 原生页面样式注入,不依赖油猴 API。
function addStyle(css) {
const styleId = 'exfish-luogu-renderer-style';
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.id = styleId;
(document.head || document.documentElement).appendChild(style);
}
style.textContent = css;
}
const ICONS = {
info: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"/></svg>`,
warning: `<svg viewBox="0 0 576 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/></svg>`,
success: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200zm90.729 158.343l-110.343 110.343c-4.686 4.686-12.284 4.686-16.971 0l-55.343-55.343c-4.686-4.686-4.686-12.284 0-16.971l22.627-22.627c4.686-4.686 12.284-4.686 16.971 0l24.686 24.686 79.686-79.686c4.686-4.686 12.284-4.686 16.971 0l22.627 22.627c4.686 4.686 4.686 12.284 0 16.971z"/></svg>`,
error: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"/></svg>`
};
const CONFIG = {
blockTypes: {
info: { color: '#3498db' },
warning: { color: '#ffc116' },
success: { color: '#52c41a' },
error: { color: '#e74c3c' },
},
alignTypes: {
center: 'center',
right: 'right',
left: 'left',
}
};
addStyle(`
/* ===== 折叠框 ===== */
.luogu-fold-block {
border: none;
border-left: 4px solid #ccc;
border-radius: 4px;
margin: 0.8em 0;
font-size: 14px;
overflow: visible !important;
transition: all 0.2s ease;
}
.luogu-fold-block .fold-title {
padding: 4px 11px;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
background: transparent !important;
gap: 4px;
}
.luogu-fold-block .fold-title .fold-title-text {
flex: 1;
min-width: 0;
text-align: left;
text-indent: 0 !important;
line-height: 1.6;
overflow-wrap: break-word;
word-break: break-word;
}
.luogu-fold-block .fold-title .fold-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1em;
height: 1.1em;
line-height: 1;
}
.luogu-fold-block .fold-title .fold-icon svg {
width: 100%;
height: 100%;
display: block;
}
.luogu-fold-block .fold-title .fold-title-text mjx-container,
.luogu-fold-block .fold-title .fold-title-text .MathJax,
.luogu-fold-block .fold-title .fold-title-text .MathJax_Display {
text-align: left !important;
margin: 0 !important;
max-width: none !important;
overflow: visible !important;
/* 保留 MathJax 自身的行内公式基线,不统一设置 vertical-align。 */
}
/* 保留 MathJax 原生块级布局;不把公式容器改为 flex,
不覆盖内部 SVG、mjx-math 的尺寸或基线。 */
.luogu-fold-block > .fold-title > .fold-title-text mjx-container[display="true"],
.luogu-fold-block > .fold-title > .fold-title-text .MathJax_Display,
.luogu-fold-block > .fold-title > .fold-title-text .MathJax_SVG_Display,
.luogu-fold-block > .fold-title > .fold-title-text .MathJax_CHTML_Display {
margin: 0 !important;
text-indent: 0 !important;
text-align: left !important;
}
.luogu-fold-block .fold-title .fold-arrow {
transition: transform 0.2s;
font-size: 12px;
opacity: 0.6;
flex-shrink: 0;
margin-left: 4px;
}
.luogu-fold-block.open > .fold-title .fold-arrow {
transform: rotate(90deg);
}
.luogu-fold-block > .fold-content {
/* 用小间距替代标题后由 Markdown 换行产生的整行 <br>。 */
padding: 0.25em 11px 6px;
overflow: visible !important;
display: none !important;
line-height: 1.6;
}
.luogu-fold-block.open > .fold-content {
display: block !important;
}
/* 避免主题给正文首段的上边距再次撑出一个空行。 */
.luogu-fold-block > .fold-content > :first-child {
margin-top: 0 !important;
}
/* 只解除排版外层的裁剪,不修改公式内部布局与辅助 MathML。 */
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph),
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
:is(.fold-title, .fold-title-text, .epigraph-content, .epigraph-line,
.epigraph-author) {
overflow: visible !important;
}
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
:is(mjx-container, .MathJax_Display, .MathJax_SVG_Display,
.MathJax_CHTML_Display) {
max-width: none !important;
overflow: visible !important;
}
/* 行内公式必须以完整宽度参与文字排版。只设置最外层,
不改分式/根号内部的定位,也不把公式压进主题的 inline-flex。 */
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
:is(mjx-container:not([display="true"]), .MathJax, .MathJax_CHTML, .MathJax_SVG) {
display: inline-block !important;
width: max-content !important;
min-width: max-content !important;
max-width: none !important;
float: none !important;
white-space: nowrap !important;
overflow: visible !important;
}
.luogu-fold-block .fold-content .luogu-fold-block {
margin: 0.3em 0;
font-size: 13px;
}
${Object.entries(CONFIG.blockTypes).map(([type, style]) => `
.luogu-fold-block.${type} {
border-left-color: ${style.color} !important;
}
.luogu-fold-block.${type} > .fold-title {
color: ${style.color};
background: transparent !important;
}
`).join('')}
/* ===== 对齐排版 ===== */
.luogu-align {
margin: 1em 0;
}
.luogu-align.center {
text-align: center;
}
.luogu-align.center > * {
margin-left: auto;
margin-right: auto;
}
.luogu-align.right {
text-align: right;
}
.luogu-align.right > * {
margin-left: auto;
margin-right: 0;
}
.luogu-align.left {
text-align: left;
}
.luogu-align.left > * {
margin-left: 0;
margin-right: auto;
}
.luogu-align.center > p,
.luogu-align.center > div,
.luogu-align.center > h1,
.luogu-align.center > h2,
.luogu-align.center > h3,
.luogu-align.center > h4,
.luogu-align.center > h5,
.luogu-align.center > h6 {
text-align: center;
}
.luogu-align.right > p,
.luogu-align.right > div,
.luogu-align.right > h1,
.luogu-align.right > h2,
.luogu-align.right > h3,
.luogu-align.right > h4,
.luogu-align.right > h5,
.luogu-align.right > h6 {
text-align: right;
}
.luogu-align.left > p,
.luogu-align.left > div,
.luogu-align.left > h1,
.luogu-align.left > h2,
.luogu-align.left > h3,
.luogu-align.left > h4,
.luogu-align.left > h5,
.luogu-align.left > h6 {
text-align: left;
}
.luogu-align img {
display: block !important;
}
.luogu-align.center img {
margin-left: auto !important;
margin-right: auto !important;
}
.luogu-align.right img {
margin-left: auto !important;
margin-right: 0 !important;
}
.luogu-align.left img {
margin-left: 0 !important;
margin-right: auto !important;
}
/* ===== epigraph ===== */
.luogu-epigraph {
margin: 1.2em 0;
padding: 0.4em 0;
font-size: 14px;
color: inherit;
opacity: 0.9;
}
.luogu-epigraph .epigraph-content {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
max-width: 20em;
margin-left: auto;
line-height: 1.7;
}
.luogu-epigraph .epigraph-content p {
margin: 0;
text-align: right;
}
.luogu-epigraph .epigraph-line {
text-align: right;
}
.luogu-epigraph .epigraph-divider {
width: 20em !important;
max-width: 100% !important;
height: 0 !important;
border: none !important;
border-top: 1px solid currentColor !important;
opacity: 0.35;
margin: 0.4em 0 0.4em auto !important;
padding: 0 !important;
background: transparent !important;
}
.luogu-epigraph .epigraph-author {
text-align: right;
color: inherit;
opacity: 0.7;
font-size: 13px;
margin-left: auto;
max-width: 20em;
}
`);
// ========== MathJax 配置补全 ==========
/**
* 确保 MathJax v3 的 tex 配置里包含 $...$ 和 $$...$$
* 博客园可能只配置了 \(...\),导致 $...$ 不被识别
*/
function ensureMathJaxDollarSupport() {
if (!window.MathJax) return false;
if (window.MathJax.Hub && window.MathJax.Hub.Config) {
const hub = window.MathJax.Hub;
const config = hub.config.tex2jax || {};
const inlineMath = (config.inlineMath || [['\\(', '\\)']]).slice();
const displayMath = (config.displayMath || [['\\[', '\\]']]).slice();
if (!inlineMath.some(p => p[0] === '$')) inlineMath.push(['$', '$']);
if (!displayMath.some(p => p[0] === '$$')) displayMath.push(['$$', '$$']);
hub.Config({tex2jax: {inlineMath, displayMath}});
return true;
}
if (!window.MathJax.config) return false;
const tex = window.MathJax.config.tex = window.MathJax.config.tex || {};
tex.inlineMath = tex.inlineMath || [];
if (!tex.inlineMath.some(p => p[0] === '$')) {
tex.inlineMath.push(['$', '$']);
}
tex.displayMath = tex.displayMath || [];
if (!tex.displayMath.some(p => p[0] === '$$')) {
tex.displayMath.push(['$$', '$$']);
}
// 确保 \[...\] 也在
if (!tex.displayMath.some(p => p[0] === '\\[')) {
tex.displayMath.push(['\\[', '\\]']);
}
console.log('[洛谷渲染] MathJax inlineMath:', JSON.stringify(tex.inlineMath));
console.log('[洛谷渲染] MathJax displayMath:', JSON.stringify(tex.displayMath));
return true;
}
// ========== 预处理 ==========
function preprocessHTML(html) {
html = html.replace(/<p[^>]*>\s*(:{3,})/gi, '$1');
html = html.replace(/(:{3,})\s*<\/p>/gi, '$1');
html = html.replace(/<br\s*\/?>\s*(:{3,})/gi, '\n$1');
html = html.replace(/(:{3,})\s*<br\s*\/?>/gi, '$1\n');
// 此处处理的是 HTML 序列化字符串,而不是纯文本。
// 必须保留 <、>、&、" 等实体,交给浏览器解析一次:
// 若提前反转义,TeX 的 0<k 会被当成 HTML 标签,导致公式被吞掉;
// 二次解码还会破坏原文中的字面量实体、属性和 aligned 的 &。
html = html.replace(/ /g, ' ')
.replace(/:/g, ':');
html = html.replace(/(:{3,})(\w+)\[([^\]]*)\]/g, (m, c, t, title) => {
return c + t + '[' + title.trim() + ']';
});
return html;
}
function findColonLevels(text) {
const levels = new Set();
const re = /(:{3,})(?:\w+\[|align\{)/g;
let m;
while ((m = re.exec(text)) !== null) {
levels.add(m[1].length);
}
return Array.from(levels).sort((a, b) => b - a);
}
function parseEpigraph(text, levels) {
let result = text;
for (const level of levels) {
const colons = ':'.repeat(level);
const regex = new RegExp(
`(${colons})epigraph\\[([^\\]]*)\\]([\\s\\S]*?)\\1`,
'g'
);
const matches = [];
let match;
while ((match = regex.exec(result)) !== null) {
matches.push({
full: match[0],
colons: match[1],
author: match[2],
content: match[3],
index: match.index
});
}
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
let content = m.content.trim();
content = content.replace(/<\/p>\s*<p>/g, '\n');
content = content.replace(/^<p>/, '').replace(/<\/p>$/, '');
const lines = content.split('\n').filter(l => l.trim() !== '');
const contentHTML = lines.map(l => `<div class="epigraph-line">${l}</div>`).join('');
const html = `<div class="luogu-epigraph">
<div class="epigraph-content">${contentHTML}</div>
<div class="epigraph-divider"></div>
<div class="epigraph-author">${m.author}</div>
</div>`;
result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
}
}
return result;
}
function cleanFoldContent(content) {
let html = content.trim();
// 标记独占段落时,预处理会留下段落边界;避免浏览器修复成空段落。
html = html.replace(/^<\/p\s*>\s*/i, '')
.replace(/\s*<p\b[^>]*>$/i, '');
// 只移除紧邻起始标记的一个换行,保留正文内及用户额外添加的换行。
html = html.replace(/^<br\b[^>]*>\s*/i, '');
return html.trim();
}
function parseFoldBlocks(text, levels) {
let result = text;
for (const level of levels) {
const colons = ':'.repeat(level);
const regex = new RegExp(
`(${colons})(\\w+)\\[([^\\]]*)\\](?:\\{(\\w+)\\})?([\\s\\S]*?)\\1`,
'g'
);
const matches = [];
let match;
while ((match = regex.exec(result)) !== null) {
matches.push({
full: match[0],
colons: match[1],
type: match[2],
title: match[3],
option: match[4],
content: match[5],
index: match.index
});
}
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const typeClass = CONFIG.blockTypes[m.type] ? m.type : 'info';
const openClass = m.option === 'open' ? ' open' : '';
const arrow = '▶';
const icon = ICONS[typeClass] || ICONS.info;
const html = `<div class="luogu-fold-block ${typeClass}${openClass}" data-luogu-type="${m.type}" data-luogu-depth="${level}">
<div class="fold-title">
<span class="fold-icon">${icon}</span>
<span class="fold-title-text">${m.title}</span>
<span class="fold-arrow">${arrow}</span>
</div>
<div class="fold-content${m.option === 'open' ? '' : ' mathjax_ignore tex2jax_ignore'}">${cleanFoldContent(m.content)}</div>
</div>`;
result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
}
}
return result;
}
function parseAlignBlocks(text, levels) {
let result = text;
for (const level of levels) {
const colons = ':'.repeat(level);
const regex = new RegExp(
`(${colons})align\\{(\\w+)\\}([\\s\\S]*?)\\1`,
'g'
);
const matches = [];
let match;
while ((match = regex.exec(result)) !== null) {
matches.push({
full: match[0],
colons: match[1],
alignType: match[2],
content: match[3],
index: match.index
});
}
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const alignClass = CONFIG.alignTypes[m.alignType] ? m.alignType : 'center';
const html = `<div class="luogu-align ${alignClass}">${m.content.trim()}</div>`;
result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
}
}
return result;
}
// 一个队列处理本插件的所有排版任务,避免 v3 的 typesetPromise 并发。
let mathQueue = Promise.resolve();
let mathRetryTimer = null;
const pendingMathRoots = new Set();
const pendingRefreshRoots = new Set();
function typesetElement(el, refresh = false) {
if (!el || !el.isConnected) return;
if (refresh) pendingRefreshRoots.add(el);
for (const root of pendingMathRoots) {
if (root === el || root.contains(el)) return;
if (el.contains(root)) pendingMathRoots.delete(root);
}
pendingMathRoots.add(el);
scheduleMath();
}
function scheduleMath(attempt = 0) {
if (mathRetryTimer !== null) return;
mathRetryTimer = setTimeout(() => {
mathRetryTimer = null;
const mj = window.MathJax;
const isV2 = mj && mj.Hub && typeof mj.Hub.Queue === 'function';
if (!mj || (!isV2 && typeof mj.typesetPromise !== 'function')) {
// 最多等待 30 秒,避免页面未加载 MathJax 时无限轮询。
if (attempt < 100) scheduleMath(attempt + 1);
else {
pendingMathRoots.clear();
pendingRefreshRoots.clear();
console.warn('[洛谷渲染] 未检测到可用的 MathJax');
}
return;
}
mathQueue = mathQueue.then(async () => {
if (mj.startup && mj.startup.promise) await mj.startup.promise;
ensureMathJaxDollarSupport();
const roots = Array.from(pendingMathRoots).filter(el => el.isConnected);
pendingMathRoots.clear();
const refreshRoots = Array.from(pendingRefreshRoots).filter(el => el.isConnected);
pendingRefreshRoots.clear();
if (!roots.length) return;
if (isV2) {
// v2 的 Typeset 不会重算已存在公式;展开后必须 Rerender。
for (const root of roots) {
await new Promise(resolve => mj.Hub.Queue(['Typeset', mj.Hub, root], resolve));
}
const rerenderSources = new Set();
for (const root of refreshRoots) {
const items = mj.Hub.getAllJax(root).filter(jax => {
const source = jax.SourceElement();
return source && !source.closest('.fold-content.mathjax_ignore');
});
items.forEach(jax => rerenderSources.add(jax.SourceElement()));
}
if (rerenderSources.size) await new Promise(resolve => mj.Hub.Queue(
['Rerender', mj.Hub, Array.from(rerenderSources)], resolve
));
} else {
refreshRegisteredMath(mj, refreshRoots);
await mj.typesetPromise(roots);
}
}).catch(err => {
console.warn('[洛谷渲染] MathJax typeset 失败:', err);
});
}, attempt ? 300 : 0);
}
// typesetPromise() 只查找新公式,不会重算已渲染公式的尺寸。
// 将本插件范围内的 TeX 从 MathJax 注册表安全还原,再在可见布局中排版。
function refreshRegisteredMath(mj, roots) {
if (typeof mj.getMathItemsWithin !== 'function' || typeof mj.typesetClear !== 'function') return;
const seen = new Set();
const entries = [];
for (const root of roots) {
for (const item of mj.getMathItemsWithin([root])) {
const node = item.typesetRoot;
if (!node || !node.isConnected || seen.has(node)) continue;
if (!node.closest('.luogu-fold-block, .luogu-align, .luogu-epigraph')) continue;
// 闭合的嵌套块等其自身展开时再测量,不能在 display:none 下重排。
if (node.closest('.fold-content.mathjax_ignore')) continue;
if (!item.inputJax || item.inputJax.name !== 'TeX') continue;
seen.add(node);
const left = item.start.delim || (item.display ? '$$' : '$');
const right = item.end.delim || (item.display ? '$$' : '$');
entries.push({node, text: left + item.math + right});
}
}
if (!entries.length) return;
mj.typesetClear(entries.map(entry => entry.node));
for (const {node, text} of entries) node.replaceWith(node.ownerDocument.createTextNode(text));
}
// 解析前保护代码区域及已渲染公式。占位符中不包含指令文本,
// 防止代码示例触发指令、提前结束外层块或与代码外的标记错误配对。
// 解析后移回原节点,保留代码高亮、事件及 MathJax 的节点关联。
function snapshotWithProtectedNodes(root) {
const clone = root.cloneNode(true);
const originalElements = Array.from(root.querySelectorAll('*'));
const clonedElements = Array.from(clone.querySelectorAll('*'));
const clones = new Map(originalElements.map((el, i) => [el, clonedElements[i]]));
const selector = 'pre, code, kbd, samp, textarea, script, style, ' +
'.cnblogs_code, .cnblogs_Highlighter, .syntaxhighlighter, ' +
'mjx-container, .MathJax_Display, .MathJax_SVG_Display, ' +
'.MathJax_CHTML_Display, .MJXc-display, .MathJax, .MathJax_SVG, .MathJax_CHTML';
const candidates = Array.from(root.querySelectorAll(selector));
const candidateSet = new Set(candidates);
const nodes = candidates.filter(el => {
for (let parent = el.parentElement; parent && parent !== root; parent = parent.parentElement) {
if (candidateSet.has(parent)) return false;
}
return true;
});
// 仅操作克隆;如果只有代码示例而没有正文指令,原始 DOM 完全不变。
const token = 'luogu-preserved-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
nodes.forEach((el, i) => {
const marker = root.ownerDocument.createElement('span');
marker.setAttribute('data-luogu-preserved-token', token + '-' + i);
clones.get(el).replaceWith(marker);
});
return {html: clone.innerHTML, nodes, token};
}
function bindFoldEvents(container) {
container.querySelectorAll('.luogu-fold-block').forEach(block => {
const titleEl = block.querySelector(':scope > .fold-title');
if (!titleEl) return;
if (titleEl.dataset.luoguBound === 'true') return;
titleEl.dataset.luoguBound = 'true';
titleEl.addEventListener('click', function(e) {
e.stopPropagation();
const wasOpen = block.classList.contains('open');
block.classList.toggle('open');
const contentEl = block.querySelector(':scope > .fold-content');
if (contentEl) {
contentEl.classList.toggle('mathjax_ignore', wasOpen);
contentEl.classList.toggle('tex2jax_ignore', wasOpen);
if (!wasOpen) {
// 等浏览器应用展开后的布局后再测量,而不是仅重扫新公式。
requestAnimationFrame(() => typesetElement(contentEl, true));
}
}
});
});
}
function findContentElement() {
const contentSelectors = [
'#cnblogs_post_body',
'.postBody',
'.post',
'.blogpost-body',
'article .entry-content',
'.post-content',
'#mainContent',
'.article-content',
];
for (const sel of contentSelectors) {
const el = document.querySelector(sel);
if (el) return el;
}
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) continue;
for (const sel of contentSelectors) {
const el = doc.querySelector(sel);
if (el) return el;
}
} catch (e) {}
}
return null;
}
const processingRoots = new WeakSet();
async function main() {
const contentEl = findContentElement();
if (!contentEl) {
console.log('[洛谷渲染] 未找到文章内容区,跳过');
return;
}
if (contentEl.dataset.luoguRendered === 'true' || processingRoots.has(contentEl)) {
return;
}
processingRoots.add(contentEl);
try {
// 避免在 MathJax 首次排版尚未完成时替换文章 DOM。
const startup = window.MathJax && window.MathJax.startup;
if (startup && startup.promise) await startup.promise;
if (!contentEl.isConnected) return;
ensureMathJaxDollarSupport();
const snapshot = snapshotWithProtectedNodes(contentEl);
let processedHTML = preprocessHTML(snapshot.html);
const levels = findColonLevels(processedHTML);
if (!levels.length) return;
const beforeParsing = processedHTML;
processedHTML = parseEpigraph(processedHTML, levels);
processedHTML = parseFoldBlocks(processedHTML, levels);
processedHTML = parseAlignBlocks(processedHTML, levels);
if (processedHTML === beforeParsing) return;
const template = contentEl.ownerDocument.createElement('template');
template.innerHTML = processedHTML;
// 移回代码、高亮和公式的原节点,而不是通过 innerHTML 重新创建它们。
snapshot.nodes.forEach((node, i) => {
const marker = template.content.querySelector(
'[data-luogu-preserved-token="' + snapshot.token + '-' + i + '"]'
);
if (marker) marker.replaceWith(node);
});
contentEl.replaceChildren(template.content);
contentEl.dataset.luoguRendered = 'true';
bindFoldEvents(contentEl);
// 初次只排版文章一次;不再同时全页排版和逐个展开块排版。
typesetElement(contentEl, true);
console.log('[洛谷渲染] 处理完成');
} catch (err) {
console.warn('[洛谷渲染] 处理失败:', err);
} finally {
processingRoots.delete(contentEl);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
const selectors = ['#cnblogs_post_body', '.postBody', 'article .entry-content', '.post'];
for (const sel of selectors) {
if (node.querySelector && (node.querySelector(sel) || (node.matches && node.matches(sel)))) {
setTimeout(main, 500);
return;
}
}
}
}
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true
});
})();
</script>
::::
注意要开通 JS 权限。
油猴插件版(原版)
本来以为页脚版用不了(事实上部分机器就是跑不起来),让 AI 写了个这个,下面也放一下源码。
::::success[代码内容]
// ==UserScript==
// @name 博客园文章 - 洛谷折叠块/特殊排版渲染
// @namespace http://tampermonkey.net/
// @version 3.6
// @description 自动渲染博客园文章中的洛谷风格折叠框与特殊排版,兼容 SimpleMemory 主题,支持 MathJax 公式、嵌套折叠框、epigraph
// @author Exfish
// @match *://*.cnblogs.com/*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const ICONS = {
info: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"/></svg>`,
warning: `<svg viewBox="0 0 576 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/></svg>`,
success: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200zm90.729 158.343l-110.343 110.343c-4.686 4.686-12.284 4.686-16.971 0l-55.343-55.343c-4.686-4.686-4.686-12.284 0-16.971l22.627-22.627c4.686-4.686 12.284-4.686 16.971 0l24.686 24.686 79.686-79.686c4.686-4.686 12.284-4.686 16.971 0l22.627 22.627c4.686 4.686 4.686 12.284 0 16.971z"/></svg>`,
error: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"/></svg>`
};
const CONFIG = {
blockTypes: {
info: { color: '#3498db' },
warning: { color: '#ffc116' },
success: { color: '#52c41a' },
error: { color: '#e74c3c' },
},
alignTypes: {
center: 'center',
right: 'right',
left: 'left',
}
};
GM_addStyle(`
/* ===== 折叠框 ===== */
.luogu-fold-block {
border: none;
border-left: 4px solid #ccc;
border-radius: 4px;
margin: 0.8em 0;
font-size: 14px;
overflow: visible !important;
transition: all 0.2s ease;
}
.luogu-fold-block .fold-title {
padding: 4px 11px;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
background: transparent !important;
gap: 4px;
}
.luogu-fold-block .fold-title .fold-title-text {
flex: 1;
min-width: 0;
text-align: left;
text-indent: 0 !important;
line-height: 1.6;
overflow-wrap: break-word;
word-break: break-word;
}
.luogu-fold-block .fold-title .fold-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1em;
height: 1.1em;
line-height: 1;
}
.luogu-fold-block .fold-title .fold-icon svg {
width: 100%;
height: 100%;
display: block;
}
.luogu-fold-block .fold-title .fold-title-text mjx-container,
.luogu-fold-block .fold-title .fold-title-text .MathJax,
.luogu-fold-block .fold-title .fold-title-text .MathJax_Display {
text-align: left !important;
margin: 0 !important;
max-width: none !important;
overflow: visible !important;
/* 保留 MathJax 自身的行内公式基线,不统一设置 vertical-align。 */
}
/* 保留 MathJax 原生块级布局;不把公式容器改为 flex,
不覆盖内部 SVG、mjx-math 的尺寸或基线。 */
.luogu-fold-block > .fold-title > .fold-title-text mjx-container[display="true"],
.luogu-fold-block > .fold-title > .fold-title-text .MathJax_Display,
.luogu-fold-block > .fold-title > .fold-title-text .MathJax_SVG_Display,
.luogu-fold-block > .fold-title > .fold-title-text .MathJax_CHTML_Display {
margin: 0 !important;
text-indent: 0 !important;
text-align: left !important;
}
.luogu-fold-block .fold-title .fold-arrow {
transition: transform 0.2s;
font-size: 12px;
opacity: 0.6;
flex-shrink: 0;
margin-left: 4px;
}
.luogu-fold-block.open > .fold-title .fold-arrow {
transform: rotate(90deg);
}
.luogu-fold-block > .fold-content {
/* 用小间距替代标题后由 Markdown 换行产生的整行 <br>。 */
padding: 0.25em 11px 6px;
overflow: visible !important;
display: none !important;
line-height: 1.6;
}
.luogu-fold-block.open > .fold-content {
display: block !important;
}
/* 避免主题给正文首段的上边距再次撑出一个空行。 */
.luogu-fold-block > .fold-content > :first-child {
margin-top: 0 !important;
}
/* 只解除排版外层的裁剪,不修改公式内部布局与辅助 MathML。 */
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph),
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
:is(.fold-title, .fold-title-text, .epigraph-content, .epigraph-line,
.epigraph-author) {
overflow: visible !important;
}
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
:is(mjx-container, .MathJax_Display, .MathJax_SVG_Display,
.MathJax_CHTML_Display) {
max-width: none !important;
overflow: visible !important;
}
/* 行内公式必须以完整宽度参与文字排版。只设置最外层,
不改分式/根号内部的定位,也不把公式压进主题的 inline-flex。 */
:is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
:is(mjx-container:not([display="true"]), .MathJax, .MathJax_CHTML, .MathJax_SVG) {
display: inline-block !important;
width: max-content !important;
min-width: max-content !important;
max-width: none !important;
float: none !important;
white-space: nowrap !important;
overflow: visible !important;
}
.luogu-fold-block .fold-content .luogu-fold-block {
margin: 0.3em 0;
font-size: 13px;
}
${Object.entries(CONFIG.blockTypes).map(([type, style]) => `
.luogu-fold-block.${type} {
border-left-color: ${style.color} !important;
}
.luogu-fold-block.${type} > .fold-title {
color: ${style.color};
background: transparent !important;
}
`).join('')}
/* ===== 对齐排版 ===== */
.luogu-align {
margin: 1em 0;
}
.luogu-align.center {
text-align: center;
}
.luogu-align.center > * {
margin-left: auto;
margin-right: auto;
}
.luogu-align.right {
text-align: right;
}
.luogu-align.right > * {
margin-left: auto;
margin-right: 0;
}
.luogu-align.left {
text-align: left;
}
.luogu-align.left > * {
margin-left: 0;
margin-right: auto;
}
.luogu-align.center > p,
.luogu-align.center > div,
.luogu-align.center > h1,
.luogu-align.center > h2,
.luogu-align.center > h3,
.luogu-align.center > h4,
.luogu-align.center > h5,
.luogu-align.center > h6 {
text-align: center;
}
.luogu-align.right > p,
.luogu-align.right > div,
.luogu-align.right > h1,
.luogu-align.right > h2,
.luogu-align.right > h3,
.luogu-align.right > h4,
.luogu-align.right > h5,
.luogu-align.right > h6 {
text-align: right;
}
.luogu-align.left > p,
.luogu-align.left > div,
.luogu-align.left > h1,
.luogu-align.left > h2,
.luogu-align.left > h3,
.luogu-align.left > h4,
.luogu-align.left > h5,
.luogu-align.left > h6 {
text-align: left;
}
.luogu-align img {
display: block !important;
}
.luogu-align.center img {
margin-left: auto !important;
margin-right: auto !important;
}
.luogu-align.right img {
margin-left: auto !important;
margin-right: 0 !important;
}
.luogu-align.left img {
margin-left: 0 !important;
margin-right: auto !important;
}
/* ===== epigraph ===== */
.luogu-epigraph {
margin: 1.2em 0;
padding: 0.4em 0;
font-size: 14px;
color: inherit;
opacity: 0.9;
}
.luogu-epigraph .epigraph-content {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
max-width: 20em;
margin-left: auto;
line-height: 1.7;
}
.luogu-epigraph .epigraph-content p {
margin: 0;
text-align: right;
}
.luogu-epigraph .epigraph-line {
text-align: right;
}
.luogu-epigraph .epigraph-divider {
width: 20em !important;
max-width: 100% !important;
height: 0 !important;
border: none !important;
border-top: 1px solid currentColor !important;
opacity: 0.35;
margin: 0.4em 0 0.4em auto !important;
padding: 0 !important;
background: transparent !important;
}
.luogu-epigraph .epigraph-author {
text-align: right;
color: inherit;
opacity: 0.7;
font-size: 13px;
margin-left: auto;
max-width: 20em;
}
`);
// ========== MathJax 配置补全 ==========
/**
* 确保 MathJax v3 的 tex 配置里包含 $...$ 和 $$...$$
* 博客园可能只配置了 \(...\),导致 $...$ 不被识别
*/
function ensureMathJaxDollarSupport() {
if (!window.MathJax) return false;
if (window.MathJax.Hub && window.MathJax.Hub.Config) {
const hub = window.MathJax.Hub;
const config = hub.config.tex2jax || {};
const inlineMath = (config.inlineMath || [['\\(', '\\)']]).slice();
const displayMath = (config.displayMath || [['\\[', '\\]']]).slice();
if (!inlineMath.some(p => p[0] === '$')) inlineMath.push(['$', '$']);
if (!displayMath.some(p => p[0] === '$$')) displayMath.push(['$$', '$$']);
hub.Config({tex2jax: {inlineMath, displayMath}});
return true;
}
if (!window.MathJax.config) return false;
const tex = window.MathJax.config.tex = window.MathJax.config.tex || {};
tex.inlineMath = tex.inlineMath || [];
if (!tex.inlineMath.some(p => p[0] === '$')) {
tex.inlineMath.push(['$', '$']);
}
tex.displayMath = tex.displayMath || [];
if (!tex.displayMath.some(p => p[0] === '$$')) {
tex.displayMath.push(['$$', '$$']);
}
// 确保 \[...\] 也在
if (!tex.displayMath.some(p => p[0] === '\\[')) {
tex.displayMath.push(['\\[', '\\]']);
}
console.log('[洛谷渲染] MathJax inlineMath:', JSON.stringify(tex.inlineMath));
console.log('[洛谷渲染] MathJax displayMath:', JSON.stringify(tex.displayMath));
return true;
}
// ========== 预处理 ==========
function preprocessHTML(html) {
html = html.replace(/<p[^>]*>\s*(:{3,})/gi, '$1');
html = html.replace(/(:{3,})\s*<\/p>/gi, '$1');
html = html.replace(/<br\s*\/?>\s*(:{3,})/gi, '\n$1');
html = html.replace(/(:{3,})\s*<br\s*\/?>/gi, '$1\n');
// 此处处理的是 HTML 序列化字符串,而不是纯文本。
// 必须保留 <、>、&、" 等实体,交给浏览器解析一次:
// 若提前反转义,TeX 的 0<k 会被当成 HTML 标签,导致公式被吞掉;
// 二次解码还会破坏原文中的字面量实体、属性和 aligned 的 &。
html = html.replace(/ /g, ' ')
.replace(/:/g, ':');
html = html.replace(/(:{3,})(\w+)\[([^\]]*)\]/g, (m, c, t, title) => {
return c + t + '[' + title.trim() + ']';
});
return html;
}
function findColonLevels(text) {
const levels = new Set();
const re = /(:{3,})(?:\w+\[|align\{)/g;
let m;
while ((m = re.exec(text)) !== null) {
levels.add(m[1].length);
}
return Array.from(levels).sort((a, b) => b - a);
}
function parseEpigraph(text, levels) {
let result = text;
for (const level of levels) {
const colons = ':'.repeat(level);
const regex = new RegExp(
`(${colons})epigraph\\[([^\\]]*)\\]([\\s\\S]*?)\\1`,
'g'
);
const matches = [];
let match;
while ((match = regex.exec(result)) !== null) {
matches.push({
full: match[0],
colons: match[1],
author: match[2],
content: match[3],
index: match.index
});
}
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
let content = m.content.trim();
content = content.replace(/<\/p>\s*<p>/g, '\n');
content = content.replace(/^<p>/, '').replace(/<\/p>$/, '');
const lines = content.split('\n').filter(l => l.trim() !== '');
const contentHTML = lines.map(l => `<div class="epigraph-line">${l}</div>`).join('');
const html = `<div class="luogu-epigraph">
<div class="epigraph-content">${contentHTML}</div>
<div class="epigraph-divider"></div>
<div class="epigraph-author">${m.author}</div>
</div>`;
result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
}
}
return result;
}
function cleanFoldContent(content) {
let html = content.trim();
// 标记独占段落时,预处理会留下段落边界;避免浏览器修复成空段落。
html = html.replace(/^<\/p\s*>\s*/i, '')
.replace(/\s*<p\b[^>]*>$/i, '');
// 只移除紧邻起始标记的一个换行,保留正文内及用户额外添加的换行。
html = html.replace(/^<br\b[^>]*>\s*/i, '');
return html.trim();
}
function parseFoldBlocks(text, levels) {
let result = text;
for (const level of levels) {
const colons = ':'.repeat(level);
const regex = new RegExp(
`(${colons})(\\w+)\\[([^\\]]*)\\](?:\\{(\\w+)\\})?([\\s\\S]*?)\\1`,
'g'
);
const matches = [];
let match;
while ((match = regex.exec(result)) !== null) {
matches.push({
full: match[0],
colons: match[1],
type: match[2],
title: match[3],
option: match[4],
content: match[5],
index: match.index
});
}
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const typeClass = CONFIG.blockTypes[m.type] ? m.type : 'info';
const openClass = m.option === 'open' ? ' open' : '';
const arrow = '▶';
const icon = ICONS[typeClass] || ICONS.info;
const html = `<div class="luogu-fold-block ${typeClass}${openClass}" data-luogu-type="${m.type}" data-luogu-depth="${level}">
<div class="fold-title">
<span class="fold-icon">${icon}</span>
<span class="fold-title-text">${m.title}</span>
<span class="fold-arrow">${arrow}</span>
</div>
<div class="fold-content${m.option === 'open' ? '' : ' mathjax_ignore tex2jax_ignore'}">${cleanFoldContent(m.content)}</div>
</div>`;
result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
}
}
return result;
}
function parseAlignBlocks(text, levels) {
let result = text;
for (const level of levels) {
const colons = ':'.repeat(level);
const regex = new RegExp(
`(${colons})align\\{(\\w+)\\}([\\s\\S]*?)\\1`,
'g'
);
const matches = [];
let match;
while ((match = regex.exec(result)) !== null) {
matches.push({
full: match[0],
colons: match[1],
alignType: match[2],
content: match[3],
index: match.index
});
}
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const alignClass = CONFIG.alignTypes[m.alignType] ? m.alignType : 'center';
const html = `<div class="luogu-align ${alignClass}">${m.content.trim()}</div>`;
result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
}
}
return result;
}
// 一个队列处理本插件的所有排版任务,避免 v3 的 typesetPromise 并发。
let mathQueue = Promise.resolve();
let mathRetryTimer = null;
const pendingMathRoots = new Set();
const pendingRefreshRoots = new Set();
function typesetElement(el, refresh = false) {
if (!el || !el.isConnected) return;
if (refresh) pendingRefreshRoots.add(el);
for (const root of pendingMathRoots) {
if (root === el || root.contains(el)) return;
if (el.contains(root)) pendingMathRoots.delete(root);
}
pendingMathRoots.add(el);
scheduleMath();
}
function scheduleMath(attempt = 0) {
if (mathRetryTimer !== null) return;
mathRetryTimer = setTimeout(() => {
mathRetryTimer = null;
const mj = window.MathJax;
const isV2 = mj && mj.Hub && typeof mj.Hub.Queue === 'function';
if (!mj || (!isV2 && typeof mj.typesetPromise !== 'function')) {
// 最多等待 30 秒,避免页面未加载 MathJax 时无限轮询。
if (attempt < 100) scheduleMath(attempt + 1);
else {
pendingMathRoots.clear();
pendingRefreshRoots.clear();
console.warn('[洛谷渲染] 未检测到可用的 MathJax');
}
return;
}
mathQueue = mathQueue.then(async () => {
if (mj.startup && mj.startup.promise) await mj.startup.promise;
ensureMathJaxDollarSupport();
const roots = Array.from(pendingMathRoots).filter(el => el.isConnected);
pendingMathRoots.clear();
const refreshRoots = Array.from(pendingRefreshRoots).filter(el => el.isConnected);
pendingRefreshRoots.clear();
if (!roots.length) return;
if (isV2) {
// v2 的 Typeset 不会重算已存在公式;展开后必须 Rerender。
for (const root of roots) {
await new Promise(resolve => mj.Hub.Queue(['Typeset', mj.Hub, root], resolve));
}
const rerenderSources = new Set();
for (const root of refreshRoots) {
const items = mj.Hub.getAllJax(root).filter(jax => {
const source = jax.SourceElement();
return source && !source.closest('.fold-content.mathjax_ignore');
});
items.forEach(jax => rerenderSources.add(jax.SourceElement()));
}
if (rerenderSources.size) await new Promise(resolve => mj.Hub.Queue(
['Rerender', mj.Hub, Array.from(rerenderSources)], resolve
));
} else {
refreshRegisteredMath(mj, refreshRoots);
await mj.typesetPromise(roots);
}
}).catch(err => {
console.warn('[洛谷渲染] MathJax typeset 失败:', err);
});
}, attempt ? 300 : 0);
}
// typesetPromise() 只查找新公式,不会重算已渲染公式的尺寸。
// 将本插件范围内的 TeX 从 MathJax 注册表安全还原,再在可见布局中排版。
function refreshRegisteredMath(mj, roots) {
if (typeof mj.getMathItemsWithin !== 'function' || typeof mj.typesetClear !== 'function') return;
const seen = new Set();
const entries = [];
for (const root of roots) {
for (const item of mj.getMathItemsWithin([root])) {
const node = item.typesetRoot;
if (!node || !node.isConnected || seen.has(node)) continue;
if (!node.closest('.luogu-fold-block, .luogu-align, .luogu-epigraph')) continue;
// 闭合的嵌套块等其自身展开时再测量,不能在 display:none 下重排。
if (node.closest('.fold-content.mathjax_ignore')) continue;
if (!item.inputJax || item.inputJax.name !== 'TeX') continue;
seen.add(node);
const left = item.start.delim || (item.display ? '$$' : '$');
const right = item.end.delim || (item.display ? '$$' : '$');
entries.push({node, text: left + item.math + right});
}
}
if (!entries.length) return;
mj.typesetClear(entries.map(entry => entry.node));
for (const {node, text} of entries) node.replaceWith(node.ownerDocument.createTextNode(text));
}
// 解析前保护代码区域及已渲染公式。占位符中不包含指令文本,
// 防止代码示例触发指令、提前结束外层块或与代码外的标记错误配对。
// 解析后移回原节点,保留代码高亮、事件及 MathJax 的节点关联。
function snapshotWithProtectedNodes(root) {
const clone = root.cloneNode(true);
const originalElements = Array.from(root.querySelectorAll('*'));
const clonedElements = Array.from(clone.querySelectorAll('*'));
const clones = new Map(originalElements.map((el, i) => [el, clonedElements[i]]));
const selector = 'pre, code, kbd, samp, textarea, script, style, ' +
'.cnblogs_code, .cnblogs_Highlighter, .syntaxhighlighter, ' +
'mjx-container, .MathJax_Display, .MathJax_SVG_Display, ' +
'.MathJax_CHTML_Display, .MJXc-display, .MathJax, .MathJax_SVG, .MathJax_CHTML';
const candidates = Array.from(root.querySelectorAll(selector));
const candidateSet = new Set(candidates);
const nodes = candidates.filter(el => {
for (let parent = el.parentElement; parent && parent !== root; parent = parent.parentElement) {
if (candidateSet.has(parent)) return false;
}
return true;
});
// 仅操作克隆;如果只有代码示例而没有正文指令,原始 DOM 完全不变。
const token = 'luogu-preserved-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
nodes.forEach((el, i) => {
const marker = root.ownerDocument.createElement('span');
marker.setAttribute('data-luogu-preserved-token', token + '-' + i);
clones.get(el).replaceWith(marker);
});
return {html: clone.innerHTML, nodes, token};
}
function bindFoldEvents(container) {
container.querySelectorAll('.luogu-fold-block').forEach(block => {
const titleEl = block.querySelector(':scope > .fold-title');
if (!titleEl) return;
if (titleEl.dataset.luoguBound === 'true') return;
titleEl.dataset.luoguBound = 'true';
titleEl.addEventListener('click', function(e) {
e.stopPropagation();
const wasOpen = block.classList.contains('open');
block.classList.toggle('open');
const contentEl = block.querySelector(':scope > .fold-content');
if (contentEl) {
contentEl.classList.toggle('mathjax_ignore', wasOpen);
contentEl.classList.toggle('tex2jax_ignore', wasOpen);
if (!wasOpen) {
// 等浏览器应用展开后的布局后再测量,而不是仅重扫新公式。
requestAnimationFrame(() => typesetElement(contentEl, true));
}
}
});
});
}
function findContentElement() {
const contentSelectors = [
'#cnblogs_post_body',
'.postBody',
'.post',
'.blogpost-body',
'article .entry-content',
'.post-content',
'#mainContent',
'.article-content',
];
for (const sel of contentSelectors) {
const el = document.querySelector(sel);
if (el) return el;
}
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) continue;
for (const sel of contentSelectors) {
const el = doc.querySelector(sel);
if (el) return el;
}
} catch (e) {}
}
return null;
}
const processingRoots = new WeakSet();
async function main() {
const contentEl = findContentElement();
if (!contentEl) {
console.log('[洛谷渲染] 未找到文章内容区,跳过');
return;
}
if (contentEl.dataset.luoguRendered === 'true' || processingRoots.has(contentEl)) {
return;
}
processingRoots.add(contentEl);
try {
// 避免在 MathJax 首次排版尚未完成时替换文章 DOM。
const startup = window.MathJax && window.MathJax.startup;
if (startup && startup.promise) await startup.promise;
if (!contentEl.isConnected) return;
ensureMathJaxDollarSupport();
const snapshot = snapshotWithProtectedNodes(contentEl);
let processedHTML = preprocessHTML(snapshot.html);
const levels = findColonLevels(processedHTML);
if (!levels.length) return;
const beforeParsing = processedHTML;
processedHTML = parseEpigraph(processedHTML, levels);
processedHTML = parseFoldBlocks(processedHTML, levels);
processedHTML = parseAlignBlocks(processedHTML, levels);
if (processedHTML === beforeParsing) return;
const template = contentEl.ownerDocument.createElement('template');
template.innerHTML = processedHTML;
// 移回代码、高亮和公式的原节点,而不是通过 innerHTML 重新创建它们。
snapshot.nodes.forEach((node, i) => {
const marker = template.content.querySelector(
'[data-luogu-preserved-token="' + snapshot.token + '-' + i + '"]'
);
if (marker) marker.replaceWith(node);
});
contentEl.replaceChildren(template.content);
contentEl.dataset.luoguRendered = 'true';
bindFoldEvents(contentEl);
// 初次只排版文章一次;不再同时全页排版和逐个展开块排版。
typesetElement(contentEl, true);
console.log('[洛谷渲染] 处理完成');
} catch (err) {
console.warn('[洛谷渲染] 处理失败:', err);
} finally {
processingRoots.delete(contentEl);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
const selectors = ['#cnblogs_post_body', '.postBody', 'article .entry-content', '.post'];
for (const sel of selectors) {
if (node.querySelector && (node.querySelector(sel) || (node.matches && node.matches(sel)))) {
setTimeout(main, 500);
return;
}
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
::::
功能简述
目前能使用的语法:这里可以测试。
- 排版功能。
:::align{center}以及:::align{right}支持。 - CF 风格 epigraph 功能。
:::epigraph[发布者]功能支持。 - 折叠块功能(主要功能)。
::::info[标题]以及::::info[标题]{open}支持,嵌套支持,数学公式显示(::::success[$$\displaystyle\sum_{i = 1}^n \sum_{j = 1}^n \gcd(i, j)$$])支持。
折叠深度限制:未知,理论无限制,但是请注意过多嵌套对性能的影响(测试页面那里是
问题反馈
如果有问题可以在讨论区内回复,或到博客园内这里的评论区反馈。欢迎私信咨询。