MediaWiki:Timeless.js
来自「荏苒之境」
注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。
- Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
- Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
- Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */
// 目标容器:确认一下你们布局里“右侧侧边栏”对应哪个元素。
// Timeless 默认布局中,#mw-site-navigation 是左侧站点导航,
// #mw-related-navigation 才是右侧栏;如果确实要放右侧,把下面的选择器换掉。
const SIDEBAR_SELECTOR = '#mw-site-navigation';
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('DOMContentLoaded', () => this.cacheExpired = true);
}
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, 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.ignoreScrollUpdate = false;
const tocTextList = Array.from(toc.querySelectorAll('.toctext'));
this.tocTexts = new Map(tocTextList.map(t => {
const textContent = t.textContent;
t.parentElement.onclick = () => {
this.ignoreScrollUpdate = true;
this.setCurrentHeading(textContent);
};
return [textContent, t];
}));
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) {
const lastHeading = this.lastHeading;
if (heading === lastHeading) return;
if (lastHeading) {
const text = this.tocTexts.get(lastHeading);
if (text) text.classList.remove('current-heading');
}
this.lastHeading = heading;
if (heading) {
const text = this.tocTexts.get(heading);
if (text) text.classList.add('current-heading');
}
}
onScroll() {
if (window.innerWidth < 1100) return;
if (this.ignoreScrollUpdate) {
this.ignoreScrollUpdate = false;
return;
}
const heading = this.tracker.getNearestHeadingAboveViewport();
this.setCurrentHeading(heading ? heading.textContent : null);
}
}
const userAgent = navigator.userAgent;
// 原来的 isSafari 会把 Chrome/Edge 也误判成 Safari(它们的 UA 里都带 Safari),这里排除一下
const isSafari = /Safari/i.test(userAgent)
&& !/Chrome|Chromium|CriOS|Edg|OPR/i.test(userAgent);
mw.hook('ve.newTarget').add(target => {
if (target.constructor.static.name !== 'article') {
return;
}
target.on('teardown', () => {
const event = new CustomEvent('pageupdate');
document.dispatchEvent(event);
});
});
let toc = null;
let tocInitialParent = null;
let tocAnchor = null; // toc 在文章里的原始“下一个兄弟节点”,用于放回去
let floatingToC = null;
let siteNavigation = null;
// 关键修复1:先查一次初始 DOM,元素已经存在就直接回调;
// MutationObserver 只在元素是“之后”被插入时才兜底。
// 原来只挂 observer,而皮肤脚本运行时 DOM 早已解析完,
// #toc / #mw-site-navigation 已经存在,observer 永远不会触发,整段逻辑成了死代码。
function waitForElement(selector, callback) {
const existing = document.querySelector(selector);
if (existing) {
callback(existing);
return;
}
// body 还没生成时先观察 documentElement,避免 observe(null) 直接抛错中断脚本
const root = document.body || document.documentElement;
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
callback(element);
}
});
observer.observe(root, { childList: true, subtree: true });
}
const isWideEnough = () =>
(isSafari ? window.innerWidth > 1354 : window.innerWidth > 1339);
const updateToCParent = () => {
if (siteNavigation == null || toc == null) return;
if (isWideEnough()) {
// 宽屏:确保 toc 挂在侧边栏里
if (toc.parentNode !== siteNavigation) {
siteNavigation.appendChild(toc);
}
} else if (toc.parentNode === siteNavigation) {
// 窄屏:放回文章里原来的位置。
// 原来那句 siteNavigation.parentNode.lastChild.before(toc)
// 会把它插到导航容器的某个兄弟节点前面,既不是文章原位,位置也不可控。
if (tocAnchor && tocAnchor.parentNode === tocInitialParent) {
tocInitialParent.insertBefore(toc, tocAnchor);
} else {
tocInitialParent.appendChild(toc);
}
}
};
const initToC = (tocElement) => {
if (floatingToC) {
floatingToC.remove();
floatingToC = null;
}
toc = tocElement;
tocInitialParent = tocElement.parentNode;
tocAnchor = tocElement.nextSibling;
floatingToC = new FloatingToC(tocElement);
updateToCParent();
};
document.addEventListener('pageupdate', () => {
const newToC = document.querySelector('.mw-parser-output > #toc');
if (!newToC) return;
// 同一颗 toc(比如事件重复派发),没必要先移除再重建
if (newToC === toc && floatingToC) {
updateToCParent();
return;
}
initToC(newToC);
});
// 关键修复2:侧边栏回调里也补一次 updateToCParent。
// 如果 #toc 先初始化而 #mw-site-navigation 还没就位,
// 原来 updateToCParent 会直接 return,之后没有任何地方再触发它,toc 就永远卡在文章里。
waitForElement(SIDEBAR_SELECTOR, elem => {
siteNavigation = elem;
updateToCParent();
});
waitForElement('#toc', initToC);
// 关键修复3:窗口在断点两侧变化时也要重新摆放 toc
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(updateToCParent, 150);
});