MediaWiki:Timeless.js:修订间差异

来自「荏苒之境」
无编辑摘要
无编辑摘要
 
第1行: 第1行:
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */
/* 将为Timeless皮肤的用户加载此处的所有JavaScript */
 
// 目标容器:确认一下你们布局里“右侧侧边栏”对应哪个元素。
// Timeless 默认布局中,#mw-site-navigation 是左侧站点导航,
// #mw-related-navigation 才是右侧栏;如果确实要放右侧,把下面的选择器换掉。
const SIDEBAR_SELECTOR = '#mw-site-navigation';


class HeadingTracker {
class HeadingTracker {
     constructor(rootElement) {
     constructor(rootElement) {
        this.rootElement = rootElement;
    this.rootElement = rootElement
         this.headings = [];
         this.headings = [];
         this.cacheExpired = true;
         this.cacheExpired = true;
第52行: 第47行:


class FloatingToC {
class FloatingToC {
    constructor(toc) {
constructor(toc) {
        this.element = toc;
this.element = toc;
        this.ignoreScrollUpdate = false;
this.ignoreScrollUpdate = false;
 
   
        const tocTextList = Array.from(toc.querySelectorAll('.toctext'));
    let tocTextList = Array.from(toc.querySelectorAll(".toctext"));
        this.tocTexts = new Map(tocTextList.map(t => {
    this.tocTexts = new Map(tocTextList.map(t => {
            const textContent = t.textContent;
    let textContent = t.textContent
            t.parentElement.onclick = () => {
    t.parentElement.onclick = () => {
                this.ignoreScrollUpdate = true;
    this.ignoreScrollUpdate = true;
                this.setCurrentHeading(textContent);
    this.setCurrentHeading(textContent);
            };
    };
            return [textContent, t];
    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();
    }


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) {
        const lastHeading = this.lastHeading;
    const lastHeading = this.lastHeading;
        if (heading === lastHeading) return;
    if (heading == lastHeading) {
 
    return;
        if (lastHeading) {
    }
            const text = this.tocTexts.get(lastHeading);
    if (lastHeading) {
            if (text) text.classList.remove('current-heading');
    let text = this.tocTexts.get(lastHeading)
        }
    if (text) text.classList.remove('current-heading');
        this.lastHeading = heading;
    }
        if (heading) {
    this.lastHeading = heading;
            const text = this.tocTexts.get(heading);
    if (heading) {
            if (text) text.classList.add('current-heading');
    let text = this.tocTexts.get(heading)
        }
    if (text) text.classList.add('current-heading');
     }
    }
 
     };
    onScroll() {
        if (window.innerWidth < 1100) return;
onScroll() {
        if (this.ignoreScrollUpdate) {
if (window.innerWidth < 1100) {
            this.ignoreScrollUpdate = false;
return;
            return;
}
        }
if (this.ignoreScrollUpdate) {
        const heading = this.tracker.getNearestHeadingAboveViewport();
this.ignoreScrollUpdate = false;
        this.setCurrentHeading(heading ? heading.textContent : null);
return;
    }
}
    let heading = this.tracker.getNearestHeadingAboveViewport();
    this.setCurrentHeading(heading ? heading.textContent : null);
}
}
}


const userAgent = navigator.userAgent;
const userAgent = navigator.userAgent;
// 原来的 isSafari 会把 Chrome/Edge 也误判成 Safari(它们的 UA 里都带 Safari),这里排除一下
// Chrome/Edge 的 UA 里同样包含 "Safari",需要排除掉,否则宽度阈值会误用
const isSafari = /Safari/i.test(userAgent)
const isSafari = userAgent.includes("Safari") && !/(Chrome|CriOS|Edg|OPR|Android)/i.test(userAgent);
    && !/Chrome|Chromium|CriOS|Edg|OPR/i.test(userAgent);


mw.hook('ve.newTarget').add(target => {
mw.hook('ve.newTarget').add(target => {
    if (target.constructor.static.name !== 'article') {
if (target.constructor.static.name !== 'article') {
        return;
return;
    }
}
    target.on('teardown', () => {
target.on('teardown', () => {
        const event = new CustomEvent('pageupdate');
var event = new CustomEvent("pageupdate");
        document.dispatchEvent(event);
document.dispatchEvent(event);
    });
});
});
});


let toc = null;
let toc = null;
let tocInitialParent = null;
let tocInitialParent = null;
let tocAnchor = null;  // toc 在文章里的原始“下一个兄弟节点”,用于放回去
let floatingToC = null;
let floatingToC = null;
let siteNavigation = null;
let siteNavigation = null;


// 关键修复1:先查一次初始 DOM,元素已经存在就直接回调;
const updateToCParent = () => {
// MutationObserver 只在元素是“之后”被插入时才兜底。
// 两个依赖元素都就绪后才真正搬移,避免竞态导致 ToC 一直留在文章容器里
// 原来只挂 observer,而皮肤脚本运行时 DOM 早已解析完,
if (siteNavigation == null || toc == null) {
// #toc / #mw-site-navigation 已经存在,observer 永远不会触发,整段逻辑成了死代码。
return;
}
let width = window.innerWidth;
if (isSafari && width > 1354 || !isSafari && width > 1339) {
if (toc.parentNode != siteNavigation) {
toc.parentNode.removeChild(toc);
siteNavigation.appendChild(toc);
}
}
else {
if (toc.parentNode == tocInitialParent || toc.parentNode == siteNavigation) {
toc.parentNode.removeChild(toc);
siteNavigation.parentNode.lastChild.before(toc);
}
}
}
document.addEventListener('pageupdate', () => {
let newToC = document.querySelectorAll('.mw-parser-output > #toc')[0];
if (newToC == null) {
return;
}
if (floatingToC != null) {
floatingToC.remove();
}
toc = newToC;
tocInitialParent = toc.parentNode;
floatingToC = new FloatingToC(toc);
updateToCParent();
});
 
function waitForElement(selector, callback) {
function waitForElement(selector, callback) {
    const existing = document.querySelector(selector);
// 元素如果已经存在于 DOM(ResourceLoader 的脚本常在页面渲染后才执行),
    if (existing) {
// 必须立刻回调;否则只挂 MutationObserver 会永远等不到变化,整段逻辑都不运行。
        callback(existing);
const existing = document.querySelector(selector);
        return;
if (existing) {
    }
callback(existing);
 
return;
    // body 还没生成时先观察 documentElement,避免 observe(null) 直接抛错中断脚本
}
    const root = document.body || document.documentElement;
const observer = new MutationObserver(() => {
    const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
        const element = document.querySelector(selector);
if (element) {
        if (element) {
observer.disconnect(); // 找到后停止观察
            observer.disconnect();
callback(element);
            callback(element);
}
        }
});
    });
observer.observe(document.body, { childList: true, subtree: true }); // 观察整个文档的变化
    observer.observe(root, { childList: true, subtree: true });
}
}


const isWideEnough = () =>
waitForElement('#mw-site-navigation', elem => {
    (isSafari ? window.innerWidth > 1354 : window.innerWidth > 1339);
siteNavigation = elem;
 
// 侧栏就绪后必须重新触发一次搬移判断:如果 ToC 先加载完成,
const updateToCParent = () => {
// 上面那次 updateToCParent 会因 siteNavigation 仍为 null 提前 return,
    if (siteNavigation == null || toc == null) return;
// 之后没有任何代码再搬它,卡片就会一直卡在文章容器里。
 
updateToCParent();
    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。
waitForElement('#toc', loadedToc => {
// 如果 #toc 先初始化而 #mw-site-navigation 还没就位,
toc = loadedToc;
// 原来 updateToCParent 会直接 return,之后没有任何地方再触发它,toc 就永远卡在文章里。
tocInitialParent = toc.parentElement;
waitForElement(SIDEBAR_SELECTOR, elem => {
floatingToC = new FloatingToC(toc);
    siteNavigation = elem;
updateToCParent();
    updateToCParent();
});
});


waitForElement('#toc', initToC);
// 窗口宽度变化时重新判断 ToC 该留在侧栏还是回到正文,避免卡片停在错误的容器里
 
window.addEventListener('resize', updateToCParent);
// 关键修复3:窗口在断点两侧变化时也要重新摆放 toc
let resizeTimer = null;
window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(updateToCParent, 150);
});

2026年9月3日 (四) 21:55的最新版本

/* 将为Timeless皮肤的用户加载此处的所有JavaScript  */

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;
	    
	    let tocTextList = Array.from(toc.querySelectorAll(".toctext"));
	    this.tocTexts = new Map(tocTextList.map(t => {
	    	let 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) {
    		let text = this.tocTexts.get(lastHeading)
    		if (text) text.classList.remove('current-heading');
    	}
    	this.lastHeading = heading;
    	if (heading) {
    		let 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;
		}
	    let heading = this.tracker.getNearestHeadingAboveViewport();
	    this.setCurrentHeading(heading ? heading.textContent : null);
	}
}

const userAgent = navigator.userAgent;
// Chrome/Edge 的 UA 里同样包含 "Safari",需要排除掉,否则宽度阈值会误用
const isSafari = userAgent.includes("Safari") && !/(Chrome|CriOS|Edg|OPR|Android)/i.test(userAgent);

mw.hook('ve.newTarget').add(target => {
	if (target.constructor.static.name !== 'article') {
		return;
	}
	target.on('teardown', () => {
		var event = new CustomEvent("pageupdate");
		document.dispatchEvent(event);
	});
});

let toc = null;
let tocInitialParent = null;
let floatingToC = null;
let siteNavigation = null;

const updateToCParent = () => {
	// 两个依赖元素都就绪后才真正搬移,避免竞态导致 ToC 一直留在文章容器里
	if (siteNavigation == null || toc == null) {
		return;
	}
	let width = window.innerWidth;
	if (isSafari && width > 1354 || !isSafari && width > 1339) {
		if (toc.parentNode != siteNavigation) {
			toc.parentNode.removeChild(toc);
			siteNavigation.appendChild(toc);
		}
	}
	else {
		if (toc.parentNode == tocInitialParent || toc.parentNode == siteNavigation) {
			toc.parentNode.removeChild(toc);
			siteNavigation.parentNode.lastChild.before(toc);
		}
	}
}
	
document.addEventListener('pageupdate', () => {
	let newToC = document.querySelectorAll('.mw-parser-output > #toc')[0];
	if (newToC == null) {
		return;
	}
	if (floatingToC != null) {
		floatingToC.remove();
	}
	toc = newToC;
	tocInitialParent = toc.parentNode;
	floatingToC = new FloatingToC(toc);
	updateToCParent();
});

function waitForElement(selector, callback) {
	// 元素如果已经存在于 DOM(ResourceLoader 的脚本常在页面渲染后才执行),
	// 必须立刻回调;否则只挂 MutationObserver 会永远等不到变化,整段逻辑都不运行。
	const existing = document.querySelector(selector);
	if (existing) {
		callback(existing);
		return;
	}
	const observer = new MutationObserver(() => {
		const element = document.querySelector(selector);
		if (element) {
			observer.disconnect(); // 找到后停止观察
			callback(element);
		}
	});
	observer.observe(document.body, { childList: true, subtree: true }); // 观察整个文档的变化
}

waitForElement('#mw-site-navigation', elem => {
	siteNavigation = elem;
	// 侧栏就绪后必须重新触发一次搬移判断:如果 ToC 先加载完成,
	// 上面那次 updateToCParent 会因 siteNavigation 仍为 null 提前 return,
	// 之后没有任何代码再搬它,卡片就会一直卡在文章容器里。
	updateToCParent();
});

waitForElement('#toc', loadedToc => {
	toc = loadedToc;
	tocInitialParent = toc.parentElement;
	floatingToC = new FloatingToC(toc);
	updateToCParent();
});

// 窗口宽度变化时重新判断 ToC 该留在侧栏还是回到正文,避免卡片停在错误的容器里
window.addEventListener('resize', updateToCParent);