Collapse ChatGPT Code Blocks

Collapse/expand user & assistant code blocks on chat.openai.com/chatgpt.com using stable DOM attributes instead of brittle classnames.

目前為 2025-09-24 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Collapse ChatGPT Code Blocks
// @namespace    https://github.com/you/collapse-chatgpt-code
// @version      1.0.0
// @description  Collapse/expand user & assistant code blocks on chat.openai.com/chatgpt.com using stable DOM attributes instead of brittle classnames.
// @author       Chef D
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ---------------------------
  // SVGs (inline, no external deps)
  // ---------------------------
  const TOGGLE_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" style="transition: transform 120ms ease;">
  <path d="M8.12 9.29L12 13.17l3.88-3.88a1 1 0 011.41 1.41l-4.59 4.59a1 1 0 01-1.41 0L6.71 10.7a1 1 0 111.41-1.41z" fill="currentColor"/>
</svg>`.trim();

  const COPY_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24">
  <path d="M16 1H4a2 2 0 00-2 2v12h2V3h12V1zm3 4H8a2 2 0 00-2 2v14a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2zm0 16H8V7h11v14z" fill="currentColor"/>
</svg>`.trim();

  // ---------------------------
  // Utilities
  // ---------------------------
  const debounce = (fn, wait = 150) => {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), wait);
    };
  };

  function createBtn({ label, onClick, title, style = '' }) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.title = title || label;
    btn.textContent = label;
    btn.style.cssText = [
      'font: 12px/1 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;',
      'padding: 4px 8px;',
      'border-radius: 6px;',
      'border: 1px solid rgba(0,0,0,0.2);',
      'background: rgba(240,240,240,0.9);',
      'color: #111;',
      'cursor: pointer;',
      'user-select: none;',
      'display: inline-flex;',
      'align-items: center;',
      'gap: 6px;',
      'transition: background 120ms ease, transform 60ms ease;',
      style
    ].join('');
    btn.addEventListener('mouseenter', () => (btn.style.background = 'rgba(230,230,230,1)'));
    btn.addEventListener('mouseleave', () => (btn.style.background = 'rgba(240,240,240,0.9)'));
    btn.addEventListener('mousedown', () => (btn.style.transform = 'translateY(1px)'));
    btn.addEventListener('mouseup', () => (btn.style.transform = 'translateY(0)'));
    if (onClick) btn.addEventListener('click', onClick);
    return btn;
  }

  function createIconLabelBtn({ iconSVG, label, onClick, title }) {
    const btn = createBtn({ label: '', onClick, title });
    const span = document.createElement('span');
    span.textContent = label;
    btn.insertAdjacentHTML('afterbegin', iconSVG);
    btn.appendChild(span);
    return btn;
  }

  function ensureHeader(preEl) {
    // If there's a sibling header/toolbar above the <pre>, use it.
    // Otherwise, create a minimal toolbar container.
    let header = preEl.previousElementSibling;
    const looksLikeToolbar =
      header &&
      (header.tagName === 'DIV' || header.tagName === 'SECTION') &&
      header.childElementCount <= 6;

    if (!looksLikeToolbar) {
      header = document.createElement('div');
      header.style.cssText = [
        'display:flex;',
        'align-items:center;',
        'gap:8px;',
        'margin: 0 0 6px 0;',
        'flex-wrap: wrap;'
      ].join('');
      preEl.parentElement?.insertBefore(header, preEl);
    }
    return header;
    }

  // ---------------------------
  // Toggle helpers
  // ---------------------------
  function createToggle(label, target, options = {}) {
    const {
      showText = 'Show code',
      hideText = 'Hide code',
      initialHidden = true
    } = options;

    // Initial state
    target.style.display = initialHidden ? 'none' : '';
    let hidden = initialHidden;

    const btn = createIconLabelBtn({
      iconSVG: TOGGLE_SVG,
      label: initialHidden ? showText : hideText,
      title: 'Toggle code',
      onClick: (e) => {
        e.stopPropagation();
        hidden = !hidden;
        target.style.display = hidden ? 'none' : '';
        btn.querySelector('span').textContent = hidden ? showText : hideText;
        const svg = btn.querySelector('svg');
        if (svg) svg.style.transform = hidden ? 'rotate(-90deg)' : 'rotate(0deg)';
      }
    });

    // set initial icon orientation
    const svg = btn.querySelector('svg');
    if (svg) svg.style.transform = hidden ? 'rotate(-90deg)' : 'rotate(0deg)';

    return btn;
  }

  // ---------------------------
  // Processing functions
  // ---------------------------
  function processAssistantPre(pre) {
    if (!pre || pre.dataset.__collapsed) return;
    const code = pre.querySelector('code');
    if (!code) return;

    pre.dataset.__collapsed = '1';

    const header = ensureHeader(pre);
    // Toggle 'initialHidden' to false if you want code blocks to be expanded by default
    // Make sure to also toggle 'initialHidden' in the processUserPre function below
    const toggleBtn = createToggle('Show code', code, { showText: 'Show code', hideText: 'Hide code', initialHidden: true });
    header.appendChild(toggleBtn);
  }

  function processUserPre(pre) {
    if (!pre || pre.dataset.__collapsed) return;
    const code = pre.querySelector('code');
    if (!code) return;

    pre.dataset.__collapsed = '1';
    pre.style.position = pre.style.position || 'relative';

    // Controls container
    const ctr = document.createElement('div');
    ctr.style.cssText = [
      'position:absolute;',
      'top:6px;',
      'right:8px;',
      'display:flex;',
      'gap:6px;',
      'z-index: 2;'
    ].join('');
    pre.appendChild(ctr);

    // Copy
    const copyBtn = createIconLabelBtn({
      iconSVG: COPY_SVG,
      label: 'Copy',
      title: 'Copy code',
      onClick: (e) => {
        e.stopPropagation();
        const text = code.innerText || code.textContent || '';
        navigator.clipboard.writeText(text).then(() => {
          const lbl = copyBtn.querySelector('span');
          const old = lbl.textContent;
          lbl.textContent = 'Copied';
          setTimeout(() => (lbl.textContent = old), 900);
        });
      }
    });

    // Toggle 'initialHidden' to false if you want code blocks to be expanded by default
    const toggleBtn = createToggle('Show', code, { showText: 'Show', hideText: 'Hide', initialHidden: true });

    ctr.append(copyBtn, toggleBtn);
  }

  // ---------------------------
  // Scanner
  // ---------------------------
  function scan() {
    // Find all <pre> blocks (assistant & user) and classify by bubble role
    const pres = document.querySelectorAll('pre');
    pres.forEach((pre) => {
      if (pre.dataset.__collapsed) return;

      const bubble = pre.closest('[data-message-author-role]');
      const role = bubble?.getAttribute('data-message-author-role');

      if (role === 'assistant') {
        processAssistantPre(pre);
      } else if (role === 'user') {
        processUserPre(pre);
      } else {
        // Unknown: treat like assistant (safe default)
        processAssistantPre(pre);
      }
    });
  }

  // Initial scan
  const start = () => {
    try {
      scan();
    } catch (e) {
      // no-op
    }
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start, { once: true });
  } else {
    start();
  }

  // Observe DOM changes to catch new messages/edits/streamed content
  const observer = new MutationObserver(debounce(() => scan(), 200));
  observer.observe(document.documentElement, { childList: true, subtree: true });

  // Re-run on resize (some layouts lazily attach code blocks)
  window.addEventListener('resize', debounce(scan, 300));
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址