Gemini Nav Buttons

A horizontal navigation panel with chat width adjustment. Merges Gemini Nav Pro with Gemini Better UI features.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Gemini Nav Buttons
// @namespace    https://greasyfork.org/en/users/1509088-eithon
// @version      5.0
// @description  A horizontal navigation panel with chat width adjustment. Merges Gemini Nav Pro with Gemini Better UI features.
// @author       Te55eract, Eithon, JonathanLU, & Gemini AI
// @match        https://gemini.google.com/*
// @grant        GM_addStyle
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const STORAGE_KEY_WIDTH = 'geminiNavProWidth';
    const DEFAULT_WIDTH = 90;
    const MIN_WIDTH = 50;
    const MAX_WIDTH = 100;
    const STEP_WIDTH = 10;

    // --- CSS Styles ---
    // Includes structural overrides to allow width changing and the new panel style
    GM_addStyle(`
        :root { --gemini-dynamic-width: ${localStorage.getItem(STORAGE_KEY_WIDTH) || DEFAULT_WIDTH}%; }

        /* --- Panel Styling (Horizontal) --- */
        #gemini-nav-panel {
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 9999;
            background-color: rgba(255, 255, 255, 0.95);
            border: 1px solid #DDE2E7;
            border-radius: 24px; /* Pill shape */
            padding: 6px 12px;
            display: none; /* Hidden until loaded */
            flex-direction: row;
            align-items: center;
            gap: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            backdrop-filter: blur(10px);
            transition: opacity 0.3s ease;
        }

        .gemini-nav-btn {
            cursor: pointer;
            width: 32px;
            height: 32px;
            display: flex;
            align-items: center;
            justify-content: center;
            background-color: transparent;
            color: #444746;
            border-radius: 50%;
            font-size: 16px;
            border: 1px solid transparent;
            transition: all 0.2s ease;
            font-family: 'Google Sans', sans-serif;
            padding: 0;
        }

        .gemini-nav-btn:hover:not(:disabled) {
            background-color: #f0f4f9;
            color: #1f1f1f;
            border-color: #d3e3fd;
        }

        .gemini-nav-btn:disabled {
            opacity: 0.3;
            cursor: default;
        }

        .gemini-nav-divider {
            width: 1px;
            height: 20px;
            background-color: #DDE2E7;
            margin: 0 4px;
        }

        /* --- Width Adjustment Text --- */
        #gemini-width-display {
            font-size: 12px;
            font-family: monospace;
            color: #444746;
            min-width: 30px;
            text-align: center;
            user-select: none;
        }

        /* --- Dark Mode Support --- */
        body.dark-theme #gemini-nav-panel {
            background-color: rgba(30, 31, 34, 0.95);
            border-color: #444746;
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
        }
        body.dark-theme .gemini-nav-btn { color: #e3e3e3; }
        body.dark-theme .gemini-nav-btn:hover:not(:disabled) { background-color: #383b3e; border-color: #5e6063; }
        body.dark-theme .gemini-nav-divider { background-color: #444746; }
        body.dark-theme #gemini-width-display { color: #e3e3e3; }

        /* --- Gemini Layout Overrides (To enable Width Adjustment) --- */
        /* These force the chat container to respect our custom variable */
        .chat-history-scroll-container,
        div#chat-history {
            width: 100% !important;
            max-width: 100% !important;
        }

        .conversation-container {
            width: var(--gemini-dynamic-width) !important;
            max-width: 100% !important;
            margin: 0 auto !important;
        }

        /* Force messages to expand to fill the new width */
        user-query, model-response,
        .user-query-container, .model-response-container {
            max-width: none !important;
            width: 100% !important;
        }

        /* Adjust internal message bubbles to look good at wide widths */
        .user-query-bubble-with-background,
        .markdown.markdown-main-panel {
            max-width: 100% !important;
            box-sizing: border-box !important;
        }

        /* Prevent text from getting too wide to read efficiently (optional cap, set to 1800px) */
        .markdown.markdown-main-panel {
             max-width: 1800px !important;
        }
    `);

    // --- State Variables ---
    let navPanel = null;
    let cachedScrollContainer = null;
    let currentConvWidth = parseInt(localStorage.getItem(STORAGE_KEY_WIDTH)) || DEFAULT_WIDTH;

    // --- Core Logic ---

    // 1. Width Management
    function setConversationWidth(newWidth) {
        // Clamp values
        if (newWidth < MIN_WIDTH) newWidth = MIN_WIDTH;
        if (newWidth > MAX_WIDTH) newWidth = MAX_WIDTH;

        currentConvWidth = newWidth;
        localStorage.setItem(STORAGE_KEY_WIDTH, currentConvWidth);

        // Update CSS Variable
        document.documentElement.style.setProperty('--gemini-dynamic-width', currentConvWidth + '%');

        // Update Display Text
        const displayEl = document.getElementById('gemini-width-display');
        if (displayEl) displayEl.textContent = currentConvWidth + '%';
    }

    // 2. Navigation Utilities
    function findScrollContainer() {
        if (cachedScrollContainer && document.body.contains(cachedScrollContainer)) return cachedScrollContainer;
        // Locate the main scrollable area
        const possibleContainers = document.querySelectorAll('.chat-history-scroll-container, #chat-history');
        for (let el of possibleContainers) {
            if (el.scrollHeight > el.clientHeight) {
                cachedScrollContainer = el;
                return el;
            }
        }
        // Fallback: look for parent of last response
        const lastResponse = Array.from(document.querySelectorAll('model-response')).pop();
        if (lastResponse) {
            let parent = lastResponse.parentElement;
            while (parent && parent !== document.body) {
                if (parent.scrollHeight > parent.clientHeight || getComputedStyle(parent).overflowY === 'auto') {
                    cachedScrollContainer = parent;
                    return parent;
                }
                parent = parent.parentElement;
            }
        }
        return document.documentElement; // Final fallback
    }

    function smoothScrollToElement(element) {
        if (!element) return;
        element.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }

    // 3. UI Construction
    function createIcon(char) {
        // Simple helper for text icons, can be replaced with SVGs if desired
        return char;
    }

    function buildPanel() {
        if (document.getElementById('gemini-nav-panel')) return;

        navPanel = document.createElement('div');
        navPanel.id = 'gemini-nav-panel';

        // --- Navigation Group ---
        const btnTop = createBtn('⏫', 'Scroll to Top', () => {
            const sc = findScrollContainer();
            if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
        });

        const btnPrev = createBtn('▲', 'Previous Prompt', () => navigatePrompt(-1));
        const btnNext = createBtn('▼', 'Next Prompt', () => navigatePrompt(1));

        const btnBottom = createBtn('⏬', 'Scroll to Bottom', () => {
            const sc = findScrollContainer();
            if (sc) sc.scrollTo({ top: sc.scrollHeight, behavior: 'smooth' });
        });

        // --- Width Group ---
        const divider = document.createElement('div');
        divider.className = 'gemini-nav-divider';

        const btnWidthDec = createBtn('-', 'Decrease Width', () => setConversationWidth(currentConvWidth - STEP_WIDTH));

        const widthDisplay = document.createElement('span');
        widthDisplay.id = 'gemini-width-display';
        widthDisplay.textContent = currentConvWidth + '%';

        const btnWidthInc = createBtn('+', 'Increase Width', () => setConversationWidth(currentConvWidth + STEP_WIDTH));

        // Append All
        navPanel.append(
            btnTop, btnPrev, btnNext, btnBottom,
            divider,
            btnWidthDec, widthDisplay, btnWidthInc
        );

        document.body.appendChild(navPanel);
        navPanel.style.display = 'flex'; // Make visible

        // Initialize CSS width immediately
        setConversationWidth(currentConvWidth);
    }

    function createBtn(text, tooltip, onClick) {
        const btn = document.createElement('button');
        btn.className = 'gemini-nav-btn';
        btn.textContent = text;
        btn.title = tooltip;
        btn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            onClick();
        };
        return btn;
    }

    // 4. Prompt Navigation Logic
    function navigatePrompt(direction) {
        // direction: -1 (prev) or 1 (next)
        const userPrompts = Array.from(document.querySelectorAll('user-query'));
        if (userPrompts.length === 0) return;

        // Find which prompt is currently most visible
        const viewportMid = window.innerHeight / 2;
        let closestIndex = -1;
        let minDist = Infinity;

        userPrompts.forEach((p, index) => {
            const rect = p.getBoundingClientRect();
            const dist = Math.abs(rect.top - viewportMid);
            if (dist < minDist) {
                minDist = dist;
                closestIndex = index;
            }
        });

        // Determine target
        let targetIndex = closestIndex + direction;

        // If we are scrolling UP and the current prompt is way below the top,
        // we might actually want to scroll to the CURRENT closest index first (snap to it),
        // but simple index logic usually works best.

        // Bounds check
        if (targetIndex < 0) targetIndex = 0;
        if (targetIndex >= userPrompts.length) targetIndex = userPrompts.length - 1;

        smoothScrollToElement(userPrompts[targetIndex]);
    }

    // 5. Main Loop (Checks for chat existence to show panel)
    function mainLoop() {
        // If we are in a chat (user-query exists), show panel.
        const hasContent = document.querySelector('user-query') || document.querySelector('model-response');

        if (hasContent && !navPanel) {
            buildPanel();
        } else if (hasContent && navPanel) {
            navPanel.style.display = 'flex';
        } else if (!hasContent && navPanel) {
            navPanel.style.display = 'none';
        }
    }

    // Run loop
    setInterval(mainLoop, 1000);

    // Initial run
    setTimeout(mainLoop, 1000);

})();