Google Gemini AI: Add Table of Contents (TOC) to Chats

Add a floating Table of Contents (TOC) to each Google Gemini chat. This would allow users to easily jump to any section of the chat with a single click. The TOC is adjustable, remembering its size and position for a consistent experience across all chats.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Google Gemini AI: Add Table of Contents (TOC) to Chats
// @namespace    Violentmonkey userscripts by ReporterX
// @author       ReporterX
// @version      1.0
// @description  Add a floating Table of Contents (TOC) to each Google Gemini chat. This would allow users to easily jump to any section of the chat with a single click. The TOC is adjustable, remembering its size and position for a consistent experience across all chats.
// @match        https://gemini.google.com/*
// @grant        none
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // Saves the TOC's current position and size to localStorage.
  function saveTOCState(toc) {
    const state = {
      top: toc.style.top,
      left: toc.style.left,
      width: toc.style.width,
      height: toc.style.height,
    };
    localStorage.setItem("gemini-toc-state", JSON.stringify(state));
  }

  // Loads the TOC's saved position and size from localStorage.
  function loadTOCState() {
    const state = localStorage.getItem("gemini-toc-state");
    return state ? JSON.parse(state) : null;
  }

  // Creates and initializes the main TOC element, along with its interactive components.
  function createTOC() {
    const toc = document.createElement("div");
    toc.id = "gemini-toc";

    // Retrieve saved state or set default dimensions and position.
    const savedState = loadTOCState();
    const initialState = {
      top: savedState?.top || "20px",
      left: savedState?.left || "auto",
      right: savedState?.left ? "auto" : "20px", // Position from the right if no left coordinate is saved.
      width: savedState?.width || "250px",
      height: savedState?.height || "300px",
    };

    toc.style.cssText = `
            position: fixed;
            top: ${initialState.top};
            left: ${initialState.left};
            right: ${initialState.right};
            width: ${initialState.width};
            height: ${initialState.height};
            background: rgba(255, 255, 255, 0.6);
            border: 1px solid #ddd;
            border-radius: 6px;
            padding: 0;
            z-index: 10000;
            opacity: 0.3;
            transition: opacity 0.3s ease;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            display: flex;
            flex-direction: column;
        `;

    // Create the draggable header for the TOC window.
    const header = document.createElement("h3");
    header.textContent = "TOC";
    header.style.cssText = `
            margin: 0;
            padding: 4px;
            font-size: 11px;
            color: #666;
            text-align: center;
            border-bottom: 1px solid #eee;
            font-weight: 500;
            cursor: move;
            background-color: rgba(240, 240, 240, 0.7);
        `;
    toc.appendChild(header);

    // Create the container that will hold the scrollable TOC links.
    const content = document.createElement("div");
    content.id = "gemini-toc-content";
    content.style.cssText = `
        overflow-y: auto;
        flex-grow: 1;
        padding: 8px;
        min-width: 0; /* Flexbox fix to allow shrinking */
    `;
    toc.appendChild(content);

    // Make the TOC more visible when the user hovers over it.
    toc.addEventListener("mouseenter", () => {
      toc.style.opacity = "1.0";
    });
    toc.addEventListener("mouseleave", () => {
      toc.style.opacity = "0.3";
    });

    document.body.appendChild(toc);

    // Initialize the new, unified interaction handler.
    makeInteractive(toc, header);

    return toc;
  }

  // A new, single function to handle both dragging and resizing.
  function makeInteractive(element, header) {
    let action = null;
    let startX, startY, startWidth, startHeight, startLeft, startTop;
    const minWidth = 150;
    const minHeight = 100;

    // Determines the resize direction based on mouse position.
    function getResizeDirection(e) {
        const rect = element.getBoundingClientRect();
        const zone = 8;
        const onRight = e.clientX > rect.right - zone;
        const onLeft = e.clientX < rect.left + zone;
        const onBottom = e.clientY > rect.bottom - zone;

        if (onRight && onBottom) return 'se';
        if (onLeft && onBottom) return 'sw';
        if (onRight) return 'e';
        if (onLeft) return 'w';
        if (onBottom) return 's';
        return null;
    }

    // Handles the initial mousedown event to determine the action.
    function onMouseDown(e) {
        if (e.button !== 0) return;

        const resizeDir = getResizeDirection(e);

        if (e.target === header && !resizeDir) {
            action = { type: 'drag' };
        } else if (resizeDir) {
            action = { type: 'resize', dir: resizeDir };
        } else {
            return;
        }

        e.preventDefault();
        e.stopPropagation();

        const rect = element.getBoundingClientRect();
        startX = e.clientX;
        startY = e.clientY;
        startWidth = rect.width;
        startHeight = rect.height;
        startLeft = rect.left;
        startTop = rect.top;

        // Ensure positioning is done via 'left' and 'top'.
        element.style.right = 'auto';
        element.style.left = `${startLeft}px`;
        element.style.top = `${startTop}px`;

        document.addEventListener('mousemove', onMouseMove);
        document.addEventListener('mouseup', onMouseUp);
    }

    // Handles the mouse movement for dragging or resizing.
    function onMouseMove(e) {
        if (!action) return;

        const dx = e.clientX - startX;
        const dy = e.clientY - startY;

        if (action.type === 'drag') {
            element.style.left = `${startLeft + dx}px`;
            element.style.top = `${startTop + dy}px`;
        } else if (action.type === 'resize') {
            if (action.dir.includes('e')) {
                element.style.width = `${Math.max(minWidth, startWidth + dx)}px`;
            }
            if (action.dir.includes('w')) {
                const newWidth = Math.max(minWidth, startWidth - dx);
                element.style.width = `${newWidth}px`;
                element.style.left = `${startLeft + startWidth - newWidth}px`;
            }
            if (action.dir.includes('s')) {
                element.style.height = `${Math.max(minHeight, startHeight + dy)}px`;
            }
        }
    }

    // Cleans up event listeners and saves state on mouse up.
    function onMouseUp() {
        if (action) {
            saveTOCState(element);
        }
        action = null;
        document.removeEventListener('mousemove', onMouseMove);
        document.removeEventListener('mouseup', onMouseUp);
    }

    // Updates the cursor style based on mouse position.
    function updateCursor(e) {
        if (action) return;
        const resizeDir = getResizeDirection(e);
        element.style.cursor = resizeDir ? `${resizeDir}-resize` : 'default';
        header.style.cursor = 'move';
    }

    element.addEventListener('mousedown', onMouseDown);
    element.addEventListener('mousemove', updateCursor);
  }

  // Scans the page for user prompts to include in the TOC.
  function findUserPrompts() {
    const prompts = [];
    // Prioritize specific selectors that are known to work.
    let queryTextElements = document.querySelectorAll("p.query-text-line.ng-star-inserted");
    if (queryTextElements.length === 0) {
      queryTextElements = document.querySelectorAll('p[class*="query-text"]');
    }
    if (queryTextElements.length > 0) {
      queryTextElements.forEach((element) => {
        const text = element.textContent.trim();
        if (text) prompts.push({ element, text });
      });
      return prompts;
    }
    // Fallback to a broader set of selectors if the primary ones fail.
    const selectors = [
      '[data-message-author-role="user"]', ".user-message", '[role="user"]',
      ".message.user", 'div[data-test-id*="user"]', 'div[data-test-id*="prompt"]',
      ".prompt-content", ".user-input", '[class*="user"][class*="message"]',
    ];
    for (const selector of selectors) {
      const elements = document.querySelectorAll(selector);
      if (elements.length > 0) {
        elements.forEach((element) => {
          const text = element.textContent.trim();
          if (text) prompts.push({ element, text });
        });
        if (prompts.length > 0) break;
      }
    }
    return prompts;
  }

  // Finds user prompts and populates the TOC content.
  function updateTOC(tocContainer) {
    const contentContainer = tocContainer.querySelector("#gemini-toc-content");
    const prompts = findUserPrompts();

    // Remove old items before repopulating.
    while (contentContainer.firstChild) {
      contentContainer.removeChild(contentContainer.firstChild);
    }

    if (prompts.length === 0) {
        const noContent = document.createElement("div");
        noContent.textContent = "No chat found";
        noContent.style.cssText = `color: #999; font-style: italic; text-align: center; padding: 15px 0;`;
        contentContainer.appendChild(noContent);
        return;
    }

    prompts.forEach((prompt, index) => {
      const item = document.createElement("div");
      item.className = "toc-item";
      item.style.cssText = `
                padding: 6px 8px;
                margin: 2px 0;
                background: rgba(240, 240, 240, 0.5);
                border-radius: 3px;
                cursor: pointer;
                transition: background-color 0.2s ease;
                font-size: 12px;
                line-height: 1.3;
                border-left: 2px solid #4285f4;
            `;

      const itemText = document.createElement("span");
      // Use CSS for intelligent text truncation based on the container's width.
      itemText.style.cssText = `
                display: block;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            `;
      // Set the full text content; the browser will handle truncating it.
      itemText.textContent = `${index + 1}. ${prompt.text}`;
      item.appendChild(itemText);

      item.addEventListener("mouseenter", () => {
        item.style.backgroundColor = "rgba(66, 133, 244, 0.1)";
      });
      item.addEventListener("mouseleave", () => {
        item.style.backgroundColor = "rgba(240, 240, 240, 0.5)";
      });

      item.addEventListener("click", () => {
        prompt.element.scrollIntoView({ behavior: "smooth", block: "center" });
        const originalBg = prompt.element.style.backgroundColor;
        prompt.element.style.transition = "background-color 0.5s ease";
        prompt.element.style.backgroundColor = "rgba(66, 133, 244, 0.2)";
        setTimeout(() => {
          prompt.element.style.backgroundColor = originalBg;
        }, 2000);
      });

      contentContainer.appendChild(item);
    });
  }

  // Main initialization function.
  function init() {
    // Wait for the DOM to be fully loaded before running.
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", init);
      return;
    }

    // Create the main TOC container.
    const tocContainer = createTOC();

    // A short delay to allow the Gemini UI to load before the first TOC update.
    setTimeout(() => {
      updateTOC(tocContainer);
    }, 1000);

    // Use a MutationObserver to detect when the conversation content changes.
    const observer = new MutationObserver(() => {
      // Debounce the update function to avoid excessive calls during rapid DOM changes.
      clearTimeout(window.tocUpdateTimeout);
      window.tocUpdateTimeout = setTimeout(() => {
        updateTOC(tocContainer);
      }, 500);
    });

    // Observe changes to the entire document body to catch all relevant updates.
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // Start the script.
  init();
})();