您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add up/down arrow navigation for ChatGPT messages
// ==UserScript== // @name ChatGPT Arrow Navigator // @version 1.0 // @description Add up/down arrow navigation for ChatGPT messages // @author Bui Quoc Dung // @match https://chatgpt.com/* // @grant none // @license MIT // @namespace https://greasyfork.org/users/1485470 // ==/UserScript== (() => { 'use strict'; /* ─── CONFIGURATION ────────────────────────────────────────────────── */ const CONFIG = { SELECTORS: { userWrapper: 'div[data-message-author-role="user"]', composerTextarea: '#prompt-textarea', spaContainers: ['div.my-auto.text-base', 'div.m-auto.text-base', 'main'], }, TIMINGS: { scrollSuppress: 300, debounce: 100, keepAlive: 5000, bootDelay: 300 }, CSS: ` :root{--nav-bg:#ddd;--nav-fg:#333;} html.dark{--nav-bg:#444;--nav-fg:#eee;} .gm-anchor{scroll-margin-top:96px !important;} #gmNav{position:fixed;display:flex;z-index:1001;transition:top .15s ease-out, left .15s ease-out;} #gmNav .col{display:flex;flex-direction:column;align-items:center;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:12px;font-weight:bold;line-height:1;min-width:2ch;text-align:center;} ` }; /* ─── HELPER FUNCTIONS ─────────────────────────────────────────────── */ const $ = q => document.querySelector(q); const $$ = q => [...document.querySelectorAll(q)]; /* ─── GLOBAL VARIABLES ─────────────────────────────────────────────── */ let gmNav, upBtn, dnBtn, counter, obs, resizeObs, rebuildTimer; let curIdx = 0, suppress = false; /* ─── BUILD INTERFACE ──────────────────────────────────────────────── */ function buildUI() { if ($('#gmNav')) return; gmNav = document.createElement('div'); gmNav.id = 'gmNav'; gmNav.style.display = 'none'; const col = document.createElement('div'); col.className = 'col'; upBtn = document.createElement('button'); upBtn.textContent = '↑'; upBtn.title = 'Previous message'; dnBtn = document.createElement('button'); dnBtn.textContent = '↓'; dnBtn.title = 'Next message'; counter = document.createElement('span'); col.append(upBtn, dnBtn, counter); gmNav.append(col); document.body.append(gmNav); window.addEventListener('resize', () => positionNav()); upBtn.addEventListener('click', () => jump(curIdx - 1)); dnBtn.addEventListener('click', () => jump(curIdx + 1)); } /* ─── CORE LOGIC ───────────────────────────────────────────────────── */ function userTurns() { return $$(CONFIG.SELECTORS.userWrapper); } function updateState() { const turns = userTurns(); const isVisible = turns.length > 0; turns.forEach((turn, i) => { turn.id = `gm-q-${i}`; turn.classList.add('gm-anchor'); }); if (gmNav) gmNav.style.display = isVisible ? 'flex' : 'none'; if (isVisible) counter.textContent = curIdx + 1; } function jump(idx) { const turns = userTurns(); if (!turns.length) return; curIdx = Math.max(0, Math.min(idx, turns.length - 1)); const targetNode = turns[curIdx]; suppress = true; targetNode.scrollIntoView({ behavior: 'auto', block: 'start' }); updateState(); 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; let bestDistance = Infinity; turns.forEach((node, i) => { const r = node.getBoundingClientRect(); const distance = Math.abs(r.top + r.height / 2 - mid); if (distance < bestDistance) { bestDistance = distance; best = i; } }); curIdx = best; updateState(); } /* ─── POSITIONING & RESIZE OBSERVER ───────────────────────────────── */ function positionNav(retry = true) { if (!gmNav) return; const textarea = $(CONFIG.SELECTORS.composerTextarea); const anchorEl = textarea?.closest('div.relative'); if (!anchorEl) { if (retry) setTimeout(() => positionNav(false), 200); return; } const r = anchorEl.getBoundingClientRect(); // --- POSITION ADJUSTMENTS --- const horizontalPadding = 12; const verticalOffset = 5; const t = r.top + (r.height - gmNav.offsetHeight) / 2 + verticalOffset; let l = r.left - gmNav.offsetWidth - horizontalPadding; if (l < horizontalPadding) l = r.right + horizontalPadding; gmNav.style.top = `${Math.max(4, t)}px`; gmNav.style.left = `${Math.max(4, l)}px`; } function observeComposerResize() { resizeObs?.disconnect(); const anchorEl = $(CONFIG.SELECTORS.composerTextarea)?.closest('div.relative'); if (anchorEl) { resizeObs = new ResizeObserver(() => positionNav(false)); resizeObs.observe(anchorEl); } } /* ─── BOOTSTRAP & PAGE CHANGE OBSERVER ────────────────────────────── */ let lastUrl = location.href; function boot() { if (location.href !== lastUrl) { lastUrl = location.href; curIdx = 0; } const root = CONFIG.SELECTORS.spaContainers.map($).find(Boolean); if (!root) return; buildUI(); observeComposerResize(); root.addEventListener('scroll', syncFromScroll, { passive: true }); window.addEventListener('scroll', syncFromScroll, { passive: true }); obs?.disconnect(); obs = new MutationObserver(() => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(() => { updateState(); positionNav(); }, CONFIG.TIMINGS.debounce); }); obs.observe(root, { childList: true, subtree: true }); updateState(); syncFromScroll(); positionNav(); } document.head.appendChild(Object.assign(document.createElement('style'), { textContent: CONFIG.CSS })); ['pushState', 'replaceState'].forEach(fn => { const original = history[fn]; history[fn] = function() { const result = original.apply(this, arguments); setTimeout(boot, CONFIG.TIMINGS.bootDelay); return result; }; }); window.addEventListener('popstate', () => setTimeout(boot, CONFIG.TIMINGS.bootDelay)); window.addEventListener('DOMContentLoaded', () => setTimeout(boot, CONFIG.TIMINGS.bootDelay)); setInterval(() => $('#gmNav') || boot(), CONFIG.TIMINGS.keepAlive); })();