// ==UserScript== // @name Gemini 对话导航 // @namespace https://github.com/marioplus/gemini-navigator // @version 0.1.3 // @author marioplus // @description Gemini 对话导航,增加上一条、下一条、到底部导航按钮 // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com // @homepage https://github.com/marioplus/gemini-navigator // @homepageURL https://github.com/marioplus/gemini-navigator // @match https://gemini.google.com/* // @grant GM_addStyle // @run-at document-end // ==/UserScript== (function () { 'use strict'; const d=new Set;const importCSS = async e=>{d.has(e)||(d.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));}; const styleCss = ".md3-nav-container{--md-nav-bg: rgba(243, 246, 252, .7);--md-nav-border: rgba(224, 226, 230, .5);--md-nav-shadow: 0 4px 12px rgba(0, 0, 0, .08);--md-nav-shadow-hover: 0 12px 32px rgba(0, 0, 0, .12);--md-btn-hover: #dfe2eb;--md-btn-active: #c2c7d0;--md-text-primary: #1b1b1f;--md-text-secondary: #444746;position:fixed;right:28px;top:50%;transform:translateY(-50%) scale(.95);z-index:9999;display:flex;flex-direction:column;padding:10px;background-color:var(--md-nav-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-radius:24px;box-shadow:var(--md-nav-shadow);border:1px solid var(--md-nav-border);transition:all .5s cubic-bezier(.3,0,0,1);opacity:0;pointer-events:none;visibility:hidden}.md3-nav-container.active{opacity:.6;pointer-events:auto;visibility:visible;transform:translateY(-50%) scale(1)}.md3-nav-container:hover{opacity:1!important;transform:translateY(-51%) scale(1.02);box-shadow:var(--md-nav-shadow-hover);background-color:#f3f6fce6}.md3-btn{width:48px;height:48px;border:none;border-radius:18px;background-color:transparent;color:var(--md-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .25s cubic-bezier(.4,0,.2,1);overflow:hidden;max-height:48px;margin:4px 0;transform:scale(1);opacity:1;outline:none;padding:0}.md3-btn svg{width:26px;height:26px;transition:transform .3s ease}.md3-btn:hover{background-color:var(--md-btn-hover);color:var(--md-text-primary)}.md3-btn:hover svg{transform:scale(1.1)}.md3-btn:active{transform:scale(.92);background-color:var(--md-btn-active)}.md3-btn:disabled{opacity:.2;cursor:not-allowed;pointer-events:none;filter:grayscale(1)}.md3-btn.hidden{max-height:0;margin-top:0;margin-bottom:0;opacity:0;transform:scale(.5);pointer-events:none}.md3-divider{height:1px;background-color:#c4c7c566;margin:6px 10px;transition:all .4s cubic-bezier(.4,0,.2,1);opacity:1}.md3-divider.hidden{opacity:0;height:0;margin-top:0;margin-bottom:0}@media(prefers-color-scheme:dark){.md3-nav-container{background-color:#1e1f23b3;border-color:#44474680;--md-text-secondary: #c4c7c5;--md-text-primary: #e3e3e3;--md-btn-hover: #333537}}"; importCSS(styleCss); const SCROLLER_SELECTOR = 'infinite-scroller[data-test-id="chat-history-container"]'; const MESSAGE_TAGS = ["user-query"]; const NAV_DATA_ATTR = "data-gemini-nav-idx"; const PATHS = { up: "M440-160v-487L216-423l-56-57 320-320 320 320-56 57-224-224v487h-80Z", down: "M440-800v487L216-537l-56 57 320 320 320-320-56-57-224 224v-487h-80Z", bottom: "M480-200 240-440l56-56 184 183 184-183 56 56-240 240Zm0-240L240-680l56-56 184 183 184-183 56 56-240 240Z" }; class NavState { static activeIdx = -1; static elements = []; } let btnUp, btnDown, btnBottom, divider, container; function createSvgIcon(pathData) { const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("viewBox", "0 -960 960 960"); svg.setAttribute("style", "width:24px; height:24px; fill:currentColor; flex-shrink:0;"); const path = document.createElementNS(svgNS, "path"); path.setAttribute("d", pathData); svg.appendChild(path); return svg; } function updateBtnStatus() { if (!btnUp || !container) return; const scroller = document.querySelector(SCROLLER_SELECTOR); if (!scroller) return; const isAtBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 50; const canUp = NavState.activeIdx > 0; const canDown = NavState.activeIdx !== -1 && NavState.activeIdx < NavState.elements.length - 1 && !isAtBottom; const canBottom = !isAtBottom; btnUp.classList.toggle("hidden", !canUp); btnDown.classList.toggle("hidden", !canDown); btnBottom.classList.toggle("hidden", !canBottom); divider.classList.toggle("hidden", !canBottom); const hasVisibleAction = canUp || canDown || canBottom; container.classList.toggle("active", NavState.elements.length > 0 && hasVisibleAction); } class MessageTracker { mutationObs; intersectionObs; constructor() { this.mutationObs = new MutationObserver(() => this.tagMessages()); this.intersectionObs = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const idx = parseInt(entry.target.getAttribute(NAV_DATA_ATTR) || "-1"); if (idx !== -1) NavState.activeIdx = idx; updateBtnStatus(); } }); }, { threshold: 0.5 } ); } start() { this.tagMessages(); this.mutationObs.observe(document.body, { childList: true, subtree: true }); } tagMessages() { const scroller = document.querySelector(SCROLLER_SELECTOR); if (!scroller) return; const messages = Array.from(scroller.querySelectorAll(MESSAGE_TAGS.join(","))); let changed = false; messages.forEach((el, index) => { if (el.getAttribute(NAV_DATA_ATTR) !== index.toString()) { el.setAttribute(NAV_DATA_ATTR, index.toString()); this.intersectionObs.observe(el); changed = true; } }); if (changed || NavState.elements.length !== messages.length) { NavState.elements = messages; updateBtnStatus(); } } } function navigate(direction) { const scroller = document.querySelector(SCROLLER_SELECTOR); if (!scroller) return; if (direction === "bottom") { scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); } else { const currentIdx = NavState.activeIdx; let targetIdx = direction === "up" ? currentIdx - 1 : currentIdx + 1; if (targetIdx < 0) targetIdx = 0; if (targetIdx >= NavState.elements.length) targetIdx = NavState.elements.length - 1; const targetEl = NavState.elements[targetIdx]; if (targetEl) { const targetPos = targetEl.offsetTop; scroller.scrollTo({ top: targetPos, behavior: "smooth" }); NavState.activeIdx = targetIdx; } } setTimeout(updateBtnStatus, 800); } function initUI() { if (container) return; container = document.createElement("div"); container.className = "md3-nav-container"; const createBtn = (pathKey, label, onClick) => { const btn = document.createElement("button"); btn.className = "md3-btn"; btn.title = label; btn.onclick = onClick; btn.appendChild(createSvgIcon(PATHS[pathKey])); container.appendChild(btn); return btn; }; btnUp = createBtn("up", "上一条", () => navigate("up")); btnDown = createBtn("down", "下一条", () => navigate("down")); divider = document.createElement("div"); divider.className = "md3-divider"; container.appendChild(divider); btnBottom = createBtn("bottom", "到底部", () => navigate("bottom")); document.body.appendChild(container); document.addEventListener("scroll", (e) => { if (e.target instanceof HTMLElement && e.target.matches(SCROLLER_SELECTOR)) { updateBtnStatus(); } }, { passive: true, capture: true }); } const tracker = new MessageTracker(); tracker.start(); initUI(); })();