diff --git a/Update.json b/Update.json index 66797755..8dc5b4f8 100644 --- a/Update.json +++ b/Update.json @@ -3817,6 +3817,28 @@ } ], "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 页面与旧版页面共享黑白主题字体加载,避免菜单栏使用不同字体。

" + }, + "3.8.2": { + "UpdateDate": 1791075550636, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1039, + "Description": "fix: match Chinese navbar fonts between web and classic UI" + } + ], + "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index bddb25ba..ea1c87d8 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -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 @@ -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; } @@ -197,6 +200,7 @@ 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; @@ -204,7 +208,7 @@ const MonochromeSkinCSS = ` .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; @@ -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; @@ -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) { @@ -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; }`; @@ -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; @@ -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; @@ -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")) { @@ -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); @@ -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', @@ -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; @@ -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']); + } } catch (e) { console.error(e); if (UtilityEnabled("DebugMode")) { @@ -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")) { @@ -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', @@ -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) { @@ -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"); diff --git a/package.json b/package.json index bb93213b..834ca508 100644 --- a/package.json +++ b/package.json @@ -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" 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/fixtures/fonts/OFL.txt b/tests/fixtures/fonts/OFL.txt new file mode 100644 index 00000000..318074ec --- /dev/null +++ b/tests/fixtures/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tests/fixtures/fonts/README.md b/tests/fixtures/fonts/README.md new file mode 100644 index 00000000..b4de304b --- /dev/null +++ b/tests/fixtures/fonts/README.md @@ -0,0 +1,10 @@ +This is the unmodified Latin subset of Source Serif 4 (regular, v15) served by +Google Fonts, the same font used by the monochrome theme. It is distributed under +the SIL Open Font License in OFL.txt. + +Source: https://fonts.gstatic.com/s/sourceserif4/v15/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjihdqnhtdDs4DrjTMXw.woff2 +License: https://github.com/google/fonts/blob/main/ofl/sourceserif4/OFL.txt + +The navbar rendering regression embeds this 20 KB font offline. A local font or +an unloaded font does not reproduce Chromium's language-dependent Chinese +fallback for this webfont; checking computed font-family alone also misses it. diff --git a/tests/fixtures/fonts/source-serif-4-latin.woff2 b/tests/fixtures/fonts/source-serif-4-latin.woff2 new file mode 100644 index 00000000..12f5b101 Binary files /dev/null and b/tests/fixtures/fonts/source-serif-4-latin.woff2 differ diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs new file mode 100644 index 00000000..2cdb28fe --- /dev/null +++ b/tests/navbar-styler.test.cjs @@ -0,0 +1,266 @@ +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(`
`); + await page.evaluate(() => { window.UtilityEnabled = name => ['NewTopBar', 'NewDownload', 'MonochromeUI'].includes(name); }); + await page.addScriptTag({content: navbarSource + '\nUpdateNavbarStyler();\n' + downloadsSource}); + const before = await page.evaluate(() => { + const list = document.querySelector('.software_list'); + return { + inPageContainer: document.getElementById('page-content').contains(list), + inSpacer: document.getElementById('navbar-spacer').contains(list), + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + firstCardTop: list.firstElementChild.getBoundingClientRect().top, + cards: list.children.length + }; + }); + assert.equal(before.inPageContainer, true, 'downloads must not be appended to the spacer'); + assert.equal(before.inSpacer, false); + assert.equal(before.cards, 15); + assert.ok(before.firstCardTop >= before.navbarBottom, 'the fixed navbar must not cover the first download row'); + const after = await page.evaluate(() => { + document.querySelector('nav').style.height = '96px'; + UpdateNavbarStyler(); + return { + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + firstCardTop: document.querySelector('.software_item').getBoundingClientRect().top + }; + }); + assert.ok(after.firstCardTop >= after.navbarBottom, 'the spacing must follow navbar height changes'); + } finally { + await browser.close(); + } +}); + +test('Chinese navbar glyphs use the same fonts under legacy and web document languages', {timeout: 60000}, async () => { + const skin = source.match(/const MonochromeSkinCSS = `([\s\S]*?)`;/)[1]; + const font = fs.readFileSync(path.join(__dirname, "fixtures/fonts/source-serif-4-latin.woff2")).toString("base64"); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + const rendered = []; + for (const language of ['en', 'zh-CN']) { + const page = await browser.newPage(); + // The public legacy page declares en, while /web declares zh-CN. Source + // Serif 4 has no Chinese glyphs: compare the fonts Chromium actually uses, + // not just getComputedStyle().fontFamily, which matched before the fix. + const html = ``; + await page.route("https://navbar-font.test/**", route => route.fulfill({contentType: "text/html; charset=utf-8", body: html})); + await page.goto("https://navbar-font.test/" + language); + await page.evaluate(() => document.fonts.ready); + const session = await page.context().newCDPSession(page); + await session.send('DOM.enable'); + await session.send('CSS.enable'); + const {root} = await session.send('DOM.getDocument'); + const fonts = {}; + for (const selector of ['.navbar-brand', '.navbar-nav .nav-link', '.dropdown-toggle span', '.dropdown-item']) { + const {nodeId} = await session.send('DOM.querySelector', {nodeId: root.nodeId, selector}); + const result = await session.send('CSS.getPlatformFontsForNode', {nodeId}); + fonts[selector] = result.fonts.map(font => ({name: font.familyName, glyphs: font.glyphCount})).sort((a, b) => a.name.localeCompare(b.name)); + assert.ok(fonts[selector].length, 'must inspect rendered glyphs: ' + selector); + } + assert.ok(fonts[".navbar-brand"].some(font => font.name === "Source Serif 4"), "the Latin webfont must be loaded to exercise Chinese fallback"); + rendered.push(fonts); + await session.detach(); + await page.close(); + } + assert.deepEqual(rendered[1], rendered[0], 'document language must not change the navbar Chinese fallback font'); + } finally { + await browser.close(); + } +}); + +test('navbar reserves content space before Bootstrap CSS loads and restores its original top', {timeout: 60000}, async () => { + const start = 'let navbarStyler = null;'; + const end = '// Wrapped in an async IIFE'; + const navbarSource = source.slice(source.indexOf(start), source.indexOf(end)); + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + for (const width of [375, 1440]) { + const page = await browser.newPage({viewport: {width, height: 800}}); + for (const mono of [false, true]) { + // The resource cache can be empty and the CDN fallback can be slow + // or fail. No .fixed-top rule is available yet in this fixture. + await page.setContent('
Page content
'); + await page.evaluate(mono => { window.UtilityEnabled = name => name === 'MonochromeUI' ? mono : name === 'NewTopBar'; }, mono); + await page.addScriptTag({content: '{\n' + navbarSource + '\nUpdateNavbarStyler(); window.testStyler = navbarStyler;\n}'}); + const Measure = () => page.evaluate(() => ({ + navbarTop: document.querySelector('nav').getBoundingClientRect().top, + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + contentTop: document.getElementById('content').getBoundingClientRect().top + })); + const before = await Measure(); + assert.equal(before.navbarTop, mono ? 0 : 16, 'the navbar must anchor to the viewport without Bootstrap'); + assert.ok(before.contentTop >= before.navbarBottom, 'the navbar must not cover content while CSS is unavailable'); + // When Bootstrap finally arrives, the reserved space still works. + await page.addStyleTag({content: '.fixed-top { position: fixed; top: 0; right: 0; left: 0; }'}); + const after = await Measure(); + assert.ok(after.contentTop >= after.navbarBottom); + await page.evaluate(() => window.testStyler.destroy()); + assert.equal(await page.locator('nav').evaluate(node => node.style.top), '', 'remove the owned offset on teardown'); + } + await page.close(); + } + const page = await browser.newPage(); + await page.setContent(''); + await page.evaluate(() => { window.UtilityEnabled = () => true; }); + await page.addScriptTag({content: navbarSource + '\nUpdateNavbarStyler(); navbarStyler.destroy();'}); + assert.deepEqual(await page.locator('nav').evaluate(node => [node.style.top, node.style.getPropertyPriority('top')]), ['7px', 'important']); + } finally { + await browser.close(); + } +});