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 */
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());