diff --git a/Update.json b/Update.json index d784cc30..362302d3 100644 --- a/Update.json +++ b/Update.json @@ -3850,6 +3850,17 @@ } ], "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文,隐藏账号表单中的语言选项并统一提交中文。修复最后在线时间显示为 HTML 源码的问题。重新绘制个人中心提交与正确数量的历史图表,兼容浅色、深色主题及移动端,默认清理设置不再隐藏图表。提交与正确数量在同一图表中以细线和半透明柱形叠加展示,减少密集曲线遮挡。恢复暂存更改中的 API 请求超时和错误日志。

" + }, + "3.8.4": { + "UpdateDate": 1791114523418, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1044, + "Description": "fix: keep navbar spacer visible under browser cosmetic filters" + } + ], + "Notes": "

修复浏览器过滤规则隐藏导航栏占位元素、导致页面顶部内容被遮挡的问题。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index ab400e36..5f659d6e 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.3 +// @version 3.8.4 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen @@ -3442,13 +3442,23 @@ class NavbarStyler { } } - preserveStyles(element, properties) { + preserveStyles(element, properties, preserveOrder = false) { + const order = preserveOrder ? Array.from(element.style) : null; 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); } + if (order) { + // Attribute selectors can depend on declaration order. Restore that + // order while retaining unrelated styles changed by the page. + const current = Array.from(element.style); + const names = [...order.filter(name => current.includes(name)), ...current.filter(name => !order.includes(name))]; + const values = names.map(name => [name, element.style.getPropertyValue(name), element.style.getPropertyPriority(name)]); + element.style.cssText = ''; + for (const [name, value, priority] of values) element.style.setProperty(name, value, priority); + } }; } @@ -3459,9 +3469,11 @@ class NavbarStyler { this.restoreNavbarStyles(); this.restoreOverlayStyles?.(); this.restoreSpacerStyles?.(); + this.restoreSpacerLayoutStyles?.(); this.overlay?.remove(); this.overlayStyle?.remove(); this.spacer?.remove(); + this.spacerStyle?.remove(); this.navbar = null; } @@ -3577,20 +3589,36 @@ class NavbarStyler { try { let spacer = document.getElementById('navbar-spacer'); let newHeight = this.navbar.offsetHeight + 24; + // Browser cosmetic filters can hide empty body children whose inline + // style starts with display:block; width:100%; height:. Keep static + // layout rules outside that attribute, including when Bootstrap is late. + if (!this.spacerStyle?.isConnected) { + this.spacerStyle = document.createElement('style'); + this.spacerStyle.textContent = '#navbar-spacer { display: block; width: 100%; }'; + (document.head || document.documentElement).appendChild(this.spacerStyle); + } if (!spacer) { // 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.setAttribute('aria-hidden', 'true'); - spacer.style.display = 'block'; - spacer.style.width = '100%'; document.body.insertBefore(spacer, document.body.firstChild); this.spacer = spacer; } else if (spacer !== this.spacer && spacer !== this.borrowedSpacer) { + this.restoreSpacerStyles?.(); + this.restoreSpacerLayoutStyles?.(); + this.restoreSpacerLayoutStyles = null; this.borrowedSpacer = spacer; this.restoreSpacerStyles = this.preserveStyles(spacer, ['height']); } + // Older initializers may leave the same fingerprint on a borrowed spacer. + // Normalize only matching nodes, then restore their layout on teardown. + if (spacer !== this.spacer && spacer.matches('body > [style^="display: block; width: 100%; height:"]:empty')) { + if (!this.restoreSpacerLayoutStyles) this.restoreSpacerLayoutStyles = this.preserveStyles(spacer, ['display', 'width'], true); + spacer.style.removeProperty('display'); + spacer.style.removeProperty('width'); + } // 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) { diff --git a/package.json b/package.json index 5f8df3e5..69334302 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xmoj-script", - "version": "3.8.3", + "version": "3.8.4", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { diff --git a/tests/navbar-styler.test.cjs b/tests/navbar-styler.test.cjs index 2cdb28fe..4a581ed7 100644 --- a/tests/navbar-styler.test.cjs +++ b/tests/navbar-styler.test.cjs @@ -264,3 +264,79 @@ test('navbar reserves content space before Bootstrap CSS loads and restores its await browser.close(); } }); + +test('browser cosmetic filtering does not hide the navbar spacer', {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}); + const page = await browser.newPage(); + try { + // The reporter's video shows this browser-injected cosmetic rule. A block + // spacer whose inline style begins with these declarations is hidden. + await page.setContent(`
Page content
`); + await page.evaluate(() => { window.UtilityEnabled = () => true; }); + await page.addScriptTag({content: navbarSource + '\nUpdateNavbarStyler();'}); + const Measure = () => page.evaluate(() => { + const spacer = document.getElementById('navbar-spacer'); + return { + display: getComputedStyle(spacer).display, + height: spacer.getBoundingClientRect().height, + filtered: spacer.matches('body > [style^="display: block; width: 100%; height:"]:empty'), + navbarBottom: document.querySelector('nav').getBoundingClientRect().bottom, + contentTop: document.querySelector('main').getBoundingClientRect().top + }; + }); + for (const height of [64, 96]) { + await page.evaluate(height => { document.querySelector('nav').style.height = height + 'px'; UpdateNavbarStyler(); }, height); + const result = await Measure(); + assert.equal(result.display, 'block'); + assert.equal(result.filtered, false, 'our spacer must not match the recorded browser filter'); + assert.equal(result.height, height + 24); + assert.ok(result.contentTop >= result.navbarBottom, 'the first content must remain below the navbar'); + } + // Switching navbars releases and recreates the spacer and its stylesheet. + await page.evaluate(() => { + document.querySelector('nav').outerHTML = ''; + UpdateNavbarStyler(); + }); + assert.equal((await Measure()).height, 72); + await page.evaluate(() => { window.UtilityEnabled = () => false; UpdateNavbarStyler(); }); + assert.equal(await page.locator('#navbar-spacer').count(), 0); + assert.equal(await page.locator('head style').count(), 1, 'remove the owned styles but keep the browser-rule fixture'); + // An existing spacer from an earlier initializer can carry the old inline + // fingerprint. Borrow it without losing its styles or page-added changes. + await page.evaluate(() => { + document.body.innerHTML = '
Page content
'; + window.originalSpacer = document.getElementById('navbar-spacer'); + window.UtilityEnabled = () => true; + }); + assert.equal((await Measure()).display, 'none', 'the old borrowed spacer must reproduce the filter'); + await page.evaluate(() => UpdateNavbarStyler()); + const borrowed = await Measure(); + assert.equal(borrowed.display, 'block'); + assert.equal(borrowed.filtered, false); + assert.equal(borrowed.height, 88); + assert.ok(borrowed.contentTop >= borrowed.navbarBottom); + await page.evaluate(() => { + document.getElementById('navbar-spacer').style.color = 'red'; + window.UtilityEnabled = () => false; + UpdateNavbarStyler(); + }); + assert.deepEqual(await page.locator('#navbar-spacer').evaluate(node => ({ + sameNode: node === window.originalSpacer, + display: node.style.display, width: node.style.width, height: node.style.height, + color: node.style.color, + originalOrder: node.getAttribute('style').startsWith('display: block; width: 100%; height:'), + filteredAgain: getComputedStyle(node).display === 'none' + })), {sameNode: true, display: 'block', width: '100%', height: '9px', color: 'red', originalOrder: true, filteredAgain: true}); + assert.equal(await page.locator('head style').count(), 1); + } finally { + await browser.close(); + } +});