MediaWiki:Timeless.js

来自「荏苒之境」
Sicusa​(留言 | 贡献)2026年9月3日 (四) 21:28的版本

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • 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());