Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions Update.json
Original file line number Diff line number Diff line change
Expand Up @@ -3817,6 +3817,17 @@
}
],
"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>"
}
}
}
121 changes: 90 additions & 31 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.1
// @description XMOJ增强脚本
// @author @XMOJ-Script-dev, @langningchen and the community
// @namespace https://github/langningchen
Expand Down Expand Up @@ -572,6 +572,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 +627,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 +2337,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 +2749,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 +2776,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 +2802,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', '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 +2841,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;
Comment thread
boomzero marked this conversation as resolved.
Comment thread
boomzero marked this conversation as resolved.
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

init() {
try {
this.applyStyles();
this.createOverlay();
this.createSpacer();
window.addEventListener('resize', () => this.updateBlurOverlay());
this.updateBlurOverlay();
} catch (e) {
console.error(e);
Expand Down Expand Up @@ -2859,8 +2918,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 +2938,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']);
}
} catch (e) {
console.error(e);
if (UtilityEnabled("DebugMode")) {
Expand Down Expand Up @@ -2907,22 +2974,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 +3143,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 +3214,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 +3280,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.1",
"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