T3 Chat Zoomed-Out Scrollbar

Adds a zoomed-out preview scrollbar to T3 Chat, showing only the chat log.

// ==UserScript==
// @name         T3 Chat Zoomed-Out Scrollbar
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  Adds a zoomed-out preview scrollbar to T3 Chat, showing only the chat log.
// @author       Dimava, T3 Chat, Gemini 2.0 Flash
// @match        https://t3.chat/*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
  "use strict";

  const PREVIEW_SCROLLBAR_ID = "t3-chat-preview-scrollbar";
  const PREVIEW_CONTENT_ID = "t3-chat-preview-content";
  const PREVIEW_THUMB_ID = "t3-chat-preview-thumb";
  const PREVIEW_SCALE = 0.1; // Scale factor for the preview
  const THUMB_HEIGHT_VH = 10; // Thumb height in viewport height units
  const SCROLLBAR_OFFSET_PX = 20; // Offset from the right edge
  const THROTTLE_DELAY_MS = 3000; // Throttle updates to every 3 seconds

  let lastContentUpdate = 0; // Timestamp of the last content update
  let scrollParent;
  let previewContent;
  let contentObserver;
  let thumb;
  let logDiv;
  let scrollbar;

  // Cleanup function
  function cleanup() {
    if (scrollbar) scrollbar.remove();
    window.removeEventListener("resize", updatePreviewContent);
    if (scrollParent)
      scrollParent.removeEventListener("scroll", updateThumbPosition);
    stopHeightPolling();
    if (contentObserver) contentObserver.disconnect();
  }

  // Call cleanup if it exists on the window
  if (window.t3ChatPreviewScrollbarCleanup) {
    window.t3ChatPreviewScrollbarCleanup();
  }

  // Function to update the preview content (throttled)
  function updatePreviewContent() {
    if (!logDiv || !previewContent) return;
    const now = Date.now();
    if (now - lastContentUpdate >= THROTTLE_DELAY_MS) {
      // Clone the logDiv content
      const clonedLogDiv = logDiv.cloneNode(true);

      // Apply styles to remove interactive elements
      const allElements = clonedLogDiv.querySelectorAll("*");
      allElements.forEach((el) => {
        el.style.pointerEvents = "none";
        el.style.userSelect = "none";
      });

      // Clear existing content and append the cloned content
      while (previewContent.firstChild) {
        previewContent.removeChild(previewContent.firstChild);
      }
      previewContent.appendChild(clonedLogDiv);

      // Set preview content width
      const logWidth = logDiv.getBoundingClientRect().width;
      previewContent.style.width = `${logWidth}px`;

      lastContentUpdate = now;
    }
  }

  // Function to update the thumb position
  function updateThumbPosition() {
    if (!logDiv || !scrollParent || !thumb || !scrollbar || !previewContent)
      return;

    const scrollHeight = scrollParent.scrollHeight;
    const clientHeight = scrollParent.clientHeight;
    const scrollTop = scrollParent.scrollTop;

    const scrollbarHeight = scrollbar.offsetHeight;
    const thumbHeightPx = (THUMB_HEIGHT_VH / 100) * window.innerHeight; // Constant thumb height

    let thumbPosition =
      (scrollbarHeight - thumbHeightPx) * (scrollTop / scrollHeight); // Proportional thumb position

    // Correct thumb position to allow scrolling to the very bottom
    thumbPosition = Math.min(
      thumbPosition,
      scrollbarHeight - thumbHeightPx
    );

    thumb.style.height = `${thumbHeightPx}px`;
    thumb.style.top = `${thumbPosition}px`;

    // Move the preview content up to align with the thumb
    const thumbCenter = thumbPosition + thumbHeightPx / 2;
    previewContent.style.top = `-${scrollTop * PREVIEW_SCALE - thumbCenter}px`;
  }

  // Function to handle thumb dragging
  function handleThumbDrag(event) {
    if (!logDiv || !scrollParent || !thumb || !scrollbar || !previewContent)
      return;

    const scrollHeight = scrollParent.scrollHeight;
    const clientHeight = scrollParent.clientHeight;
    const scrollbarHeight = scrollbar.offsetHeight;

    let startY = event.clientY;
    let startTop = thumb.offsetTop;

    function drag(e) {
      const deltaY = e.clientY - startY;
      let newTop = startTop + deltaY;
      const h = (THUMB_HEIGHT_VH / 100) * window.innerHeight;

      // Keep thumb within scrollbar bounds
      newTop = Math.max(0, Math.min(newTop, scrollbarHeight - thumb.offsetHeight));

      thumb.style.top = `${newTop}px`;

      // Calculate scroll position based on thumb position
      let scrollPosition = (newTop / (scrollbarHeight - h)) * scrollHeight;

      // Correct scroll position to allow scrolling to the very bottom
      scrollPosition = Math.min(
        scrollPosition,
        scrollHeight - clientHeight
      );

      scrollParent.scrollTop = scrollPosition;

      // Move the preview content up to align with the thumb
      const thumbCenter = newTop + (THUMB_HEIGHT_VH / 100) * window.innerHeight / 2;
      previewContent.style.top = `-${scrollPosition * PREVIEW_SCALE - thumbCenter}px`;
    }

    function stopDrag() {
      document.removeEventListener("mousemove", drag);
      document.removeEventListener("mouseup", stopDrag);
    }

    document.addEventListener("mousemove", drag);
    document.addEventListener("mouseup", stopDrag);

    event.preventDefault(); // Prevent text selection during drag
  }

  // Function to create the preview scrollbar
  function createPreviewScrollbar() {
    scrollbar = document.createElement("div");
    scrollbar.id = PREVIEW_SCROLLBAR_ID;
    scrollbar.style.cssText = `
            position: fixed;
            top: 0;
            right: ${SCROLLBAR_OFFSET_PX}px; /* Offset from the right edge */
            width: 50px; /* Initial width, will be updated */
            height: 100vh;
            background-color: rgba(0, 0, 0, 0.1);
            overflow: hidden;
            z-index: 1000;
        `;

    previewContent = document.createElement("div");
    previewContent.id = PREVIEW_CONTENT_ID;
    previewContent.style.cssText = `
            position: relative;
            width: 100%; /* Initial width, will be updated */
            height: auto;
            transform: scale(${PREVIEW_SCALE});
            transform-origin: top left;
            overflow: hidden;
            pointer-events: none;
            top: 0; /* Initial top position */
        `;
    scrollbar.appendChild(previewContent);

    thumb = document.createElement("div");
    thumb.id = PREVIEW_THUMB_ID;
    thumb.style.cssText = `
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: ${THUMB_HEIGHT_VH}vh; /* Constant thumb height */
            background-color: rgba(66, 135, 245, 0.5);
            cursor: grab;
        `;
    scrollbar.appendChild(thumb);

    document.body.appendChild(scrollbar);

    // Event listener for thumb dragging
    thumb.addEventListener("mousedown", handleThumbDrag);

    // Event listener for scrollbar mousedown
    scrollbar.addEventListener("mousedown", handleScrollbarMousedown);
  }

  // Function to handle scrollbar click
  function handleScrollbarMousedown(event) {
    if (event.target === thumb) return; // Ignore clicks on the thumb
    if (!logDiv || !scrollParent || !thumb || !scrollbar) return;

    const scrollbarHeight = scrollbar.offsetHeight;
    const clickY = event.clientY - scrollbar.getBoundingClientRect().top;

    const thumbHeightPx = (THUMB_HEIGHT_VH / 100) * window.innerHeight;
    const thumbPosition = thumb.offsetTop;
    const thumbCenter = thumbPosition + thumbHeightPx / 2;

    // Calculate the difference between the click position and the thumb center
    const deltaY = clickY - thumbCenter;

    // Adjust the scroll position by the scaled difference
    scrollParent.scrollTop += deltaY / PREVIEW_SCALE;

    // Ensure scroll position stays within bounds
    scrollParent.scrollTop = Math.max(
      0,
      Math.min(scrollParent.scrollTop, scrollParent.scrollHeight - scrollParent.clientHeight)
    );

    // Update thumb position immediately
    updateThumbPosition();
  }

  let heightPollingInterval;

  function pollElementHeight() {
    if (!logDiv || !scrollbar || !previewContent) return;
    const logWidth = logDiv.getBoundingClientRect().width;
    const scrollbarWidth = logWidth / 10;
    scrollbar.style.width = `${scrollbarWidth}px`;
    previewContent.style.width = `${logWidth}px`;
    requestAnimationFrame(pollElementHeight);
  }

  function startHeightPolling() {
    heightPollingInterval = requestAnimationFrame(pollElementHeight);
  }

  function stopHeightPolling() {
    cancelAnimationFrame(heightPollingInterval);
  }

  function initialize() {
    // Initialize
    createPreviewScrollbar();

    // Get elements
    logDiv = document.querySelector('[role="log"]');
    if (!logDiv) return;

    scrollParent = logDiv.parentNode;

    // Initial update
    updatePreviewContent();
    updateThumbPosition();

    // Event listeners
    contentObserver = new MutationObserver(() => {
      updatePreviewContent();
    });

    contentObserver.observe(logDiv, {
      childList: true,
      subtree: true,
      characterData: true,
    });

    window.addEventListener("resize", updatePreviewContent);

    if (!scrollParent) return;

    scrollParent.addEventListener("scroll", updateThumbPosition);

    // Start polling for height changes
    startHeightPolling();
  }

  function waitForLogDiv() {
    logDiv = document.querySelector('[role="log"]');
    if (logDiv) {
      initialize();
    } else {
      setTimeout(waitForLogDiv, 200); // Check every 200ms
    }
  }

  // Attach cleanup function to window
  window.t3ChatPreviewScrollbarCleanup = () => {
    cleanup();
    delete window.t3ChatPreviewScrollbarCleanup;
  };

  // Start waiting for the log div
  waitForLogDiv();

  // Public functions
  window.updatePreview = updatePreviewContent;
  window.updateThumb = updateThumbPosition;
})();