Greasy Fork

来自缓存

Greasy Fork is available in English.

LeetCode|力扣 题单多功能目录插件

自动生成题单目录+一键跳转+自动标记已做题目+自动跳转到上一次浏览位置+题目标题一键复制

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         LeetCode|力扣 题单多功能目录插件
// @license MIT
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  自动生成题单目录+一键跳转+自动标记已做题目+自动跳转到上一次浏览位置+题目标题一键复制
// @author       0xff (Fixed by Assistant)
// @match        *://leetcode.cn/discuss/*
// @match        *://leetcode.cn/problems/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=leetcode.cn
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // === 全局状态变量 ===
    let tocContainer = null;      // 目录容器DOM
    let currentPath = location.pathname; // 当前路径
    let checkContentTimer = null; // 内容检测定时器
    let refreshTimer = null;      // 自动刷新定时器

    // === 配置参数 (动态获取) ===
    const getConfig = () => ({
        title: "大纲目录",
        width: 240,
        indent: 20,
        bgColor: "#ffffff",
        textColor: "#37352f",
        hoverColor: "#f0f0f0",
        // 关键:keyPrefix 必须是个函数或动态获取,确保SPA跳转后key能变
        keyPrefix: "tm_toc_save_" + location.pathname,
        refreshInterval: 5 * 60 * 1000 // 5分钟
    });

    // === 辅助函数:存取本地数据 ===
    const Storage = {
        get: (key, def) => {
            const config = getConfig();
            const val = localStorage.getItem(config.keyPrefix + key);
            return val ? JSON.parse(val) : def;
        },
        set: (key, val) => {
            const config = getConfig();
            localStorage.setItem(config.keyPrefix + key, JSON.stringify(val));
        }
    };

    // ==========================================
    // Feature 1: 题目页面 - 复制标题按钮
    // ==========================================
    function renderCopyButton() {
        // 1. 仅在题目页面运行
        if (!location.pathname.startsWith('/problems/')) return false;

        // 2. 防止重复添加
        if (document.getElementById('tm-lc-copy-btn')) return true;

        // 3. 寻找标题元素
        const titleContainer = document.querySelector('.text-title-large');
        const titleLink = titleContainer ? titleContainer.querySelector('a') : null;

        if (!titleContainer || !titleLink) return false; // 还没加载出来

        // 4. 创建复制按钮
        const btn = document.createElement('div');
        btn.id = 'tm-lc-copy-btn';
        btn.style.cssText = `
            display: inline-flex; align-items: center; justify-content: center;
            margin-left: 8px; cursor: pointer; color: #9ca3af;
            width: 24px; height: 24px; border-radius: 4px; transition: all 0.2s;
            vertical-align: middle;
        `;
        // SVG 图标 (复制图标)
        const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
        const checkIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;

        btn.innerHTML = copyIcon;
        btn.title = "复制 Markdown (标题+链接图标)";

        // 5. 悬停效果
        btn.onmouseenter = () => { btn.style.backgroundColor = 'rgba(0,0,0,0.05)'; btn.style.color = '#333'; };
        btn.onmouseleave = () => { btn.style.backgroundColor = 'transparent'; btn.style.color = '#9ca3af'; };

        // 6. 点击事件
        btn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();

            let text = titleLink.innerText.trim();
            // 去除开头的数字和点 (如 "1749. " -> "")
            text = text.replace(/^\d+\.\s*/, '');

            const url = titleLink.href;

            // --- 核心修改:**标题** [🔗](链接) ---
            const markdown = `**${text}** [🔗](${url})`;

            navigator.clipboard.writeText(markdown).then(() => {
                btn.innerHTML = checkIcon;
                setTimeout(() => {
                    btn.innerHTML = copyIcon;
                }, 2000);
            }).catch(err => {
                console.error('复制失败:', err);
                alert('复制失败,请手动复制');
            });
        };

        // 7. 插入到标题后面
        titleContainer.appendChild(btn);

        return true;
    }


    // ==========================================
    // Feature 2: 讨论区/题单 - 目录渲染逻辑
    // ==========================================

    function removeTOC() {
        if (tocContainer && tocContainer.parentNode) {
            tocContainer.parentNode.removeChild(tocContainer);
        }
        tocContainer = null;
    }

    function renderTOC() {
        // 如果不是讨论区或没有标题,直接返回
        const headings = document.querySelectorAll('h2, h3');
        if (headings.length === 0) return false;

        // 先清理旧的
        removeTOC();

        const config = getConfig();
        const savedPos = Storage.get('pos', { top: 100, left: 20 });
        const savedState = Storage.get('expanded', true);

        // 注入样式 (移除固定写死的top和left,改用内联样式,以防SPA跳转时跳回首次位置)
        if (!document.getElementById('tm-toc-style')) {
            const css = `
                #tm-toc-container {
                    position: fixed;
                    width: ${config.width}px; max-height: 80vh; background: ${config.bgColor};
                    box-shadow: rgba(15, 15, 15, 0.05) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 3px 6px, rgba(15, 15, 15, 0.2) 0px 9px 24px;
                    border-radius: 8px; z-index: 9999;
                    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                    color: ${config.textColor}; overflow: hidden; display: flex; flex-direction: column;
                    font-size: 14px; transition: opacity 0.2s;
                }
                #tm-toc-header {
                    padding: 12px 16px; font-weight: 600; border-bottom: 1px solid rgba(55, 53, 47, 0.09);
                    cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center;
                    background: #fbfbfa;
                }
                #tm-toc-toggle { cursor: pointer; color: #999; font-size: 12px; padding: 4px; }
                #tm-toc-toggle:hover { color: #333; }
                #tm-toc-content {
                    overflow-y: auto; padding: 8px 0; flex: 1;
                    display: ${savedState ? 'block' : 'none'};
                }
                #tm-toc-content::-webkit-scrollbar { width: 6px; }
                #tm-toc-content::-webkit-scrollbar-thumb { background: #e0e0e0; border-radius: 3px; }
                .tm-toc-item {
                    padding: 6px 16px; cursor: pointer; white-space: nowrap; overflow: hidden;
                    text-overflow: ellipsis; line-height: 1.5; text-decoration: none; display: block; color: inherit;
                }
                .tm-toc-item:hover { background-color: ${config.hoverColor}; }
                .tm-toc-h2 { font-weight: 500; }
                .tm-toc-h3 { font-weight: 400; padding-left: ${16 + config.indent}px; color: #666; font-size: 0.95em; }
            `;
            if (typeof GM_addStyle !== 'undefined') {
                GM_addStyle(css);
            } else {
                const style = document.createElement('style');
                style.id = 'tm-toc-style';
                style.innerHTML = css;
                document.head.appendChild(style);
            }
        }

        // 构建DOM
        const container = document.createElement('div');
        container.id = 'tm-toc-container';

        // --- 修复:防止初始化位置超出当前窗口大小 ---
        const maxSafeTop = Math.max(0, window.innerHeight - 40);
        const maxSafeLeft = Math.max(0, window.innerWidth - config.width);
        container.style.top = `${Math.max(0, Math.min(savedPos.top, maxSafeTop))}px`;
        container.style.left = `${Math.max(0, Math.min(savedPos.left, maxSafeLeft))}px`;

        tocContainer = container;

        const header = document.createElement('div');
        header.id = 'tm-toc-header';
        header.innerHTML = `<span>${config.title}</span><span id="tm-toc-toggle">${savedState ? '▼' : '◀'}</span>`;
        container.appendChild(header);

        const contentBox = document.createElement('div');
        contentBox.id = 'tm-toc-content';

        headings.forEach((node, index) => {
            if (!node.id) node.id = 'tm-toc-heading-' + index;
            const link = document.createElement('div');
            link.className = `tm-toc-item tm-toc-${node.tagName.toLowerCase()}`;
            link.innerText = node.innerText.replace(/^§/, '');
            link.title = node.innerText;
            link.addEventListener('click', (e) => {
                e.preventDefault();
                Storage.set('scrollY', window.scrollY);
                node.scrollIntoView({ behavior: 'smooth', block: 'start' });
            });
            contentBox.appendChild(link);
        });

        container.appendChild(contentBox);
        document.body.appendChild(container);

        // 绑定事件
        bindEvents(container, header, contentBox);

        // 恢复上次阅读位置
        setTimeout(() => {
            const lastScrollY = Storage.get('scrollY', 0);
            if (lastScrollY > 0) window.scrollTo(0, lastScrollY);
        }, 300);

        return true;
    }

    function bindEvents(container, header, contentBox) {
        // 拖拽
        let isDragging = false, startX, startY, initialLeft, initialTop;
        header.addEventListener('mousedown', (e) => {
            if(e.target.id === 'tm-toc-toggle') return;
            isDragging = true;
            startX = e.clientX; startY = e.clientY;
            const rect = container.getBoundingClientRect();
            initialLeft = rect.left; initialTop = rect.top;
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });
        function onMouseMove(e) {
            if (!isDragging) return;
            let newLeft = initialLeft + (e.clientX - startX);
            let newTop = initialTop + (e.clientY - startY);

            // --- 修复:限制拖拽边界,防止移出可视区域 ---
            const minLeft = 0;
            const minTop = 0;
            const maxLeft = Math.max(0, window.innerWidth - container.offsetWidth);
            const maxTop = Math.max(0, window.innerHeight - header.offsetHeight);

            newLeft = Math.max(minLeft, Math.min(newLeft, maxLeft));
            newTop = Math.max(minTop, Math.min(newTop, maxTop));

            container.style.left = `${newLeft}px`;
            container.style.top = `${newTop}px`;
        }
        function onMouseUp() {
            isDragging = false;
            const rect = container.getBoundingClientRect();
            Storage.set('pos', { top: rect.top, left: rect.left });
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
        }

        // 折叠
        const toggleBtn = header.querySelector('#tm-toc-toggle');
        let isExpanded = contentBox.style.display !== 'none';
        toggleBtn.addEventListener('click', () => {
            if (isExpanded) {
                contentBox.style.display = 'none'; toggleBtn.innerText = '◀'; container.style.height = 'auto';
            } else {
                contentBox.style.display = 'block'; toggleBtn.innerText = '▼';
            }
            isExpanded = !isExpanded;
            Storage.set('expanded', isExpanded);
        });
    }

    // ==========================================
    // Core: SPA 监听与生命周期管理
    // ==========================================

    function init() {
        console.log('[LeetCode助手] 正在初始化...');

        let attempts = 0;
        if (checkContentTimer) clearInterval(checkContentTimer);

        // 轮询检测内容是否加载完毕
        checkContentTimer = setInterval(() => {
            attempts++;
            let isReady = false;

            if (location.pathname.startsWith('/problems/')) {
                // 如果是题目页,尝试渲染复制按钮
                if (renderCopyButton()) isReady = true;
            } else {
                // 如果是其他页(如discuss),尝试渲染目录
                if (renderTOC()) isReady = true;
            }

            // 如果成功渲染 或者 尝试超过10秒(20*500ms),停止轮询
            if (isReady || attempts > 20) {
                clearInterval(checkContentTimer);
            }
        }, 500);
    }

    // 监听 URL 变化 (SPA 核心逻辑)
    setInterval(() => {
        if (location.pathname !== currentPath) {
            console.log(`[LeetCode助手] 页面跳转: ${currentPath} -> ${location.pathname}`);
            currentPath = location.pathname;
            removeTOC();
            init();
        }
    }, 1000);

    // 启动
    init();

    // ==========================================
    // Core 3: 自动刷新 & 位置保存
    // ==========================================

    window.addEventListener('beforeunload', () => {
        Storage.set('scrollY', window.scrollY);
    });

    function handleVisibilityChange() {
        const config = getConfig();
        if (document.hidden) {
            // 只有在discuss页面才考虑自动刷新
            if (location.pathname.includes('/discuss/')) {
                 refreshTimer = setTimeout(() => {
                     location.reload();
                 }, config.refreshInterval);
            }
        } else {
            if (refreshTimer) {
                clearTimeout(refreshTimer);
                refreshTimer = null;
            }
        }
    }
    document.addEventListener("visibilitychange", handleVisibilityChange);
    if (document.hidden) handleVisibilityChange();

})();