Gemini Nav Buttons

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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);

})();