MediaWiki:Timeless.js:修订间差异

来自「荏苒之境」
无编辑摘要
无编辑摘要
第1行: 第1行:
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */


const ua = navigator.userAgent;
// 目标容器:确认一下你们布局里“右侧侧边栏”对应哪个元素。
// 精确判断 Safari:Chrome / Edge 的 UA 里也带 "Safari"
// Timeless 默认布局中,#mw-site-navigation 是左侧站点导航,
const isSafari = /Safari/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS/i.test(ua);
// #mw-related-navigation 才是右侧栏;如果确实要放右侧,把下面的选择器换掉。
const SIDEBAR_SELECTOR = '#mw-site-navigation';


// ---------- 标题追踪 ----------
class HeadingTracker {
class HeadingTracker {
     constructor(rootElement) {
     constructor(rootElement) {
第16行: 第16行:
     init() {
     init() {
         this.collectHeadings();
         this.collectHeadings();
         window.addEventListener('resize', () => {
         window.addEventListener('resize', () => this.cacheExpired = true);
            this.cacheExpired = true;
         window.addEventListener('DOMContentLoaded', () => this.cacheExpired = true);
        });
        // 图片/字体加载完可能改变标题位置,加载结束后重新测一次
         window.addEventListener('load', () => this.collectHeadings());
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => this.collectHeadings());
        }
     }
     }


     collectHeadings() {
     collectHeadings() {
         const rawHeadings = Array.from(
         const rawHeadings = Array.from(this.rootElement.querySelectorAll('h1, h2, h3, h4, h5, h6'));
            this.rootElement.querySelectorAll('h1, h2, h3, h4, h5, h6')
        );
         this.headings = rawHeadings
         this.headings = rawHeadings
             .map(heading => ({
             .map(heading => ({
第40行: 第32行:


     getNearestHeadingAboveViewport() {
     getNearestHeadingAboveViewport() {
         if (this.cacheExpired) {
         if (this.cacheExpired) this.collectHeadings();
            this.collectHeadings();
 
        }
         const scrollY = window.scrollY + 60;
         const scrollY = window.scrollY + 60;
         let left = 0;
         let left = 0, right = this.headings.length - 1;
        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);
第60行: 第51行:
}
}


// ---------- 浮动目录 ----------
class FloatingToC {
class FloatingToC {
     constructor(toc) {
     constructor(toc) {
         this.element = toc;
         this.element = toc;
         this.lastHeading = null;
         this.ignoreScrollUpdate = false;
        // 点击目录项后的一小段时间内,忽略滚动事件带来的高亮更新
        this.ignoreScrollUntil = 0;


         // 用标题 id 关联目录项与正文标题,而不是用文字匹配
         const tocTextList = Array.from(toc.querySelectorAll('.toctext'));
        // (标题重名时文字会撞 key,id 不会)
        this.tocTexts = new Map(tocTextList.map(t => {
        this.headingToTocText = new Map();
             const textContent = t.textContent;
        for (const text of toc.querySelectorAll('.toctext')) {
             t.parentElement.onclick = () => {
            const link = text.closest('a');
                 this.ignoreScrollUpdate = true;
            if (!link) {
                 this.setCurrentHeading(textContent);
                continue;
             };
            }
            return [textContent, t];
            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(
         this.tracker = new HeadingTracker(
             document.getElementById('mw-content-text')
             document.getElementById('mw-content-text'));
        );


         this.scrollHandler = this.onScroll.bind(this);
         this.scrollHandler = this.onScroll.bind(this);
第104行: 第79行:


     setCurrentHeading(heading) {
     setCurrentHeading(heading) {
         if (heading === this.lastHeading) {
        const lastHeading = this.lastHeading;
            return;
         if (heading === lastHeading) return;
        }
 
         if (this.lastHeading) {
         if (lastHeading) {
             const text = this.headingToTocText.get(this.lastHeading);
             const text = this.tocTexts.get(lastHeading);
             if (text) {
             if (text) text.classList.remove('current-heading');
                text.classList.remove('current-heading');
            }
         }
         }
         this.lastHeading = heading;
         this.lastHeading = heading;
         if (heading) {
         if (heading) {
             const text = this.headingToTocText.get(heading);
             const text = this.tocTexts.get(heading);
             if (text) {
             if (text) text.classList.add('current-heading');
                text.classList.add('current-heading');
            }
         }
         }
     }
     }


     onScroll() {
     onScroll() {
         if (window.innerWidth < 1100) {
         if (window.innerWidth < 1100) return;
            return;
         if (this.ignoreScrollUpdate) {
        }
            this.ignoreScrollUpdate = false;
         if (Date.now() < this.ignoreScrollUntil) {
             return;
             return;
         }
         }
         const heading = this.tracker.getNearestHeadingAboveViewport();
         const heading = this.tracker.getNearestHeadingAboveViewport();
         this.setCurrentHeading(heading);
         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 toc = null;
let tocInitialParent = null;   // 目录原本在哪个容器里
let tocInitialParent = null;
let tocNextSibling = null;     // 目录原本插在哪个元素前面(还原用)
let tocAnchor = null;   // toc 在文章里的原始“下一个兄弟节点”,用于放回去
let floatingToC = null;
let floatingToC = null;
let siteNavigation = null;
let siteNavigation = null;


// ---------- 目录的移动逻辑 ----------
// 关键修复1:先查一次初始 DOM,元素已经存在就直接回调;
function isWideEnough() {
// MutationObserver 只在元素是“之后”被插入时才兜底。
     return isSafari ? window.innerWidth > 1354 : window.innerWidth > 1339;
// 原来只挂 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 });
}
}


function findArticleTOC() {
const isWideEnough = () =>
    const content = document.getElementById('mw-content-text');
     (isSafari ? window.innerWidth > 1354 : window.innerWidth > 1339);
     return content ? content.querySelector('#toc') : null;
}


function restoreTOCToArticle() {
const updateToCParent = () => {
     if (tocInitialParent == null) {
     if (siteNavigation == null || toc == null) return;
        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 (isWideEnough()) {
         // 宽屏:进侧边栏
         // 宽屏:确保 toc 挂在侧边栏里
         if (toc.parentNode !== siteNavigation) {
         if (toc.parentNode !== siteNavigation) {
             siteNavigation.appendChild(toc);
             siteNavigation.appendChild(toc);
         }
         }
         return;
    } else if (toc.parentNode === siteNavigation) {
    }
         // 窄屏:放回文章里原来的位置。
    // 窄屏:放回文章里原本的位置
        // 原来那句 siteNavigation.parentNode.lastChild.before(toc)
    if (toc.parentNode === siteNavigation) {
        // 会把它插到导航容器的某个兄弟节点前面,既不是文章原位,位置也不可控。
         restoreTOCToArticle();
        if (tocAnchor && tocAnchor.parentNode === tocInitialParent) {
            tocInitialParent.insertBefore(toc, tocAnchor);
         } else {
            tocInitialParent.appendChild(toc);
        }
     }
     }
}
};


// ---------- 初始化 / 清理 ----------
const initToC = (tocElement) => {
function teardownFloatingToC() {
     if (floatingToC) {
     if (floatingToC != null) {
         floatingToC.remove();
         floatingToC.remove();
         floatingToC = null;
         floatingToC = null;
     }
     }
     toc = null;
     toc = tocElement;
     tocInitialParent = null;
     tocInitialParent = tocElement.parentNode;
     tocNextSibling = null;
     tocAnchor = tocElement.nextSibling;
}
    floatingToC = new FloatingToC(tocElement);
    updateToCParent();
};


function initFloatingToC(newToc) {
document.addEventListener('pageupdate', () => {
    if (newToc == null || newToc === toc) {
     const newToC = document.querySelector('.mw-parser-output > #toc');
        return;
     if (!newToC) return;
    }
     if (floatingToC != null) {
        // 旧的目录可能还挂在侧边栏里,先摘掉再重新初始化
        floatingToC.remove();
        floatingToC = null;
    }
    toc = newToc;
    tocInitialParent = toc.parentNode;
    tocNextSibling = toc.nextSibling;
    floatingToC = new FloatingToC(toc);
     updateToCParent();
}


// 等待元素出现:先检查当前 DOM,没有再挂 MutationObserver。
    // 同一颗 toc(比如事件重复派发),没必要先移除再重建
// 之前只挂 observer,页面加载完就绪、没有任何后续变更时回调永远不会触发。
     if (newToC === toc && floatingToC) {
function waitForElement(getElement, callback) {
         updateToCParent();
    const element = getElement();
     if (element) {
         callback(element);
         return;
         return;
     }
     }
     const observer = new MutationObserver(() => {
     initToC(newToC);
        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);
});
});


// 启动:两个元素谁先出现都能收敛到正确状态
// 关键修复2:侧边栏回调里也补一次 updateToCParent。
waitForElement(() => document.getElementById('mw-site-navigation'), elem => {
// 如果 #toc 先初始化而 #mw-site-navigation 还没就位,
// 原来 updateToCParent 会直接 return,之后没有任何地方再触发它,toc 就永远卡在文章里。
waitForElement(SIDEBAR_SELECTOR, elem => {
     siteNavigation = elem;
     siteNavigation = elem;
     updateToCParent(); // 目录可能先出现,这里补一次移动
     updateToCParent();
});
});


waitForElement(findArticleTOC, initFloatingToC);
waitForElement('#toc', initToC);


// 跨断点缩放时也要重新摆放目录
// 关键修复3:窗口在断点两侧变化时也要重新摆放 toc
window.addEventListener('resize', () => updateToCParent());
let resizeTimer = null;
window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(updateToCParent, 150);
});

2026年9月3日 (四) 21:49的版本

/* 将为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);
});