GitHub Enhancer

GitHub 增强:子目录下载 + 日期高亮 + 图片预览 + 标题折叠

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 or Violentmonkey 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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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.1.3
// @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
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  // ================================================================
  // 模块0: 子目录下载 (GitHub Directory Downloader)
  // 将当前 GitHub 子目录直接打包为 ZIP,不下载仓库中的其他内容
  // ================================================================

  const DIRECTORY_DOWNLOAD_BUTTON_CLASS = 'ghed-download-button';
  const DIRECTORY_DOWNLOAD_CONCURRENCY = 6;
  const DIRECTORY_DOWNLOAD_TIMEOUT_MS = 60_000;
  let directoryDownloadScanTimer = 0;
  let isDirectoryDownloading = false;

  addGhivStyle(`
    .${DIRECTORY_DOWNLOAD_BUTTON_CLASS} {
      align-items: center;
      background: var(--button-default-bgColor-rest, var(--color-btn-bg, #f6f8fa));
      border: 1px solid var(--button-default-borderColor-rest, var(--color-btn-border, #d0d7de));
      border-radius: 6px;
      box-shadow: var(--button-default-shadow-resting, var(--color-btn-shadow, 0 1px 0 rgba(31, 35, 40, 0.04)));
      color: var(--button-default-fgColor-rest, var(--color-btn-text, #24292f));
      cursor: pointer;
      display: inline-flex;
      font-family: inherit;
      font-size: 14px;
      font-weight: 500;
      gap: 6px;
      height: 32px;
      justify-content: center;
      line-height: 20px;
      padding: 0 12px;
      white-space: nowrap;
    }
    .${DIRECTORY_DOWNLOAD_BUTTON_CLASS}:hover:not(:disabled) {
      background: var(--button-default-bgColor-hover, var(--color-btn-hover-bg, #f3f4f6));
      border-color: var(--button-default-borderColor-hover, var(--color-btn-hover-border, #d0d7de));
    }
    .${DIRECTORY_DOWNLOAD_BUTTON_CLASS}:disabled {
      cursor: wait;
      opacity: 0.65;
    }
    .${DIRECTORY_DOWNLOAD_BUTTON_CLASS} svg { fill: currentColor; flex: none; }
    .ghed-download-toast {
      background: var(--bgColor-default, var(--color-canvas-default, #fff));
      border: 1px solid var(--borderColor-default, var(--color-border-default, #d0d7de));
      border-radius: 6px;
      box-shadow: var(--shadow-floating-small, 0 8px 24px rgba(140, 149, 159, 0.2));
      color: var(--fgColor-default, var(--color-fg-default, #1f2328));
      font-size: 14px;
      left: 50%;
      max-width: min(460px, calc(100vw - 32px));
      padding: 10px 16px;
      position: fixed;
      top: 20px;
      transform: translateX(-50%);
      z-index: 2147483646;
    }
    @media (max-width: 543px) {
      .${DIRECTORY_DOWNLOAD_BUTTON_CLASS} { padding: 0 8px; }
      .${DIRECTORY_DOWNLOAD_BUTTON_CLASS} span { display: none; }
    }
  `);

  scheduleDirectoryDownloadButton();
  ['turbo:load', 'turbo:render', 'pjax:end'].forEach((eventName) => {
    document.addEventListener(eventName, scheduleDirectoryDownloadButton);
  });
  const directoryDownloadObserver = new MutationObserver(scheduleDirectoryDownloadButton);
  directoryDownloadObserver.observe(document.documentElement, { childList: true, subtree: true });

  function scheduleDirectoryDownloadButton() {
    window.clearTimeout(directoryDownloadScanTimer);
    directoryDownloadScanTimer = window.setTimeout(injectDirectoryDownloadButtons, 120);
  }

  function injectDirectoryDownloadButtons() {
    if (!isCurrentDirectoryPage()) {
      document.querySelectorAll(`.${DIRECTORY_DOWNLOAD_BUTTON_CLASS}`).forEach((button) => button.remove());
      return;
    }
    document.querySelectorAll('[data-testid="tree-overflow-menu-anchor"]').forEach((menuButton) => {
      const toolbar = menuButton.parentElement;
      if (!toolbar || toolbar.querySelector(`.${DIRECTORY_DOWNLOAD_BUTTON_CLASS}`)) return;
      const button = createDirectoryDownloadButton();
      const actionsHeading = toolbar.querySelector(':scope > [data-testid="screen-reader-heading"]');
      const addFileControl = actionsHeading?.nextElementSibling;
      toolbar.insertBefore(button, addFileControl || menuButton);
    });
  }

  function createDirectoryDownloadButton() {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = DIRECTORY_DOWNLOAD_BUTTON_CLASS;
    button.title = '将当前目录下载为 ZIP';
    button.setAttribute('aria-label', '将当前目录下载为 ZIP');
    button.innerHTML = `
      <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16">
        <path d="M3.5 1.75a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 .75.75V7h1.25a.75.75 0 0 1 .53 1.28l-3.75 3.75a.75.75 0 0 1-1.06 0L5.72 8.28A.75.75 0 0 1 6.25 7H7.5V4h1v4.5H8.06L10 10.44l1.94-1.94h-.94V2.5h-6v3a.75.75 0 0 1-1.5 0V1.75ZM2.75 13a.75.75 0 0 1 .75.75v.75h9v-.75a.75.75 0 0 1 1.5 0v1.5a.75.75 0 0 1-.75.75H2.75a.75.75 0 0 1-.75-.75v-1.5a.75.75 0 0 1 .75-.75Z"></path>
      </svg>
      <span>下载目录</span>
    `;
    button.addEventListener('click', downloadCurrentDirectory);
    return button;
  }

  function isCurrentDirectoryPage() {
    const pathParts = location.pathname.split('/').filter(Boolean);
    return location.hostname === 'github.com' && pathParts.length >= 5 && pathParts[2] === 'tree';
  }

  function getCurrentDirectoryContext(root = document, pathname = location.pathname) {
    if (location.hostname !== 'github.com') return null;
    const pathParts = pathname.split('/').filter(Boolean);
    if (pathParts.length < 5 || pathParts[2] !== 'tree') return null;
    const treePath = pathParts.slice(3).map(decodeURIComponent).join('/');

    for (const script of root.querySelectorAll('script[data-target="react-app.embeddedData"]')) {
      try {
        const route = JSON.parse(script.textContent || '{}')?.payload?.codeViewTreeRoute;
        const directoryPath = route?.path?.replace(/^\/+|\/+$/g, '');
        const refName = route?.refInfo?.name?.replace(/^\/+|\/+$/g, '');
        const revision = route?.refInfo?.currentOid;
        if (!directoryPath || !refName || !revision) continue;
        if (treePath !== `${refName}/${directoryPath}`) continue;
        return {
          owner: decodeURIComponent(pathParts[0]),
          repository: decodeURIComponent(pathParts[1]),
          directoryPath,
          revision
        };
      } catch (_) { }
    }
    return null;
  }

  async function resolveCurrentDirectoryContext() {
    const currentContext = getCurrentDirectoryContext();
    if (currentContext) return currentContext;

    const response = await fetch(location.href, { credentials: 'same-origin' });
    if (!response.ok) throw new Error(`重新读取当前目录失败(HTTP ${response.status})。`);
    const page = new DOMParser().parseFromString(await response.text(), 'text/html');
    return getCurrentDirectoryContext(page, new URL(response.url).pathname);
  }

  async function downloadCurrentDirectory() {
    if (isDirectoryDownloading) return;
    if (typeof JSZip !== 'function') {
      showDirectoryDownloadToast('ZIP 组件加载失败,请检查网络后刷新页面。');
      return;
    }

    isDirectoryDownloading = true;
    updateDirectoryDownloadButtons('读取目录...', true);
    try {
      const context = await resolveCurrentDirectoryContext();
      if (!context) throw new Error('无法识别当前目录,请刷新页面后重试。');
      const files = await getDirectoryFiles(context);
      if (files.length === 0) throw new Error('当前目录中没有可下载的文件。');

      const zip = new JSZip();
      const rootName = context.directoryPath.split('/').pop();
      let completed = 0;
      await runWithConcurrency(files, DIRECTORY_DOWNLOAD_CONCURRENCY, async (file) => {
        const blob = await fetchDirectoryFile(file);
        zip.file(`${rootName}/${file.relativePath}`, blob);
        completed += 1;
        updateDirectoryDownloadButtons(`下载 ${completed}/${files.length}`, true);
      });

      updateDirectoryDownloadButtons('正在压缩...', true);
      const archive = await zip.generateAsync({
        type: 'blob',
        compression: 'DEFLATE',
        compressionOptions: { level: 5 }
      });
      saveDirectoryArchive(archive, `${rootName}.zip`);
      showDirectoryDownloadToast(`已生成 ${rootName}.zip,共 ${files.length} 个文件。`);
    } catch (error) {
      console.error('[GitHub Enhancer] 子目录下载失败:', error);
      showDirectoryDownloadToast(formatDirectoryDownloadError(error));
    } finally {
      isDirectoryDownloading = false;
      updateDirectoryDownloadButtons('下载目录', false);
    }
  }

  async function getDirectoryFiles(context) {
    const apiRoot = `https://api.github.com/repos/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)}`;
    let tree = await fetchGitHubTree(`${apiRoot}/git/trees/${encodeURIComponent(context.revision)}`);
    const pathParts = context.directoryPath.split('/').filter(Boolean);

    for (const [index, part] of pathParts.entries()) {
      const entry = tree.tree?.find((item) => item.type === 'tree' && item.path === part);
      if (!entry) throw new Error(`GitHub API 中找不到目录:${context.directoryPath}`);
      const recursiveQuery = index === pathParts.length - 1 ? '?recursive=1' : '';
      tree = await fetchGitHubTree(`${apiRoot}/git/trees/${encodeURIComponent(entry.sha)}${recursiveQuery}`);
    }

    if (tree.truncated) throw new Error('当前目录过大,GitHub API 未能返回完整文件列表。');
    const revision = encodeURIComponent(context.revision);
    const rawRoot = `https://raw.githubusercontent.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repository)}/${revision}`;
    return (tree.tree || [])
      .filter((item) => item.type === 'blob')
      .map((item) => ({
        relativePath: item.path,
        downloadUrl: `${rawRoot}/${encodePath(`${context.directoryPath}/${item.path}`)}`
      }));
  }

  async function fetchDirectoryFile(file) {
    const controller = new AbortController();
    const timeout = window.setTimeout(() => controller.abort(), DIRECTORY_DOWNLOAD_TIMEOUT_MS);
    try {
      const response = await fetch(file.downloadUrl, { signal: controller.signal });
      if (!response.ok) throw new Error(`下载 ${file.relativePath} 失败(HTTP ${response.status})。`);
      return response.blob();
    } catch (error) {
      if (error?.name === 'AbortError') {
        throw new Error(`下载 ${file.relativePath} 超时,请检查网络后重试。`);
      }
      throw error;
    } finally {
      window.clearTimeout(timeout);
    }
  }

  async function fetchGitHubTree(url) {
    const response = await fetch(url, {
      headers: {
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28'
      }
    });
    if (response.ok) return response.json();
    if (response.status === 403 || response.status === 429) {
      throw new Error('GitHub API 请求次数已达上限,请稍后再试。');
    }
    if (response.status === 404) {
      throw new Error('无法读取目录。当前版本仅支持无需额外授权的公开仓库。');
    }
    throw new Error(`GitHub API 请求失败(HTTP ${response.status})。`);
  }

  function encodePath(path) {
    return path.split('/').map(encodeURIComponent).join('/');
  }

  async function runWithConcurrency(items, concurrency, task) {
    let nextIndex = 0;
    const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
      while (nextIndex < items.length) {
        const item = items[nextIndex];
        nextIndex += 1;
        await task(item);
      }
    });
    await Promise.all(workers);
  }

  function updateDirectoryDownloadButtons(text, disabled) {
    document.querySelectorAll(`.${DIRECTORY_DOWNLOAD_BUTTON_CLASS}`).forEach((button) => {
      const label = button.querySelector('span');
      if (label) label.textContent = text;
      button.disabled = disabled;
      button.setAttribute('aria-busy', String(disabled));
    });
  }

  function saveDirectoryArchive(blob, filename) {
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.style.display = 'none';
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
  }

  function formatDirectoryDownloadError(error) {
    const message = error instanceof Error ? error.message : String(error);
    return `下载失败:${message}`;
  }

  function showDirectoryDownloadToast(message) {
    document.querySelector('.ghed-download-toast')?.remove();
    const toast = document.createElement('div');
    toast.className = 'ghed-download-toast';
    toast.setAttribute('role', 'status');
    toast.textContent = message;
    document.body.appendChild(toast);
    window.setTimeout(() => toast.remove(), 5000);
  }

  // ================================================================
  // 模块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);

})();