GitHub 增强:子目录下载 + 日期高亮 + 图片预览 + 标题折叠
// ==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); })();