GitHub AI Reader Redirector (Tampermonkey Version)

在 GitHub 页面添加悬浮按钮和快捷键,一键在新标签页中打开 ZRead.ai 或 DeepWiki 进行 AI 项目解析。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GitHub AI Reader Redirector (Tampermonkey Version)
// @namespace    https://zread.ai/
// @version      1.0.0
// @description  在 GitHub 页面添加悬浮按钮和快捷键,一键在新标签页中打开 ZRead.ai 或 DeepWiki 进行 AI 项目解析。
// @author       Andrew05060414
// @match        *://*.github.com/*

// @grant        none

// @run-at       document-end
// ==/UserScript==

(function() {
  'use strict';

  // Config targets
  const TARGETS = {
    zread: {
      name: 'ZRead.ai',
      urlTemplate: 'https://zread.ai/{owner}/{repo}'
    },
    deepwiki: {
      name: 'DeepWiki',
      urlTemplate: 'https://deepwiki.com/{owner}/{repo}'
    }
  };

  // State variables
  let repoInfo = null;
  let isDarkMode = false;
  
  // DOM Elements
  let widgetWrapper = null;
  let floatBtn = null;
  let hoverMenu = null;

  // Translation Dictionary
  const userLang = navigator.language || navigator.userLanguage;
  const isZh = userLang.startsWith('zh');
  const t = {
    title: isZh ? 'AI 项目解析' : 'AI Repo Reader',
    newTab: isZh ? '新标签页' : 'New Tab',
    zread: 'ZRead.ai',
    deepwiki: 'DeepWiki',
    dragTip: isZh ? '双击拖动按钮' : 'Double-click to drag'
  };

  // Initialize
  function init() {
    handleUrlChange();

    // Monitor URL changes for GitHub client-side transitions
    let lastUrl = location.href;
    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        handleUrlChange();
      }
    }, 1000);

    // Sync theme
    detectTheme();
    const themeObserver = new MutationObserver(detectTheme);
    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-color-mode', 'class'] });

    // Keyboard hotkeys
    document.addEventListener('keydown', (e) => {
      if (!repoInfo) return;
      
      // Ignore keypress inside input/textarea fields
      const activeEl = document.activeElement;
      if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
        return;
      }

      // Alt + Z (ZRead.ai)
      if (e.altKey && e.key.toLowerCase() === 'z') {
        openTarget('zread');
        e.preventDefault();
      }
      
      // Alt + D (DeepWiki)
      if (e.altKey && e.key.toLowerCase() === 'd') {
        openTarget('deepwiki');
        e.preventDefault();
      }
    });
  }

  // Handle URL change
  function handleUrlChange() {
    repoInfo = parseGitHubUrl(location.href);
    if (repoInfo) {
      createWidget();
      if (widgetWrapper) widgetWrapper.style.display = 'flex';
    } else {
      if (widgetWrapper) widgetWrapper.style.display = 'none';
    }
  }

  // Check GitHub Theme
  function detectTheme() {
    const colorMode = document.documentElement.getAttribute('data-color-mode');
    const hasDarkClass = document.documentElement.classList.contains('dark');
    isDarkMode = (colorMode === 'dark' || hasDarkClass || (colorMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches));

    if (widgetWrapper) {
      if (isDarkMode) {
        widgetWrapper.classList.remove('theme-light');
        widgetWrapper.classList.add('theme-dark');
      } else {
        widgetWrapper.classList.remove('theme-dark');
        widgetWrapper.classList.add('theme-light');
      }
    }
  }

  // Parse GitHub repo owner and name
  function parseGitHubUrl(urlStr) {
    try {
      const url = new URL(urlStr);
      const parts = url.pathname.split('/').filter(Boolean);
      if (parts.length >= 2) {
        const owner = parts[0];
        const repo = parts[1];
        const ignoredPaths = [
          'settings', 'marketplace', 'explore', 'notifications', 
          'trending', 'topics', 'sponsors', 'pricing', 'features', 
          'customer-stories', 'about', 'readme', 'search', 'pulls', 'issues'
        ];
        if (ignoredPaths.includes(owner.toLowerCase())) {
          return null;
        }
        return { owner, repo };
      }
    } catch (e) {
      console.error(e);
    }
    return null;
  }

  // Create UI Widget
  function createWidget() {
    if (document.getElementById('gh-ai-reader-userscript-root')) return;

    // Create wrapper container
    widgetWrapper = document.createElement('div');
    widgetWrapper.id = 'gh-ai-reader-userscript-root';
    widgetWrapper.className = 'ai-widget-wrapper';
    
    // Inject Custom CSS
    const styleEl = document.createElement('style');
    styleEl.textContent = `
      .ai-widget-wrapper {
        position: fixed;
        right: 20px;
        bottom: 45%; /* Centered vertically by default */
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 8px;
        z-index: 999999;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        box-sizing: border-box;
      }

      /* Hover Menu */
      .ai-hover-menu {
        width: 170px;
        border-radius: 12px;
        padding: 5px;
        display: flex;
        flex-direction: column;
        gap: 3px;
        opacity: 0;
        transform: translateY(10px) scale(0.95);
        transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
        pointer-events: none;
        box-sizing: border-box;
        border-width: 1px;
        border-style: solid;
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
        backdrop-filter: blur(12px);
        -webkit-backdrop-filter: blur(12px);
      }

      /* Hover Bridge to prevent menu closing when moving cursor */
      .ai-hover-bridge {
        position: absolute;
        bottom: 35px;
        height: 25px;
        left: 0;
        right: 0;
        background: transparent;
        pointer-events: auto;
        display: none;
      }

      .ai-widget-wrapper:hover .ai-hover-menu {
        opacity: 1;
        transform: translateY(0) scale(1);
        pointer-events: auto;
      }
      
      .ai-widget-wrapper:hover .ai-hover-bridge {
        display: block;
      }

      .menu-header {
        font-size: 10px;
        font-weight: 700;
        text-transform: uppercase;
        letter-spacing: 0.5px;
        padding: 5px 8px 3px;
      }

      .menu-item {
        display: flex;
        align-items: center;
        padding: 7px 8px;
        border-radius: 8px;
        cursor: pointer;
        font-size: 12px;
        transition: background 0.2s;
        text-decoration: none;
        box-sizing: border-box;
        gap: 6px;
      }

      .menu-item .badge {
        margin-left: auto;
        font-size: 9px;
        padding: 1px 4px;
        border-radius: 4px;
        font-weight: 500;
      }

      /* Floating trigger button */
      .ai-float-btn {
        width: 40px;
        height: 40px;
        border-radius: 50%;
        cursor: grab;
        display: flex;
        align-items: center;
        justify-content: center;
        transition: transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        border-style: solid;
        border-width: 1px;
        padding: 0;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      }

      .ai-float-btn:hover {
        transform: scale(1.08);
      }

      .ai-float-btn:active {
        cursor: grabbing;
        transform: scale(0.95);
      }

      .ai-float-btn svg {
        width: 18px;
        height: 18px;
      }

      /* Theme styling */
      .theme-dark .ai-hover-menu {
        background: rgba(13, 17, 23, 0.85);
        border-color: rgba(240, 246, 252, 0.15);
      }
      .theme-dark .menu-header {
        color: #8b949e;
      }
      .theme-dark .menu-item {
        color: #f0f6fc;
      }
      .theme-dark .menu-item:hover {
        background: rgba(255, 255, 255, 0.08);
      }
      .theme-dark .menu-item .badge {
        background: rgba(59, 130, 246, 0.15);
        color: #3b82f6;
      }
      .theme-dark .ai-float-btn {
        background: linear-gradient(135deg, #a855f7 0%, #3b82f6 100%);
        border-color: rgba(240, 246, 252, 0.15);
        color: #ffffff;
        box-shadow: 0 4px 16px rgba(147, 51, 234, 0.3);
      }

      .theme-light .ai-hover-menu {
        background: rgba(255, 255, 255, 0.88);
        border-color: rgba(208, 215, 222, 0.5);
      }
      .theme-light .menu-header {
        color: #57606a;
      }
      .theme-light .menu-item {
        color: #24292f;
      }
      .theme-light .menu-item:hover {
        background: rgba(0, 0, 0, 0.05);
      }
      .theme-light .menu-item .badge {
        background: rgba(37, 99, 235, 0.1);
        color: #2563eb;
      }
      .theme-light .ai-float-btn {
        background: linear-gradient(135deg, #9333ea 0%, #2563eb 100%);
        border-color: rgba(208, 215, 222, 0.5);
        color: #ffffff;
        box-shadow: 0 4px 16px rgba(37, 99, 235, 0.25);
      }
    `;

    // Load persisted coordinates
    const savedBottom = localStorage.getItem('gh-ai-reader-btn-bottom');
    if (savedBottom) {
      widgetWrapper.style.bottom = savedBottom;
    }

    // Build DOM structure
    widgetWrapper.innerHTML = `
      <div class="ai-hover-menu">
        <div class="ai-hover-bridge"></div>
        <div class="menu-header">${t.title}</div>
        <div class="menu-item" id="menu-zread">
          <span>📖</span>
          <span>${t.zread}</span>
          <span class="badge">${t.newTab}</span>
        </div>
        <div class="menu-item" id="menu-deepwiki">
          <span>🧠</span>
          <span>${t.deepwiki}</span>
          <span class="badge">${t.newTab}</span>
        </div>
      </div>
      <button class="ai-float-btn" title="${t.dragTip}">
        <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M12 2L14.85 9.15L22 12L14.85 14.85L12 22L9.15 14.85L2 12L9.15 9.15L12 2Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
          <path d="M19 4L20 6.5L22.5 7.5L20 8.5L19 11L18 8.5L15.5 7.5L18 6.5L19 4Z" fill="currentColor" opacity="0.65"/>
        </svg>
      </button>
    `;

    document.body.appendChild(styleEl);
    document.body.appendChild(widgetWrapper);

    // Dynamic theme setup
    detectTheme();

    // Map elements
    floatBtn = widgetWrapper.querySelector('.ai-float-btn');
    hoverMenu = widgetWrapper.querySelector('.ai-hover-menu');

    // Click behavior
    floatBtn.addEventListener('click', (e) => {
      if (hasDragged) {
        e.preventDefault();
        return;
      }
      openTarget('zread'); // Default target is ZRead
    });

    // Hover Menu actions
    widgetWrapper.querySelector('#menu-zread').addEventListener('click', () => openTarget('zread'));
    widgetWrapper.querySelector('#menu-deepwiki').addEventListener('click', () => openTarget('deepwiki'));

    // Draggable functionality
    let isDragging = false;
    let startY = 0;
    let startBottom = 0;
    let hasDragged = false;
    const dragThreshold = 5;

    floatBtn.addEventListener('mousedown', (e) => {
      isDragging = true;
      startY = e.clientY;
      startBottom = parseInt(window.getComputedStyle(widgetWrapper).bottom);
      widgetWrapper.style.transition = 'none';
      hasDragged = false;
      e.preventDefault();
    });

    document.addEventListener('mousemove', (e) => {
      if (!isDragging) return;
      const diff = startY - e.clientY;
      if (Math.abs(diff) > dragThreshold) {
        hasDragged = true;
      }
      const newBottom = startBottom + diff;
      const maxBottom = window.innerHeight - 100;
      const minBottom = 20;
      
      const computedBottom = `${Math.min(maxBottom, Math.max(minBottom, newBottom))}px`;
      widgetWrapper.style.bottom = computedBottom;
    });

    document.addEventListener('mouseup', () => {
      if (isDragging) {
        isDragging = false;
        widgetWrapper.style.transition = 'bottom 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)';
        // Save position
        localStorage.setItem('gh-ai-reader-btn-bottom', widgetWrapper.style.bottom);
      }
    });
  }

  // Action: Open reader URL in a new tab
  function openTarget(key) {
    if (!repoInfo) return;
    const template = TARGETS[key]?.urlTemplate;
    if (template) {
      const url = template.replace('{owner}', repoInfo.owner).replace('{repo}', repoInfo.repo);
      window.open(url, '_blank');
    }
  }

  // Start
  init();
})();