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 @@ -3850,6 +3850,17 @@
}
],
"Notes": "<p>修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文,隐藏账号表单中的语言选项并统一提交中文。修复最后在线时间显示为 HTML 源码的问题。重新绘制个人中心提交与正确数量的历史图表,兼容浅色、深色主题及移动端,默认清理设置不再隐藏图表。提交与正确数量在同一图表中以细线和半透明柱形叠加展示,减少密集曲线遮挡。恢复暂存更改中的 API 请求超时和错误日志。</p>"
},
"3.8.4": {
"UpdateDate": 1791114523418,
"Prerelease": true,
"UpdateContents": [
{
"PR": 1044,
"Description": "fix: keep navbar spacer visible under browser cosmetic filters"
}
],
"Notes": "<p>修复浏览器过滤规则隐藏导航栏占位元素、导致页面顶部内容被遮挡的问题。</p>"
}
}
}
36 changes: 32 additions & 4 deletions XMOJ.user.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
};
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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%; }';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
(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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
76 changes: 76 additions & 0 deletions tests/navbar-styler.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<style>
body > [style^="display: block; width: 100%; height:"]:empty { display: none !important; }
body { margin: 0; }
.navbar { height: 64px; }
.fixed-top { top: 0; left: 0; right: 0; }
</style><div id="content"><nav class="navbar navbar-expand-lg bg-body-tertiary">Navbar</nav><main>Page content</main></div>`);
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 = '<nav class="navbar navbar-expand-lg bg-body-tertiary" style="height:48px">Replacement</nav>';
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 = '<span id="navbar-spacer" style="display: block; width: 100%; height: 9px;"></span><div id="content"><nav class="navbar navbar-expand-lg bg-body-tertiary" style="height:64px">Navbar</nav><main>Page content</main></div>';
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();
}
});
Loading