ChatGPT Navigator & Fold-Plus

ChatGPT userscript: ↑↓ navigator, directory, refresh & fold long "You" messages.

目前為 2025-06-18 提交的版本,檢視 最新版本

// ==UserScript==
// @name         ChatGPT Navigator & Fold-Plus
// @version      1.0.0
// @description  ChatGPT userscript: ↑↓ navigator, directory, refresh & fold long "You" messages.
// @match        https://chatgpt.com/*
// @grant        none
// @license      MIT
// @namespace https://gf.qytechs.cn/users/1485470
// ==/UserScript==

(() => {
  'use strict';

  /* ─── CONFIG: tweak here when ChatGPT's DOM changes ─────────────── */
  const CONFIG = {
    /* selectors ChatGPT might rename */
    SELECTORS: {
      userWrapper:      'div[data-message-author-role="user"]',
      content:          '.whitespace-pre-wrap',
      composerForm:     'form',
      spaContainers:    ['div.my-auto.text-base', 'div.m-auto.text-base', 'main'],
      directoryWrap:    '#gmDirWrap'
    },

    /* timing or size knobs you sometimes adjust */
    TIMINGS: {
      scrollSuppress: 300,      // ms to ignore scroll after jump
      debounce:       100,      // ms for MutationObserver debounce
      keepAlive:      5000,     // ms between auto-boot checks
      bootDelay:      300       // ms delay for SPA navigation
    },

    FOLD: {
      lineThreshold: 3          // fold user messages > this many lines
    },

    /* one big template-literal for all CSS so colours & sizes live in one spot */
    CSS: `
      :root{--dir-bg:#fff;--dir-border:#ddd;--dir-hover:#f0f0f0;--nav-bg:#ddd;--nav-fg:#333;--highlight:rgba(38,198,218,0.3);}
      html.dark{--dir-bg:#333;--dir-border:#555;--dir-hover:#444;--nav-bg:#444;--nav-fg:#eee;--highlight:rgba(38,198,218,0.6);}
      .gm-anchor{scroll-margin-top:96px!important}
      #gmDirWrap{position:fixed;top:140px;right:12px;display:flex;flex-direction:column;align-items:flex-end;z-index:9999}
      #gmToggle,#gmRefresh{all:unset;cursor:pointer;padding:6px 10px;background:var(--nav-bg);color:var(--nav-fg);border-radius:6px;margin-bottom:6px;margin-left:4px}
      #gmDir{display:none;width:280px;max-height:70vh;overflow:auto;background:var(--dir-bg);border:1px solid var(--dir-border);border-radius:8px;padding:8px;box-shadow:0 4px 12px rgba(0,0,0,.1);}
      #gmList{list-style:none;margin:0;padding:0}
      #gmList li{padding:6px;border-radius:4px;cursor:pointer}
      #gmList li:hover{background:var(--dir-hover)}
      #gmNav{position:fixed;display:flex;align-items:center;gap:4px;z-index:1001}
      #gmNav .col{display:flex;flex-direction:column;gap:4px}
      #gmNav button{all:unset;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--nav-bg);color:var(--nav-fg);cursor:pointer}
      #gmNav span{color:var(--nav-fg);font-size:14px}
      .gm-highlight{background:var(--highlight);transition:background .8s ease-out;}
      /* ── folding CSS ───────────────────────────────────────── */
      .cgpt-folded { max-height:120px!important;overflow:hidden!important;position:relative;border-radius:12px }
      .cgpt-folded::after {
        content:'';position:absolute;left:0;right:0;bottom:0;height:3em;
        background:linear-gradient(to bottom,rgba(255,255,255,0),var(--background-primary,#f5f5f7));
      }
      html.dark .cgpt-folded::after {
        background:linear-gradient(to bottom,rgba(58,58,61,0),var(--background-secondary,#3a3b46));
      }
    `
  };

  /* ─── 0) HELPERS ───────────────────────────────────────────────────── */
  const $ = q => document.querySelector(q);
  const $$ = q => [...document.querySelectorAll(q)];
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const smoothOK = CSS.supports('scroll-behavior','smooth') && !prefersReduced;

  function findScrollContainer(el){
    let cur = el;
    while(cur){
      if(cur.scrollHeight > cur.clientHeight) return cur;
      cur = cur.parentElement;
    }
    return window;
  }

  /* ─── 1) GLOBAL STYLES ─────────────────────────────────────────────── */
  const style = document.createElement('style');
  style.textContent = CONFIG.CSS;
  document.head.appendChild(style);

  /* ─── 2) BUILD UI ────────────────────────────────────────────────── */
  let gmDirWrap, gmToggle, gmRefresh, gmDir, gmList;
  let gmNav, upBtn, dnBtn, counter;
  let curIdx = 0, suppress = false, scrollParent = window, obs, rebuildTimer, currentContainer;

  function buildUI(){
    if ($(CONFIG.SELECTORS.directoryWrap)) return;
    gmDirWrap = document.createElement('div'); gmDirWrap.id='gmDirWrap';

    const toolbar = document.createElement('div');
    toolbar.style.display='flex'; toolbar.style.gap='4px';

    gmRefresh = document.createElement('button');
    gmRefresh.id='gmRefresh'; gmRefresh.textContent='⟳'; gmRefresh.title='Refresh';
    gmRefresh.onclick=performRefresh;

    gmToggle  = document.createElement('button');
    gmToggle.id='gmToggle'; gmToggle.textContent='▼'; gmToggle.title='Toggle directory';
    gmToggle.onclick=toggleDirectory;

    toolbar.append(gmRefresh,gmToggle);
    gmDirWrap.append(toolbar);

    gmDir  = document.createElement('div'); gmDir.id='gmDir';
    gmList = document.createElement('ul');  gmList.id='gmList';
    gmDir.append(gmList); gmDirWrap.append(gmDir);
    document.body.append(gmDirWrap);

    gmNav = document.createElement('div'); gmNav.id='gmNav';
    gmNav.style.display = 'none';          // hidden until we have messages
    const col=document.createElement('div'); col.className='col';
    upBtn=document.createElement('button'); upBtn.textContent='↑'; upBtn.title='Prev';
    dnBtn=document.createElement('button'); dnBtn.textContent='↓'; dnBtn.title='Next';
    col.append(upBtn,dnBtn);
    counter=document.createElement('span'); counter.textContent='0 / 0';
    gmNav.append(col,counter); document.body.append(gmNav);

    window.addEventListener('resize',positionNav);
    upBtn.addEventListener('click',()=>jump(curIdx-1));
    dnBtn.addEventListener('click',()=>jump(curIdx+1));
  }

  function toggleDirectory(){
    const show = gmDir.style.display!=='block';
    gmDir.style.display = show?'block':'none';
    gmToggle.textContent=show?'▲':'▼';
    positionNav();
  }

  function performRefresh(){
    obs?.disconnect();
    scrollParent.removeEventListener('scroll',syncFromScroll);
    window.removeEventListener('scroll',syncFromScroll);
    boot();
  }

  /* ─── 3) CORE LOGIC ──────────────────────────────────────────────── */
  function userTurns(){
    return [...new Set($$(CONFIG.SELECTORS.userWrapper).map(el=>el.closest('article')))];
  }

  function updateCounter(){
    const turns       = userTurns();
    const total       = turns.length;
    const nowVisible  = total > 0;

    // if visibility changed, full reboot
    if (gmNav && (nowVisible !== gmNav._prevVisible)) {
      gmNav._prevVisible = nowVisible;
      boot();
      return;
    }

    counter.textContent = `${nowVisible ? curIdx + 1 : 0} / ${total}`;
    if (gmNav) gmNav.style.display = nowVisible ? 'flex' : 'none';
  }

  function jump(idx){
    const turns=userTurns();
    if(!turns.length)return;
    curIdx=Math.max(0,Math.min(idx,turns.length-1));
    const n=turns[curIdx];

    // suppress scroll-sync while we smooth-scroll
    suppress = true;

    n.scrollIntoView({behavior:smoothOK?'smooth':'auto',block:'start'});
    n.classList.add('gm-highlight');
    setTimeout(()=>n.classList.remove('gm-highlight'),800);
    updateCounter();
    // re-enable after scroll settles
    setTimeout(()=>suppress = false, CONFIG.TIMINGS.scrollSuppress);
  }

  function syncFromScroll(){
    if (suppress) return;
    const turns=userTurns(); if(!turns.length)return;
    const mid=window.innerHeight/2;let best=0,bd=1e9;
    turns.forEach((n,i)=>{
      const r=n.getBoundingClientRect(),c=r.top+r.height/2,d=Math.abs(c-mid);
      if(d<bd){bd=d;best=i;}
    });
    curIdx=best;updateCounter();
  }

  function rebuildList(){
    gmList.textContent='';
    userTurns().forEach((art,i)=>{
      art.id=`gm-q-${i}`;art.classList.add('gm-anchor');
      const li=document.createElement('li');
      li.textContent=`${i+1}. ${art.innerText.trim().slice(0,40)}${art.innerText.length>40?'…':''}`;
      li.onclick=()=>jump(i);
      gmList.append(li);
    });
    updateCounter();
    foldLongMessages();
  }

  /* ─── 4) FOLD‐MESSAGES ───────────────────────────────────────────── */
  function foldLongMessages(){
    document.querySelectorAll(CONFIG.SELECTORS.userWrapper).forEach(msg=>{
      if(msg.dataset.foldProcessed)return;
      const content=msg.querySelector(CONFIG.SELECTORS.content);
      if(!content)return;
      if(content.innerText.split('\n').length>CONFIG.FOLD.lineThreshold){
        msg.dataset.foldProcessed='1';
        content.classList.add('cgpt-folded');
        content.style.cursor='pointer';
        content.addEventListener('click',()=>content.classList.toggle('cgpt-folded'));
      }
    });
  }

  /* ─── 5) POSITION ───────────────────────────────────────────────── */
  function positionNav(retry=true){
    if(!gmNav)return;
    const form=$(CONFIG.SELECTORS.composerForm);
    if(!form){ if(retry)setTimeout(()=>positionNav(false),200); return; }
    const r=form.getBoundingClientRect(),p=8,
          t=r.top+(r.height-gmNav.offsetHeight)/2; let l=r.right+p;
    if(l+gmNav.offsetWidth>window.innerWidth)l=r.left-gmNav.offsetWidth-p;
    gmNav.style.top=`${Math.max(4,t)}px`;gmNav.style.left=`${Math.max(4,l)}px`;
  }

  /* ─── 6) OBSERVE & BOOT ─────────────────────────────────────────── */
  let lastUrl=location.href;
  function connectObservers(root){
    obs?.disconnect();
    obs=new MutationObserver(m=>{
      if(m.some(x=>x.addedNodes.length)){
        clearTimeout(rebuildTimer);
        rebuildTimer=setTimeout(()=>{rebuildList();positionNav();},CONFIG.TIMINGS.debounce);
      }
    });
    obs.observe(root,{childList:true,subtree:true});
    scrollParent.removeEventListener('scroll',syncFromScroll);
    scrollParent=findScrollContainer(root);
    scrollParent.addEventListener('scroll',syncFromScroll,{passive:true});
    window.addEventListener('scroll',syncFromScroll,{passive:true});
    currentContainer=root;
  }

  function boot(){
    if(location.href!==lastUrl){ lastUrl=location.href; curIdx=0; }
    const root=CONFIG.SELECTORS.spaContainers.map($).find(Boolean);
    if(!root)return;
    buildUI();rebuildList();syncFromScroll();positionNav();connectObservers(root);
  }

  ['pushState','replaceState'].forEach(fn=>{
    const o=history[fn];
    history[fn]=function(){const r=o.apply(this,arguments);setTimeout(boot,CONFIG.TIMINGS.bootDelay);return r;};
  });
  window.addEventListener('popstate',()=>setTimeout(boot,CONFIG.TIMINGS.bootDelay));
  window.addEventListener('DOMContentLoaded',()=>setTimeout(boot,CONFIG.TIMINGS.bootDelay));
  setInterval(()=>$(CONFIG.SELECTORS.directoryWrap)||boot(),CONFIG.TIMINGS.keepAlive);

})();

QingJ © 2025

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