MediaWiki:Timeless.js:修订间差异
来自「荏苒之境」
无编辑摘要 |
无编辑摘要 |
||
| 第1行: | 第1行: | ||
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */ | /* 将为Timeless皮肤的用户加载此处的所有JavaScript */ | ||
const ua = navigator.userAgent; | |||
// 精确判断 Safari:Chrome / Edge 的 UA 里也带 "Safari" | |||
const isSafari = /Safari/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS/i.test(ua); | |||
// ---------- 标题追踪 ---------- | |||
class HeadingTracker { | class HeadingTracker { | ||
constructor(rootElement) { | constructor(rootElement) { | ||
this.rootElement = rootElement; | |||
this.headings = []; | this.headings = []; | ||
this.cacheExpired = true; | this.cacheExpired = true; | ||
| 第11行: | 第16行: | ||
init() { | init() { | ||
this.collectHeadings(); | this.collectHeadings(); | ||
window.addEventListener('resize', () => this.cacheExpired = true); | window.addEventListener('resize', () => { | ||
window.addEventListener('DOMContentLoaded', () => this. | this.cacheExpired = true; | ||
}); | |||
// 图片/字体加载完可能改变标题位置,加载结束后重新测一次 | |||
window.addEventListener('load', () => this.collectHeadings()); | |||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', () => this.collectHeadings()); | |||
} | |||
} | } | ||
collectHeadings() { | collectHeadings() { | ||
const rawHeadings = Array.from(this.rootElement.querySelectorAll('h1, h2, h3, h4, h5, h6')); | const rawHeadings = Array.from( | ||
this.rootElement.querySelectorAll('h1, h2, h3, h4, h5, h6') | |||
); | |||
this.headings = rawHeadings | this.headings = rawHeadings | ||
.map(heading => ({ | .map(heading => ({ | ||
| 第27行: | 第40行: | ||
getNearestHeadingAboveViewport() { | getNearestHeadingAboveViewport() { | ||
if (this.cacheExpired) this.collectHeadings(); | if (this.cacheExpired) { | ||
this.collectHeadings(); | |||
} | |||
const scrollY = window.scrollY + 60; | const scrollY = window.scrollY + 60; | ||
let left = 0 | let left = 0; | ||
let right = this.headings.length - 1; | |||
let result = null; | let result = null; | ||
while (left <= right) { | while (left <= right) { | ||
const mid = Math.floor((left + right) / 2); | const mid = Math.floor((left + right) / 2); | ||
| 第46行: | 第60行: | ||
} | } | ||
// ---------- 浮动目录 ---------- | |||
class FloatingToC { | class FloatingToC { | ||
constructor(toc) { | |||
this.element = toc; | |||
this.lastHeading = null; | |||
// 点击目录项后的一小段时间内,忽略滚动事件带来的高亮更新 | |||
this.ignoreScrollUntil = 0; | |||
// 用标题 id 关联目录项与正文标题,而不是用文字匹配 | |||
// (标题重名时文字会撞 key,id 不会) | |||
this.headingToTocText = new Map(); | |||
for (const text of toc.querySelectorAll('.toctext')) { | |||
const link = text.closest('a'); | |||
if (!link) { | |||
continue; | |||
} | |||
const id = decodeURIComponent( | |||
(link.getAttribute('href') || '').replace(/^#/, '') | |||
); | |||
const heading = id ? document.getElementById(id) : null; | |||
if (!heading) { | |||
continue; | |||
} | |||
this.headingToTocText.set(heading, text); | |||
link.addEventListener('click', () => { | |||
this.ignoreScrollUntil = Date.now() + 500; | |||
this.setCurrentHeading(heading); | |||
}); | |||
} | |||
this.tracker = new HeadingTracker( | |||
document.getElementById('mw-content-text') | |||
); | |||
this.scrollHandler = this.onScroll.bind(this); | |||
window.addEventListener('scroll', this.scrollHandler); | |||
} | |||
remove() { | |||
window.removeEventListener('scroll', this.scrollHandler); | |||
this.element.remove(); | |||
} | |||
setCurrentHeading(heading) { | setCurrentHeading(heading) { | ||
if (heading === this.lastHeading) { | |||
return; | |||
} | |||
if (this.lastHeading) { | |||
const text = this.headingToTocText.get(this.lastHeading); | |||
if (text) { | |||
text.classList.remove('current-heading'); | |||
} | |||
} | |||
this.lastHeading = heading; | |||
if (heading) { | |||
const text = this.headingToTocText.get(heading); | |||
if (text) { | |||
} | text.classList.add('current-heading'); | ||
} | |||
} | |||
} | |||
onScroll() { | |||
if (window.innerWidth < 1100) { | |||
return; | |||
} | |||
if (Date.now() < this.ignoreScrollUntil) { | |||
return; | |||
} | |||
const heading = this.tracker.getNearestHeadingAboveViewport(); | |||
this.setCurrentHeading(heading); | |||
} | |||
} | } | ||
// ---------- 状态 ---------- | |||
let toc = null; | let toc = null; | ||
let tocInitialParent = null; | let tocInitialParent = null; // 目录原本在哪个容器里 | ||
let tocNextSibling = null; // 目录原本插在哪个元素前面(还原用) | |||
let floatingToC = null; | let floatingToC = null; | ||
let siteNavigation = null; | let siteNavigation = null; | ||
const | // ---------- 目录的移动逻辑 ---------- | ||
function isWideEnough() { | |||
return isSafari ? window.innerWidth > 1354 : window.innerWidth > 1339; | |||
} | |||
function findArticleTOC() { | |||
const content = document.getElementById('mw-content-text'); | |||
return content ? content.querySelector('#toc') : null; | |||
} | |||
function restoreTOCToArticle() { | |||
if (tocInitialParent == null) { | |||
return; | |||
} | |||
if (tocNextSibling && tocNextSibling.parentNode === tocInitialParent) { | |||
tocInitialParent.insertBefore(toc, tocNextSibling); | |||
} else { | |||
tocInitialParent.appendChild(toc); | |||
} | |||
} | |||
function updateToCParent() { | |||
if (siteNavigation == null || toc == null) { | |||
return; | |||
} | |||
if (isWideEnough()) { | |||
// 宽屏:进侧边栏 | |||
if (toc.parentNode !== siteNavigation) { | |||
siteNavigation.appendChild(toc); | |||
} | |||
return; | |||
} | |||
// 窄屏:放回文章里原本的位置 | |||
if (toc.parentNode === siteNavigation) { | |||
restoreTOCToArticle(); | |||
} | |||
} | |||
// ---------- 初始化 / 清理 ---------- | |||
function teardownFloatingToC() { | |||
if (floatingToC != null) { | |||
floatingToC.remove(); | |||
floatingToC = null; | |||
} | |||
toc = null; | |||
tocInitialParent = null; | |||
tocNextSibling = null; | |||
} | |||
function initFloatingToC(newToc) { | |||
if (newToc == null || newToc === toc) { | |||
return; | |||
} | |||
if (floatingToC != null) { | |||
// 旧的目录可能还挂在侧边栏里,先摘掉再重新初始化 | |||
floatingToC.remove(); | |||
floatingToC = null; | |||
} | |||
toc = newToc; | |||
tocInitialParent = toc.parentNode; | |||
tocNextSibling = toc.nextSibling; | |||
floatingToC = new FloatingToC(toc); | |||
updateToCParent(); | |||
} | } | ||
function waitForElement( | // 等待元素出现:先检查当前 DOM,没有再挂 MutationObserver。 | ||
// 之前只挂 observer,页面加载完就绪、没有任何后续变更时回调永远不会触发。 | |||
function waitForElement(getElement, callback) { | |||
const element = getElement(); | |||
if (element) { | |||
callback(element); | |||
return; | |||
} | |||
const observer = new MutationObserver(() => { | |||
const el = getElement(); | |||
if (el) { | |||
observer.disconnect(); | |||
callback(el); | |||
} | |||
}); | |||
observer.observe(document.body, { childList: true, subtree: true }); | |||
} | } | ||
// VE 保存后内容重渲染,重走一遍:清掉旧目录,等新目录出现 | |||
document.addEventListener('pageupdate', () => { | |||
teardownFloatingToC(); | |||
waitForElement(findArticleTOC, initFloatingToC); | |||
}); | }); | ||
waitForElement(' | // 启动:两个元素谁先出现都能收敛到正确状态 | ||
waitForElement(() => document.getElementById('mw-site-navigation'), elem => { | |||
siteNavigation = elem; | |||
updateToCParent(); // 目录可能先出现,这里补一次移动 | |||
}); | }); | ||
waitForElement(findArticleTOC, initFloatingToC); | |||
// 跨断点缩放时也要重新摆放目录 | |||
window.addEventListener('resize', () => updateToCParent()); | |||
2026年9月3日 (四) 21:28的版本
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */
const ua = navigator.userAgent;
// 精确判断 Safari:Chrome / Edge 的 UA 里也带 "Safari"
const isSafari = /Safari/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS/i.test(ua);
// ---------- 标题追踪 ----------
class HeadingTracker {
constructor(rootElement) {
this.rootElement = rootElement;
this.headings = [];
this.cacheExpired = true;
this.init();
}
init() {
this.collectHeadings();
window.addEventListener('resize', () => {
this.cacheExpired = true;
});
// 图片/字体加载完可能改变标题位置,加载结束后重新测一次
window.addEventListener('load', () => this.collectHeadings());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.collectHeadings());
}
}
collectHeadings() {
const rawHeadings = Array.from(
this.rootElement.querySelectorAll('h1, h2, h3, h4, h5, h6')
);
this.headings = rawHeadings
.map(heading => ({
element: heading,
top: heading.getBoundingClientRect().top + window.scrollY
}))
.sort((a, b) => a.top - b.top);
this.cacheExpired = false;
}
getNearestHeadingAboveViewport() {
if (this.cacheExpired) {
this.collectHeadings();
}
const scrollY = window.scrollY + 60;
let left = 0;
let right = this.headings.length - 1;
let result = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (this.headings[mid].top <= scrollY) {
result = this.headings[mid].element;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
}
// ---------- 浮动目录 ----------
class FloatingToC {
constructor(toc) {
this.element = toc;
this.lastHeading = null;
// 点击目录项后的一小段时间内,忽略滚动事件带来的高亮更新
this.ignoreScrollUntil = 0;
// 用标题 id 关联目录项与正文标题,而不是用文字匹配
// (标题重名时文字会撞 key,id 不会)
this.headingToTocText = new Map();
for (const text of toc.querySelectorAll('.toctext')) {
const link = text.closest('a');
if (!link) {
continue;
}
const id = decodeURIComponent(
(link.getAttribute('href') || '').replace(/^#/, '')
);
const heading = id ? document.getElementById(id) : null;
if (!heading) {
continue;
}
this.headingToTocText.set(heading, text);
link.addEventListener('click', () => {
this.ignoreScrollUntil = Date.now() + 500;
this.setCurrentHeading(heading);
});
}
this.tracker = new HeadingTracker(
document.getElementById('mw-content-text')
);
this.scrollHandler = this.onScroll.bind(this);
window.addEventListener('scroll', this.scrollHandler);
}
remove() {
window.removeEventListener('scroll', this.scrollHandler);
this.element.remove();
}
setCurrentHeading(heading) {
if (heading === this.lastHeading) {
return;
}
if (this.lastHeading) {
const text = this.headingToTocText.get(this.lastHeading);
if (text) {
text.classList.remove('current-heading');
}
}
this.lastHeading = heading;
if (heading) {
const text = this.headingToTocText.get(heading);
if (text) {
text.classList.add('current-heading');
}
}
}
onScroll() {
if (window.innerWidth < 1100) {
return;
}
if (Date.now() < this.ignoreScrollUntil) {
return;
}
const heading = this.tracker.getNearestHeadingAboveViewport();
this.setCurrentHeading(heading);
}
}
// ---------- 状态 ----------
let toc = null;
let tocInitialParent = null; // 目录原本在哪个容器里
let tocNextSibling = null; // 目录原本插在哪个元素前面(还原用)
let floatingToC = null;
let siteNavigation = null;
// ---------- 目录的移动逻辑 ----------
function isWideEnough() {
return isSafari ? window.innerWidth > 1354 : window.innerWidth > 1339;
}
function findArticleTOC() {
const content = document.getElementById('mw-content-text');
return content ? content.querySelector('#toc') : null;
}
function restoreTOCToArticle() {
if (tocInitialParent == null) {
return;
}
if (tocNextSibling && tocNextSibling.parentNode === tocInitialParent) {
tocInitialParent.insertBefore(toc, tocNextSibling);
} else {
tocInitialParent.appendChild(toc);
}
}
function updateToCParent() {
if (siteNavigation == null || toc == null) {
return;
}
if (isWideEnough()) {
// 宽屏:进侧边栏
if (toc.parentNode !== siteNavigation) {
siteNavigation.appendChild(toc);
}
return;
}
// 窄屏:放回文章里原本的位置
if (toc.parentNode === siteNavigation) {
restoreTOCToArticle();
}
}
// ---------- 初始化 / 清理 ----------
function teardownFloatingToC() {
if (floatingToC != null) {
floatingToC.remove();
floatingToC = null;
}
toc = null;
tocInitialParent = null;
tocNextSibling = null;
}
function initFloatingToC(newToc) {
if (newToc == null || newToc === toc) {
return;
}
if (floatingToC != null) {
// 旧的目录可能还挂在侧边栏里,先摘掉再重新初始化
floatingToC.remove();
floatingToC = null;
}
toc = newToc;
tocInitialParent = toc.parentNode;
tocNextSibling = toc.nextSibling;
floatingToC = new FloatingToC(toc);
updateToCParent();
}
// 等待元素出现:先检查当前 DOM,没有再挂 MutationObserver。
// 之前只挂 observer,页面加载完就绪、没有任何后续变更时回调永远不会触发。
function waitForElement(getElement, callback) {
const element = getElement();
if (element) {
callback(element);
return;
}
const observer = new MutationObserver(() => {
const el = getElement();
if (el) {
observer.disconnect();
callback(el);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// VE 保存后内容重渲染,重走一遍:清掉旧目录,等新目录出现
document.addEventListener('pageupdate', () => {
teardownFloatingToC();
waitForElement(findArticleTOC, initFloatingToC);
});
// 启动:两个元素谁先出现都能收敛到正确状态
waitForElement(() => document.getElementById('mw-site-navigation'), elem => {
siteNavigation = elem;
updateToCParent(); // 目录可能先出现,这里补一次移动
});
waitForElement(findArticleTOC, initFloatingToC);
// 跨断点缩放时也要重新摆放目录
window.addEventListener('resize', () => updateToCParent());