ChatGPT Code Block Scroller (v2.0)

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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');
})();