ChatGPT Code Block Scroller (v2.0)

ChatGPT 代码块滚动增强:保留原生头部按钮、语言标签居中、状态跟随(生成中/完成)、自动历史补全、双击展开/收起。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ChatGPT Code Block Scroller (v2.0)
// @namespace    http://tampermonkey.net/
// @version      2.0.0
// @description  ChatGPT 代码块滚动增强:保留原生头部按钮、语言标签居中、状态跟随(生成中/完成)、自动历史补全、双击展开/收起。
// @author       User
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ================= 配置区域 =================
    const CONFIG = {
        maxHeight: '33vh',       // 默认限制高度(约屏幕 1/3)
        expandedHeight: '85vh',  // 展开时高度(也可改为 'none' 彻底不限制)
        transitionTime: '0.22s',
        statusText: {
            generating: '⏳ 生成中...',
            done: '✅ 代码生成完毕'
        },
        scanIntervalMs: 500
    };

    // ================= 样式注入 =================
    GM_addStyle(`
        /* 仅作用于 ChatGPT 代码块:pre 内含 code 的场景 */
        pre.cgpt-cbs-pre {
            border-radius: 12px !important;
            overflow: hidden !important;
        }

        /* 代码内容容器:强制内部滚动 */
        .cgpt-cbs-scroll {
            max-height: ${CONFIG.maxHeight} !important;
            overflow-y: auto !important;
            display: block !important;
            cursor: zoom-in !important;
            transition: max-height ${CONFIG.transitionTime} ease-out;
        }

        .cgpt-cbs-scroll.cgpt-cbs-expanded {
            max-height: ${CONFIG.expandedHeight} !important;
            cursor: zoom-out !important;
        }

        /* 滚动条美化(仅 WebKit 系) */
        .cgpt-cbs-scroll::-webkit-scrollbar { width: 12px; }
        .cgpt-cbs-scroll::-webkit-scrollbar-thumb {
            background-color: rgba(95, 99, 104, 0.35);
            border-radius: 999px;
        }

        /* ===== 头部布局增强(语言居中 + 状态徽章 + 按钮组靠右) ===== */

        /* 语言标签:推到中间组的左侧(靠中) */
        .cgpt-cbs-lang-centered {
            margin-left: auto !important;
        }

        /* 状态徽章:紧跟语言标签,且用 margin-right:auto 把按钮组推到最右侧 */
        .cgpt-cbs-status-badge {
            font-size: 12px;
            padding: 2px 8px;
            border-radius: 999px;
            margin-left: 10px !important;
            margin-right: auto !important;
            font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
            font-weight: 600;
            display: inline-flex;
            align-items: center;
            white-space: nowrap;
            height: 24px;
            user-select: none;
        }

        .cgpt-cbs-status-badge.generating {
            background-color: rgba(253, 214, 99, 0.16);
            color: #b58100;
            border: 1px solid rgba(253, 214, 99, 0.35);
            animation: cgpt-cbs-pulse 1.5s infinite;
        }

        .cgpt-cbs-status-badge.done {
            background-color: rgba(129, 201, 149, 0.16);
            color: #1f7a3d;
            border: 1px solid rgba(129, 201, 149, 0.35);
        }

        @keyframes cgpt-cbs-pulse {
            0% { opacity: 0.65; }
            50% { opacity: 1; }
            100% { opacity: 0.65; }
        }
    `);

    // ================= 交互:双击展开/收起 =================
    document.addEventListener('dblclick', (e) => {
        const code = e.target && (e.target.closest ? e.target.closest('pre') : null);
        if (!code) return;

        // 仅处理包含 code 的 pre
        if (!code.querySelector('code')) return;

        const scroll = findScrollContainer(code);
        if (!scroll) return;

        e.preventDefault();
        e.stopPropagation();
        window.getSelection()?.removeAllRanges();
        scroll.classList.toggle('cgpt-cbs-expanded');
    }, true);

    // ================= 核心:周期扫描(自动历史补全 + 动态更新状态) =================
    const timer = setInterval(() => {
        try { fixAllBlocks(); } catch (_) { /* ignore */ }
    }, CONFIG.scanIntervalMs);

    // 页面卸载时清理(可选)
    window.addEventListener('beforeunload', () => clearInterval(timer));

    // ================= 工具函数 =================

    function checkIsGenerating() {
        // ChatGPT 正在生成时通常会出现 “Stop generating / 停止生成” 类按钮
        const buttons = Array.from(document.querySelectorAll('button[aria-label], button[data-testid], button'));
        const stopBtn = buttons.find(btn => {
            const label = (btn.getAttribute('aria-label') || '').trim();
            const testid = (btn.getAttribute('data-testid') || '').trim();

            // 典型 stop:aria-label 或 data-testid
            const hasStop = /stop/i.test(label) || /停止/.test(label) || /stop/i.test(testid) || /停止/.test(testid);

            // 排除朗读/收听/播放等媒体按钮(避免误判)
            const isMedia = /朗读|收听|播放|read|listen|audio|voice/i.test(label) || /朗读|收听|播放|read|listen|audio|voice/i.test(testid);

            return hasStop && !isMedia;
        });
        return !!stopBtn;
    }

    function getAllCodePres() {
        // 兼容:有些代码块不一定在 article 内,但通常都在主内容区
        const pres = Array.from(document.querySelectorAll('pre'));
        return pres.filter(pre => pre.querySelector('code'));
    }

    function findScrollContainer(pre) {
        // ChatGPT 常见结构:pre > div(头部) + div(代码内容);代码内容 div 内含 code
        // 为稳健,优先选 “直接子元素中含 code 的 div”,找不到就退化为 pre 本身
        const directDivs = Array.from(pre.children).filter(el => el && el.tagName === 'DIV');
        const codeDiv = directDivs.find(div => div.querySelector('code'));
        return codeDiv || pre;
    }

    function findHeader(pre) {
        // 头部一般是 pre 的第一个 div(不含 code)且包含 button 或文本标签
        const directDivs = Array.from(pre.children).filter(el => el && el.tagName === 'DIV');
        const header = directDivs.find(div => !div.querySelector('code') && (div.querySelector('button') || div.textContent?.trim()));
        return header || null;
    }

    function findLangLabel(header) {
        if (!header) return null;

        // ChatGPT 语言标签可能是 <span> 或 <div>,通常靠左,且内容短(如 “python”)
        const candidates = Array.from(header.querySelectorAll('span, div')).filter(el => {
            const t = (el.textContent || '').trim();
            if (!t) return false;
            // 排除明显是按钮或图标容器
            if (el.querySelector('button')) return false;
            // 语言通常较短;不做强假设,仅作为启发式
            return t.length <= 20;
        });

        // 更偏向第一个候选
        return candidates[0] || null;
    }

    function ensureBadgeAndLayout(pre, statusType) {
        const header = findHeader(pre);
        if (!header) return;

        // 语言标签居中:给语言节点加 class
        const lang = findLangLabel(header);
        if (lang && !lang.classList.contains('cgpt-cbs-lang-centered')) {
            lang.classList.add('cgpt-cbs-lang-centered');
        }

        // 确保 pre 本身标记(便于样式收敛)
        if (!pre.classList.contains('cgpt-cbs-pre')) {
            pre.classList.add('cgpt-cbs-pre');
        }

        // 状态徽章:插在语言标签后
        let badge = header.querySelector('.cgpt-cbs-status-badge');
        const targetText = statusType === 'generating' ? CONFIG.statusText.generating : CONFIG.statusText.done;

        if (!badge) {
            badge = document.createElement('span');
            badge.className = 'cgpt-cbs-status-badge ' + statusType;
            badge.textContent = targetText;

            if (lang) {
                // 插在语言标签后面
                if (lang.nextSibling) {
                    header.insertBefore(badge, lang.nextSibling);
                } else {
                    header.appendChild(badge);
                }
            } else {
                header.appendChild(badge);
            }
        } else {
            // 更新文本与状态 class
            if (badge.textContent !== targetText || !badge.classList.contains(statusType)) {
                badge.className = 'cgpt-cbs-status-badge ' + statusType;
                badge.textContent = targetText;
            }
        }
    }

    function ensureScrollStyle(pre) {
        const scroll = findScrollContainer(pre);
        if (!scroll) return;

        // 如果 scroll 实际是 pre,本身就能滚动;但为了不破坏原布局,尽量只给 codeDiv 加样式
        if (!scroll.classList.contains('cgpt-cbs-scroll')) {
            scroll.classList.add('cgpt-cbs-scroll');
        }
    }

    function fixAllBlocks() {
        const pres = getAllCodePres();
        if (pres.length === 0) return;

        const isGenerating = checkIsGenerating();

        pres.forEach((pre, idx) => {
            ensureScrollStyle(pre);

            const isLast = idx === pres.length - 1;
            const status = (isGenerating && isLast) ? 'generating' : 'done';

            ensureBadgeAndLayout(pre, status);
        });
    }

    console.log('ChatGPT Code Block Scroller (v2.0) - Loaded');
})();