Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c1b6d4b
fix: reuse navbar styler and clean up resize listeners
boomzero Oct 3, 2026
d175414
3.8.1
github-actions[bot] Oct 3, 2026
0e3cbb5
Update version info to 3.8.1
github-actions[bot] Oct 3, 2026
519b281
fix: restore navbar styling and remove owned artifacts on teardown
boomzero Oct 3, 2026
965f08a
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
51dc11e
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
56c4989
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
f21d58c
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
ac477fe
fix: keep downloads out of the navbar spacer
boomzero Oct 3, 2026
4654237
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
3fcc576
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
a64b24a
fix: load the same monochrome fonts on web and classic pages
boomzero Oct 3, 2026
8381325
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
8758133
Update time and description of 3.8.1
github-actions[bot] Oct 3, 2026
b46e5c2
Merge pull request #1036 from XMOJ-Script-dev/codex/fix-navbar-listen…
boomzero Oct 3, 2026
1aeb907
fix: use consistent Chinese navbar fallback fonts
boomzero Oct 3, 2026
66d0fbb
3.8.2
github-actions[bot] Oct 3, 2026
ac3405d
Update version info to 3.8.2
github-actions[bot] Oct 3, 2026
a7efe58
fix: anchor navbar before Bootstrap CSS loads
boomzero Oct 4, 2026
c730563
Update time and description of 3.8.2
github-actions[bot] Oct 4, 2026
11dd0db
Merge pull request #1039 from XMOJ-Script-dev/codex/fix-navbar-cjk-font
boomzero Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Update.json
Original file line number Diff line number Diff line change
Expand Up @@ -3817,6 +3817,28 @@
}
],
"Notes": "<p>本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。</p>\n<p>题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。</p>"
},
"3.8.1": {
"UpdateDate": 1791033862020,
"Prerelease": true,
"UpdateContents": [
{
"PR": 1036,
"Description": "fix: navbar listener leaks, download overlap, and web fonts"
}
],
"Notes": "<p>修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。</p>"
},
"3.8.2": {
"UpdateDate": 1791075550636,
"Prerelease": true,
"UpdateContents": [
{
"PR": 1039,
"Description": "fix: match Chinese navbar fonts between web and classic UI"
}
],
"Notes": "<p>修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。</p>"
}
}
}
130 changes: 98 additions & 32 deletions XMOJ.user.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// ==UserScript==
// @name XMOJ
// @version 3.8.0
// @version 3.8.2
// @description XMOJ增强脚本
// @author @XMOJ-Script-dev, @langningchen and the community
// @namespace https://github/langningchen
Expand Down Expand Up @@ -143,6 +143,9 @@ const MonochromeSkinCSS = `
--mono-border-thin: 1px solid var(--mono-gray-300);
--mono-font-heading: 'Playfair Display', Georgia, serif;
--mono-font-body: 'Source Serif 4', 'Source Serif Pro', Georgia, serif;
/* Source Serif has no Chinese glyphs. An explicit navbar fallback
avoids different fonts under legacy lang=en and /web lang=zh-CN. */
--mono-font-navbar: 'Source Serif 4', 'Source Serif Pro', Georgia, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', serif;
--mono-font-mono: 'JetBrains Mono', 'Consolas', monospace;
--mono-transition: 100ms ease;
}
Expand Down Expand Up @@ -197,14 +200,15 @@ const MonochromeSkinCSS = `

/* Navbar */
.navbar, nav.navbar {
font-family: var(--mono-font-navbar) !important;
border-bottom: 4px solid var(--mono-black) !important;
background-color: var(--mono-white) !important;
opacity: 1 !important;
}
.navbar .nav-link {
color: var(--mono-black) !important;
text-decoration: none !important;
font-family: var(--mono-font-body) !important;
font-family: var(--mono-font-navbar) !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
font-size: 0.85rem !important;
Expand Down Expand Up @@ -572,6 +576,17 @@ const ThemeCanvasCSS = `
html[data-bs-theme='light'] { background: var(--mono-white, var(--bs-body-bg, #fff)); color-scheme: light; }
`;

// Both UI initializers use this link; loading only in legacy main() leaves /web
// on the fallback fonts even though it applies the same monochrome font stack.
function LoadMonochromeFonts() {
if (document.getElementById("xmoj-monochrome-fonts")) return;
const link = document.createElement("link");
link.id = "xmoj-monochrome-fonts";
link.rel = "stylesheet";
link.href = "https://fonts.loli.net/css2?family=Playfair+Display:wght@400;700&family=Source+Serif+4:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap";
(document.head || document.documentElement).appendChild(link);
}

// Set to true by the early block if Bootstrap CSS was injected from the @resource
// cache. Checked in the IIFE to decide whether a CDN fallback is needed.
let _earlyBootstrapInjected = false;
Expand Down Expand Up @@ -616,6 +631,7 @@ let _earlyObs = null;
document.documentElement.setAttribute("data-bs-theme", dark ? "dark" : "light");

let head = document.head || document.documentElement;
if (get("MonochromeUI")) LoadMonochromeFonts();

let bootstrapCSS = GM_getResourceText("BootstrapCSS");
if (!bootstrapCSS) {
Expand Down Expand Up @@ -2325,6 +2341,7 @@ async function InitializeContestWebApp() {
bootstrap.href = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css";
document.head.appendChild(bootstrap);
const mono = UtilityEnabled("MonochromeUI");
if (mono) LoadMonochromeFonts();
const skin = document.createElement("style");
skin.textContent = mono ? MonochromeSkinCSS : NewBootstrapSkinCSS;
if (UtilityEnabled("AddAnimation")) skin.textContent += `.status, .test-case { transition: ${mono ? "100ms ease" : "0.5s"} !important; }`;
Expand Down Expand Up @@ -2736,7 +2753,7 @@ async function InitializeContestWebApp() {
if (!revealed && root.querySelector(".navbar")) {
// As on the legacy pages: top bar first, then show the page.
revealed = true;
if (UtilityEnabled("NewTopBar")) new NavbarStyler();
UpdateNavbarStyler();
RevealPage();
}
if (!route) return;
Expand All @@ -2763,7 +2780,7 @@ async function InitializeContestWebApp() {
window.addEventListener("popstate", ScheduleEnhance);
setInterval(() => {
UpdateCountdowns();
if (UtilityEnabled("NewTopBar")) new NavbarStyler();
UpdateNavbarStyler();
}, 1000);
window.addEventListener("focus", () => {
if (!UtilityEnabled("AutoRefresh") || !["list", "contest", "rank"].includes(route?.page)) return;
Expand All @@ -2789,13 +2806,37 @@ async function InitializeContestWebApp() {
} catch (error) { console.error("[XMOJ-Script] Navigation API:", error); }
}

let navbarStyler = null;

function UpdateNavbarStyler() {
const navbar = document.querySelector('.navbar.navbar-expand-lg.bg-body-tertiary');
// Both UI refresh timers share one listener. Release the old navbar when Vue
// replaces it, or when the top bar is disabled or removed.
if (navbarStyler && (navbarStyler.navbar !== navbar || !UtilityEnabled("NewTopBar"))) {
navbarStyler.destroy();
navbarStyler = null;
}
if (!navbar || !UtilityEnabled("NewTopBar")) return;
if (!navbarStyler) navbarStyler = new NavbarStyler(navbar);
else navbarStyler.init();
}

class NavbarStyler {
constructor() {
constructor(navbar) {
try {
this.navbar = document.querySelector('.navbar.navbar-expand-lg.bg-body-tertiary');
if (this.navbar && UtilityEnabled("NewTopBar")) {
this.init();
}
this.navbar = navbar;
this.addedClasses = ['fixed-top', 'container', 'ml-auto'].filter(name => !navbar.classList.contains(name));
this.restoreNavbarStyles = this.preserveStyles(navbar, [
'position', 'top', 'border-top-left-radius', 'border-top-right-radius',
'border-bottom-left-radius', 'border-bottom-right-radius', 'box-shadow',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'max-width', 'background-color', 'opacity', 'z-index'
]);
this.resizeHandler = () => {
if (this.navbar?.isConnected) this.updateBlurOverlay();
};
window.addEventListener('resize', this.resizeHandler);
this.init();
} catch (e) {
console.error(e);
if (UtilityEnabled("DebugMode")) {
Expand All @@ -2804,12 +2845,34 @@ class NavbarStyler {
}
}

preserveStyles(element, properties) {
const original = properties.map(property => [property, element.style.getPropertyValue(property), element.style.getPropertyPriority(property)]);
return () => {
for (const [property, value, priority] of original) {
if (value) element.style.setProperty(property, value, priority);
else element.style.removeProperty(property);
}
};
}

destroy() {
window.removeEventListener('resize', this.resizeHandler);
// Restore only properties/classes we changed, and remove only our own DOM.
this.navbar.classList.remove(...this.addedClasses);
this.restoreNavbarStyles();
this.restoreOverlayStyles?.();
this.restoreSpacerStyles?.();
this.overlay?.remove();
this.overlayStyle?.remove();
this.spacer?.remove();
this.navbar = null;
}

init() {
try {
this.applyStyles();
this.createOverlay();
this.createSpacer();
window.addEventListener('resize', () => this.updateBlurOverlay());
this.updateBlurOverlay();
} catch (e) {
console.error(e);
Expand All @@ -2823,6 +2886,9 @@ class NavbarStyler {
try {
let n = this.navbar;
n.classList.add('fixed-top', 'container', 'ml-auto');
// The CDN fallback may still be loading when the top bar starts. Without
// Bootstrap's .fixed-top rule, top:auto moves it down with the spacer.
n.style.top = '0';
if (UtilityEnabled("MonochromeUI")) {
Object.assign(n.style, {
position: 'fixed',
Expand Down Expand Up @@ -2859,8 +2925,11 @@ class NavbarStyler {
let overlay = document.createElement('div');
overlay.id = 'blur-overlay';
document.body.appendChild(overlay);
this.overlay = overlay;

let style = document.createElement('style');
this.overlayStyle?.remove();
this.overlayStyle = style;
style.textContent = UtilityEnabled("MonochromeUI") ? `
#blur-overlay {
display: none !important;
Expand All @@ -2876,6 +2945,11 @@ class NavbarStyler {
`;
document.head.appendChild(style);
}
const overlay = document.getElementById('blur-overlay');
if (overlay !== this.overlay && overlay !== this.borrowedOverlay) {
this.borrowedOverlay = overlay;
this.restoreOverlayStyles = this.preserveStyles(overlay, ['top', 'left', 'width', 'height']);
}
Comment on lines +2948 to +2952

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): When a page-owned blur-overlay or navbar-spacer is replaced before the next UpdateNavbarStyler() call, the new element overwrites restoreOverlayStyles or restoreSpacerStyles while the old modified element is no longer tracked. Teardown restores only the replacement, leaving the original page-owned element with XMOJ's dimensions.

Triggers: When Vue or another page handler replaces an existing page-owned overlay or spacer between navbar refreshes.

Suggested fix: Restore the previously borrowed element before replacing its restore callback, or track each borrowed element and its restoration callback independently.

} catch (e) {
console.error(e);
if (UtilityEnabled("DebugMode")) {
Expand Down Expand Up @@ -2907,22 +2981,21 @@ class NavbarStyler {
let spacer = document.getElementById('navbar-spacer');
let newHeight = this.navbar.offsetHeight + 24;
if (!spacer) {
spacer = document.createElement('div');
// Legacy page handlers use body > div for the content container.
// A block span reserves space without becoming their first match.
spacer = document.createElement('span');
spacer.id = 'navbar-spacer';
spacer.style.height = `${newHeight}px`;
spacer.setAttribute('aria-hidden', 'true');
spacer.style.display = 'block';
spacer.style.width = '100%';
document.body.insertBefore(spacer, document.body.firstChild);
} else {
let currentHeight = parseInt(spacer.style.height, 10);
if (currentHeight !== newHeight) {
document.body.removeChild(spacer);
spacer = document.createElement('div');
spacer.id = 'navbar-spacer';
spacer.style.height = `${newHeight}px`;
spacer.style.width = '100%';
document.body.insertBefore(spacer, document.body.firstChild);
}
this.spacer = spacer;
} else if (spacer !== this.spacer && spacer !== this.borrowedSpacer) {
this.borrowedSpacer = spacer;
this.restoreSpacerStyles = this.preserveStyles(spacer, ['height']);
}
// Update in place so an existing page-owned spacer is never replaced.
if (spacer.style.height !== `${newHeight}px`) spacer.style.height = `${newHeight}px`;
} catch (e) {
console.error(e);
if (UtilityEnabled("DebugMode")) {
Expand Down Expand Up @@ -3077,12 +3150,7 @@ async function main() {
Temp[i].remove();
}
}
if (UtilityEnabled("MonochromeUI")) {
let fontLink = document.createElement("link");
fontLink.rel = "stylesheet";
fontLink.href = "https://fonts.loli.net/css2?family=Playfair+Display:wght@400;700&family=Source+Serif+4:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap";
document.head.appendChild(fontLink);
}
if (UtilityEnabled("MonochromeUI")) LoadMonochromeFonts();
var resources = [{
type: 'link',
href: 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/6.65.7/codemirror.min.css',
Expand Down Expand Up @@ -3153,7 +3221,7 @@ async function main() {
if (document.querySelector("#navbar > ul.nav.navbar-nav.navbar-right > li > a") != null) document.querySelector("#navbar > ul.nav.navbar-nav.navbar-right > li > a").removeAttribute("data-toggle");
// The navbar is in its final state: apply the top bar now rather than on
// the first interval tick, then show the page.
if (UtilityEnabled("NewTopBar")) new NavbarStyler();
UpdateNavbarStyler();
RevealPage();
}
if (UtilityEnabled("RemoveUseless") && document.getElementsByTagName("marquee")[0] != undefined) {
Expand Down Expand Up @@ -3219,9 +3287,7 @@ async function main() {
document.getElementById("nowdate").innerHTML = Year + "-" + (Month < 10 ? "0" : "") + Month + "-" + (_Date < 10 ? "0" : "") + _Date + " " + (Hours < 10 ? "0" : "") + Hours + ":" + (Minutes < 10 ? "0" : "") + Minutes + ":" + (Seconds < 10 ? "0" : "") + Seconds;
} catch (Error) {
}
if (UtilityEnabled("NewTopBar")) {
new NavbarStyler();
}
UpdateNavbarStyler();
if (UtilityEnabled("ResetType")) {
if (document.querySelector("#profile") != undefined && document.querySelector("#profile").innerHTML == "登录") {
let PopupUL = document.querySelector("#navbar > ul.nav.navbar-nav.navbar-right > li > ul");
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "xmoj-script",
"version": "3.8.0",
"version": "3.8.2",
"description": "an improvement script for xmoj.tech",
"main": "AddonScript.js",
"scripts": {
"test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs"
"test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs"
},
"devDependencies": {
"playwright": "1.59.1"
Expand Down
43 changes: 43 additions & 0 deletions tests/contest-web.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,46 @@ test('the load-time hide keeps innerText readable for the page handlers', () =>
assert.match(hide, /body \{ opacity: 0 !important; \}/);
assert.doesNotMatch(hide, /visibility/);
});

test('web and classic pages load the same monochrome fonts before either Bootstrap path', () => {
const early = between('// Set to true by the early block', 'const CaptchaSiteKey');
const fontURLs = new Set();
for (const pathname of ['/web/contest', '/problem.php?id=1000']) {
for (const cachedBootstrap of [false, true]) {
for (const headReady of [false, true]) {
const errors = [];
const {scope, styles} = context({
location: new URL('https://www.xmoj.tech' + pathname),
GM_getResourceText: () => cachedBootstrap ? 'body { color: black; }' : '',
MutationObserver: class { observe() {} },
console: {error: (...args) => errors.push(args)}
});
if (headReady) scope.document.head = {appendChild: node => styles.push(node)};
scope.document.querySelectorAll = () => [];
vm.runInContext(early, scope);
const fonts = styles.filter(node => node.id === 'xmoj-monochrome-fonts');
assert.equal(fonts.length, 1, pathname + ': font loading must not depend on the Bootstrap cache or head readiness');
assert.equal(fonts[0].rel, 'stylesheet');
fontURLs.add(fonts[0].href);
scope.LoadMonochromeFonts();
scope.LoadMonochromeFonts();
assert.equal(styles.filter(node => node.id === 'xmoj-monochrome-fonts').length, 1, 'late initializers must reuse the early font link');
assert.deepEqual(errors, []);
}
}
}
assert.equal(fontURLs.size, 1);
const url = [...fontURLs][0];
assert.match(url, /Source\+Serif\+4/);
assert.match(url, /Playfair\+Display/);
assert.match(url, /JetBrains\+Mono/);
for (const disabled of ['NewBootstrap', 'MonochromeUI']) {
const {scope, storage, styles} = context({
GM_getResourceText: () => '',
location: new URL('https://www.xmoj.tech/web/contest')
});
storage.set('UserScript-Setting-' + disabled, 'false');
vm.runInContext(early, scope);
assert.equal(styles.filter(node => node.id === 'xmoj-monochrome-fonts').length, 0, 'do not load fonts for a disabled skin');
}
});
Loading
Loading