Greasy Fork

GitHub 增强套件

融合 GitHub 增强功能:新窗口打开链接、MD 文件目录化、固定页面头部

// ==UserScript==
// @name         GitHub 增强套件
// @namespace    http://tampermonkey.net/
// @version      0.8
// @description  融合 GitHub 增强功能:新窗口打开链接、MD 文件目录化、固定页面头部
// @author       lecoler & contributors
// @match        *://github.com/*
// @match        *://gitee.com/*/*
// @match        *://npmjs.com/*/*
// @include      *.md
// @icon         https://github.com/favicon.ico
// @license      GPL-3.0
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // === 通用工具和配置 ===
    const githubRegex = /^https?:\/\/(www\.)?github\.com\//i; // GitHub 链接正则
    const processedLinks = new WeakSet(); // 缓存已处理的链接
    const log = (...args) => console.log('[GitHub 增强套件]', ...args); // 简化的日志函数

    // ===========================================================================
    // === 功能1:强制在新窗口打开 GitHub 链接 ===
    // ===========================================================================

    function handleNewWindowClick(e) {
        const link = e.target.closest('a');
        if (!link || processedLinks.has(link)) return;

        const href = link.href;
        if (!href || !githubRegex.test(href) || href.includes('mailto:') || href.includes('#') || link.target === '_blank') return;

        e.preventDefault();
        e.stopPropagation();
        window.open(href, '_blank');
        processedLinks.add(link);
    }

    document.addEventListener('click', handleNewWindowClick, { capture: true, passive: false });

    // ===========================================================================
    // === 功能2:MD 文件目录化 ===
    // ===========================================================================

    let $main, $menu, $button, lastPathName = '', moveStatus = false, titleHeight = 0;

    function createMdDom() {
        const css = document.createElement('style');
        css.innerHTML = `
            .le-md { position: fixed; top: 16%; left: 90%; z-index: 999; }
            .le-md-btn { width: 60px; height: 60px; border-radius: 50%; color: #fff; background: hsla(230,50%,50%,0.6); text-align: center; line-height: 60px; cursor: move; }
            .le-md-btn:hover { background: hsla(220,50%,47%,1); }
            .le-md-btn-hidden { display: none; }
            .hidden { height: 0 !important; min-height: 0 !important; border: 0 !important; }
            .le-md-left { right: 0; margin-right: 100px; } .le-md-right { left: 0; margin-left: 100px; }
            .le-md-top { bottom: 0; } .le-md-bottom { top: 0; }
            .le-md > ul { width: 200px; max-height: 700px; list-style: none; position: absolute; overflow: auto; padding-right: 10px; }
            .le-md > ul a { text-decoration: none; color: #909399; display: block; padding: 5px 10px; background: #f4f4f5; }
            .le-md li.le-md-title-active a { background: linear-gradient(-135deg, #ffcccc 0.6em, #fff 0); }
            .le-md li.le-md-title-active.le-md-title-active-first a { background: linear-gradient(-135deg, #ff9999 0.6em, #fff 0); color: #000; font-weight: 700; }
        `;
        document.head.appendChild(css);

        $main = document.createElement('div');
        $button = document.createElement('div');
        $menu = document.createElement('ul');
        $button.innerHTML = '目录';
        $button.title = '右键返回顶部';
        $button.addEventListener('click', toggleMenu);
        $button.oncontextmenu = e => { scrollTo(0, 0); return false; };
        $main.appendChild($button);
        $main.appendChild($menu);
        $main.className = 'le-md';
        document.body.appendChild($main);

        $button.onmousedown = e => {
            const eleX = e.offsetX, eleY = e.offsetY;
            let count = 0;
            document.onmousemove = ev => {
                if (count++ > 9) moveStatus = true;
                const x = ev.clientX - eleX, y = ev.clientY - eleY;
                const winWidth = document.documentElement.clientWidth, winHeight = document.documentElement.clientHeight;
                $main.style.left = `${(x / winWidth * 100).toFixed(3)}%`;
                $main.style.top = `${(y / winHeight * 100).toFixed(3)}%`;
            };
            $button.onmouseup = $button.onmouseout = () => document.onmousemove = null;
        };

        window.onresize = () => { if (!$menu.className.match(/hidden/)) $menu.className += ' hidden'; };
    }

    function toggleMenu(e) {
        if (moveStatus) { moveStatus = false; return; }
        if ($menu.className.match(/hidden/)) {
            if (lastPathName !== location.pathname) generateMdMenu(true);
            const winWidth = document.documentElement.clientWidth, winHeight = document.documentElement.clientHeight;
            const x = e.clientX, y = e.clientY;
            $menu.className = `${winWidth / 2 - x > 0 ? 'le-md-right' : 'le-md-left'} ${winHeight / 2 - y > 0 ? 'le-md-bottom' : 'le-md-top'}`;
        } else {
            $menu.className += ' hidden';
        }
    }

    function generateMdMenu(flag) {
        lastPathName = location.pathname;
        let $content, list = [];
        const host = location.host;

        if (host === 'github.com') {
            const $parent = document.getElementById('readme') || document.getElementById('wiki-body');
            $content = $parent?.getElementsByClassName('markdown-body')[0];
            titleHeight = ($parent?.parentElement?.getElementsByClassName('js-sticky')[0]?.offsetHeight || 0) + 2;
            !$menu && window.addEventListener('pjax:complete', generateMdMenu);
        } else if (host === 'gitee.com') {
            $content = document.getElementById('tree-content-holder')?.getElementsByClassName('markdown-body')[0];
        } else if (host === 'www.npmjs.com') {
            $content = document.getElementById('readme');
        } else {
            $content = detectMdContent();
        }

        const $children = $content?.children || [];
        for (let $dom of $children) {
            const tag = $dom.tagName;
            if (tag.length === 2 && tag.startsWith('H') && !isNaN(+tag[1])) {
                const value = $dom.innerText.trim();
                const $a = $dom.getElementsByTagName('a')[0];
                if ($a) list.push({ type: +tag[1], value, href: $a.getAttribute('href'), offsetTop: getOffsetTop($a) });
            }
        }

        if ($menu) $menu.innerHTML = '';
        else createMdDom();
        if (!flag) $menu.className = 'hidden';

        if (list.length) {
            list.forEach(i => {
                const li = document.createElement('li');
                li.setAttribute('data-offsetTop', i.offsetTop);
                const a = document.createElement('a');
                a.href = i.href;
                a.title = i.value;
                a.style = `font-size:${1.3 - i.type * 0.1}em;margin-left:${i.type - 1}em;border-left:0.5em groove hsla(200,80%,${45 + i.type * 10}%,0.8);`;
                a.innerText = i.value;
                li.appendChild(a);
                $menu.appendChild(li);
            });
            $button.className = 'le-md-btn';
        } else {
            $button.className = 'le-md-btn le-md-btn-hidden';
        }

        initScrollHighlight();
    }

    function detectMdContent() {
        let tmp = [];
        for (let i = 1; i < 7; i++) {
            const list = document.body.getElementsByTagName(`h${i}`);
            for (let j of list) {
                const parent = j.parentElement;
                const item = tmp.find(k => k.ele.isEqualNode(parent));
                if (item) item.count++; else tmp.push({ ele: parent, count: 1 });
            }
        }
        return tmp.sort((a, b) => b.count - a.count)[0]?.ele || null;
    }

    function getOffsetTop($dom, val = 0) {
        return $dom ? getOffsetTop($dom.offsetParent, ($dom.offsetTop || 0) + val) : val;
    }

    function initScrollHighlight() {
        const update = debounce(() => {
            const scrollTop = (document.documentElement.scrollTop || document.body.scrollTop || 0) + titleHeight;
            const offsetHeight = document.documentElement.clientHeight || document.body.clientHeight || 0;
            const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight || 0;
            if ($menu) {
                Array.from($menu.children).forEach((li, i) => {
                    const val = +li.getAttribute('data-offsetTop');
                    const nextVal = $menu.children[i + 1] ? +$menu.children[i + 1].getAttribute('data-offsetTop') : scrollHeight;
                    li.className = '';
                    if (scrollTop <= val && val <= offsetHeight + scrollTop) li.className = 'le-md-title-active';
                    if (scrollTop >= val && nextVal > scrollTop) li.className = 'le-md-title-active le-md-title-active-first';
                });
            }
        }, 500);
        window.onscroll = window.onscroll ? function() { window.onscroll.call(this); update(); } : update;
    }

    function debounce(func, time) {
        let timeId;
        return function() {
            clearTimeout(timeId);
            timeId = setTimeout(() => func.apply(this, arguments), time);
        };
    }

    // ===========================================================================
    // === 功能3:固定页面头部 ===
    // ===========================================================================

    // 固定 GitHub 页面头部
    function fixPageHeader() {
        // 使用更健壮的选择器定位 GitHub 头部(兼容不同页面)
        const header = document.querySelector('.AppHeader, header[role="banner"]');
        if (!header) {
            log('未找到页面头部元素');
            return;
        }

        // 注入固定样式的 CSS
        const css = document.createElement('style');
        css.id = 'fixed-header-style'; // 添加 ID 以便后续更新
        css.innerHTML = `
            .AppHeader, header[role="banner"] {
                position: fixed !important;
                top: 0 !important;
                left: 0 !important;
                width: 100% !important;
                z-index: 10000 !important; /* 高优先级,避免被覆盖 */
                background-color: #24292e !important; /* GitHub 默认头部背景色 */
                box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* 添加阴影,提升视觉效果 */
            }
            body {
                padding-top: ${header.offsetHeight}px !important; /* 根据头部高度调整内容偏移 */
            }
        `;
        document.head.appendChild(css);

        // 监听 PJAX 事件,确保动态加载后更新样式
        window.addEventListener('pjax:complete', () => {
            const newHeader = document.querySelector('.AppHeader, header[role="banner"]');
            if (newHeader) {
                document.body.style.paddingTop = `${newHeader.offsetHeight}px`;
            }
        });

        // 使用 MutationObserver 监听 DOM 变化,确保头部始终固定
        const observer = new MutationObserver(() => {
            const currentHeader = document.querySelector('.AppHeader, header[role="banner"]');
            if (currentHeader && !currentHeader.style.position) {
                document.body.style.paddingTop = `${currentHeader.offsetHeight}px`;
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // ===========================================================================
    // === 初始化脚本 ===
    // ===========================================================================

    document.onreadystatechange = () => {
        if (document.readyState === 'complete') {
            generateMdMenu(); // 初始化 MD 目录
            fixPageHeader();  // 初始化固定页面头部
            log('已加载');
        }
    };
})();