GitHub Enhancer

GitHub 增强:日期高亮 + 图片预览 + 标题折叠

Fra 19.05.2026. Se den seneste versjonen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         GitHub Enhancer
// @namespace    https://github.com/
// @version      1.0.0
// @description  GitHub 增强:日期高亮 + 图片预览 + 标题折叠
// @author       orangelckc
// @match        https://github.com/*
// @match        https://gist.github.com/*
// @match        https://help.github.com/*
// @match        https://docs.github.com/*
// @run-at       document-idle
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  // ================================================================
  // 模块1: 日期高亮 (GitHub Freshness)
  // 通过颜色高亮判断仓库更新活跃度
  // ================================================================

  let HIGHLIGHT_COLOR = '#93EC77';
  let GREY_COLOR = '#4DFFFF40';
  let TIME_THRESHOLD_MONTHS = GM_getValue('timeThresholdMonths', 2);
  let isHighlighting = false;
  let currentURL = location.href;

  function highlightDates() {
    if (isHighlighting) return;
    isHighlighting = true;
    const now = new Date();
    const elements = document.querySelectorAll('relative-time');
    if (elements.length === 0) {
      isHighlighting = false;
      return;
    }
    elements.forEach(element => {
      const datetime = element.getAttribute('datetime');
      if (datetime) {
        const date = new Date(datetime);
        const timeDiff = now - date;
        const daysDiff = timeDiff / (1000 * 3600 * 24);
        const monthsDiff = daysDiff / 30;
        if (monthsDiff <= TIME_THRESHOLD_MONTHS) {
          element.style.setProperty('color', HIGHLIGHT_COLOR, 'important');
        } else {
          element.style.setProperty('color', GREY_COLOR, 'important');
        }
      }
    });
    isHighlighting = false;
  }

  function onUrlChange() {
    if (currentURL !== location.href) {
      currentURL = location.href;
      const regex = /^https:\/\/github\.com\/[^/]+\/[^/]+\/tree\/[^/]+/;
      if (regex.test(location.href)) {
        const codeTab = document.getElementById('code-tab');
        if (codeTab && codeTab.classList.contains('selected')) {
          setTimeout(() => { highlightDates(); }, 1000);
        }
      }
    }
  }

  const freshnessObserver = new MutationObserver(() => {
    const codeTab = document.getElementById('code-tab');
    if (codeTab && codeTab.classList.contains('selected')) {
      highlightDates();
    }
  });
  freshnessObserver.observe(document.body, { childList: true, subtree: true });
  setInterval(onUrlChange, 1000);
  setTimeout(() => {
    const codeTab = document.getElementById('code-tab');
    if (codeTab && codeTab.classList.contains('selected')) {
      highlightDates();
    }
  }, 1000);
  let isScrolling = false;
  window.addEventListener('scroll', () => {
    if (!isScrolling) {
      isScrolling = true;
      setTimeout(() => { isScrolling = false; }, 100);
    }
  });
  setTimeout(highlightDates, 1000);

  // ================================================================
  // 模块2: 图片预览 (README Image Viewer)
  // Markdown 图片查看器:切换/滚轮缩放/拖动平移
  // ================================================================

  const ROOT_SELECTORS = [
    'article.markdown-body',
    '.markdown-body',
    '.js-comment-body',
    '[data-testid="markdown-body"]'
  ];
  const BADGE_HOSTS = ['shields.io', 'badgen.net', 'badge.fury.io', 'poser.pugx.org', 'nodei.co'];
  const BADGE_TEXT_PATTERN = /(?:badge|shield|build|status|ci|coverage|version|license|npm|pypi|downloads|release|codecov|coveralls|sonarcloud|quality|dependencies|dependabot)/i;
  const PREVIEW_MIN_DIMENSION = 100;

  let viewerEl = null;
  let scanTimer = 0;
  let previousBodyOverflow = '';
  let viewerScale = 1;

  addGhivStyle(`
    .ghiv-ready { cursor: zoom-in !important; }
    .ghiv-overlay {
      position: fixed; inset: 0; z-index: 2147483647;
      display: none; place-items: center;
      background: rgba(1, 4, 9, 0.85);
      backdrop-filter: blur(6px);
      cursor: zoom-out;
    }
    .ghiv-overlay.ghiv-open { display: grid; }
    .ghiv-overlay img {
      max-width: 92vw; max-height: 92vh;
      object-fit: contain;
      box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
      border-radius: 4px;
      cursor: zoom-out;
      transition: transform 0.1s ease-out;
      transform-origin: center center;
    }
  `);

  scheduleScan();
  observeGitHubNavigation();
  document.addEventListener('click', onImageClick, true);
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && viewerEl) closeViewer();
  }, true);

  function addGhivStyle(css) {
    if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; }
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }

  function observeGitHubNavigation() {
    ['turbo:load', 'turbo:render', 'pjax:end'].forEach((eventName) => {
      document.addEventListener(eventName, scheduleScan);
    });
    const observer = new MutationObserver((mutations) => {
      const hasPageChange = mutations.some((mutation) => {
        if (viewerEl && mutation.target === viewerEl) return false;
        if (viewerEl && viewerEl.contains(mutation.target)) return false;
        return Array.from(mutation.addedNodes).some((node) => !viewerEl || !viewerEl.contains(node));
      });
      if (hasPageChange) scheduleScan();
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
  }

  function scheduleScan() {
    window.clearTimeout(scanTimer);
    scanTimer = window.setTimeout(scanImages, 120);
  }

  function scanImages() {
    const nodes = new Set();
    ROOT_SELECTORS.forEach((selector) => {
      document.querySelectorAll(`${selector} img`).forEach((img) => nodes.add(img));
    });
    Array.from(nodes).filter(isPreviewableImage).forEach((img) => {
      img.classList.add('ghiv-ready');
      if (!img.title) img.title = '点击预览图片';
    });
  }

  function isPreviewableImage(img) {
    if (!(img instanceof HTMLImageElement)) return false;
    if (!ROOT_SELECTORS.some((selector) => img.closest(selector))) return false;
    if (img.closest('g-emoji, .emoji, .avatar, .octicon, .reaction-summary-item')) return false;
    const src = img.currentSrc || img.src || img.getAttribute('src') || img.dataset.canonicalSrc || '';
    if (!src || src.startsWith('data:')) return false;
    const rect = img.getBoundingClientRect();
    const width = img.naturalWidth || img.width || rect.width;
    const height = img.naturalHeight || img.height || rect.height;
    if (width < PREVIEW_MIN_DIMENSION || height < PREVIEW_MIN_DIMENSION) return false;
    if (rect.width === 0 && rect.height === 0) return false;
    if (isBadgeImage(img, src, width, height)) return false;
    return true;
  }

  function isBadgeImage(img, src, width, height) {
    const link = img.closest('a');
    const href = link ? (link.getAttribute('href') || '') : '';
    const alt = img.getAttribute('alt') || '';
    const title = img.getAttribute('title') || '';
    const text = `${src} ${href} ${alt} ${title}`;
    const badgeShape = width <= 260 && height <= 42;
    try {
      const url = new URL(src, location.href);
      const host = url.hostname.toLowerCase();
      const path = url.pathname.toLowerCase();
      if (BADGE_HOSTS.some((bh) => host === bh || host.endsWith(`.${bh}`))) return true;
      if (/\/actions\/workflows\/[^/]+\/badge\.svg$/i.test(path)) return true;
      if (path.endsWith('/badge.svg') && badgeShape) return true;
    } catch (_) { }
    return badgeShape && BADGE_TEXT_PATTERN.test(text);
  }

  function onImageClick(event) {
    const target = event.target instanceof Element ? event.target : null;
    const img = target ? target.closest('img') : null;
    if (!img || !ROOT_SELECTORS.some((s) => img.closest(s))) return;
    if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
    if (!isPreviewableImage(img)) return;
    event.preventDefault();
    event.stopPropagation();
    event.stopImmediatePropagation();
    openViewer(img);
  }

  function openViewer(img) {
    if (!viewerEl) {
      viewerEl = document.createElement('div');
      viewerEl.className = 'ghiv-overlay';
      viewerEl.setAttribute('role', 'dialog');
      viewerEl.setAttribute('aria-modal', 'true');
      viewerEl.tabIndex = -1;
      const previewImg = document.createElement('img');
      previewImg.alt = '';
      viewerEl.appendChild(previewImg);
      viewerEl.addEventListener('click', (e) => {
        e.preventDefault();
        closeViewer();
      });
      viewerEl.addEventListener('wheel', (e) => {
        e.preventDefault();
        const factor = e.deltaY < 0 ? 1.06 : 1 / 1.06;
        viewerScale = Math.min(8, Math.max(0.2, viewerScale * factor));
        viewerEl.querySelector('img').style.transform = `scale(${viewerScale})`;
      }, { passive: false });
      document.body.appendChild(viewerEl);
    }
    const src = img.dataset.canonicalSrc || img.currentSrc || img.src;
    viewerScale = 1;
    const previewImg = viewerEl.querySelector('img');
    previewImg.src = src;
    previewImg.style.transform = '';
    viewerEl.querySelector('img').alt = (img.getAttribute('alt') || '图片预览');
    previousBodyOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    viewerEl.classList.add('ghiv-open');
    viewerEl.focus({ preventScroll: true });
  }

  function closeViewer() {
    if (!viewerEl) return;
    viewerEl.classList.remove('ghiv-open');
    document.body.style.overflow = previousBodyOverflow;
  }

  // ================================================================
  // 模块3: 标题折叠 (GitHub Collapse Markdown - 精简版)
  // 仅保留核心折叠功能,去掉目录/搜索/书签/菜单/快捷键/帮助
  // ================================================================

  const CONFIG = {
    debug: false,
    colors: GM_getValue("ghcm-colors", [
      "#6778d0", "#ac9c3d", "#b94a73", "#56ae6c", "#9750a1", "#ba543d"
    ]),
    animation: {
      duration: 200,
      easing: "cubic-bezier(0.4, 0, 0.2, 1)",
      maxAnimatedElements: GM_getValue("ghcm-performance-mode", false) ? 0 : 20,
      batchSize: 10
    },
    selectors: {
      markdownContainers: [".markdown-body", ".comment-body"],
      headers: ["H1", "H2", "H3", "H4", "H5", "H6"],
      excludeClicks: [".anchor", ".octicon-link", "a", "img"]
    },
    classes: {
      collapsed: "ghcm-collapsed",
      hidden: "ghcm-hidden",
      hiddenByParent: "ghcm-hidden-by-parent",
      noContent: "ghcm-no-content",
      activeHeading: "ghcm-active-heading",
      hoverHeading: "ghcm-hover-heading"
    },
    memory: {
      enabled: GM_getValue("ghcm-memory-enabled", true),
      key: "ghcm-page-states"
    },
    ui: {
      showLevelNumber: GM_getValue('ghcm-show-level-number', true),
      arrowSize: GM_getValue('ghcm-arrow-size', '0.8em')
    }
  };

  const Logger = {
    log: (...args) => { if (CONFIG.debug) console.log(...args); },
    warn: (...args) => { console.warn(...args); },
    error: (...args) => { console.error(...args); }
  };

  // --- StateManager ---
  class StateManager {
    constructor() {
      this.headerStates = new Map();
      this.pageUrl = this.getPageKey();
      this._saveTimer = null;
      this._pendingSave = false;
      this._saveDelay = 200;
      try { window.addEventListener('beforeunload', () => this.flushPendingSave()); } catch { }
    }
    getPageKey() {
      try { return `${window.location.origin}${window.location.pathname}`; }
      catch (e) { return window.location.href; }
    }
    updatePageKey() {
      const newKey = this.getPageKey();
      if (newKey !== this.pageUrl) {
        this.headerStates.clear();
        this.pageUrl = newKey;
      }
    }
    setHeaderState(headerKey, state) {
      this.headerStates.set(headerKey, state);
      this.scheduleSave();
    }
    getHeaderState(headerKey) { return this.headerStates.get(headerKey); }
    generateHeaderKey(element) {
      try {
        const normalize = value => (typeof value === 'string' ? value.trim() : '');
        const isSynthetic = id => /^ghcm-(?:bookmark|h)-/i.test(id || '');
        const stableId = (() => {
          const directId = normalize(element.getAttribute?.('id') || element.id);
          if (directId && !isSynthetic(directId)) return directId;
          const anchor = element.querySelector?.('.anchor');
          if (anchor) {
            const anchorId = normalize(anchor.getAttribute('id'));
            if (anchorId && !isSynthetic(anchorId)) return anchorId;
            const hrefId = normalize(anchor.getAttribute('href')?.replace(/^#/, ''));
            if (hrefId && !isSynthetic(hrefId)) return hrefId;
          }
          const anyWithId = element.querySelector?.('[id]');
          const childId = normalize(anyWithId?.getAttribute('id'));
          if (childId && !isSynthetic(childId)) return childId;
          return null;
        })();
        if (stableId) return `id:${stableId}`;
      } catch { }
      const level = this.getHeaderLevel(element);
      const text = element.textContent?.trim() || "";
      const position = Array.from(element.parentElement?.children || []).indexOf(element);
      return `${level}-${text}-${position}`;
    }
    getHeaderLevel(element) { return DOMUtils.getHeadingLevel(element); }
    clear() { this.headerStates.clear(); this.scheduleSave({ force: true }); }
    scheduleSave({ force = false } = {}) {
      if (!CONFIG.memory.enabled) { this.cancelScheduledSave(); return; }
      this._pendingSave = true;
      if (force) { this.flushPendingSave(); return; }
      if (this._saveTimer) return;
      this._saveTimer = setTimeout(() => { this.flushPendingSave(); }, this._saveDelay);
    }
    cancelScheduledSave() {
      if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
      this._pendingSave = false;
    }
    flushPendingSave() {
      if (!this._pendingSave) return;
      this._pendingSave = false;
      if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
      if (!CONFIG.memory.enabled) return;
      try {
        const pageStates = GM_getValue(CONFIG.memory.key, {});
        const currentStates = {};
        this.headerStates.forEach((state, key) => { currentStates[key] = state.isCollapsed; });
        pageStates[this.pageUrl] = currentStates;
        GM_setValue(CONFIG.memory.key, pageStates);
      } catch (e) { Logger.warn("[GHCM] 保存状态失败:", e); }
    }
    loadFromMemory() {
      if (!CONFIG.memory.enabled) return;
      try {
        const pageStates = GM_getValue(CONFIG.memory.key, {});
        const currentStates = pageStates[this.pageUrl];
        if (currentStates) {
          Object.entries(currentStates).forEach(([key, isCollapsed]) => {
            this.headerStates.set(key, { isCollapsed });
          });
        }
      } catch (e) { Logger.warn("[GHCM] 加载状态失败:", e); }
    }
  }

  // --- DOMUtils ---
  class DOMUtils {
    static getHeadingTagsLower() {
      if (!DOMUtils._headingTagsLower) {
        DOMUtils._headingTagsLower = CONFIG.selectors.headers.map(tag => tag.toLowerCase());
      }
      return DOMUtils._headingTagsLower;
    }
    static getUpperHeadingSelector() {
      if (!DOMUtils._upperHeadingSelector) {
        DOMUtils._upperHeadingSelector = CONFIG.selectors.headers.join(',');
      }
      return DOMUtils._upperHeadingSelector;
    }
    static getHeadingTags({ level, upToLevel } = {}) {
      const tags = DOMUtils.getHeadingTagsLower();
      if (typeof level === 'number') { const tag = tags[level - 1]; return tag ? [tag] : []; }
      if (typeof upToLevel === 'number') return tags.slice(0, upToLevel);
      return tags;
    }
    static getCachedSelector(key, builder) {
      if (!DOMUtils._selectorCache) DOMUtils._selectorCache = new Map();
      if (!DOMUtils._selectorCache.has(key)) DOMUtils._selectorCache.set(key, builder());
      return DOMUtils._selectorCache.get(key);
    }
    static buildSelector(tags, { scopedTo, includeWrapper } = {}) {
      if (!tags || !tags.length) return '';
      const selectors = [];
      tags.forEach(tag => {
        const base = scopedTo ? `${scopedTo} ${tag}` : tag;
        selectors.push(base);
        if (includeWrapper) selectors.push(`${base}.heading-element`);
      });
      return selectors.join(', ');
    }
    static getHeadingSelector() {
      return DOMUtils.getCachedSelector('all-headings', () => DOMUtils.buildSelector(DOMUtils.getHeadingTags()));
    }
    static getHeadingSelectorUpToLevel(level) {
      return DOMUtils.getCachedSelector(`upto-${level}`, () =>
        DOMUtils.buildSelector(DOMUtils.getHeadingTags({ upToLevel: level }))
      );
    }
    static getScopedHeadingSelector(container, { includeWrapper = false, level, upToLevel } = {}) {
      if (!container) return '';
      const key = `scope-${container}|wrap:${includeWrapper}|level:${level ?? 'all'}|upto:${upToLevel ?? 'na'}`;
      return DOMUtils.getCachedSelector(key, () =>
        DOMUtils.buildSelector(DOMUtils.getHeadingTags({ level, upToLevel }), { scopedTo: container, includeWrapper })
      );
    }
    static collectHeadings(containers = CONFIG.selectors.markdownContainers) {
      const useCache = containers === CONFIG.selectors.markdownContainers;
      if (useCache && DOMUtils._headingCache) return DOMUtils._headingCache.slice();
      const selectors = containers.map(container => DOMUtils.getScopedHeadingSelector(container)).filter(Boolean);
      if (!selectors.length) return [];
      try {
        const list = DOMUtils.$$(selectors.join(', ')).filter(element => DOMUtils.shouldIncludeHeading(element));
        if (useCache) { DOMUtils._headingCache = list; return list.slice(); }
        return list;
      } catch { return []; }
    }
    static hasMarkdownHeadings() {
      return CONFIG.selectors.markdownContainers.some(container => {
        try {
          const selector = DOMUtils.getScopedHeadingSelector(container);
          return selector ? !!document.querySelector(selector) : false;
        } catch { return false; }
      });
    }
    static getHeadingLevel(element) {
      if (!element || !element.nodeName) return 0;
      const match = element.nodeName.match(/h([1-6])/i);
      return match ? parseInt(match[1], 10) : 0;
    }
    static $(selector, parent = document) { return parent.querySelector(selector); }
    static $$(selector, parent = document) { return Array.from(parent.querySelectorAll(selector)); }
    static isHeader(element) { return CONFIG.selectors.headers.includes(element.nodeName); }
    static isInMarkdown(element) {
      return CONFIG.selectors.markdownContainers.some(selector => element.closest(selector));
    }
    static getHeaderContainer(header) {
      return header.closest('.markdown-heading') || header;
    }
    static clearSelection() {
      const selection = window.getSelection?.() || document.selection;
      if (selection) {
        if (selection.removeAllRanges) selection.removeAllRanges();
        else if (selection.empty) selection.empty();
      }
    }
    static blurActiveElement() {
      try {
        const active = document.activeElement;
        if (!active || active === document.body) return;
        if (typeof active.blur === 'function') active.blur();
      } catch { }
    }
    static isVisible(el) {
      try {
        if (!el || el.getAttribute('aria-hidden') === 'true' || el.hidden) return false;
        const cls = el.className || '';
        if (typeof cls === 'string' && /(sr-only|visually-hidden)/i.test(cls)) return false;
        const rects = el.getClientRects?.();
        if (!rects || rects.length === 0) return false;
        return (el.offsetWidth + el.offsetHeight) > 0;
      } catch { return true; }
    }
    static inIgnoredRegion(el) {
      try { return !!el.closest('nav, header, footer, aside, [role="navigation"], [role="menu"], [role="menubar"], [role="toolbar"]'); }
      catch { return false; }
    }
    static shouldIncludeHeading(el) {
      if (!DOMUtils.isHeader(el)) return false;
      if (!DOMUtils.isInMarkdown(el)) return false;
      if (DOMUtils.inIgnoredRegion(el)) return false;
      if (!DOMUtils.isVisible(el)) return false;
      return true;
    }
    static invalidateHeadingCache() { DOMUtils._headingCache = null; }
  }

  // --- StyleManager (精简版) ---
  class StyleManager {
    constructor() {
      this.arrowColors = document.createElement("style");
      this.arrowContentOverride = document.createElement("style");
      this.init();
    }
    init() {
      this.addBaseStyles();
      this.addColorStyles();
      document.head.appendChild(this.arrowColors);
      document.head.appendChild(this.arrowContentOverride);
      this.updateArrowContentOverride();
      this.applyArrowSize(CONFIG.ui.arrowSize);
    }
    addBaseStyles() {
      const headerSelectors = this.generateHeaderSelectors();
      GM_addStyle(`
        ${headerSelectors.base} {
          position: relative;
          padding-right: 3em;
          cursor: pointer;
          transition: all ${CONFIG.animation.duration}ms ${CONFIG.animation.easing};
        }
        ${headerSelectors.after} {
          display: inline-block;
          position: absolute;
          right: 0.5em;
          top: 50%;
          transform: translateY(-50%);
          font-size: var(--ghcm-arrow-size, 0.8em);
          font-weight: bold;
          pointer-events: none;
          transition: transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing};
        }
        ${this.generateArrowContent()}
        .${CONFIG.classes.collapsed}:after {
          content: "展开" !important;
          transform: translateY(-50%);
        }
        .${CONFIG.classes.activeHeading} {
          background: rgba(191, 219, 254, 0.55);
          border-radius: 4px;
        }
        .${CONFIG.classes.hoverHeading} {
          background: rgba(107, 114, 128, 0.12);
          border-radius: 4px;
        }
        .ghcm-temp-highlight {
          background: rgba(191, 219, 254, 0.4);
          transition: background 0.4s ease;
        }
        .${CONFIG.classes.hidden},
        .${CONFIG.classes.hiddenByParent} {
          display: none !important;
          opacity: 0 !important;
        }
        .${CONFIG.classes.noContent}:after {
          display: none !important;
        }
        .ghcm-transitioning {
          transition: opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing},
                     transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing};
        }
      `);
    }
    generateHeaderSelectors() {
      const containers = CONFIG.selectors.markdownContainers;
      const headers = DOMUtils.getHeadingTagsLower();
      const baseSelectors = [];
      const afterSelectors = [];
      containers.forEach(container => {
        if (container) {
          headers.forEach(header => {
            baseSelectors.push(`${container} ${header}`);
            baseSelectors.push(`${container} ${header}.heading-element`);
            afterSelectors.push(`${container} ${header}:after`);
            afterSelectors.push(`${container} ${header}.heading-element:after`);
          });
        }
      });
      return { base: baseSelectors.join(", "), after: afterSelectors.join(", ") };
    }
    generateArrowContent() {
      const headers = DOMUtils.getHeadingTagsLower();
      return headers.map((header, index) => {
        const level = index + 1;
        const containers = CONFIG.selectors.markdownContainers;
        const selectors = [];
        containers.forEach(container => {
          if (container) {
            selectors.push(`${container} ${header}:after`);
            selectors.push(`${container} ${header}.heading-element:after`);
          }
        });
        return `${selectors.join(", ")} { content: "收起"; }`;
      }).join("\n");
    }
    addColorStyles() {
      const headers = DOMUtils.getHeadingTagsLower();
      const styles = headers.map((header, index) => {
        const containers = CONFIG.selectors.markdownContainers;
        const selectors = [];
        containers.forEach(container => {
          if (container) {
            selectors.push(`${container} ${header}:after`);
            selectors.push(`${container} ${header}.heading-element:after`);
          }
        });
        return `${selectors.join(", ")} { color: ${CONFIG.colors[index]}; }`;
      }).join("\n");
      this.arrowColors.textContent = styles;
    }
    updateColors(newColors) {
      CONFIG.colors = newColors;
      GM_setValue("ghcm-colors", newColors);
      this.addColorStyles();
    }
    applyArrowSize(size) {
      try { document.documentElement.style.setProperty('--ghcm-arrow-size', size || '0.8em'); } catch { }
    }
    updateArrowContentOverride() {
      const headers = DOMUtils.getHeadingTagsLower();
      const rules = headers.map((header, index) => {
        const level = index + 1;
        const containers = CONFIG.selectors.markdownContainers;
        const selectors = [];
        containers.forEach(container => {
          if (container) {
            selectors.push(`${container} ${header}:not(.${CONFIG.classes.collapsed}):after`);
            selectors.push(`${container} ${header}.heading-element:not(.${CONFIG.classes.collapsed}):after`);
          }
        });
        const text = '收起';
        return `${selectors.join(", ")} { content: "${text}" !important; }`;
      }).join("\n");
      this.arrowContentOverride.textContent = rules;
    }
  }

  // --- CollapseManager ---
  class CollapseManager {
    constructor(stateManager) {
      this.stateManager = stateManager;
      this.animationQueue = new Map();
      this._scrollEnsureTimeout = null;
      this.activeHeading = null;
      this._activeNotification = null;
      this.tocGenerator = null;
      this.searchManager = null;
      this.bookmarkManager = null;
    }
    trackTimeout(headerKey, timeoutId) {
      if (!this.animationQueue.has(headerKey)) this.animationQueue.set(headerKey, new Set());
      this.animationQueue.get(headerKey).add(timeoutId);
    }
    cancelTimeouts(headerKey) {
      const set = this.animationQueue.get(headerKey);
      if (!set) return;
      set.forEach(id => clearTimeout(id));
      this.animationQueue.delete(headerKey);
    }
    clearAllAnimations() {
      for (const set of this.animationQueue.values()) set.forEach(id => clearTimeout(id));
      this.animationQueue.clear();
    }
    toggle(header, isShiftClicked = false) {
      if (!header || header.classList.contains(CONFIG.classes.noContent)) return;
      const startTime = performance.now();
      const level = this.stateManager.getHeaderLevel(header);
      const isCollapsed = !header.classList.contains(CONFIG.classes.collapsed);
      if (isShiftClicked) { this.toggleAllSameLevel(level, isCollapsed); }
      else { this.toggleSingle(header, isCollapsed); }
      const endTime = performance.now();
      if (endTime - startTime > 100 && CONFIG.animation.maxAnimatedElements > 0) {
        if (!GM_getValue("ghcm-auto-performance-warned", false)) {
          CONFIG.animation.maxAnimatedElements = Math.max(5, CONFIG.animation.maxAnimatedElements / 2);
          GM_setValue("ghcm-auto-performance-warned", true);
        }
      }
      this.setActiveHeading(header);
      DOMUtils.clearSelection();
      DOMUtils.blurActiveElement();
      this.dispatchToggleEvent(header, level, isCollapsed);
    }
    toggleSingle(header, isCollapsed) {
      header.classList.toggle(CONFIG.classes.collapsed, isCollapsed);
      this.updateAriaExpanded(header);
      this.updateContent(header, isCollapsed);
    }
    toggleAllSameLevel(level, isCollapsed) {
      const selectors = CONFIG.selectors.markdownContainers
        .map(container => DOMUtils.getScopedHeadingSelector(container, { level, includeWrapper: true }))
        .filter(Boolean).join(', ');
      if (!selectors) return;
      DOMUtils.$$(selectors).forEach(header => {
        if (DOMUtils.isHeader(header)) {
          header.classList.toggle(CONFIG.classes.collapsed, isCollapsed);
          this.updateAriaExpanded(header);
          this.updateContent(header, isCollapsed);
        }
      });
    }
    updateAriaExpanded(header) {
      try { header.setAttribute('aria-expanded', String(!header.classList.contains(CONFIG.classes.collapsed))); } catch { }
    }
    updateContent(header, isCollapsed) {
      const level = this.stateManager.getHeaderLevel(header);
      const headerKey = this.stateManager.generateHeaderKey(header);
      const elements = this.getContentElements(header, level);
      const analyzedElements = elements.map(el => {
        const childHeader = DOMUtils.isHeader(el) ? el : el.querySelector(DOMUtils.getUpperHeadingSelector());
        return {
          element: el,
          isHeader: !!childHeader,
          childHeader: childHeader,
          childHeaderCollapsed: childHeader ? childHeader.classList.contains(CONFIG.classes.collapsed) : false
        };
      });
      this.stateManager.setHeaderState(headerKey, { isCollapsed });
      this.animateElementsIntelligent(analyzedElements, isCollapsed, headerKey);
    }
    getContentElements(header, level) {
      const container = DOMUtils.getHeaderContainer(header);
      const elements = [];
      let nextElement = container.nextElementSibling;
      const higherLevelSelectors = DOMUtils.getHeadingSelectorUpToLevel(level);
      while (nextElement) {
        if (nextElement.matches(higherLevelSelectors) ||
          (nextElement.classList?.contains('markdown-heading') &&
            nextElement.querySelector(higherLevelSelectors))) break;
        elements.push(nextElement);
        nextElement = nextElement.nextElementSibling;
      }
      return elements;
    }
    animateElementsIntelligent(analyzedElements, isCollapsed, headerKey) {
      this.cancelTimeouts(headerKey);
      if (analyzedElements.length > CONFIG.animation.maxAnimatedElements) {
        this.toggleElementsIntelligentInstantly(analyzedElements, isCollapsed);
        return;
      }
      this.animateElementsIntelligentBatch(analyzedElements, isCollapsed, headerKey);
    }
    toggleElementsIntelligentInstantly(analyzedElements, isCollapsed) {
      analyzedElements.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
        if (isCollapsed) {
          element.classList.add(CONFIG.classes.hiddenByParent);
          element.style.removeProperty('display');
        } else {
          element.classList.remove(CONFIG.classes.hiddenByParent);
          element.style.removeProperty('display');
          if (isHeader && childHeaderCollapsed) {
            setTimeout(() => { this.ensureChildHeaderContentHidden(childHeader); }, 10);
          }
          element.style.removeProperty('opacity');
          element.style.removeProperty('transform');
          element.style.removeProperty('transition');
          element.classList.remove('ghcm-transitioning');
        }
      });
    }
    animateElementsIntelligentBatch(analyzedElements, isCollapsed, headerKey) {
      if (CONFIG.animation.maxAnimatedElements === 0) {
        this.toggleElementsIntelligentInstantly(analyzedElements, isCollapsed);
        return;
      }
      const batches = this.createIntelligentBatches(analyzedElements, CONFIG.animation.batchSize);
      const processBatch = (batchIndex) => {
        if (batchIndex >= batches.length) return;
        const batch = batches[batchIndex];
        if (isCollapsed) { this.collapseIntelligentBatch(batch, headerKey); }
        else { this.expandIntelligentBatch(batch, headerKey); }
        if (batchIndex < batches.length - 1) {
          const timeout = setTimeout(() => { processBatch(batchIndex + 1); }, 30);
          this.trackTimeout(headerKey, timeout);
        }
      };
      processBatch(0);
    }
    createIntelligentBatches(analyzedElements, batchSize) {
      const batches = [];
      for (let i = 0; i < analyzedElements.length; i += batchSize) batches.push(analyzedElements.slice(i, i + batchSize));
      return batches;
    }
    collapseIntelligentBatch(batch, headerKey) {
      batch.forEach(({ element }) => {
        element.style.opacity = '1';
        element.style.transform = 'translateY(0)';
        element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}, transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`;
      });
      requestAnimationFrame(() => {
        batch.forEach(({ element }) => {
          element.style.opacity = '0';
          element.style.transform = 'translateY(-8px)';
        });
        const t = setTimeout(() => {
          batch.forEach(({ element }) => {
            element.classList.add(CONFIG.classes.hiddenByParent);
            element.style.removeProperty('display');
            element.style.removeProperty('opacity');
            element.style.removeProperty('transform');
            element.style.removeProperty('transition');
          });
        }, CONFIG.animation.duration);
        this.trackTimeout(headerKey, t);
      });
    }
    expandIntelligentBatch(batch, headerKey) {
      batch.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
        element.classList.remove(CONFIG.classes.hiddenByParent);
        element.style.removeProperty('display');
        element.style.opacity = '0';
        element.style.transform = 'translateY(-8px)';
        element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}, transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`;
      });
      requestAnimationFrame(() => {
        batch.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
          element.style.opacity = '1';
          element.style.transform = 'translateY(0)';
          if (isHeader && childHeaderCollapsed) {
            setTimeout(() => { this.ensureChildHeaderContentHidden(childHeader); }, CONFIG.animation.duration + 50);
          }
        });
        const t = setTimeout(() => {
          batch.forEach(({ element }) => {
            element.style.removeProperty('opacity');
            element.style.removeProperty('transform');
            element.style.removeProperty('transition');
          });
        }, CONFIG.animation.duration);
        this.trackTimeout(headerKey, t);
      });
    }
    ensureChildHeaderContentHidden(childHeader) {
      if (!childHeader || !childHeader.classList.contains(CONFIG.classes.collapsed)) return;
      const childLevel = this.stateManager.getHeaderLevel(childHeader);
      const childElements = this.getContentElements(childHeader, childLevel);
      childElements.forEach(element => {
        element.classList.add(CONFIG.classes.hiddenByParent);
        element.style.removeProperty('display');
        element.style.removeProperty('opacity');
        element.style.removeProperty('transform');
        element.classList.remove('ghcm-transitioning');
      });
    }
    expandToHeader(targetHeader, { scroll = true, setActive = true } = {}) {
      if (!targetHeader) return;
      const level = this.stateManager.getHeaderLevel(targetHeader);
      let current = targetHeader;
      while (current) {
        const container = DOMUtils.getHeaderContainer(current);
        let previous = container.previousElementSibling;
        let foundParent = false;
        while (previous) {
          const parentHeader = this.findHeaderInElement(previous, level - 1);
          if (parentHeader) {
            if (parentHeader.classList.contains(CONFIG.classes.collapsed)) this.toggleSingle(parentHeader, false);
            current = parentHeader;
            foundParent = true;
            break;
          }
          previous = previous.previousElementSibling;
        }
        if (!foundParent) break;
      }
      if (scroll) this.scrollToElement(targetHeader);
      if (setActive) this.setActiveHeading(targetHeader, { scroll: false });
    }
    findHeaderInElement(element, maxLevel) {
      if (DOMUtils.isHeader(element)) {
        if (this.stateManager.getHeaderLevel(element) <= maxLevel) return element;
      }
      for (let i = 1; i < maxLevel; i++) {
        const headerName = CONFIG.selectors.headers[i - 1].toLowerCase();
        const header = element.querySelector(headerName) || element.querySelector(`${headerName}.heading-element`);
        if (header) return header;
      }
      return null;
    }
    scrollToElement(element) {
      if (!element) return;
      const headerEl = document.querySelector('header[role="banner"], .Header, .AppHeader-globalBar');
      const headerOffset = (headerEl?.offsetHeight || 80) + 20;
      const rect = element.getBoundingClientRect();
      const targetPosition = Math.max(0, rect.top + window.pageYOffset - headerOffset);
      window.scrollTo({ top: targetPosition, behavior: 'smooth' });
      if (this._scrollEnsureTimeout) clearTimeout(this._scrollEnsureTimeout);
      this._scrollEnsureTimeout = setTimeout(() => {
        if (Math.abs(window.scrollY - targetPosition) > 50) {
          window.scrollTo({ top: targetPosition, behavior: 'smooth' });
        }
      }, 500);
    }
    setActiveHeading(element, { scroll = false } = {}) {
      if (!element) return;
      let header = element;
      if (!DOMUtils.isHeader(header)) header = header.querySelector(DOMUtils.getUpperHeadingSelector());
      if (!header) return;
      if (this.activeHeading && this.activeHeading !== header) {
        try { this.activeHeading.classList.remove(CONFIG.classes.activeHeading); } catch { }
      }
      this.activeHeading = header;
      try { header.classList.add(CONFIG.classes.activeHeading); } catch { }
      if (scroll) this.scrollToElement(header);
    }
    getActiveHeaderElement(force = false) {
      if (!force && this.activeHeading && document.contains(this.activeHeading)) return this.activeHeading;
      const headers = this.getAllHeaders();
      if (!headers.length) return null;
      const headerEl = document.querySelector('header[role="banner"], .Header, .AppHeader-globalBar');
      const headerOffset = (headerEl?.offsetHeight || 80) + 20;
      const position = window.scrollY + headerOffset + 1;
      let active = headers[0];
      for (const header of headers) {
        const top = header.getBoundingClientRect().top + window.pageYOffset;
        if (top <= position) active = header;
        else break;
      }
      if (active) this.setActiveHeading(active);
      return active;
    }
    isHeaderNavigable(header) {
      if (!header) return false;
      if (header.classList?.contains(CONFIG.classes.hidden) || header.classList?.contains(CONFIG.classes.hiddenByParent)) return false;
      try { if (header.closest(`.${CONFIG.classes.hiddenByParent}`)) return false; } catch { }
      try {
        const style = window.getComputedStyle(header);
        if (style.display === 'none' || style.visibility === 'hidden') return false;
      } catch { }
      return true;
    }
    dispatchToggleEvent(header, level, isCollapsed) {
      document.dispatchEvent(new CustomEvent("ghcm:toggle-complete", { detail: { header, level, isCollapsed } }));
      if (!isCollapsed) {
        setTimeout(() => { this.checkAndRestoreChildHeaderStates(header, level); }, CONFIG.animation.duration + 100);
      }
    }
    checkAndRestoreChildHeaderStates(parentHeader, parentLevel) {
      const container = DOMUtils.getHeaderContainer(parentHeader);
      let nextElement = container.nextElementSibling;
      const higherLevelSelectors = DOMUtils.getHeadingSelectorUpToLevel(parentLevel);
      while (nextElement) {
        if (nextElement.matches(higherLevelSelectors) ||
          (nextElement.classList?.contains('markdown-heading') && nextElement.querySelector(higherLevelSelectors))) break;
        const childHeader = DOMUtils.isHeader(nextElement) ? nextElement : nextElement.querySelector(DOMUtils.getUpperHeadingSelector());
        if (childHeader && childHeader.classList.contains(CONFIG.classes.collapsed)) {
          this.ensureChildHeaderContentHidden(childHeader);
        }
        nextElement = nextElement.nextElementSibling;
      }
    }
    getAllHeaders() { return DOMUtils.collectHeadings(); }
    syncAriaExpandedForAll() {
      try {
        this.getAllHeaders().forEach(h => {
          h.setAttribute('aria-expanded', String(!h.classList.contains(CONFIG.classes.collapsed)));
        });
      } catch { }
    }
    collapseAll() {
      let count = 0;
      this.getAllHeaders().forEach(header => {
        if (!header.classList.contains(CONFIG.classes.collapsed) && !header.classList.contains(CONFIG.classes.noContent)) {
          header.classList.add(CONFIG.classes.collapsed);
          this.updateAriaExpanded(header);
          this.updateContent(header, true);
          count++;
        }
      });
      this.showNotification(`📁 已折叠 ${count} 个标题`);
    }
    expandAll() {
      let count = 0;
      this.getAllHeaders().forEach(header => {
        if (header.classList.contains(CONFIG.classes.collapsed)) {
          header.classList.remove(CONFIG.classes.collapsed);
          this.updateAriaExpanded(header);
          this.updateContent(header, false);
          count++;
        }
      });
      this.showNotification(`📂 已展开 ${count} 个标题`);
    }
    toggleAll() {
      const headers = this.getAllHeaders();
      const collapsedCount = headers.filter(h => h.classList.contains(CONFIG.classes.collapsed)).length;
      const totalCount = headers.filter(h => !h.classList.contains(CONFIG.classes.noContent)).length;
      if (collapsedCount > totalCount / 2) this.expandAll();
      else this.collapseAll();
    }
    showNotification(message) {
      if (this._activeNotification) { try { this._activeNotification.remove(); } catch { } this._activeNotification = null; }
      const notification = document.createElement('div');
      notification.style.cssText = `
        position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
        background: var(--color-canvas-default, #ffffff);
        border: 1px solid var(--color-border-default, #d0d7de);
        border-radius: 8px; padding: 12px 20px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10002;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        font-size: 14px; color: var(--color-fg-default, #24292f);
        opacity: 0; transition: opacity 0.3s ease;
      `;
      notification.textContent = message;
      document.body.appendChild(notification);
      this._activeNotification = notification;
      requestAnimationFrame(() => { notification.style.opacity = '1'; });
      setTimeout(() => {
        notification.style.opacity = '0';
        setTimeout(() => {
          if (notification.parentNode) notification.parentNode.removeChild(notification);
          if (this._activeNotification === notification) this._activeNotification = null;
        }, 300);
      }, 2000);
    }
    loadSavedStates() {
      this.stateManager.loadFromMemory();
      for (let level = 1; level <= 6; level++) this.applyStatesForLevel(level);
    }
    applyStatesForLevel(level) {
      this.getAllHeaders().filter(h => this.stateManager.getHeaderLevel(h) === level).forEach(header => {
        const headerKey = this.stateManager.generateHeaderKey(header);
        const savedState = this.stateManager.getHeaderState(headerKey);
        if (savedState && savedState.isCollapsed) {
          header.classList.add(CONFIG.classes.collapsed);
          this.updateAriaExpanded(header);
          this.updateContent(header, true);
        }
      });
    }
    markEmptyHeaders() {
      CONFIG.selectors.markdownContainers.forEach(containerSelector => {
        const selector = DOMUtils.getScopedHeadingSelector(containerSelector, { includeWrapper: true });
        if (!selector) return;
        DOMUtils.$$(selector).forEach(header => {
          const level = this.stateManager.getHeaderLevel(header);
          const elements = this.getContentElements(header, level);
          if (elements.length === 0) header.classList.add(CONFIG.classes.noContent);
          else header.classList.remove(CONFIG.classes.noContent);
        });
      });
    }
  }

  // --- EventManager (精简版) ---
  class EventManager {
    constructor(collapseManager) {
      this.collapseManager = collapseManager;
      this.hoverHeader = null;
      this.setupEventListeners();
    }
    setupEventListeners() {
      document.addEventListener("click", this.handleClick.bind(this), true);
      this._hoverHandler = this.handleHover.bind(this);
      this._hoverLeaveHandler = this.handleHoverLeave.bind(this);
      document.addEventListener('mouseover', this._hoverHandler, true);
      document.addEventListener('mouseout', this._hoverLeaveHandler, true);
      window.addEventListener("hashchange", this.handleHashChange.bind(this));
      if (window.ghmo) window.addEventListener("ghmo:dom", this.handleDOMChange.bind(this));
      document.addEventListener("pjax:end", this.handleNavigation.bind(this));
      document.addEventListener("turbo:load", this.handleNavigation.bind(this));
      document.addEventListener("turbo:render", this.handleNavigation.bind(this));
      window.addEventListener("pageshow", this.handleNavigation.bind(this));
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', this.handleDOMChange.bind(this));
      } else {
        setTimeout(() => this.handleDOMChange(), 200);
      }
    }
    handleClick(event) {
      let target = event.target;
      if (event.button !== 0) return;
      try {
        const sel = window.getSelection?.();
        if (sel && sel.toString && sel.toString().trim().length > 0) return;
      } catch { }
      if (target.nodeName === "path") target = target.closest("svg");
      if (!target || this.shouldSkipElement(target)) return;
      const header = target.closest(DOMUtils.getHeadingSelector());
      if (header && DOMUtils.isHeader(header) && DOMUtils.isInMarkdown(header)) {
        this.collapseManager.toggle(header, event.shiftKey);
      }
    }
    handleHover(event) {
      const header = event.target.closest(DOMUtils.getHeadingSelector());
      if (!header || !DOMUtils.isHeader(header)) return;
      if (this.hoverHeader === header) return;
      try {
        if (this.hoverHeader) this.hoverHeader.classList.remove(CONFIG.classes.hoverHeading);
        header.classList.add(CONFIG.classes.hoverHeading);
        this.hoverHeader = header;
      } catch { }
    }
    handleHoverLeave(event) {
      const header = event.target.closest(DOMUtils.getHeadingSelector());
      if (!header || !DOMUtils.isHeader(header)) return;
      const related = event.relatedTarget;
      if (related && (related === header || related.closest?.(DOMUtils.getHeadingSelector()) === header)) return;
      if (this.hoverHeader === header) {
        header.classList.remove(CONFIG.classes.hoverHeading);
        this.hoverHeader = null;
      }
    }
    shouldSkipElement(element) {
      const nodeName = element.nodeName?.toLowerCase();
      try {
        if (element.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]')) return true;
      } catch { }
      return CONFIG.selectors.excludeClicks.some(selector => {
        if (selector.startsWith('.')) return element.classList.contains(selector.slice(1));
        return nodeName === selector;
      });
    }
    handleHashChange() {
      const hash = window.location.hash.replace(/#/, "");
      if (hash) this.openHashTarget(hash);
    }
    handleDOMChange() {
      DOMUtils.invalidateHeadingCache();
      this.collapseManager.markEmptyHeaders();
      this.handleHashChange();
      try {
        const active = this.collapseManager.getActiveHeaderElement();
        if (active) this.collapseManager.setActiveHeading(active);
      } catch { }
    }
    handleNavigation() {
      DOMUtils.invalidateHeadingCache();
      try { this.collapseManager.clearAllAnimations(); } catch { }
      try { this.collapseManager.stateManager.updatePageKey(); } catch (e) { }
      this.handleDOMChange();
      if (CONFIG.memory.enabled) {
        setTimeout(() => { try { this.collapseManager.loadSavedStates(); } catch (e) { } }, 300);
      }
    }
    openHashTarget(id) {
      const possibleSelectors = [`#user-content-${id}`, `#${id}`, `[id="${id}"]`];
      let targetElement = null;
      for (const selector of possibleSelectors) {
        targetElement = DOMUtils.$(selector);
        if (targetElement) break;
      }
      if (!targetElement) return;
      let header = targetElement;
      if (!DOMUtils.isHeader(header)) header = targetElement.closest(DOMUtils.getHeadingSelector());
      if (header && DOMUtils.isHeader(header)) {
        this.collapseManager.expandToHeader(header, { scroll: false, setActive: false });
        this.collapseManager.scrollToElement(header);
        this.collapseManager.setActiveHeading(header);
      }
    }
  }

  // --- 初始化 ---
  const stateManager = new StateManager();
  const styleManager = new StyleManager();
  const collapseManager = new CollapseManager(stateManager);
  const eventManager = new EventManager(collapseManager);

  // 初始化折叠标记和状态恢复
  setTimeout(() => {
    collapseManager.markEmptyHeaders();
    if (CONFIG.memory.enabled) collapseManager.loadSavedStates();
    collapseManager.syncAriaExpandedForAll();
  }, 500);

})();