// ==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();
})();