diff --git a/Update.json b/Update.json index 66797755..a456d0f0 100644 --- a/Update.json +++ b/Update.json @@ -3817,6 +3817,17 @@ } ], "Notes": "
本次正式版整合自 3.7.0 以来的修复,恢复因页面结构变化而异常的脚本功能,并兼容新版比赛页面。旧版比赛入口会转到新版页面;题目与提交状态显示得到修复,页面加载时的闪烁和布局偏移减少。
\n题目复制支持按章节、样例或完整题面生成 Markdown,并保留公式、代码语言、表格、链接与样例换行,便于直接使用复制结果。
" + }, + "3.8.1": { + "UpdateDate": 1791033862020, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1036, + "Description": "fix: navbar listener leaks, download overlap, and web fonts" + } + ], + "Notes": "修复新版比赛页面和旧版界面顶部导航栏持续增加窗口缩放监听器的问题,减少长时间打开页面后的内存占用和缩放卡顿;页面导航栏被替换时会清理旧监听器。修复下载页面的卡片被插入导航栏占位元素、导致首行被顶部导航栏遮挡的问题。新版 /web 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。
" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index bddb25ba..745489f9 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -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 @@ -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; @@ -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) { @@ -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; }`; @@ -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; @@ -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; @@ -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")) { @@ -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; + } + init() { try { this.applyStyles(); this.createOverlay(); this.createSpacer(); - window.addEventListener('resize', () => this.updateBlurOverlay()); this.updateBlurOverlay(); } catch (e) { console.error(e); @@ -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; @@ -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")) { @@ -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")) { @@ -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', @@ -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) { @@ -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"); diff --git a/package.json b/package.json index bb93213b..cddb8886 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/contest-web.test.cjs b/tests/contest-web.test.cjs index b59f044c..9528d2bb 100644 --- a/tests/contest-web.test.cjs +++ b/tests/contest-web.test.cjs @@ -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'); + } +}); diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs new file mode 100644 index 00000000..371f2a48 --- /dev/null +++ b/tests/navbar-styler.test.cjs @@ -0,0 +1,188 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium} = require('playwright'); + +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); + +test('navbar refreshes keep one resize listener and release replaced navbars', {timeout: 60000}, async () => { + const start = 'let navbarStyler = null;'; + const end = '// Wrapped in an async IIFE'; + for (const marker of [start, end]) { + assert.equal(source.split(marker).length - 1, 1, 'expected one navbar extraction marker: ' + marker); + } + const navbarSource = source.slice(source.indexOf(start), source.indexOf(end)); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + try { + await page.setContent(''); + await page.evaluate(() => { + window.topBarEnabled = true; + window.UtilityEnabled = name => name === 'NewTopBar' && window.topBarEnabled; + window.resizeListeners = new Set(); + const add = window.addEventListener; + const remove = window.removeEventListener; + window.addEventListener = function(type, listener, options) { + if (type === 'resize') window.resizeListeners.add(listener); + return add.call(this, type, listener, options); + }; + window.removeEventListener = function(type, listener, options) { + if (type === 'resize') window.resizeListeners.delete(listener); + return remove.call(this, type, listener, options); + }; + }); + await page.addScriptTag({content: navbarSource}); + const result = await page.evaluate(() => { + const counts = []; + // Simulate sustained polling without waiting for either UI's timer. + for (let i = 0; i < 2000; i++) UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const originalListener = [...window.resizeListeners][0]; + const firstNavbar = document.querySelector('nav'); + const overlayBeforeDetachedResize = document.getElementById('blur-overlay').style.cssText; + firstNavbar.outerHTML = ''; + window.dispatchEvent(new Event('resize')); + const detachedResizePreservedOverlay = document.getElementById('blur-overlay').style.cssText === overlayBeforeDetachedResize; + for (let i = 0; i < 2000; i++) UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const removedOldListener = !window.resizeListeners.has(originalListener); + const navbar = document.querySelector('nav'); + navbar.style.height = '96px'; + navbar.style.color = 'red'; + navbar.classList.add('page-added'); + window.dispatchEvent(new Event('resize')); + const overlayHeightAfterResize = document.getElementById('blur-overlay').style.height; + UpdateNavbarStyler(); + const spacerHeight = document.getElementById('navbar-spacer').style.height; + const overlayCount = document.querySelectorAll('#blur-overlay').length; + const spacerCount = document.querySelectorAll('#navbar-spacer').length; + window.topBarEnabled = false; + UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const disabled = { + artifacts: document.querySelectorAll('#blur-overlay, #navbar-spacer').length, + styles: document.head.querySelectorAll('style').length, + fixedTop: navbar.classList.contains('fixed-top'), + container: navbar.classList.contains('container'), + pageClass: navbar.classList.contains('page-added'), + position: navbar.style.position, + positionPriority: navbar.style.getPropertyPriority('position'), + margin: navbar.style.margin, + borderRadius: navbar.style.borderRadius, + opacity: navbar.style.opacity, + color: navbar.style.color, + height: navbar.style.height + }; + window.topBarEnabled = true; + UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + navbar.remove(); + window.dispatchEvent(new Event('resize')); + UpdateNavbarStyler(); + counts.push(window.resizeListeners.size); + const retainedNavbar = navbarStyler !== null; + const artifactsAfterRemoval = document.querySelectorAll('#blur-overlay, #navbar-spacer').length; + document.body.innerHTML = ''; + const pageOverlay = document.getElementById('blur-overlay'); + const pageSpacer = document.getElementById('navbar-spacer'); + UpdateNavbarStyler(); + const pageSpacerReused = document.getElementById('navbar-spacer') === pageSpacer; + window.topBarEnabled = false; + UpdateNavbarStyler(); + const borrowed = { + overlayPreserved: document.getElementById('blur-overlay') === pageOverlay, + spacerPreserved: document.getElementById('navbar-spacer') === pageSpacer, + top: pageOverlay.style.top, + topPriority: pageOverlay.style.getPropertyPriority('top'), + left: pageOverlay.style.left, + width: pageOverlay.style.width, + height: pageOverlay.style.height, + spacerHeight: pageSpacer.style.height, + spacerPriority: pageSpacer.style.getPropertyPriority('height'), + spacerWidth: pageSpacer.style.width, + listeners: window.resizeListeners.size + }; + return {counts, removedOldListener, detachedResizePreservedOverlay, overlayHeightAfterResize, spacerHeight, overlayCount, spacerCount, retainedNavbar, disabled, artifactsAfterRemoval, pageSpacerReused, borrowed}; + }); + assert.deepEqual(result.counts, [1, 1, 0, 1, 0]); + assert.equal(result.removedOldListener, true); + assert.equal(result.detachedResizePreservedOverlay, true); + assert.equal(result.overlayHeightAfterResize, '96px'); + assert.equal(result.spacerHeight, '120px'); + assert.equal(result.overlayCount, 1); + assert.equal(result.spacerCount, 1); + assert.equal(result.retainedNavbar, false); + assert.equal(result.artifactsAfterRemoval, 0); + assert.deepEqual(result.disabled, { + artifacts: 0, styles: 0, fixedTop: false, container: true, pageClass: true, + position: 'relative', positionPriority: 'important', margin: '1px 2px 3px 4px', + borderRadius: '1px 2px 3px 4px', opacity: '0.9', color: 'red', height: '96px' + }); + assert.equal(result.pageSpacerReused, true); + assert.deepEqual(result.borrowed, { + overlayPreserved: true, spacerPreserved: true, + top: '2px', topPriority: 'important', left: '3px', width: '11px', height: '12px', + spacerHeight: '9px', spacerPriority: 'important', spacerWidth: '17px', listeners: 0 + }); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +}); + +test('downloads stay in the page container below the fixed navbar', {timeout: 60000}, async () => { + const navbarStart = 'let navbarStyler = null;'; + const navbarEnd = '// Wrapped in an async IIFE'; + const downloadsStart = '} else if (location.pathname == "/downloads.php") {'; + const downloadsEnd = '} else if (location.pathname == "/problemstatus.php") {'; + for (const marker of [navbarStart, navbarEnd, downloadsStart, downloadsEnd]) { + assert.equal(source.split(marker).length - 1, 1, 'expected one extraction marker: ' + marker); + } + const navbarSource = source.slice(source.indexOf(navbarStart), source.indexOf(navbarEnd)); + const downloadsSource = source.slice(source.indexOf(downloadsStart) + downloadsStart.length, source.indexOf(downloadsEnd)); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + const page = await browser.newPage(); + try { + // Run the real downloads handler without external image requests or login. + await page.route('**/*', route => route.abort()); + await page.setContent(`