您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
🚀 简洁高效的GitHub Markdown标题折叠脚本:智能嵌套🧠+快捷键⌨️+目录📑+搜索🔍+状态记忆💾+简约GUI🔘
// ==UserScript== // @name GitHub Collapse Markdown // @version 3.3.0 // @description 🚀 简洁高效的GitHub Markdown标题折叠脚本:智能嵌套🧠+快捷键⌨️+目录📑+搜索🔍+状态记忆💾+简约GUI🔘 // @license MIT // @author Xyea // @namespace https://github.com/XyeaOvO/GitHub-Collapse-Markdown // @homepageURL https://github.com/XyeaOvO/GitHub-Collapse-Markdown // @supportURL https://github.com/XyeaOvO/GitHub-Collapse-Markdown/issues // @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_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @noframes // @icon https://github.githubassets.com/pinned-octocat.svg // ==/UserScript== (() => { "use strict"; // 配置常量 const CONFIG = { debug: GM_getValue("ghcm-debug-mode", 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", tocContainer: "ghcm-toc-container", searchContainer: "ghcm-search-container", menuContainer: "ghcm-menu-container", menuButton: "ghcm-menu-button", bookmarked: "ghcm-bookmarked", activeHeading: "ghcm-active-heading", hoverHeading: "ghcm-hover-heading" }, hotkeys: { enabled: GM_getValue("ghcm-hotkeys-enabled", true), toggleAll: "ctrl+shift+a", // 切换所有折叠 collapseAll: "ctrl+shift+c", // 折叠所有 expandAll: "ctrl+shift+e", // 展开所有 showToc: "ctrl+shift+l", // 显示目录 search: "ctrl+shift+f", // 搜索 menu: "ctrl+shift+m", // 显示菜单 bookmark: GM_getValue('ghcm-hotkey-bookmark', 'ctrl+shift+b'), nextHeading: 'j', prevHeading: 'k', navEnabled: GM_getValue('ghcm-nav-enabled', false) }, memory: { enabled: GM_getValue("ghcm-memory-enabled", true), key: "ghcm-page-states" }, bookmarks: { key: 'ghcm-bookmarks' }, ui: { showLevelNumber: GM_getValue('ghcm-show-level-number', true), arrowSize: GM_getValue('ghcm-arrow-size', '0.8em') }, colorSchemes: { default: ["#6778d0", "#ac9c3d", "#b94a73", "#56ae6c", "#9750a1", "#ba543d"], pastel: ["#7aa2f7", "#e6a23c", "#f48fb1", "#9ccc65", "#b39ddb", "#ffab91"], vibrant: ["#3b82f6", "#f59e0b", "#ef4444", "#10b981", "#8b5cf6", "#f97316"], mono: ["#6b7280", "#6b7280", "#6b7280", "#6b7280", "#6b7280", "#6b7280"] } }; const storedCustomColors = GM_getValue('ghcm-custom-colors', null); if (Array.isArray(storedCustomColors) && storedCustomColors.length) { CONFIG.colorSchemes.custom = storedCustomColors; } // 日志控制函数 const Logger = { log: (...args) => { if (CONFIG.debug) { console.log(...args); } }, warn: (...args) => { console.warn(...args); }, error: (...args) => { console.error(...args); } }; // GUI菜单管理器 class MenuManager { constructor(app) { this.app = app; this.isVisible = false; this.menuContainer = null; this.menuButton = null; this.init(); } init() { this.createMenuButton(); this.addMenuStyles(); // 根据页面是否有 markdown 容器显示/隐藏按钮 this.updateButtonVisibility(); ['pjax:end','turbo:load','turbo:render','pageshow'].forEach(evt => { try { document.addEventListener(evt, () => this.updateButtonVisibility()); } catch {} }); } addMenuStyles() { GM_addStyle(` /* 菜单按钮 */ .${CONFIG.classes.menuButton} { position: fixed; bottom: 20px; right: 20px; width: 50px; height: 50px; background: #6b7280; border: none; border-radius: 50%; cursor: pointer; z-index: 9999; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; font-size: 18px; color: white; user-select: none; } .${CONFIG.classes.menuButton}:hover { background: #4b5563; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .${CONFIG.classes.menuButton}:active { transform: translateY(0) scale(0.95); } .${CONFIG.classes.menuButton}.menu-open { background: #374151; transform: rotate(45deg); } /* 菜单容器 */ .${CONFIG.classes.menuContainer} { position: fixed; bottom: 80px; right: 20px; width: 300px; background: rgba(255, 255, 255, 0.98); backdrop-filter: blur(10px); border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); z-index: 9998; opacity: 0; transform: translateY(10px) scale(0.95); transition: all 0.25s ease; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .${CONFIG.classes.menuContainer}.show { opacity: 1; transform: translateY(0) scale(1); } /* 菜单头部 */ .ghcm-menu-header { padding: 16px 20px 12px; background: #f9fafb; color: #374151; text-align: center; border-bottom: 1px solid #e5e7eb; } .ghcm-menu-title { font-size: 16px; font-weight: 600; margin: 0 0 4px; } .ghcm-menu-subtitle { font-size: 11px; opacity: 0.7; margin: 0; } /* 菜单内容 */ .ghcm-menu-content { padding: 0; max-height: 400px; overflow-y: auto; } /* 菜单分组 */ .ghcm-menu-group { padding: 12px 0; border-bottom: 1px solid #f3f4f6; } .ghcm-menu-group:last-child { border-bottom: none; } .ghcm-menu-group-title { font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; margin: 0 20px 8px; } /* 菜单项 */ .ghcm-menu-item { display: flex; align-items: center; padding: 10px 20px; cursor: pointer; transition: background-color 0.15s ease; color: #374151; text-decoration: none; font-size: 13px; line-height: 1.4; } .ghcm-menu-item:hover { background: #f3f4f6; color: #1f2937; } .ghcm-menu-item:active { background: #e5e7eb; } .ghcm-menu-item-icon { width: 20px; height: 20px; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; } .ghcm-menu-item-text { flex: 1; font-weight: 500; } .ghcm-menu-item-shortcut { font-size: 10px; color: #9ca3af; background: #f3f4f6; padding: 2px 6px; border-radius: 3px; font-family: Monaco, 'Courier New', monospace; } .ghcm-menu-item-note { margin-left: auto; font-size: 11px; color: #9ca3af; } .ghcm-menu-item-badge { background: #6b7280; color: white; font-size: 10px; padding: 2px 6px; border-radius: 6px; font-weight: 500; } /* 切换开关 */ .ghcm-menu-toggle { position: relative; width: 36px; height: 18px; background: #d1d5db; border-radius: 9px; transition: background 0.2s ease; cursor: pointer; } .ghcm-menu-toggle.active { background: #6b7280; } .ghcm-menu-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: white; border-radius: 50%; transition: transform 0.2s ease; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .ghcm-menu-toggle.active::after { transform: translateX(18px); } /* 统计信息 */ .ghcm-menu-stats { padding: 12px 20px; background: #f9fafb; font-size: 11px; color: #6b7280; line-height: 1.5; } .ghcm-menu-stats-item { display: flex; justify-content: space-between; margin-bottom: 3px; } .ghcm-menu-stats-item:last-child { margin-bottom: 0; } .ghcm-menu-stats-value { font-weight: 600; color: #374151; } .ghcm-bookmark-list { padding: 6px 10px; max-height: 160px; overflow-y: auto; } .ghcm-bookmark-item { display: flex; align-items: center; justify-content: space-between; } .ghcm-bookmark-info { display: flex; flex: 1; align-items: center; gap: 6px; } .ghcm-bookmark-level { font-size: 10px; font-weight: 600; color: #6b7280; } .ghcm-bookmark-text { flex: 1; font-size: 12px; color: #374151; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ghcm-bookmark-remove { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 4px; border-radius: 4px; font-size: 12px; } .ghcm-bookmark-remove:hover { background: rgba(148, 163, 184, 0.18); color: #4b5563; } .ghcm-bookmark-empty { padding: 6px 0; text-align: center; font-size: 12px; color: #9ca3af; } /* 深色主题适配 */ @media (prefers-color-scheme: dark) { .${CONFIG.classes.menuContainer} { background: rgba(31, 41, 55, 0.98); border-color: #374151; } .ghcm-menu-header { background: #1f2937; color: #f9fafb; border-bottom-color: #374151; } .ghcm-menu-item { color: #e5e7eb; } .ghcm-menu-item:hover { background: #374151; color: #f9fafb; } .ghcm-menu-group { border-bottom-color: #374151; } .ghcm-menu-group-title { color: #9ca3af; } .ghcm-menu-item-shortcut { background: #374151; color: #9ca3af; } .ghcm-menu-stats { background: #1f2937; color: #9ca3af; } .ghcm-menu-stats-value { color: #e5e7eb; } .${CONFIG.classes.bookmarked} { background: rgba(202, 138, 4, 0.38); } .ghcm-bookmark-text { color: #e5e7eb; } .ghcm-bookmark-remove:hover { background: rgba(75, 85, 99, 0.35); color: #f3f4f6; } .${CONFIG.classes.hoverHeading} { background: rgba(75, 85, 99, 0.32); } } /* 响应式设计 */ @media (max-width: 480px) { .${CONFIG.classes.menuContainer} { right: 15px; width: calc(100vw - 30px); max-width: 320px; } .${CONFIG.classes.menuButton} { right: 15px; bottom: 15px; } } `); } createMenuButton() { this.menuButton = document.createElement('button'); this.menuButton.className = CONFIG.classes.menuButton; this.menuButton.innerHTML = '⚙️'; this.menuButton.title = 'GitHub Collapse Markdown 设置'; this.menuButton.setAttribute('aria-label', '打开设置'); this.menuButton.setAttribute('aria-expanded', 'false'); this.menuButton.setAttribute('aria-controls', 'ghcm-menu-panel'); this.menuButton.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(); }); document.body.appendChild(this.menuButton); } shouldShowButton() { try { return DOMUtils.hasMarkdownHeadings(); } catch { return true; } } updateButtonVisibility() { if (!this.menuButton) return; const visible = this.shouldShowButton(); this.menuButton.style.display = visible ? 'flex' : 'none'; } createMenuContainer() { const container = document.createElement('div'); container.className = CONFIG.classes.menuContainer; container.id = 'ghcm-menu-panel'; container.setAttribute('role', 'dialog'); container.setAttribute('aria-modal', 'true'); container.setAttribute('aria-label', 'Collapse Markdown 设置'); container.innerHTML = ` <div class="ghcm-menu-header"> <h3 class="ghcm-menu-title">📝 Collapse Markdown</h3> <p class="ghcm-menu-subtitle">智能标题折叠工具</p> </div> <div class="ghcm-menu-content"> ${this.generateMenuContent()} </div> `; this.setupMenuEvents(container); return container; } generateMenuContent() { const stats = this.getStatistics(); return ` <div class="ghcm-menu-stats"> <div class="ghcm-menu-stats-item"> <span>总标题数</span> <span class="ghcm-menu-stats-value" data-stat="total">${stats.total}</span> </div> <div class="ghcm-menu-stats-item"> <span>已折叠</span> <span class="ghcm-menu-stats-value" data-stat="collapsed">${stats.collapsed}</span> </div> <div class="ghcm-menu-stats-item"> <span>可见</span> <span class="ghcm-menu-stats-value" data-stat="visible">${stats.visible}</span> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">快速书签</div> <div class="ghcm-menu-item" data-action="bookmark-add"> <div class="ghcm-menu-item-icon">⭐</div> <div class="ghcm-menu-item-text">收藏当前标题</div> <div class="ghcm-menu-item-shortcut">${CONFIG.hotkeys.bookmark}</div> </div> <div class="ghcm-menu-item" data-action="bookmark-clear"> <div class="ghcm-menu-item-icon">🗂️</div> <div class="ghcm-menu-item-text">清空本页书签</div> </div> <div class="ghcm-bookmark-list"> ${this.renderBookmarkListItems()} </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">基础操作</div> <div class="ghcm-menu-item" data-action="collapseAll"> <div class="ghcm-menu-item-icon">📁</div> <div class="ghcm-menu-item-text">折叠所有</div> <div class="ghcm-menu-item-shortcut">${CONFIG.hotkeys.collapseAll}</div> </div> <div class="ghcm-menu-item" data-action="expandAll"> <div class="ghcm-menu-item-icon">📂</div> <div class="ghcm-menu-item-text">展开所有</div> <div class="ghcm-menu-item-shortcut">${CONFIG.hotkeys.expandAll}</div> </div> <div class="ghcm-menu-item" data-action="toggleAll"> <div class="ghcm-menu-item-icon">🔄</div> <div class="ghcm-menu-item-text">智能切换</div> <div class="ghcm-menu-item-shortcut">${CONFIG.hotkeys.toggleAll}</div> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">工具功能</div> <div class="ghcm-menu-item" data-action="showToc"> <div class="ghcm-menu-item-icon">📑</div> <div class="ghcm-menu-item-text">目录导航</div> <div class="ghcm-menu-item-shortcut">${CONFIG.hotkeys.showToc}</div> </div> <div class="ghcm-menu-item" data-action="showSearch"> <div class="ghcm-menu-item-icon">🔍</div> <div class="ghcm-menu-item-text">搜索标题</div> <div class="ghcm-menu-item-shortcut">${CONFIG.hotkeys.search}</div> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">按级别操作</div> <div class="ghcm-menu-item" data-action="collapseLevel-2"> <div class="ghcm-menu-item-icon">➖</div> <div class="ghcm-menu-item-text">仅折叠 H2</div> </div> <div class="ghcm-menu-item" data-action="expandLevel-2"> <div class="ghcm-menu-item-icon">➕</div> <div class="ghcm-menu-item-text">仅展开 H2</div> </div> <div class="ghcm-menu-item" data-action="collapseLevel-3"> <div class="ghcm-menu-item-icon">➖</div> <div class="ghcm-menu-item-text">仅折叠 H3</div> </div> <div class="ghcm-menu-item" data-action="expandLevel-3"> <div class="ghcm-menu-item-icon">➕</div> <div class="ghcm-menu-item-text">仅展开 H3</div> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">设置选项</div> <div class="ghcm-menu-item" data-action="togglePerformance"> <div class="ghcm-menu-item-icon">⚡</div> <div class="ghcm-menu-item-text">性能模式</div> <div class="ghcm-menu-toggle ${CONFIG.animation.maxAnimatedElements === 0 ? 'active' : ''}" data-toggle="performance"></div> </div> <div class="ghcm-menu-item" data-action="toggleMemory"> <div class="ghcm-menu-item-icon">💾</div> <div class="ghcm-menu-item-text">状态记忆</div> <div class="ghcm-menu-toggle ${CONFIG.memory.enabled ? 'active' : ''}" data-toggle="memory"></div> </div> <div class="ghcm-menu-item" data-action="toggleHotkeys"> <div class="ghcm-menu-item-icon">⌨️</div> <div class="ghcm-menu-item-text">快捷键</div> <div class="ghcm-menu-toggle ${CONFIG.hotkeys.enabled ? 'active' : ''}" data-toggle="hotkeys"></div> </div> <div class="ghcm-menu-item" data-action="toggleVimNav"> <div class="ghcm-menu-item-icon">🧭</div> <div class="ghcm-menu-item-text">Vim 导航热键</div> <div class="ghcm-menu-toggle ${CONFIG.hotkeys.navEnabled ? 'active' : ''}" data-toggle="vimNav"></div> </div> <div class="ghcm-menu-item" data-action="toggleDebug"> <div class="ghcm-menu-item-icon">🐛</div> <div class="ghcm-menu-item-text">调试模式</div> <div class="ghcm-menu-toggle ${CONFIG.debug ? 'active' : ''}" data-toggle="debug"></div> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">样式设置</div> <div class="ghcm-menu-item" data-action="toggleShowLevelNumber"> <div class="ghcm-menu-item-icon">🔽</div> <div class="ghcm-menu-item-text">仅显示箭头</div> <div class="ghcm-menu-toggle ${CONFIG.ui.showLevelNumber ? '' : 'active'}" data-toggle="showLevel"></div> </div> <div class="ghcm-menu-item" data-action="customColors"> <div class="ghcm-menu-item-icon">🖌️</div> <div class="ghcm-menu-item-text">自定义配色</div> </div> <div class="ghcm-menu-item" data-action="adjustArrowSize"> <div class="ghcm-menu-item-icon">🔠</div> <div class="ghcm-menu-item-text">箭头大小</div> <div class="ghcm-menu-item-note" data-arrow-size-value>${CONFIG.ui.arrowSize}</div> </div> <div class="ghcm-menu-item" data-action="setColors-default"> <div class="ghcm-menu-item-icon">🎨</div> <div class="ghcm-menu-item-text">默认配色</div> </div> <div class="ghcm-menu-item" data-action="setColors-pastel"> <div class="ghcm-menu-item-icon">🎨</div> <div class="ghcm-menu-item-text">柔和 Pastel</div> </div> <div class="ghcm-menu-item" data-action="setColors-vibrant"> <div class="ghcm-menu-item-icon">🎨</div> <div class="ghcm-menu-item-text">鲜艳 Vibrant</div> </div> <div class="ghcm-menu-item" data-action="setColors-mono"> <div class="ghcm-menu-item-icon">🎨</div> <div class="ghcm-menu-item-text">单色 Mono</div> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">重置功能</div> <div class="ghcm-menu-item" data-action="resetStates"> <div class="ghcm-menu-item-icon">🔄</div> <div class="ghcm-menu-item-text">重置状态</div> </div> <div class="ghcm-menu-item" data-action="clearMemory"> <div class="ghcm-menu-item-icon">🗑️</div> <div class="ghcm-menu-item-text">清除记忆</div> </div> </div> <div class="ghcm-menu-group"> <div class="ghcm-menu-group-title">帮助信息</div> <div class="ghcm-menu-item" data-action="showHelp"> <div class="ghcm-menu-item-icon">ℹ️</div> <div class="ghcm-menu-item-text">使用说明</div> </div> </div> `; } setupMenuEvents(container) { // 点击菜单项事件 container.addEventListener('click', (e) => { const removeBtn = e.target.closest('[data-remove-bookmark]'); if (removeBtn) { const index = parseInt(removeBtn.getAttribute('data-remove-bookmark'), 10); this.app.bookmarkManager.removeBookmarkByIndex(index); this.updateBookmarkList(); e.stopPropagation(); return; } const item = e.target.closest('.ghcm-menu-item'); if (!item) return; const action = item.getAttribute('data-action'); const toggle = e.target.closest('.ghcm-menu-toggle'); if (toggle) { this.handleToggle(toggle); return; } if (action) { const shouldClose = this.handleAction(action); if (shouldClose !== false) { this.hide(); } } }); // 阻止菜单容器内的点击事件冒泡 container.addEventListener('click', (e) => { e.stopPropagation(); }); } handleAction(action) { let shouldClose = true; switch (action) { case 'collapseAll': this.app.collapseManager.collapseAll(); break; case 'expandAll': this.app.collapseManager.expandAll(); break; case 'toggleAll': this.app.collapseManager.toggleAll(); break; case 'showToc': this.app.tocGenerator.toggle(); break; case 'showSearch': this.app.searchManager.toggle(); break; case 'togglePerformance': this.app.togglePerformanceMode(); this.refreshMenu(); break; case 'toggleMemory': this.app.toggleMemory(); this.refreshMenu(); break; case 'toggleHotkeys': this.app.toggleHotkeys(); this.refreshMenu(); break; case 'toggleVimNav': this.app.toggleVimNav(); this.refreshMenu(); break; case 'toggleDebug': this.app.toggleDebug(); this.refreshMenu(); break; case 'bookmark-add': this.app.bookmarkManager.addBookmarkFromViewport(); this.refreshMenu(); shouldClose = false; break; case 'bookmark-clear': this.app.bookmarkManager.clearPageBookmarks(); this.refreshMenu(); shouldClose = false; break; case 'customColors': this.app.promptCustomColors(); this.refreshMenu(); shouldClose = false; break; case 'adjustArrowSize': this.app.promptArrowSize(); this.refreshMenu(); shouldClose = false; break; case 'resetStates': if (confirm('确定要重置当前页面的所有折叠状态吗?')) { this.app.resetAllStates(); this.refreshMenu(); } break; case 'clearMemory': if (confirm('确定要清除所有页面的记忆数据吗?')) { this.app.clearAllMemory(); this.refreshMenu(); } break; case 'showHelp': this.app.showHotkeyHelp(); break; default: if (action.startsWith('bookmark-open-')) { const idx = parseInt(action.split('-')[2], 10); if (!Number.isFinite(idx)) { shouldClose = false; break; } this.app.bookmarkManager.openBookmarkByIndex(idx); break; } if (action.startsWith('collapseLevel-')) { const lvl = parseInt(action.split('-')[1], 10); this.app.collapseManager.collapseLevel(lvl); break; } if (action.startsWith('expandLevel-')) { const lvl = parseInt(action.split('-')[1], 10); this.app.collapseManager.expandLevel(lvl); break; } if (action === 'toggleShowLevelNumber') { this.app.toggleShowLevelNumber(); this.refreshMenu(); break; } if (action.startsWith('setColors-')) { const scheme = action.split('-')[1]; this.app.setColorScheme(scheme); this.refreshMenu(); break; } } return shouldClose; } handleToggle(toggle) { const toggleType = toggle.getAttribute('data-toggle'); const isActive = toggle.classList.contains('active'); toggle.classList.toggle('active', !isActive); switch (toggleType) { case 'performance': this.app.togglePerformanceMode(); break; case 'memory': this.app.toggleMemory(); break; case 'hotkeys': this.app.toggleHotkeys(); break; case 'vimNav': this.app.toggleVimNav(); break; case 'debug': this.app.toggleDebug(); break; case 'showLevel': this.app.toggleShowLevelNumber(); break; } } getStatistics() { const headers = this.app.collapseManager.getAllHeaders(); const collapsed = headers.filter(h => h.classList.contains(CONFIG.classes.collapsed)); const visible = headers.filter(h => !h.classList.contains(CONFIG.classes.collapsed) && !h.classList.contains(CONFIG.classes.noContent) ); return { total: headers.length, collapsed: collapsed.length, visible: visible.length }; } refreshMenu() { if (!this.menuContainer || !this.isVisible) return; const stats = this.getStatistics(); this.updateMenuStats(stats); this.syncToggleState('performance', CONFIG.animation.maxAnimatedElements === 0); this.syncToggleState('memory', CONFIG.memory.enabled); this.syncToggleState('hotkeys', CONFIG.hotkeys.enabled); this.syncToggleState('vimNav', CONFIG.hotkeys.navEnabled); this.syncToggleState('debug', CONFIG.debug); // showLevel toggle active 代表仅显示箭头 this.syncToggleState('showLevel', !CONFIG.ui.showLevelNumber); this.updateBookmarkList(); this.updateArrowSizeValue(); } updateMenuStats(stats) { if (!this.menuContainer) return; const mapping = { total: stats.total, collapsed: stats.collapsed, visible: stats.visible }; Object.entries(mapping).forEach(([key, value]) => { const el = this.menuContainer.querySelector(`.ghcm-menu-stats-value[data-stat="${key}"]`); if (el) el.textContent = String(value); }); } syncToggleState(toggleType, isActive) { if (!this.menuContainer) return; const toggle = this.menuContainer.querySelector(`.ghcm-menu-toggle[data-toggle="${toggleType}"]`); if (toggle) { toggle.classList.toggle('active', !!isActive); } } updateBookmarkList() { if (!this.menuContainer) return; const list = this.menuContainer.querySelector('.ghcm-bookmark-list'); if (list) { list.innerHTML = this.renderBookmarkListItems(); } } updateArrowSizeValue() { if (!this.menuContainer) return; const value = this.menuContainer.querySelector('[data-arrow-size-value]'); if (value) value.textContent = CONFIG.ui.arrowSize; } renderBookmarkListItems() { const bookmarks = this.app.bookmarkManager?.getBookmarksForCurrentPage?.() || []; if (!bookmarks.length) { return `<div class="ghcm-bookmark-empty">暂无书签</div>`; } return bookmarks.map((bookmark, index) => { const levelLabel = typeof bookmark.level === 'number' ? `H${bookmark.level}` : 'H?'; return ` <div class="ghcm-menu-item ghcm-bookmark-item" data-action="bookmark-open-${index}"> <div class="ghcm-bookmark-info"> <span class="ghcm-bookmark-level">${levelLabel}</span> <span class="ghcm-bookmark-text">${this.escapeHtml(bookmark.text || '未命名标题')}</span> </div> <button class="ghcm-bookmark-remove" type="button" data-remove-bookmark="${index}" aria-label="移除书签">✕</button> </div> `; }).join(''); } escapeHtml(text) { return String(text ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); } show() { if (this.isVisible) return; if (this.menuContainer) { this.menuContainer.remove(); } // 打开菜单前关闭其他浮层 try { this.app.tocGenerator.hideToc(); } catch {} try { this.app.searchManager.hideSearch(); } catch {} this.menuContainer = this.createMenuContainer(); document.body.appendChild(this.menuContainer); // 动画显示 requestAnimationFrame(() => { this.menuContainer.classList.add('show'); }); this.menuButton.classList.add('menu-open'); this.menuButton.setAttribute('aria-expanded', 'true'); this.isVisible = true; // 点击外部关闭 setTimeout(() => { document.addEventListener('click', this.hideOnClickOutside); }, 100); // ESC 关闭 this._keyHandler = (e) => { if (e.key === 'Escape') this.hide(); }; document.addEventListener('keydown', this._keyHandler); // 初始焦点 try { this.menuContainer.setAttribute('tabindex','-1'); this.menuContainer.focus(); } catch {} } hide() { if (!this.isVisible || !this.menuContainer) return; this.menuContainer.classList.remove('show'); this.menuButton.classList.remove('menu-open'); this.menuButton.setAttribute('aria-expanded', 'false'); setTimeout(() => { if (this.menuContainer) { this.menuContainer.remove(); this.menuContainer = null; } }, 300); this.isVisible = false; document.removeEventListener('click', this.hideOnClickOutside); if (this._keyHandler) { document.removeEventListener('keydown', this._keyHandler); this._keyHandler = null; } } toggle() { if (this.isVisible) { this.hide(); } else { this.show(); } } hideOnClickOutside = (e) => { if (!this.menuContainer?.contains(e.target) && !this.menuButton?.contains(e.target)) { this.hide(); } } } class HelpModal { constructor(app) { this.app = app; this.overlay = null; this.modal = null; this.contentContainer = null; this.content = null; this.closeButton = null; this.previousActive = null; this.handleOverlayClick = this.handleOverlayClick.bind(this); this.handleKeydown = this.handleKeydown.bind(this); } ensureElements() { if (this.overlay) return; this.overlay = document.createElement('div'); this.overlay.className = 'ghcm-help-overlay'; this.modal = document.createElement('div'); this.modal.className = 'ghcm-help-modal'; this.modal.setAttribute('role', 'dialog'); this.modal.setAttribute('aria-modal', 'true'); this.modal.setAttribute('aria-label', 'GitHub Collapse Markdown 使用说明'); this.modal.setAttribute('tabindex', '-1'); const header = document.createElement('div'); header.className = 'ghcm-help-header'; const title = document.createElement('div'); title.className = 'ghcm-help-title'; const titleText = document.createElement('span'); titleText.className = 'ghcm-help-title-text'; titleText.textContent = 'GitHub Collapse Markdown'; const titleSub = document.createElement('span'); titleSub.className = 'ghcm-help-title-sub'; titleSub.textContent = '使用说明'; title.append(titleText, titleSub); this.closeButton = document.createElement('button'); this.closeButton.type = 'button'; this.closeButton.className = 'ghcm-help-close'; this.closeButton.setAttribute('aria-label', '关闭使用说明弹窗'); this.closeButton.textContent = '✕'; this.closeButton.addEventListener('click', () => this.hide()); header.append(title, this.closeButton); this.contentContainer = document.createElement('div'); this.contentContainer.className = 'ghcm-help-content'; this.content = document.createElement('article'); this.content.className = 'markdown-body'; this.contentContainer.appendChild(this.content); this.modal.append(header, this.contentContainer); this.overlay.appendChild(this.modal); } show() { this.ensureElements(); this.previousActive = document.activeElement instanceof HTMLElement ? document.activeElement : null; this.updateContent(); try { this.app.menuManager?.hide(); } catch {} try { this.app.tocGenerator?.hideToc?.(); } catch {} try { this.app.searchManager?.hideSearch?.(); } catch {} if (!this.overlay.isConnected) { document.body.appendChild(this.overlay); } requestAnimationFrame(() => { this.overlay.classList.add('show'); }); document.addEventListener('keydown', this.handleKeydown, true); this.overlay.addEventListener('click', this.handleOverlayClick); try { this.modal.focus(); } catch {} } hide() { if (!this.overlay) return; this.overlay.classList.remove('show'); document.removeEventListener('keydown', this.handleKeydown, true); this.overlay.removeEventListener('click', this.handleOverlayClick); setTimeout(() => { if (this.overlay?.parentNode) { this.overlay.parentNode.removeChild(this.overlay); } }, 220); if (this.previousActive) { try { this.previousActive.focus(); } catch {} } } handleOverlayClick(event) { if (event.target === this.overlay) { this.hide(); } } handleKeydown(event) { if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); this.hide(); } } updateContent() { if (!this.content) return; this.content.innerHTML = this.generateContentHTML(); this.contentContainer.scrollTop = 0; } generateContentHTML() { const hotkeys = CONFIG.hotkeys; const navHint = hotkeys.navEnabled ? '(Vim 导航已启用)' : '(默认关闭,可在设置中开启)'; return ` <h1>🚀 GitHub Collapse Markdown 使用指南</h1> <p>脚本为 GitHub 上的 Markdown、Issue、PR 与 Gist 页面提供标题折叠、目录导航、搜索、书签与状态记忆等增强功能。本指南涵盖快速入门、快捷键、界面操作、设置项与进阶技巧。</p> <section class="ghcm-help-section"> <h2>⚡ 快速开始</h2> <ol> <li>打开任意支持的 GitHub 页面,脚本会在右下角生成浮动菜单按钮。</li> <li>点击任意标题即可折叠/展开对应内容,嵌套标题会智能保持层级状态。</li> <li>使用右下角菜单或快捷键 ${this.wrapHotkey(hotkeys.menu)} 呼出设置面板,探索目录、搜索和自定义选项。</li> <li>折叠状态与书签会针对当前页面自动保存,刷新后仍保持。</li> </ol> </section> <section class="ghcm-help-section"> <h2>⌨️ 快捷键速查</h2> <div class="ghcm-help-grid"> <div class="ghcm-help-card"> <h3>折叠与视图</h3> ${this.renderShortcut('折叠全部', hotkeys.collapseAll)} ${this.renderShortcut('展开全部', hotkeys.expandAll)} ${this.renderShortcut('智能切换', hotkeys.toggleAll)} ${this.renderShortcut('显示菜单', hotkeys.menu)} </div> <div class="ghcm-help-card"> <h3>导航工具</h3> ${this.renderShortcut('打开目录', hotkeys.showToc)} ${this.renderShortcut('搜索标题', hotkeys.search)} ${this.renderShortcut('收藏当前标题', hotkeys.bookmark)} </div> <div class="ghcm-help-card"> <h3>高级导航 ${this.escapeHtml(navHint)}</h3> ${this.renderShortcut('下一标题', hotkeys.nextHeading)} ${this.renderShortcut('上一标题', hotkeys.prevHeading)} </div> </div> <p class="ghcm-help-footnote">快捷键可在设置菜单中整体开关;收藏、导航键位均支持自定义(Tampermonkey 菜单)。</p> </section> <section class="ghcm-help-section"> <h2>🖱️ 鼠标手势</h2> <ul class="ghcm-help-list"> <li><strong>单击标题</strong><span>折叠或展开对应内容块。</span></li> <li><strong>Shift + 单击</strong><span>同步折叠/展开当前层级所有标题。</span></li> <li><strong>悬停标题</strong><span>查看当前层级高亮,配合目录定位更直观。</span></li> <li><strong>右下角菜单按钮</strong><span>打开现代化 GUI,集中管理所有功能。</span></li> </ul> </section> <section class="ghcm-help-section"> <h2>🎛️ 主要界面</h2> <div class="ghcm-help-grid"> <div class="ghcm-help-card"> <h3>目录导航</h3> <p>以树形结构展示页面所有标题,支持自动折叠同步、快速跳转与当前标题高亮。</p> </div> <div class="ghcm-help-card"> <h3>标题搜索</h3> <p>即时索引当前页面标题,支持模糊匹配、键盘上下键切换结果以及 Tab/Shift+Tab 在输入框与结果间移动。</p> </div> <div class="ghcm-help-card"> <h3>书签面板</h3> <p>为常用段落添加收藏,支持从视窗捕获、列表跳转与一键清除,跨会话持久保存。</p> </div> </div> </section> <section class="ghcm-help-section"> <h2>⚙️ 设置选项</h2> <ul class="ghcm-help-list"> <li><strong>性能模式</strong><span>在长篇文档中禁用动画,提升滚动与切换响应。</span></li> <li><strong>状态记忆</strong><span>按页面保存折叠状态与展开偏好,可随时清空。</span></li> <li><strong>快捷键总开关</strong><span>与 Vim 导航独立控制,满足不同编辑习惯。</span></li> <li><strong>箭头外观</strong><span>可切换显示级别数字、调整箭头尺寸并自定义配色。</span></li> </ul> </section> <section class="ghcm-help-section"> <h2>💡 实用技巧</h2> <ul class="ghcm-help-list"> <li><strong>智能嵌套</strong><span>展开父级标题不会强制展开子级,保持阅读上下文。</span></li> <li><strong>哈希定位</strong><span>访问含锚点链接时自动展开相关标题并滚动到视图。</span></li> <li><strong>跨页面记忆</strong><span>Issue / PR / Wiki / 文档页面均以 URL 为键保存状态。</span></li> <li><strong>调试模式</strong><span>启用后在控制台输出内部状态,便于排查自定义冲突。</span></li> </ul> </section> <p class="ghcm-help-footnote">如遇折叠异常,可在菜单中清空记忆数据或刷新页面重新加载脚本;欢迎在 GitHub Issues 提交反馈与建议。</p> `.trim(); } renderShortcut(label, hotkey) { return ` <div class="ghcm-help-shortcut"> <span>${this.escapeHtml(label)}</span> ${this.wrapHotkey(hotkey)} </div> `.trim(); } wrapHotkey(hotkey) { return `<span class="ghcm-help-kbd"><span>${this.escapeHtml(hotkey || '未设置')}</span></span>`; } escapeHtml(text) { return String(text ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char] || char)); } } // 状态管理 class StateManager { constructor() { this.headerStates = new Map(); this.observers = []; 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) { // 优先使用稳定的 DOM id/锚点,避免文本或位置变化导致状态错配 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 {} // 回退到基于 level+文本+位置 的键 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 }); }); Logger.log(`[GHCM] 已加载 ${Object.keys(currentStates).length} 个已保存的状态`); } } catch (e) { Logger.warn("[GHCM] 加载状态失败:", e); } } clearMemory() { try { const pageStates = GM_getValue(CONFIG.memory.key, {}); delete pageStates[this.pageUrl]; GM_setValue(CONFIG.memory.key, pageStates); Logger.log("[GHCM] 已清除当前页面的记忆状态"); } catch (e) { Logger.warn("[GHCM] 清除状态失败:", e); } } } // 快捷键管理器 const EVENT_HANDLED_FLAG = '__ghcmHotkeyHandled__'; class HotkeyManager { constructor(collapseManager) { this.collapseManager = collapseManager; this._boundHandler = null; this._isBound = false; this.app = null; this._listenerOptions = { capture: true, passive: false }; const nativeWindow = (() => { try { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } catch { return window; } })(); this._listenerTargets = Array.from(new Set([nativeWindow, document])); this.setupHotkeys(); } setApp(app) { this.app = app; } setupHotkeys() { if (!CONFIG.hotkeys.enabled || this._isBound) return; if (!this._boundHandler) { this._boundHandler = this.handleKeyDown.bind(this); } this._listenerTargets.forEach(target => { try { target.addEventListener('keydown', this._boundHandler, this._listenerOptions); } catch {} }); this._isBound = true; Logger.log("[GHCM] 快捷键已启用:", Object.entries(CONFIG.hotkeys) .filter(([k]) => k !== 'enabled' && k !== 'navEnabled') .map(([k, v]) => `${k}: ${v}`) .join(', ')); } teardownHotkeys() { if (this._isBound && this._boundHandler) { this._listenerTargets.forEach(target => { try { target.removeEventListener('keydown', this._boundHandler, this._listenerOptions); } catch {} }); this._isBound = false; } } blockEvent(event) { try { event.preventDefault(); event.stopPropagation(); if (typeof event.stopImmediatePropagation === 'function') { event.stopImmediatePropagation(); } } catch {} } handleKeyDown(event) { if (event[EVENT_HANDLED_FLAG]) return; event[EVENT_HANDLED_FLAG] = true; if (!CONFIG.hotkeys.enabled) return; // 在输入/可编辑区域内不触发全局快捷键 try { const t = event.target; if (t && (t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]'))) return; } catch {} const combo = this.getKeyCombo(event); switch (combo) { case CONFIG.hotkeys.collapseAll: this.blockEvent(event); this.collapseManager.collapseAll(); break; case CONFIG.hotkeys.expandAll: this.blockEvent(event); this.collapseManager.expandAll(); break; case CONFIG.hotkeys.toggleAll: this.blockEvent(event); this.collapseManager.toggleAll(); break; case CONFIG.hotkeys.showToc: this.blockEvent(event); this.collapseManager.toggleToc(); break; case CONFIG.hotkeys.search: this.blockEvent(event); this.collapseManager.toggleSearch(); break; case CONFIG.hotkeys.menu: this.blockEvent(event); if (this.collapseManager.menuManager) { this.collapseManager.menuManager.toggle(); } break; case CONFIG.hotkeys.bookmark: this.blockEvent(event); this.app?.bookmarkManager?.toggleBookmarkForActiveHeader(); break; } if (CONFIG.hotkeys.navEnabled) { if (combo === CONFIG.hotkeys.nextHeading) { this.blockEvent(event); this.collapseManager.focusNextHeading(); return; } if (combo === CONFIG.hotkeys.prevHeading) { this.blockEvent(event); this.collapseManager.focusPreviousHeading(); return; } } } getKeyCombo(event) { const keys = []; const isMac = (() => { try { return /mac/i.test(navigator.platform || navigator.userAgent || ''); } catch { return false; } })(); if (event.ctrlKey || (isMac && event.metaKey)) keys.push('ctrl'); if (event.shiftKey) keys.push('shift'); if (event.altKey) keys.push('alt'); if (event.metaKey && !isMac) keys.push('meta'); const key = event.key.toLowerCase(); if (key !== 'control' && key !== 'shift' && key !== 'alt' && key !== 'meta') { keys.push(key); } return keys.join('+'); } } // 目录生成器 class TocGenerator { constructor() { this.tocContainer = null; this.isVisible = false; this._keyHandler = null; this._idSeed = 0; this._scrollHandler = null; this._raf = null; } generateToc() { const headers = this.getAllHeaders(); if (headers.length === 0) return null; const toc = document.createElement('div'); toc.className = CONFIG.classes.tocContainer; toc.setAttribute('role', 'dialog'); toc.setAttribute('aria-modal', 'false'); toc.setAttribute('aria-label', '目录导航'); toc.innerHTML = ` <div class="ghcm-toc-header"> <h3 id="ghcm-toc-title">📑 目录导航</h3> <button class="ghcm-toc-close" title="关闭目录" aria-label="关闭目录">✕</button> </div> <div class="ghcm-toc-content"> ${this.generateTocItems(headers)} </div> `; this.setupTocEvents(toc); return toc; } getAllHeaders() { // 复用 CollapseManager 的收集以减少重复遍历 const source = (this.collapseManager && typeof this.collapseManager.getAllHeaders === 'function') ? this.collapseManager.getAllHeaders() : DOMUtils.collectHeadings(); const list = source.map(el => ({ element: el, level: DOMUtils.getHeadingLevel(el), text: el.textContent.trim(), id: this.getHeaderId(el) })); return this.dedupeById(list); } // 以 header id 去重,避免部分页面 DOM 结构重复扫描 dedupeById(items) { try { const map = new Map(); for (const it of items) { if (!it || !it.id) continue; if (!map.has(it.id)) map.set(it.id, it); } return Array.from(map.values()); } catch { return items; } } generateTocItems(headers) { return headers.map(header => { const indent = (header.level - 1) * 20; const element = header.element; const isCollapsed = element?.classList?.contains(CONFIG.classes.collapsed); let isNavigable = true; try { if (element && this.collapseManager && typeof this.collapseManager.isHeaderNavigable === 'function') { isNavigable = this.collapseManager.isHeaderNavigable(element); } } catch {} const shouldShowCollapsed = Boolean(isCollapsed || !isNavigable); const collapseIcon = shouldShowCollapsed ? '▶' : '▼'; const collapsedClass = shouldShowCollapsed ? ' ghcm-toc-item-collapsed' : ''; return ` <div class="ghcm-toc-item${collapsedClass}" style="padding-left: ${indent}px;" data-level="${header.level}" data-header-id="${header.id}" tabindex="0"> <span class="ghcm-toc-collapse-icon">${collapseIcon}</span> <a href="#${header.id}" class="ghcm-toc-link" data-header-id="${header.id}"> ${header.text} </a> </div> `; }).join(''); } getHeaderId(element) { // 尝试获取已有的ID const anchor = element.querySelector('.anchor'); if (anchor) return anchor.getAttribute('href')?.slice(1) || ''; const id = element.id || element.getAttribute('id'); if (id) return id; // 无现成ID,则赋予一个稳定、唯一的ID const newId = `ghcm-h-${++this._idSeed}`; try { element.setAttribute('id', newId); } catch {} return newId; } getElementPosition(element) { let position = 0; let current = element; while (current && current.parentNode) { const siblings = Array.from(current.parentNode.children); position += siblings.indexOf(current); current = current.parentNode; } return position; } setupTocEvents(toc) { // 关闭按钮 toc.querySelector('.ghcm-toc-close').addEventListener('click', () => { this.hideToc(); }); // 整行可点击:事件委托在容器上处理 toc.addEventListener('click', (e) => { const item = e.target.closest('.ghcm-toc-item'); if (!item) return; e.preventDefault(); const headerId = item.getAttribute('data-header-id') || item.querySelector('.ghcm-toc-link')?.getAttribute('data-header-id'); if (headerId) this.scrollToHeader(headerId); }); // 键盘回车/空格激活整行 toc.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const item = e.target.closest('.ghcm-toc-item'); if (!item) return; e.preventDefault(); const headerId = item.getAttribute('data-header-id') || item.querySelector('.ghcm-toc-link')?.getAttribute('data-header-id'); if (headerId) this.scrollToHeader(headerId); }); } scrollToHeader(headerId) { const element = document.getElementById(headerId) || document.querySelector(`[id="${headerId}"]`) || document.querySelector(`#user-content-${headerId}`); if (element) { // 如果标题被折叠,自动展开其父级 this.expandParentHeaders(element); // 使用统一滚动函数,避免重复滚动与抖动 requestAnimationFrame(() => { this.collapseManager.scrollToElement(element); this.collapseManager.setActiveHeading(element); }); // 更新目录显示状态 setTimeout(() => { this.refreshTocStates(); }, 300); } } // 刷新目录中的折叠状态显示 refreshTocStates() { if (!this.tocContainer) return; const tocItems = this.tocContainer.querySelectorAll('.ghcm-toc-item'); tocItems.forEach(item => { const link = item.querySelector('.ghcm-toc-link'); const icon = item.querySelector('.ghcm-toc-collapse-icon'); if (!link || !icon) return; const headerId = link.getAttribute('data-header-id'); if (!headerId) return; const safeId = (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') ? CSS.escape(headerId) : headerId; let headerElement = document.getElementById(headerId); if (!headerElement) { try { headerElement = document.querySelector(`[id="${headerId}"]`); } catch {} } if (!headerElement && safeId) { try { headerElement = document.querySelector(`#${safeId}`); } catch {} } if (!headerElement && safeId) { try { headerElement = document.querySelector(`#user-content-${safeId}`); } catch {} } if (!headerElement) { try { headerElement = document.querySelector(`#user-content-${headerId}`); } catch {} } if (!headerElement) { icon.textContent = '▼'; item.classList.remove('ghcm-toc-item-collapsed'); return; } const isCollapsed = headerElement.classList.contains(CONFIG.classes.collapsed); let isNavigable = true; try { if (this.collapseManager && typeof this.collapseManager.isHeaderNavigable === 'function') { isNavigable = this.collapseManager.isHeaderNavigable(headerElement); } } catch {} const shouldShowCollapsed = Boolean(isCollapsed || !isNavigable); icon.textContent = shouldShowCollapsed ? '▶' : '▼'; item.classList.toggle('ghcm-toc-item-collapsed', shouldShowCollapsed); }); } expandParentHeaders(targetElement) { // 找到对应的collapseManager实例并展开到该标题 if (window.ghcmInstance && window.ghcmInstance.collapseManager) { window.ghcmInstance.collapseManager.expandToHeader(targetElement, { scroll: false, setActive: false }); } } showToc() { if (this.tocContainer) { this.tocContainer.remove(); } this.tocContainer = this.generateToc(); if (this.tocContainer) { // 打开目录前关闭其他浮层 try { this.collapseManager?.menuManager?.hide(); } catch {} try { this.collapseManager?.searchManager?.hideSearch(); } catch {} document.body.appendChild(this.tocContainer); this.isVisible = true; // ESC 关闭 this._keyHandler = (e) => { if (e.key === 'Escape') this.hideToc(); }; document.addEventListener('keydown', this._keyHandler); // 初始焦点 try { this.tocContainer.setAttribute('tabindex', '-1'); this.tocContainer.focus(); } catch {} // 确保状态正确显示 setTimeout(() => { this.refreshTocStates(); }, 100); // 启动滚动监听(Scroll Spy) this.startScrollSpy(); } } hideToc() { if (this.tocContainer) { this.tocContainer.remove(); this.tocContainer = null; this.isVisible = false; if (this._keyHandler) { document.removeEventListener('keydown', this._keyHandler); this._keyHandler = null; } this.stopScrollSpy(); } } toggle() { if (this.isVisible) { this.hideToc(); } else { this.showToc(); } } // ========= Scroll Spy ========= startScrollSpy() { if (this._scrollHandler) return; this._scrollHandler = () => { if (this._raf) return; this._raf = requestAnimationFrame(() => { this._raf = null; this.updateActiveFromScroll(); }); }; window.addEventListener('scroll', this._scrollHandler, { passive: true }); window.addEventListener('resize', this._scrollHandler, { passive: true }); // 初次计算 this.updateActiveFromScroll(); } stopScrollSpy() { if (!this._scrollHandler) return; window.removeEventListener('scroll', this._scrollHandler); window.removeEventListener('resize', this._scrollHandler); this._scrollHandler = null; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } updateActiveFromScroll() { try { const headers = this.getAllHeaders(); if (!headers.length || !this.tocContainer) return; const headerEl = document.querySelector('header[role="banner"], .Header, .AppHeader-globalBar'); const headerOffset = (headerEl?.offsetHeight || 80) + 20; const pos = window.scrollY + headerOffset + 1; let active = null; let firstNavigable = null; for (const h of headers) { const element = h.element; if (!element) continue; let isNavigable = true; try { if (this.collapseManager && typeof this.collapseManager.isHeaderNavigable === 'function') { isNavigable = this.collapseManager.isHeaderNavigable(element); } } catch {} if (isNavigable && !firstNavigable) { firstNavigable = h; } if (!isNavigable) { continue; } const rect = element.getBoundingClientRect(); const top = rect.top + window.pageYOffset; if (top <= pos) { active = h; } else { break; } } if (!active) { active = firstNavigable; } if (active) { if (active.id) { this.highlightTocById(active.id); } this.collapseManager?.setActiveHeading(active.element); } } catch {} } highlightTocById(id) { if (!this.tocContainer) return; this.tocContainer.querySelectorAll('.ghcm-toc-item').forEach(el => el.classList.remove('active')); const link = this.tocContainer.querySelector(`.ghcm-toc-link[data-header-id="${CSS.escape(id)}"]`); if (link) { const item = link.closest('.ghcm-toc-item'); if (item) item.classList.add('active'); } } } // 搜索功能 class SearchManager { constructor(collapseManager) { this.collapseManager = collapseManager; this.searchContainer = null; this.isVisible = false; this.activeIndex = -1; this._keyHandler = null; this._headerIndex = []; this._indexDirty = true; this._indexSeed = 0; this._idMap = new WeakMap(); } invalidateIndex() { if (this._headerIndex.length) { try { this._headerIndex.forEach(item => { item?.element?.removeAttribute?.('data-search-id'); }); } catch {} } this._headerIndex = []; this._indexDirty = true; this._idMap = new WeakMap(); } ensureIndex() { if (!this._indexDirty && this._headerIndex.length) { return this._headerIndex; } const elements = DOMUtils.collectHeadings(); const index = elements.map(el => { const existingId = this._idMap.get(el); const id = existingId || `search-header-${++this._indexSeed}`; this._idMap.set(el, id); try { el.setAttribute('data-search-id', id); } catch {} return { element: el, level: DOMUtils.getHeadingLevel(el), text: el.textContent.trim(), id }; }); this._headerIndex = index; this._indexDirty = false; return this._headerIndex; } createSearchUI() { const container = document.createElement('div'); container.className = CONFIG.classes.searchContainer; container.setAttribute('role', 'dialog'); container.setAttribute('aria-modal', 'true'); container.setAttribute('aria-label', '搜索标题'); container.innerHTML = ` <div class="ghcm-search-header"> <h3 id="ghcm-search-title">🔍 搜索标题</h3> <button class="ghcm-search-close" title="关闭搜索" aria-label="关闭搜索">✕</button> </div> <div class="ghcm-search-content"> <input type="text" class="ghcm-search-input" placeholder="输入关键词搜索标题..." autocomplete="off"> <div class="ghcm-search-filters"> <div class="ghcm-level-filters" aria-label="过滤级别"> <label><input type="checkbox" data-level="1" checked> H1</label> <label><input type="checkbox" data-level="2" checked> H2</label> <label><input type="checkbox" data-level="3" checked> H3</label> <label><input type="checkbox" data-level="4" checked> H4</label> <label><input type="checkbox" data-level="5" checked> H5</label> <label><input type="checkbox" data-level="6" checked> H6</label> </div> <div class="ghcm-search-hint-row">Enter 跳转,Shift+Enter 上一个</div> </div> <div class="ghcm-search-results"></div> </div> `; this.setupSearchEvents(container); return container; } setupSearchEvents(container) { const input = container.querySelector('.ghcm-search-input'); const results = container.querySelector('.ghcm-search-results'); const closeBtn = container.querySelector('.ghcm-search-close'); const levelBox = container.querySelector('.ghcm-level-filters'); // 级别过滤默认全部启用 this.levelFilter = new Set([1,2,3,4,5,6]); // 实时搜索 let searchTimeout; input.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { this.performSearch(input.value.trim(), results); }, 300); }); // 级别过滤变更 levelBox.addEventListener('change', (e) => { const cb = e.target.closest('input[type="checkbox"][data-level]'); if (!cb) return; const lvl = parseInt(cb.getAttribute('data-level'), 10); if (cb.checked) this.levelFilter.add(lvl); else this.levelFilter.delete(lvl); this.performSearch(input.value.trim(), results); }); // 关闭搜索 closeBtn.addEventListener('click', () => { this.hideSearch(); }); // 搜索结果点击委托 results.addEventListener('click', (event) => { const item = event.target.closest('.ghcm-search-result'); if (!item) return; const headerId = item.getAttribute('data-header-element'); if (headerId) { this.jumpToHeader(headerId); } }); results.addEventListener('focusin', (event) => { const item = event.target.closest('.ghcm-search-result'); if (!item) return; const items = Array.from(results.querySelectorAll('.ghcm-search-result')); const idx = items.indexOf(item); if (idx !== -1) { this.activeIndex = idx; this.updateActiveResult(items); } }); // 键盘导航与 ESC 关闭 this._keyHandler = (e) => { if (e.key === 'Escape') { this.hideSearch(); return; } if (e.key === 'Tab') { const focusables = this.getSearchFocusables(container); const current = document.activeElement; const idx = focusables.indexOf(current); if (idx !== -1 && focusables.length > 0) { e.preventDefault(); const nextIndex = (idx + (e.shiftKey ? -1 : 1) + focusables.length) % focusables.length; const target = focusables[nextIndex]; target?.focus(); if (target?.classList?.contains('ghcm-search-result')) { const items = Array.from(results.querySelectorAll('.ghcm-search-result')); const focusIdx = items.indexOf(target); if (focusIdx !== -1) { this.activeIndex = focusIdx; this.updateActiveResult(items); } } } return; } const items = Array.from(results.querySelectorAll('.ghcm-search-result')); if (items.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); this.activeIndex = (this.activeIndex + 1) % items.length; this.updateActiveResult(items); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.activeIndex = (this.activeIndex - 1 + items.length) % items.length; this.updateActiveResult(items); } else if (e.key === 'Enter') { if (this.activeIndex >= 0 && this.activeIndex < items.length) { if (e.shiftKey) { // Shift+Enter 上一个 this.activeIndex = (this.activeIndex - 1 + items.length) % items.length; this.updateActiveResult(items); } else { items[this.activeIndex].click(); } } } }; container.addEventListener('keydown', this._keyHandler); // 自动聚焦 setTimeout(() => input.focus(), 100); } updateActiveResult(items) { items.forEach((el, i) => el.classList.toggle('active', i === this.activeIndex)); if (this.activeIndex >= 0 && items[this.activeIndex]) { items[this.activeIndex].scrollIntoView({ block: 'nearest' }); } } getSearchFocusables(container) { const focusables = []; const input = container.querySelector('.ghcm-search-input'); if (input) focusables.push(input); focusables.push(...Array.from(container.querySelectorAll('.ghcm-level-filters input[type="checkbox"]'))); focusables.push(...Array.from(container.querySelectorAll('.ghcm-search-result'))); const closeBtn = container.querySelector('.ghcm-search-close'); if (closeBtn) focusables.push(closeBtn); return focusables; } performSearch(query, resultsContainer) { if (!query) { resultsContainer.innerHTML = '<div class="ghcm-search-hint">请输入搜索关键词</div>'; this.activeIndex = -1; return; } const headers = this.getAllSearchableHeaders(); // 级别过滤 const filtered = headers.filter(h => this.levelFilter?.has(h.level)); // 模糊匹配 + 打分 const q = query.trim(); const matches = []; for (const h of filtered) { const res = this.fuzzyMatch(h.text, q); if (res.matched) { matches.push({ h, score: res.score, indices: res.indices }); } } matches.sort((a,b) => b.score - a.score); if (matches.length === 0) { resultsContainer.innerHTML = '<div class="ghcm-search-no-results">未找到匹配的标题</div>'; this.activeIndex = -1; return; } const resultHtml = matches.map(({h, indices}) => ` <div class="ghcm-search-result" data-header-element="${h.id}" tabindex="0"> <span class="ghcm-search-level">H${h.level}</span> <span class="ghcm-search-text">${this.safeHighlightByIndices(h.text, indices)}</span> </div> `).join(''); resultsContainer.innerHTML = resultHtml; const items = Array.from(resultsContainer.querySelectorAll('.ghcm-search-result')); if (items.length) { this.activeIndex = 0; this.updateActiveResult(items); } else { this.activeIndex = -1; } } // 简单模糊匹配: // 1) 连续子串匹配给高分;2) 按字符顺序匹配有惩罚;3) 记录命中索引用于高亮 fuzzyMatch(text, query) { const t = (text || '').toLowerCase(); const q = (query || '').toLowerCase(); if (!q) return { matched: true, score: 0, indices: [] }; const i = t.indexOf(q); if (i !== -1) { const indices = Array.from({length: q.length}, (_,k)=> i+k); const score = 1000 - i; // 越靠前越高分 return { matched: true, score, indices }; } // 顺序子序列匹配 let ti = 0; const indices = []; for (let qi = 0; qi < q.length; qi++) { const ch = q[qi]; ti = t.indexOf(ch, ti); if (ti === -1) return { matched: false, score: -Infinity, indices: [] }; indices.push(ti); ti++; } // 评分:越连续、跨度越小得分越高 let gaps = 0; for (let k=1;k<indices.length;k++){ gaps += (indices[k]-indices[k-1]-1); } const span = indices[indices.length-1] - indices[0] + 1; const score = 500 - gaps*5 - span; return { matched: true, score, indices }; } safeHighlightByIndices(text, indices) { try { if (!indices || !indices.length) return this.safeHighlightMatch(text, ''); let out = ''; let last = 0; const set = new Set(indices); for (let i=0;i<text.length;i++) { if (set.has(i)) { // 开始标记连续段 let j = i; while (set.has(j)) j++; out += this.escapeHtml(text.slice(last, i)) + '<mark>' + this.escapeHtml(text.slice(i, j)) + '</mark>'; last = j; i = j-1; } } out += this.escapeHtml(text.slice(last)); return out; } catch { return this.escapeHtml(String(text||'')); } } escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); } getAllSearchableHeaders() { return this.ensureIndex(); } highlightMatch(text, query) { const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escaped})`, 'gi'); return text.replace(regex, '<mark>$1</mark>'); } safeHighlightMatch(text, query) { try { const escaped = String(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escaped})`, 'gi'); return String(text).replace(regex, '<mark>$1</mark>'); } catch (e) { return text; } } jumpToHeader(headerId) { const element = document.querySelector(`[data-search-id="${headerId}"]`); if (element) { // 展开到该标题 this.collapseManager.expandToHeader(element, { scroll: false, setActive: false }); this.collapseManager.scrollToElement(element); this.collapseManager.setActiveHeading(element); // 隐藏搜索界面 this.hideSearch(); } } showSearch() { if (this.searchContainer) { this.searchContainer.remove(); } // 打开搜索前关闭其他浮层 try { this.collapseManager?.menuManager?.hide(); } catch {} try { this.collapseManager?.tocGenerator?.hideToc(); } catch {} this.searchContainer = this.createSearchUI(); document.body.appendChild(this.searchContainer); this.isVisible = true; } hideSearch() { if (this.searchContainer) { this.searchContainer.remove(); this.searchContainer = null; this.isVisible = false; // 清理键盘事件 if (this._keyHandler) { // 绑定在容器上,容器已移除即可 this._keyHandler = null; } this.activeIndex = -1; } } toggle() { if (this.isVisible) { this.hideSearch(); } else { this.showSearch(); } } } class BookmarkManager { constructor(app) { this.app = app; this.storageKey = CONFIG.bookmarks.key; this.bookmarksByPage = this.normalizeStoredBookmarks(GM_getValue(this.storageKey, null)); this._applyRetryTimer = null; this._applyRetryAttempts = 0; this._applyRetryMax = 6; this._applyRetryDelay = 300; this.ensurePageEntry(); setTimeout(() => this.applyBookmarks(), 200); } getPageKey() { return this.app.stateManager.pageUrl; } normalizeStoredBookmarks(raw) { if (!raw) return {}; let data = raw; if (typeof raw === 'string') { try { data = JSON.parse(raw); } catch { return {}; } } if (!data || typeof data !== 'object') return {}; const normalized = {}; Object.entries(data).forEach(([pageKey, entries]) => { if (!Array.isArray(entries)) return; const cleaned = entries .map(entry => this.cloneBookmark(entry)) .filter(Boolean); if (cleaned.length) { normalized[pageKey] = cleaned; } }); return normalized; } sanitizeBookmark(entry) { if (!entry || typeof entry !== 'object') return null; const key = typeof entry.key === 'string' ? entry.key.trim() : ''; if (!key) return null; const sanitized = { key }; if (typeof entry.id === 'string' && entry.id.trim()) { sanitized.id = entry.id.trim(); } if (typeof entry.text === 'string') { sanitized.text = entry.text; } if (typeof entry.level === 'number' && entry.level >= 1 && entry.level <= 6) { sanitized.level = entry.level; } return sanitized; } cloneBookmark(entry) { const sanitized = this.sanitizeBookmark(entry); return sanitized ? { ...sanitized } : null; } serializeBookmarks() { const snapshot = {}; Object.entries(this.bookmarksByPage).forEach(([pageKey, entries]) => { if (!Array.isArray(entries) || entries.length === 0) return; const cleaned = entries .map(entry => this.sanitizeBookmark(entry)) .filter(Boolean); if (cleaned.length) { snapshot[pageKey] = cleaned; } }); return JSON.stringify(snapshot); } ensurePageEntry() { const key = this.getPageKey(); if (!Array.isArray(this.bookmarksByPage[key])) { this.bookmarksByPage[key] = []; } this.bookmarksByPage[key] = this.bookmarksByPage[key] .map(entry => this.cloneBookmark(entry)) .filter(Boolean); return this.bookmarksByPage[key]; } getBookmarksForCurrentPage() { return this.ensurePageEntry().map(entry => this.cloneBookmark(entry)).filter(Boolean); } save() { try { GM_setValue(this.storageKey, this.serializeBookmarks()); } catch (e) { Logger.warn('[GHCM] 保存书签失败:', e); } } getHeaderElement(node) { if (!node) return null; if (DOMUtils.isHeader(node)) return node; let header = null; try { if (typeof node.closest === 'function') { header = node.closest(DOMUtils.getUpperHeadingSelector()); } } catch {} if (header && DOMUtils.isHeader(header)) return header; try { const wrapper = node.closest?.('.markdown-heading'); if (wrapper) { header = wrapper.querySelector(DOMUtils.getUpperHeadingSelector()); } } catch {} return (header && DOMUtils.isHeader(header)) ? header : null; } ensureHeaderId(element) { const header = this.getHeaderElement(element) || element; if (!header) return null; const normalize = value => (typeof value === 'string' ? value.trim() : ''); const directId = normalize(header.getAttribute?.('id') || header.id); if (directId && !/^ghcm-(?:bookmark|h)-/i.test(directId)) { return directId; } try { if (this.app.tocGenerator && typeof this.app.tocGenerator.getHeaderId === 'function') { const tocId = normalize(this.app.tocGenerator.getHeaderId(header)); if (tocId) return tocId; } } catch {} let anchorId = ''; let hrefId = ''; try { const anchor = header.querySelector?.('.anchor'); if (anchor) { anchorId = normalize(anchor.getAttribute('id')); hrefId = normalize(anchor.getAttribute('href')?.replace(/^#/, '')); } } catch {} if (anchorId) return anchorId; if (hrefId) return hrefId; let childId = ''; try { const anyWithId = header.querySelector?.('[id]'); childId = normalize(anyWithId?.getAttribute('id')); } catch {} if (childId) return childId; const generated = `ghcm-bookmark-${Date.now()}-${Math.random().toString(36).slice(2)}`; try { header.setAttribute('id', generated); } catch {} return generated; } addBookmarkForElement(element, { notify = true } = {}) { const header = this.getHeaderElement(element) || element; if (!header || !DOMUtils.isHeader(header)) return; const pageBookmarks = this.ensurePageEntry(); const key = this.app.stateManager.generateHeaderKey(header); const existingIndex = pageBookmarks.findIndex(item => item.key === key); if (existingIndex !== -1) { if (notify) this.app.collapseManager.showNotification('⭐ 该标题已在书签中'); return; } const id = this.ensureHeaderId(header); this.app.collapseManager.setActiveHeading(header); const entry = { id, key, text: header.textContent?.trim() || '未命名标题', level: DOMUtils.getHeadingLevel(header) }; pageBookmarks.push(entry); this.save(); this.applyBookmarks(); if (notify) this.app.collapseManager.showNotification('⭐ 已收藏当前标题'); this.app.menuManager?.updateBookmarkList(); } toggleBookmarkForElement(element) { const header = this.getHeaderElement(element) || element; if (!header || !DOMUtils.isHeader(header)) return; this.app.collapseManager.setActiveHeading(header); const pageBookmarks = this.ensurePageEntry(); const key = this.app.stateManager.generateHeaderKey(header); const existingIndex = pageBookmarks.findIndex(item => item.key === key); if (existingIndex !== -1) { pageBookmarks.splice(existingIndex, 1); this.save(); this.applyBookmarks(); this.app.collapseManager.showNotification('🗑️ 已移除书签'); this.app.menuManager?.updateBookmarkList(); return; } this.addBookmarkForElement(header); } toggleBookmarkForActiveHeader() { let header = DOMUtils.getHeaderFromSelection(); if (!header) { header = this.app.collapseManager.getActiveHeaderElement(); } if (!header) { header = this.app.collapseManager.getActiveHeaderElement(true); } if (!header) { this.app.collapseManager.showNotification('⚠️ 未找到可收藏的标题'); return; } this.app.collapseManager.setActiveHeading(header); this.toggleBookmarkForElement(header); } addBookmarkFromViewport() { let header = DOMUtils.getHeaderFromSelection(); if (!header) { header = this.app.collapseManager.getActiveHeaderElement(); } if (!header) { header = this.app.collapseManager.getActiveHeaderElement(true); } if (!header) { this.app.collapseManager.showNotification('⚠️ 当前视图未找到标题'); return; } this.app.collapseManager.setActiveHeading(header); this.addBookmarkForElement(header); } removeBookmarkByIndex(index) { const pageBookmarks = this.ensurePageEntry(); if (index < 0 || index >= pageBookmarks.length) return; pageBookmarks.splice(index, 1); this.save(); this.applyBookmarks(); this.app.collapseManager.showNotification('🗑️ 已移除书签'); this.app.menuManager?.updateBookmarkList(); } clearPageBookmarks() { const key = this.getPageKey(); this.bookmarksByPage[key] = []; this.save(); this.applyBookmarks(); this.app.collapseManager.showNotification('🗂️ 已清空本页书签'); this.app.menuManager?.updateBookmarkList(); } openBookmarkByIndex(index) { const pageBookmarks = this.ensurePageEntry(); if (index < 0 || index >= pageBookmarks.length) return; const bookmark = pageBookmarks[index]; const element = this.resolveBookmarkElement(bookmark); if (element) { this.app.collapseManager.expandToHeader(element, { scroll: false, setActive: false }); this.app.collapseManager.scrollToElement(element); this.app.collapseManager.setActiveHeading(element); this.highlightTemporarily(element); } } resolveBookmarkElement(bookmark) { if (!bookmark) return null; const candidates = []; if (typeof bookmark.id === 'string' && bookmark.id.trim()) { const trimmed = bookmark.id.trim(); candidates.push(trimmed); if (!trimmed.startsWith('user-content-')) { candidates.push(`user-content-${trimmed}`); } } let element = null; for (const candidate of candidates) { if (!candidate) continue; let found = null; try { found = document.getElementById(candidate); } catch {} if (!found) continue; const header = this.getHeaderElement(found); if (header) { element = header; break; } } if (!element) { const headers = this.app.collapseManager.getAllHeaders(); for (const header of headers) { const key = this.app.stateManager.generateHeaderKey(header); if (key === bookmark.key) { element = header; break; } } } if (!element) return null; const newId = this.ensureHeaderId(element); if (newId && newId !== bookmark.id) { bookmark.id = newId; this.save(); } return element; } applyBookmarks({ attempt = 0 } = {}) { const headers = this.app.collapseManager.getAllHeaders(); headers.forEach(header => header.classList.remove(CONFIG.classes.bookmarked)); const pageKey = this.getPageKey(); const pageBookmarks = this.ensurePageEntry(); let unresolved = 0; pageBookmarks.forEach(bookmark => { const element = this.resolveBookmarkElement(bookmark); if (element) { element.classList.add(CONFIG.classes.bookmarked); } else { unresolved++; } }); if (unresolved > 0 && attempt < this._applyRetryMax) { if (this._applyRetryTimer) clearTimeout(this._applyRetryTimer); const nextAttempt = attempt + 1; this._applyRetryAttempts = nextAttempt; this._applyRetryTimer = setTimeout(() => { this._applyRetryTimer = null; this.applyBookmarks({ attempt: nextAttempt }); }, this._applyRetryDelay); } else if (unresolved === 0) { this._applyRetryAttempts = 0; if (this._applyRetryTimer) { clearTimeout(this._applyRetryTimer); this._applyRetryTimer = null; } } else if (unresolved > 0) { this._applyRetryAttempts = 0; if (this._applyRetryTimer) { clearTimeout(this._applyRetryTimer); this._applyRetryTimer = null; } } this.app.menuManager?.updateBookmarkList(); } highlightTemporarily(element) { if (!element) return; try { element.classList.add('ghcm-temp-highlight'); setTimeout(() => element.classList.remove('ghcm-temp-highlight'), 600); } catch {} } } // DOM 工具类 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 getHeaderFromSelection() { try { const selection = window.getSelection?.(); if (!selection || selection.rangeCount === 0) return null; const node = selection.focusNode || selection.anchorNode; if (!node) return null; const isElementNode = typeof Node !== 'undefined' && node.nodeType === Node.ELEMENT_NODE; const element = (isElementNode ? node : node.parentElement) || null; if (!element) return null; const direct = element.closest(DOMUtils.getUpperHeadingSelector()); if (direct && DOMUtils.shouldIncludeHeading(direct)) { return direct; } const wrapper = element.closest('.markdown-heading'); if (wrapper) { const header = wrapper.querySelector(DOMUtils.getUpperHeadingSelector()); if (header && DOMUtils.shouldIncludeHeading(header)) { return header; } } } catch {} return null; } // 仅收录页面中可见且非辅助导航区域的标题 static isVisible(el) { try { if (!el || el.getAttribute('aria-hidden') === 'true' || el.hidden) return false; // 常见 SR-only 类 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; } } // 样式管理器 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 { transform: translateY(-50%) rotate(-90deg); } /* 书签标记 */ .${CONFIG.classes.bookmarked} { background: rgba(252, 211, 77, 0.35); border-radius: 4px; } .${CONFIG.classes.bookmarked}::before { content: none; } /* 当前激活标题 */ .${CONFIG.classes.activeHeading} { background: rgba(191, 219, 254, 0.55); border-radius: 4px; } .${CONFIG.classes.bookmarked}.${CONFIG.classes.activeHeading} { background: rgba(224, 231, 255, 0.6); } .${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; } /* 保留 GitHub 标题锚点交互,不禁止点击 */ /* 平滑动画 */ .ghcm-transitioning { transition: opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}, transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}; } /* 目录容器样式 */ .${CONFIG.classes.tocContainer} { position: fixed; top: 20px; right: 20px; width: 300px; max-height: 70vh; background: var(--color-canvas-default, #ffffff); border: 1px solid var(--color-border-default, #d0d7de); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 10000; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .ghcm-toc-header { padding: 8px 12px; background: var(--color-canvas-subtle, #f6f8fa); border-bottom: 1px solid var(--color-border-default, #d0d7de); display: flex; justify-content: space-between; align-items: center; min-height: 36px; } .ghcm-toc-header h3 { margin: 0; font-size: 13px; font-weight: 600; color: var(--color-fg-default, #24292f); line-height: 1.2; } .ghcm-toc-close { background: none; border: none; font-size: 14px; cursor: pointer; padding: 2px 4px; border-radius: 3px; color: var(--color-fg-muted, #656d76); line-height: 1; } .ghcm-toc-close:hover { background: var(--color-danger-subtle, #ffebe9); color: var(--color-danger-fg, #cf222e); } .ghcm-toc-content { max-height: calc(70vh - 44px); overflow-y: auto; padding: 6px 0; } .ghcm-toc-item { display: flex; align-items: center; padding: 4px 16px; border-radius: 4px; margin: 1px 8px; cursor: pointer; } .ghcm-toc-item.ghcm-toc-item-collapsed { opacity: 0.78; } .ghcm-toc-item.ghcm-toc-item-collapsed .ghcm-toc-link { color: var(--color-fg-muted, #656d76); } .ghcm-toc-item:hover { background: var(--color-neutral-subtle, #f6f8fa); } /* TOC 活动高亮 */ .ghcm-toc-item.active { background: var(--color-accent-subtle, #ddf4ff); } .ghcm-toc-item.active .ghcm-toc-link { color: var(--color-accent-fg, #0969da); font-weight: 600; } .ghcm-toc-collapse-icon { font-size: 10px; margin-right: 8px; color: var(--color-fg-muted, #656d76); min-width: 12px; } .ghcm-toc-link { text-decoration: none; color: var(--color-fg-default, #24292f); font-size: 13px; line-height: 1.4; flex: 1; } .ghcm-toc-link:hover { color: var(--color-accent-fg, #0969da); } /* 搜索容器样式 */ .${CONFIG.classes.searchContainer} { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 480px; max-width: 90vw; max-height: 80vh; background: var(--color-canvas-default, #ffffff); border: 1px solid var(--color-border-default, #d0d7de); border-radius: 12px; box-shadow: 0 16px 32px rgba(0,0,0,0.24); z-index: 10001; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .ghcm-search-header { padding: 16px 20px; background: var(--color-canvas-subtle, #f6f8fa); border-bottom: 1px solid var(--color-border-default, #d0d7de); display: flex; justify-content: space-between; align-items: center; } .ghcm-search-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: var(--color-fg-default, #24292f); } .ghcm-search-close { background: none; border: none; font-size: 18px; cursor: pointer; padding: 6px; border-radius: 6px; color: var(--color-fg-muted, #656d76); } .ghcm-search-close:hover { background: var(--color-danger-subtle, #ffebe9); color: var(--color-danger-fg, #cf222e); } .ghcm-search-content { padding: 20px; } .ghcm-search-input { width: 100%; padding: 12px 16px; border: 2px solid var(--color-border-default, #d0d7de); border-radius: 8px; font-size: 16px; background: var(--color-canvas-default, #ffffff); color: var(--color-fg-default, #24292f); outline: none; transition: border-color 0.2s; } .ghcm-search-input:focus { border-color: var(--color-accent-emphasis, #0969da); } .ghcm-search-results { margin-top: 16px; max-height: 400px; overflow-y: auto; } /* 搜索过滤栏 */ .ghcm-search-filters { margin-top: 10px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; } .ghcm-level-filters label { margin-right: 8px; font-size: 12px; color: var(--color-fg-muted, #656d76); } .ghcm-search-hint-row { font-size: 12px; color: var(--color-fg-muted, #656d76); } .ghcm-search-result { display: flex; align-items: center; padding: 12px 16px; border-radius: 8px; cursor: pointer; margin: 4px 0; border: 1px solid transparent; } .ghcm-search-result:hover { background: var(--color-neutral-subtle, #f6f8fa); border-color: var(--color-border-default, #d0d7de); } /* 键盘导航高亮 */ .ghcm-search-result.active { background: var(--color-neutral-subtle, #f6f8fa); border-color: var(--color-border-default, #d0d7de); } .ghcm-search-level { background: var(--color-accent-subtle, #ddf4ff); color: var(--color-accent-fg, #0969da); padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 12px; min-width: 24px; text-align: center; } .ghcm-search-text { flex: 1; font-size: 14px; color: var(--color-fg-default, #24292f); } .ghcm-search-text mark { background: var(--color-attention-subtle, #fff8c5); color: var(--color-attention-fg, #9a6700); padding: 1px 2px; border-radius: 2px; } .ghcm-search-hint, .ghcm-search-no-results { text-align: center; padding: 40px 20px; color: var(--color-fg-muted, #656d76); font-style: italic; } /* 帮助弹窗 */ .ghcm-help-overlay { position: fixed; inset: 0; background: rgba(17, 24, 39, 0.45); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; transition: opacity 180ms ease; z-index: 10001; } .ghcm-help-overlay.show { opacity: 1; } .ghcm-help-modal { position: relative; width: min(720px, 90vw); max-height: min(90vh, 720px); background: var(--color-canvas-default, #ffffff); border-radius: 16px; border: 1px solid var(--color-border-default, #d0d7de); box-shadow: 0 24px 48px rgba(15, 23, 42, 0.26); display: flex; flex-direction: column; overflow: hidden; transform: translateY(10px) scale(0.96); transition: transform 200ms ease, opacity 200ms ease; opacity: 0; } .ghcm-help-modal:focus, .ghcm-help-modal:focus-visible { outline: none; box-shadow: 0 24px 48px rgba(15, 23, 42, 0.26), 0 0 0 3px rgba(99, 102, 241, 0.22); } .ghcm-help-overlay.show .ghcm-help-modal { transform: translateY(0) scale(1); opacity: 1; } .ghcm-help-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 16px; background: var(--color-canvas-subtle, #f6f8fa); border-bottom: 1px solid var(--color-border-default, #d0d7de); gap: 12px; } .ghcm-help-title { display: flex; flex-direction: column; gap: 4px; } .ghcm-help-title-text { font-size: 1.15rem; font-weight: 600; color: var(--color-fg-default, #1f2329); } .ghcm-help-title-sub { font-size: 0.85rem; color: var(--color-fg-muted, #4c566a); } .ghcm-help-close { width: 36px; height: 36px; border: none; border-radius: 50%; background: transparent; color: var(--color-fg-muted, #4c566a); cursor: pointer; transition: background 160ms ease, color 160ms ease, transform 160ms ease; font-size: 18px; line-height: 1; } .ghcm-help-close:hover { background: rgba(99, 102, 241, 0.08); color: var(--color-fg-default, #1f2329); transform: scale(1.05); } .ghcm-help-close:focus { outline: 2px solid rgba(99, 102, 241, 0.35); outline-offset: 2px; } .ghcm-help-content { padding: 20px 24px 28px; overflow: auto; scrollbar-width: thin; } .ghcm-help-content::-webkit-scrollbar { width: 8px; } .ghcm-help-content::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.5); border-radius: 999px; } .ghcm-help-content > .markdown-body { font-size: 14px; line-height: 1.65; color: var(--color-fg-default, #1f2329); } .ghcm-help-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; } .ghcm-help-card { padding: 16px; border-radius: 12px; border: 1px solid rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.05); } .ghcm-help-card h3 { margin-top: 0; margin-bottom: 8px; font-size: 0.95rem; } .ghcm-help-shortcut { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid rgba(148, 163, 184, 0.2); } .ghcm-help-shortcut:last-child { border-bottom: none; } .ghcm-help-kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 82px; padding: 6px 10px; border-radius: 8px; background: rgba(15, 23, 42, 0.05); border: 1px solid rgba(148, 163, 184, 0.4); font-family: ui-monospace, SFMono-Regular, SFMono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; color: var(--color-fg-default, #1f2329); } .ghcm-help-kbd span { white-space: nowrap; } .ghcm-help-list { display: grid; gap: 10px; padding-left: 0; list-style: none; } .ghcm-help-list li { display: flex; gap: 8px; } .ghcm-help-list strong { color: rgba(79, 70, 229, 1); } .ghcm-help-section + .ghcm-help-section { margin-top: 24px; } .ghcm-help-footnote { margin-top: 12px; font-size: 0.85rem; color: var(--color-fg-muted, #4c566a); } /* 深色主题适配 */ @media (prefers-color-scheme: dark) { .${CONFIG.classes.tocContainer}, .${CONFIG.classes.searchContainer} { background: var(--color-canvas-default, #0d1117); border-color: var(--color-border-default, #30363d); } .ghcm-help-overlay { background: rgba(15, 23, 42, 0.6); } .ghcm-help-modal { background: var(--color-canvas-default, #0d1117); border-color: var(--color-border-default, #30363d); box-shadow: 0 24px 48px rgba(8, 13, 23, 0.6); } .ghcm-help-modal:focus, .ghcm-help-modal:focus-visible { box-shadow: 0 24px 48px rgba(8, 13, 23, 0.6), 0 0 0 3px rgba(129, 140, 248, 0.35); } .ghcm-help-header { background: var(--color-canvas-subtle, #161b22); border-color: var(--color-border-default, #30363d); } .ghcm-help-title-text { color: var(--color-fg-default, #e6edf3); } .ghcm-help-title-sub, .ghcm-help-footnote { color: var(--color-fg-muted, #8b949e); } .ghcm-help-close { color: var(--color-fg-muted, #8b949e); } .ghcm-help-close:hover { background: rgba(99, 102, 241, 0.18); color: var(--color-fg-default, #e6edf3); } .ghcm-help-content > .markdown-body { color: var(--color-fg-default, #e6edf3); } .ghcm-help-card { background: rgba(79, 70, 229, 0.12); border-color: rgba(129, 140, 248, 0.45); } .ghcm-help-kbd { background: rgba(148, 163, 184, 0.12); border-color: rgba(148, 163, 184, 0.35); color: var(--color-fg-default, #e6edf3); } } `); } 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: "${level}▼"; }`; }).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 {} } updateArrowSize(size) { if (!size) return; CONFIG.ui.arrowSize = size; GM_setValue('ghcm-arrow-size', size); this.applyArrowSize(size); } updateArrowContentOverride() { const headerSelectors = this.generateHeaderSelectors(); const after = headerSelectors.after; const showNum = !!CONFIG.ui.showLevelNumber; if (!showNum) { // 仅显示箭头(覆盖初始带数字的内容) this.arrowContentOverride.textContent = `${after} { content: "\\25BC" !important; }`; } else { // 显示级别数字 + 箭头,覆盖以确保与当前设置一致 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}:after`); selectors.push(`${container} ${header}.heading-element:after`); } }); return `${selectors.join(", ")} { content: "${level}\\25BC" !important; }`; }).join("\n"); this.arrowContentOverride.textContent = rules; } } } // 折叠功能核心类 class CollapseManager { constructor(stateManager) { this.stateManager = stateManager; // Map<headerKey, Set<timeoutId>> to track and cancel animations per header this.animationQueue = new Map(); // 单一滚动校准定时器,防止快速点击产生来回滚动 this._scrollEnsureTimeout = null; this.activeHeading = null; this._activeNotification = null; } // Track a timeout for a header key trackTimeout(headerKey, timeoutId) { if (!this.animationQueue.has(headerKey)) { this.animationQueue.set(headerKey, new Set()); } this.animationQueue.get(headerKey).add(timeoutId); } // Cancel all pending timeouts for a header key cancelTimeouts(headerKey) { const set = this.animationQueue.get(headerKey); if (!set) return; set.forEach(id => clearTimeout(id)); this.animationQueue.delete(headerKey); } // Cancel all pending animations (used on navigation) 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); Logger.log("[GHCM] Toggle:", header, "Level:", level, "Will collapse:", isCollapsed); if (isShiftClicked) { this.toggleAllSameLevel(level, isCollapsed); } else { this.toggleSingle(header, isCollapsed); } // 性能监控 const endTime = performance.now(); const duration = endTime - startTime; if (duration > 100 && CONFIG.animation.maxAnimatedElements > 0) { Logger.warn(`[GHCM] 检测到性能问题 (${duration.toFixed(1)}ms),建议启用性能模式`); // 自动降级性能设置 if (!GM_getValue("ghcm-auto-performance-warned", false)) { CONFIG.animation.maxAnimatedElements = Math.max(5, CONFIG.animation.maxAnimatedElements / 2); Logger.log(`[GHCM] 自动调整动画阈值为: ${CONFIG.animation.maxAnimatedElements}`); 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 { const expanded = !header.classList.contains(CONFIG.classes.collapsed); header.setAttribute('aria-expanded', String(expanded)); } 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 }; }); // 更新状态(仅存折叠布尔,避免 DOM 引用常驻) 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; } animateElements(elements, isCollapsed, headerKey) { // 取消之前的动画 this.cancelTimeouts(headerKey); // 性能优化:如果元素太多,直接切换而不做动画 if (elements.length > CONFIG.animation.maxAnimatedElements) { this.toggleElementsInstantly(elements, isCollapsed); return; } // 对于适量元素,使用优化的批量动画 this.animateElementsBatch(elements, isCollapsed, headerKey); } // 新的智能动画方法,考虑子标题状态 animateElementsIntelligent(analyzedElements, isCollapsed, headerKey) { // 取消之前的动画 this.cancelTimeouts(headerKey); Logger.log(`[GHCM] 智能动画: ${analyzedElements.length} 个元素, 阈值: ${CONFIG.animation.maxAnimatedElements}`); // 性能优化:如果元素太多,直接切换 if (analyzedElements.length > CONFIG.animation.maxAnimatedElements) { Logger.log(`[GHCM] 元素过多,使用即时切换模式`); this.toggleElementsIntelligentInstantly(analyzedElements, isCollapsed); return; } // 使用智能批量动画 Logger.log(`[GHCM] 使用批量动画模式`); this.animateElementsIntelligentBatch(analyzedElements, isCollapsed, headerKey); } // 智能即时切换(性能模式) toggleElementsIntelligentInstantly(analyzedElements, isCollapsed) { Logger.log(`[GHCM] 性能模式:即时切换 ${analyzedElements.length} 个元素`); 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) { Logger.log(`[GHCM] 折叠动画批次: ${batch.length} 个元素`); // 折叠批次:先设置初始状态和过渡效果 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确保样式已应用 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'); }); Logger.log(`[GHCM] 折叠动画批次完成`); }, CONFIG.animation.duration); this.trackTimeout(headerKey, t); }); } expandIntelligentBatch(batch, headerKey) { Logger.log(`[GHCM] 展开动画批次: ${batch.length} 个元素`); // 展开批次:先显示元素但设为初始动画状态 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确保DOM更新完成 requestAnimationFrame(() => { batch.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => { element.style.opacity = '1'; element.style.transform = 'translateY(0)'; // 如果是子标题且原本是折叠的,确保其内容保持隐藏 if (isHeader && childHeaderCollapsed) { // 延迟执行,确保动画和DOM更新完成 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'); }); Logger.log(`[GHCM] 展开动画批次完成`); }, 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'); }); Logger.log(`[GHCM] 已恢复子标题的折叠状态:`, childHeader.textContent.trim()); } // 即时切换,无动画 toggleElementsInstantly(elements, isCollapsed) { // 批量DOM操作,减少重排 elements.forEach(element => { if (isCollapsed) { element.classList.add(CONFIG.classes.hiddenByParent); element.style.removeProperty('display'); } else { element.classList.remove(CONFIG.classes.hiddenByParent); element.style.removeProperty('display'); // 清理可能存在的动画样式 element.style.removeProperty('opacity'); element.style.removeProperty('transform'); element.classList.remove('ghcm-transitioning'); } }); } // 批量动画处理 animateElementsBatch(elements, isCollapsed, headerKey) { const batches = this.createBatches(elements, CONFIG.animation.batchSize); let completedBatches = 0; const processBatch = (batchIndex) => { if (batchIndex >= batches.length) return; const batch = batches[batchIndex]; // 为每个批次准备DOM变更 if (isCollapsed) { this.collapseBatch(batch, headerKey); } else { this.expandBatch(batch, headerKey); } completedBatches++; // 处理下一个批次 if (batchIndex < batches.length - 1) { const timeout = setTimeout(() => { processBatch(batchIndex + 1); }, 50); // 批次间短暂延迟 this.trackTimeout(headerKey, timeout); } }; processBatch(0); } createBatches(elements, batchSize) { const batches = []; for (let i = 0; i < elements.length; i += batchSize) { batches.push(elements.slice(i, i + batchSize)); } return batches; } collapseBatch(batch, headerKey) { // 先设置初始状态 batch.forEach(element => { element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`; element.style.opacity = '1'; }); // 触发动画 requestAnimationFrame(() => { batch.forEach(element => { element.style.opacity = '0'; }); // 动画完成后隐藏 const t = setTimeout(() => { batch.forEach(element => { element.classList.add(CONFIG.classes.hiddenByParent); element.style.removeProperty('display'); element.style.removeProperty('opacity'); element.style.removeProperty('transition'); }); }, CONFIG.animation.duration); this.trackTimeout(headerKey, t); }); } expandBatch(batch, headerKey) { // 先显示元素但设为透明 batch.forEach(element => { element.classList.remove(CONFIG.classes.hiddenByParent); element.style.removeProperty('display'); element.style.opacity = '0'; element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`; }); // 触发淡入动画 requestAnimationFrame(() => { batch.forEach(element => { element.style.opacity = '1'; }); // 清理样式 const t = setTimeout(() => { batch.forEach(element => { element.style.removeProperty('opacity'); element.style.removeProperty('transition'); }); }, CONFIG.animation.duration); this.trackTimeout(headerKey, t); }); } // 展开到指定标题(用于hash导航) 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)) { const elementLevel = this.stateManager.getHeaderLevel(element); if (elementLevel <= 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; // 顶部偏移考虑 GitHub 顶栏高度 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 {} try { const id = this.tocGenerator?.getHeaderId?.(header) || header.id || header.getAttribute('id'); if (id && this.tocGenerator?.tocContainer) { this.tocGenerator.highlightTocById(id); } } 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; } findNavigableIndex(headers, startIndex, step) { for (let i = startIndex; i >= 0 && i < headers.length; i += step) { const candidate = headers[i]; if (this.isHeaderNavigable(candidate)) { return i; } } return -1; } focusNextHeading() { DOMUtils.blurActiveElement(); const headers = this.getAllHeaders(); if (!headers.length) return; const current = this.getActiveHeaderElement(); const currentIndex = headers.indexOf(current); const startIndex = currentIndex === -1 ? 0 : currentIndex + 1; const targetIndex = this.findNavigableIndex(headers, startIndex, 1); if (targetIndex === -1) { this.showNotification('📌 已是最后一个可见标题'); return; } const target = headers[targetIndex]; this.expandToHeader(target, { scroll: false, setActive: false }); this.scrollToElement(target); this.setActiveHeading(target); } focusPreviousHeading() { DOMUtils.blurActiveElement(); const headers = this.getAllHeaders(); if (!headers.length) return; const current = this.getActiveHeaderElement(); const currentIndex = headers.indexOf(current); const startIndex = currentIndex === -1 ? headers.length - 1 : currentIndex - 1; const targetIndex = this.findNavigableIndex(headers, startIndex, -1); if (targetIndex === -1) { this.showNotification('📌 已是第一个可见标题'); return; } const target = headers[targetIndex]; this.expandToHeader(target, { scroll: false, setActive: false }); this.scrollToElement(target); this.setActiveHeading(target); } 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 => { const expanded = !h.classList.contains(CONFIG.classes.collapsed); h.setAttribute('aria-expanded', String(expanded)); }); } catch {} } collapseAll() { const headers = this.getAllHeaders(); let count = 0; headers.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++; } }); Logger.log(`[GHCM] 已折叠 ${count} 个标题`); this.showNotification(`📁 已折叠 ${count} 个标题`); } expandAll() { const headers = this.getAllHeaders(); let count = 0; headers.forEach(header => { if (header.classList.contains(CONFIG.classes.collapsed)) { header.classList.remove(CONFIG.classes.collapsed); this.updateAriaExpanded(header); this.updateContent(header, false); count++; } }); Logger.log(`[GHCM] 已展开 ${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(); } } // 按级别批量操作 collapseLevel(level) { const selectors = CONFIG.selectors.markdownContainers .map(container => DOMUtils.getScopedHeadingSelector(container, { level })) .filter(Boolean) .join(', '); if (!selectors) return; const headers = DOMUtils.$$(selectors).filter(el => DOMUtils.isHeader(el)); let count = 0; headers.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++; } }); Logger.log(`[GHCM] 已折叠 ${count} 个 H${level} 标题`); this.showNotification(`📁 已折叠 ${count} 个 H${level} 标题`); } expandLevel(level) { const selectors = CONFIG.selectors.markdownContainers .map(container => DOMUtils.getScopedHeadingSelector(container, { level })) .filter(Boolean) .join(', '); if (!selectors) return; const headers = DOMUtils.$$(selectors).filter(el => DOMUtils.isHeader(el)); let count = 0; headers.forEach(header => { if (header.classList.contains(CONFIG.classes.collapsed)) { header.classList.remove(CONFIG.classes.collapsed); this.updateAriaExpanded(header); this.updateContent(header, false); count++; } }); Logger.log(`[GHCM] 已展开 ${count} 个 H${level} 标题`); this.showNotification(`📂 已展开 ${count} 个 H${level} 标题`); } // 通知功能 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) { const headers = this.getAllHeaders().filter(h => this.stateManager.getHeaderLevel(h) === level ); headers.forEach(header => { const headerKey = this.stateManager.generateHeaderKey(header); const savedState = this.stateManager.getHeaderState(headerKey); if (savedState && savedState.isCollapsed) { Logger.log(`[GHCM] 恢复 H${level} 标题状态:`, header.textContent.trim()); header.classList.add(CONFIG.classes.collapsed); this.updateAriaExpanded(header); this.updateContent(header, true); } }); } applyStateToElement(headerKey, state) { // 保留原方法作为备用 const headers = this.getAllHeaders(); headers.forEach(header => { const currentKey = this.stateManager.generateHeaderKey(header); if (currentKey === headerKey && state.isCollapsed) { header.classList.add(CONFIG.classes.collapsed); this.updateAriaExpanded(header); this.updateContent(header, true); } }); } // 代理目录和搜索功能 toggleToc() { if (this.tocGenerator) { this.tocGenerator.toggle(); } } toggleSearch() { if (this.searchManager) { this.searchManager.toggle(); } } // 检查标题是否有内容 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); } }); }); } } // 事件管理器 class EventManager { constructor(collapseManager) { this.collapseManager = collapseManager; this.hoverHeader = null; this.setupEventListeners(); } setupEventListeners() { // 点击事件 document.addEventListener("click", this.handleClick.bind(this), true); // Hover 高亮 this._hoverHandler = this.handleHover.bind(this); this._hoverLeaveHandler = this.handleHoverLeave.bind(this); document.addEventListener('mouseover', this._hoverHandler, true); document.addEventListener('mouseout', this._hoverLeaveHandler, true); // Hash 变化事件 window.addEventListener("hashchange", this.handleHashChange.bind(this)); // DOM 变化监听(如果有其他脚本修改DOM) if (window.ghmo) { window.addEventListener("ghmo:dom", this.handleDOMChange.bind(this)); } // GitHub 导航事件(PJAX/Turbo) 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 {} // 处理SVG点击 if (target.nodeName === "path") { target = target.closest("svg"); } // 跳过排除的元素与自身UI if (!target || this.shouldSkipElement(target) || target.closest('.ghcm-menu-container, .ghcm-search-container, .ghcm-toc-container, .ghcm-menu-button')) { return; } // 查找最近的标题元素 const header = target.closest(DOMUtils.getHeadingSelector()); if (header && DOMUtils.isHeader(header) && DOMUtils.isInMarkdown(header)) { // 仅在真正执行折叠时处理,避免干扰默认链接等行为 Logger.log("[GHCM] Header clicked:", 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(); try { this.collapseManager.searchManager?.invalidateIndex?.(); } catch {} try { this.collapseManager.bookmarkManager?.applyBookmarks?.(); } catch {} // 重新标记空标题 this.collapseManager.markEmptyHeaders(); // 处理当前hash this.handleHashChange(); try { const active = this.collapseManager.getActiveHeaderElement(); if (active) this.collapseManager.setActiveHeading(active); } catch {} } handleNavigation() { DOMUtils.invalidateHeadingCache(); try { this.collapseManager.searchManager?.invalidateIndex?.(); } catch {} try { this.collapseManager.bookmarkManager?.applyBookmarks?.(); } catch {} // 先清理任何挂起的动画/定时器 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) { // 尝试多种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); } } } // 主应用类 class GitHubCollapseMarkdown { constructor() { this.stateManager = new StateManager(); this.styleManager = new StyleManager(); this.collapseManager = new CollapseManager(this.stateManager); this.tocGenerator = new TocGenerator(); this.searchManager = new SearchManager(this.collapseManager); this.bookmarkManager = new BookmarkManager(this); this.menuManager = new MenuManager(this); this.helpModal = new HelpModal(this); this.hotkeyManager = new HotkeyManager(this.collapseManager); this.hotkeyManager.setApp(this); this.eventManager = new EventManager(this.collapseManager); // 将附加功能关联到折叠管理器 this.collapseManager.tocGenerator = this.tocGenerator; this.collapseManager.searchManager = this.searchManager; this.collapseManager.menuManager = this.menuManager; this.collapseManager.bookmarkManager = this.bookmarkManager; this.tocGenerator.collapseManager = this.collapseManager; this.init(); } init() { const performanceMode = GM_getValue("ghcm-performance-mode", false); const memoryEnabled = CONFIG.memory.enabled; const hotkeysEnabled = CONFIG.hotkeys.enabled; const animationStatus = (CONFIG.animation.maxAnimatedElements === 0) ? "性能模式 (无动画)" : "标准模式 (有动画)"; Logger.log(`[GHCM] Initializing GitHub Collapse Markdown (Optimized v3.2.4) - ${animationStatus}`); Logger.log(`[GHCM] 🧠 智能嵌套状态管理: 启用`); Logger.log(`[GHCM] 🎨 现代GUI界面: 启用`); Logger.log(`[GHCM] 动画阈值: ${CONFIG.animation.maxAnimatedElements} 个元素`); Logger.log(`[GHCM] 状态记忆: ${memoryEnabled ? "启用" : "禁用"}`); Logger.log(`[GHCM] 快捷键: ${hotkeysEnabled ? "启用" : "禁用"}`); // 添加菜单命令 this.setupMenuCommands(); // 初始检查和状态加载 setTimeout(() => { this.collapseManager.markEmptyHeaders(); // 加载已保存的折叠状态 if (memoryEnabled) { this.collapseManager.loadSavedStates(); } // 同步所有标题的无障碍状态 this.collapseManager.syncAriaExpandedForAll(); this.bookmarkManager.applyBookmarks(); }, 500); // 监听折叠状态变化,更新目录显示和菜单统计 document.addEventListener('ghcm:toggle-complete', () => { if (this.tocGenerator.isVisible) { setTimeout(() => { this.tocGenerator.refreshTocStates(); }, CONFIG.animation.duration + 150); } // 如果菜单打开,刷新统计信息 if (this.menuManager.isVisible) { setTimeout(() => { this.menuManager.refreshMenu(); }, CONFIG.animation.duration + 150); } }); } setupMenuCommands() { try { // === 基础操作 === GM_registerMenuCommand("📁 折叠所有标题", () => { this.collapseManager.collapseAll(); }); GM_registerMenuCommand("📂 展开所有标题", () => { this.collapseManager.expandAll(); }); GM_registerMenuCommand("🔄 智能切换", () => { this.collapseManager.toggleAll(); }); // === 工具功能 === GM_registerMenuCommand("📑 目录导航", () => { this.tocGenerator.toggle(); }); GM_registerMenuCommand("🔍 搜索标题", () => { this.searchManager.toggle(); }); GM_registerMenuCommand("⭐ 收藏当前标题", () => { this.bookmarkManager.addBookmarkFromViewport(); }); GM_registerMenuCommand("🗂️ 清空本页书签", () => { this.bookmarkManager.clearPageBookmarks(); }); // === 设置选项 === GM_registerMenuCommand("⚡ 性能模式", () => { this.togglePerformanceMode(); }); GM_registerMenuCommand("💾 状态记忆", () => { this.toggleMemory(); }); GM_registerMenuCommand("⌨️ 快捷键", () => { this.toggleHotkeys(); }); GM_registerMenuCommand("🐛 调试模式", () => { this.toggleDebug(); }); // === 重置功能 === GM_registerMenuCommand("🔄 重置折叠状态", () => { this.resetAllStates(); }); GM_registerMenuCommand("🗑️ 清除记忆数据", () => { this.clearAllMemory(); }); // === 信息帮助 === GM_registerMenuCommand("📊 当前统计", () => { this.showStatistics(); }); GM_registerMenuCommand("ℹ️ 使用说明", () => { this.showHotkeyHelp(); }); } catch (e) { Logger.warn("[GHCM] 菜单功能不可用:", e); } } toggleMemory() { const newState = !CONFIG.memory.enabled; CONFIG.memory.enabled = newState; GM_setValue("ghcm-memory-enabled", newState); const status = newState ? "启用" : "禁用"; Logger.log(`[GHCM] 状态记忆已${status}`); this.collapseManager.showNotification(`💾 状态记忆已${status}`); if (newState) { this.stateManager.scheduleSave({ force: true }); } else { this.stateManager.cancelScheduledSave(); } } toggleHotkeys() { const newState = !CONFIG.hotkeys.enabled; CONFIG.hotkeys.enabled = newState; GM_setValue("ghcm-hotkeys-enabled", newState); const status = newState ? "启用" : "禁用"; Logger.log(`[GHCM] 快捷键已${status}`); this.collapseManager.showNotification(`⌨️ 快捷键已${status}`); if (newState) { // 重新绑定快捷键 this.hotkeyManager.setupHotkeys(); } else { // 解除绑定,避免重复与多次触发 this.hotkeyManager.teardownHotkeys(); } } toggleVimNav() { const newState = !CONFIG.hotkeys.navEnabled; CONFIG.hotkeys.navEnabled = newState; GM_setValue('ghcm-nav-enabled', newState); const status = newState ? '启用' : '禁用'; Logger.log(`[GHCM] Vim 导航热键已${status}`); this.collapseManager.showNotification(`🧭 Vim 导航热键已${status}`); } toggleShowLevelNumber() { CONFIG.ui.showLevelNumber = !CONFIG.ui.showLevelNumber; GM_setValue('ghcm-show-level-number', CONFIG.ui.showLevelNumber); try { this.styleManager.updateArrowContentOverride(); } catch {} this.collapseManager.showNotification(CONFIG.ui.showLevelNumber ? '🔢 显示级别数字' : '🔽 仅显示箭头'); } setColorScheme(name) { const scheme = CONFIG.colorSchemes[name]; if (!scheme) { this.collapseManager.showNotification('⚠️ 未找到指定的配色方案'); return; } this.styleManager.updateColors(scheme); if (name === 'custom') { this.collapseManager.showNotification('🎨 已应用自定义配色'); } else { this.collapseManager.showNotification(`🎨 已应用配色:${name}`); } } promptCustomColors() { const current = (CONFIG.colorSchemes.custom || CONFIG.colors).join(', '); const input = prompt('请输入新的配色(可用逗号或空格分隔,至少 1 个色值)', current); if (input === null) return; const parts = input.split(/[\s,]+/).map(part => part.trim()).filter(Boolean); if (!parts.length) { this.collapseManager.showNotification('⚠️ 未输入有效的颜色'); return; } while (parts.length < 6) { parts.push(parts[parts.length - 1] || parts[0]); } const colors = parts.slice(0, 6); CONFIG.colorSchemes.custom = colors; GM_setValue('ghcm-custom-colors', colors); this.setColorScheme('custom'); this.menuManager.refreshMenu(); } promptArrowSize() { const current = CONFIG.ui.arrowSize || '0.8em'; const input = prompt('设置箭头字号(如 0.8em、12px)', current); if (input === null) return; const value = input.trim(); if (!value) { this.collapseManager.showNotification('⚠️ 请输入有效的尺寸'); return; } CONFIG.ui.arrowSize = value; this.styleManager.updateArrowSize(value); this.collapseManager.showNotification(`🔠 已更新箭头大小:${value}`); this.menuManager.refreshMenu(); } toggleDebug() { const newState = !CONFIG.debug; CONFIG.debug = newState; GM_setValue("ghcm-debug-mode", newState); const status = newState ? "启用" : "禁用"; Logger.log(`[GHCM] 调试模式已${status}`); this.collapseManager.showNotification(`🐛 调试模式已${status}`); } togglePerformanceMode() { const isPerformanceMode = CONFIG.animation.maxAnimatedElements === 0; const newState = !isPerformanceMode; if (newState) { // 启用性能模式(禁用动画) CONFIG.animation.maxAnimatedElements = 0; GM_setValue("ghcm-performance-mode", true); Logger.log("[GHCM] 已启用性能模式 - 动画已禁用"); this.collapseManager.showNotification("⚡ 性能模式已启用"); } else { // 禁用性能模式(启用动画) CONFIG.animation.maxAnimatedElements = 20; GM_setValue("ghcm-performance-mode", false); Logger.log("[GHCM] 已禁用性能模式 - 动画已启用"); this.collapseManager.showNotification("🎬 动画效果已启用"); } } clearAllMemory() { if (confirm("确定要清除所有页面的折叠状态记忆吗?")) { GM_setValue(CONFIG.memory.key, {}); this.stateManager.clear(); Logger.log("[GHCM] 已清除所有记忆数据"); this.collapseManager.showNotification("🗑️ 已清除所有记忆数据"); } } showHotkeyHelp() { this.helpModal?.show(); } showStatistics() { const headers = this.collapseManager.getAllHeaders(); const collapsed = headers.filter(h => h.classList.contains(CONFIG.classes.collapsed)); const visible = headers.filter(h => !h.classList.contains(CONFIG.classes.collapsed) && !h.classList.contains(CONFIG.classes.noContent) ); const levelStats = {}; for (let i = 1; i <= 6; i++) { const levelHeaders = headers.filter(h => this.stateManager.getHeaderLevel(h) === i ); if (levelHeaders.length > 0) { levelStats[`H${i}`] = { total: levelHeaders.length, collapsed: levelHeaders.filter(h => h.classList.contains(CONFIG.classes.collapsed)).length }; } } const levelStatsText = Object.entries(levelStats) .map(([level, stats]) => `${level}: ${stats.total}个 (${stats.collapsed}个已折叠)` ).join(', '); const statsContent = ` 📊 当前页面统计 📝 标题概况: • 总计:${headers.length} 个标题 • 已折叠:${collapsed.length} 个 • 可见:${visible.length} 个 📋 级别分布:${levelStatsText || '无标题'} ⚙️ 功能状态: • 性能模式:${CONFIG.animation.maxAnimatedElements === 0 ? '🟢 启用' : '🔴 禁用'} • 状态记忆:${CONFIG.memory.enabled ? '🟢 启用' : '🔴 禁用'} • 快捷键:${CONFIG.hotkeys.enabled ? '🟢 启用' : '🔴 禁用'} `.trim(); alert(statsContent); } resetAllStates() { // 移除所有折叠状态 DOMUtils.$$(".ghcm-collapsed").forEach(element => { element.classList.remove(CONFIG.classes.collapsed); try { element.setAttribute('aria-expanded', 'true'); } catch {} }); // 显示所有隐藏的内容 DOMUtils.$$(".ghcm-hidden-by-parent").forEach(element => { element.classList.remove(CONFIG.classes.hiddenByParent); element.style.removeProperty('display'); element.style.opacity = ''; element.style.transform = ''; }); // 清空状态 this.stateManager.clear(); Logger.log("[GHCM] 已重置所有折叠状态"); } } // 启动应用 window.ghcmInstance = new GitHubCollapseMarkdown(); })();