// ==UserScript==
// @name GitHub Collapse Markdown
// @version 3.2.3
// @description 🚀 简洁高效的GitHub Markdown标题折叠脚本:智能嵌套🧠+快捷键⌨️+目录📑+搜索🔍+状态记忆💾+简约GUI🔘
// @license MIT
// @author Xyea
// @namespace https://github.com/Mottie
// @match https://github.com/*
// @match https://gist.github.com/*
// @match https://help.github.com/*
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @icon https://github.githubassets.com/pinned-octocat.svg
// @downloadURL https://update.greasyfork.icu/scripts/541407/GitHub%20Collapse%20Markdown.user.js
// @updateURL https://update.greasyfork.icu/scripts/541407/GitHub%20Collapse%20Markdown.meta.js
// ==/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",
".markdown-format",
".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"
},
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" // 显示菜单
},
memory: {
enabled: GM_getValue("ghcm-memory-enabled", true),
key: "ghcm-page-states"
}
};
// 日志控制函数
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();
}
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-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;
}
/* 深色主题适配 */
@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;
}
}
/* 响应式设计 */
@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.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
document.body.appendChild(this.menuButton);
}
createMenuContainer() {
const container = document.createElement('div');
container.className = CONFIG.classes.menuContainer;
container.innerHTML = `
`;
this.setupMenuEvents(container);
return container;
}
generateMenuContent() {
const stats = this.getStatistics();
return `
`;
}
setupMenuEvents(container) {
// 点击菜单项事件
container.addEventListener('click', (e) => {
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) {
this.handleAction(action);
this.hide();
}
});
// 阻止菜单容器内的点击事件冒泡
container.addEventListener('click', (e) => {
e.stopPropagation();
});
}
handleAction(action) {
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 'toggleDebug':
this.app.toggleDebug();
this.refreshMenu();
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;
}
}
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 'debug':
this.app.toggleDebug();
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) {
const content = this.menuContainer.querySelector('.ghcm-menu-content');
if (content) {
content.innerHTML = this.generateMenuContent();
this.setupMenuEvents(this.menuContainer);
}
}
}
show() {
if (this.isVisible) return;
if (this.menuContainer) {
this.menuContainer.remove();
}
this.menuContainer = this.createMenuContainer();
document.body.appendChild(this.menuContainer);
// 动画显示
requestAnimationFrame(() => {
this.menuContainer.classList.add('show');
});
this.menuButton.classList.add('menu-open');
this.isVisible = true;
// 点击外部关闭
setTimeout(() => {
document.addEventListener('click', this.hideOnClickOutside);
}, 100);
}
hide() {
if (!this.isVisible || !this.menuContainer) return;
this.menuContainer.classList.remove('show');
this.menuButton.classList.remove('menu-open');
setTimeout(() => {
if (this.menuContainer) {
this.menuContainer.remove();
this.menuContainer = null;
}
}, 300);
this.isVisible = false;
document.removeEventListener('click', this.hideOnClickOutside);
}
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 StateManager {
constructor() {
this.headerStates = new Map();
this.observers = [];
this.pageUrl = window.location.href;
}
setHeaderState(headerKey, state) {
this.headerStates.set(headerKey, state);
this.saveToMemory();
}
getHeaderState(headerKey) {
return this.headerStates.get(headerKey);
}
generateHeaderKey(element) {
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 parseInt(element.nodeName.replace(/[^\d]/, ""), 10);
}
clear() {
this.headerStates.clear();
this.saveToMemory();
}
// 状态记忆功能
saveToMemory() {
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);
}
}
}
// 快捷键管理器
class HotkeyManager {
constructor(collapseManager) {
this.collapseManager = collapseManager;
this.setupHotkeys();
}
setupHotkeys() {
if (!CONFIG.hotkeys.enabled) return;
document.addEventListener('keydown', this.handleKeyDown.bind(this));
Logger.log("[GHCM] 快捷键已启用:", Object.entries(CONFIG.hotkeys)
.filter(([k, v]) => k !== 'enabled')
.map(([k, v]) => `${k}: ${v}`)
.join(', '));
}
handleKeyDown(event) {
if (!CONFIG.hotkeys.enabled) return;
const combo = this.getKeyCombo(event);
switch (combo) {
case CONFIG.hotkeys.collapseAll:
event.preventDefault();
this.collapseManager.collapseAll();
break;
case CONFIG.hotkeys.expandAll:
event.preventDefault();
this.collapseManager.expandAll();
break;
case CONFIG.hotkeys.toggleAll:
event.preventDefault();
this.collapseManager.toggleAll();
break;
case CONFIG.hotkeys.showToc:
event.preventDefault();
this.collapseManager.toggleToc();
break;
case CONFIG.hotkeys.search:
event.preventDefault();
this.collapseManager.toggleSearch();
break;
case CONFIG.hotkeys.menu:
event.preventDefault();
if (this.collapseManager.menuManager) {
this.collapseManager.menuManager.toggle();
}
break;
}
}
getKeyCombo(event) {
const keys = [];
if (event.ctrlKey) keys.push('ctrl');
if (event.shiftKey) keys.push('shift');
if (event.altKey) keys.push('alt');
if (event.metaKey) 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;
}
generateToc() {
const headers = this.getAllHeaders();
if (headers.length === 0) return null;
const toc = document.createElement('div');
toc.className = CONFIG.classes.tocContainer;
toc.innerHTML = `
${this.generateTocItems(headers)}
`;
this.setupTocEvents(toc);
return toc;
}
getAllHeaders() {
const headers = [];
CONFIG.selectors.markdownContainers.forEach(container => {
if (container) {
CONFIG.selectors.headers.forEach(headerTag => {
const elements = DOMUtils.$$(`${container} ${headerTag.toLowerCase()}`);
elements.forEach(el => {
headers.push({
element: el,
level: parseInt(headerTag.replace('H', ''), 10),
text: el.textContent.trim(),
id: this.getHeaderId(el)
});
});
});
}
});
return headers.sort((a, b) => this.getElementPosition(a.element) - this.getElementPosition(b.element));
}
generateTocItems(headers) {
return headers.map(header => {
const indent = (header.level - 1) * 20;
const isCollapsed = header.element.classList.contains(CONFIG.classes.collapsed);
const collapseIcon = isCollapsed ? '▶' : '▼';
return `
`;
}).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
return 'header-' + element.textContent.trim().toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
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.querySelectorAll('.ghcm-toc-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const headerId = link.getAttribute('data-header-id');
this.scrollToHeader(headerId);
});
});
}
scrollToHeader(headerId) {
const element = document.getElementById(headerId) ||
document.querySelector(`[id="${headerId}"]`) ||
document.querySelector(`#user-content-${headerId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 如果标题被折叠,自动展开其父级
this.expandParentHeaders(element);
// 更新目录显示状态
setTimeout(() => {
this.refreshTocStates();
}, 500);
}
}
// 刷新目录中的折叠状态显示
refreshTocStates() {
if (!this.tocContainer) return;
const tocItems = this.tocContainer.querySelectorAll('.ghcm-toc-item');
tocItems.forEach(item => {
const link = item.querySelector('.ghcm-toc-link');
const headerId = link.getAttribute('data-header-id');
const icon = item.querySelector('.ghcm-toc-collapse-icon');
// 查找对应的标题元素
const headerElement = document.getElementById(headerId) ||
document.querySelector(`[id="${headerId}"]`) ||
document.querySelector(`#user-content-${headerId}`);
if (headerElement && icon) {
const isCollapsed = headerElement.classList.contains('ghcm-collapsed');
icon.textContent = isCollapsed ? '▶' : '▼';
}
});
}
expandParentHeaders(targetElement) {
// 找到对应的collapseManager实例并展开到该标题
if (window.ghcmInstance && window.ghcmInstance.collapseManager) {
window.ghcmInstance.collapseManager.expandToHeader(targetElement);
}
}
showToc() {
if (this.tocContainer) {
this.tocContainer.remove();
}
this.tocContainer = this.generateToc();
if (this.tocContainer) {
document.body.appendChild(this.tocContainer);
this.isVisible = true;
// 确保状态正确显示
setTimeout(() => {
this.refreshTocStates();
}, 100);
}
}
hideToc() {
if (this.tocContainer) {
this.tocContainer.remove();
this.tocContainer = null;
this.isVisible = false;
}
}
toggle() {
if (this.isVisible) {
this.hideToc();
} else {
this.showToc();
}
}
}
// 搜索功能
class SearchManager {
constructor(collapseManager) {
this.collapseManager = collapseManager;
this.searchContainer = null;
this.isVisible = false;
}
createSearchUI() {
const container = document.createElement('div');
container.className = CONFIG.classes.searchContainer;
container.innerHTML = `
`;
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');
// 实时搜索
let searchTimeout;
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.performSearch(input.value.trim(), results);
}, 300);
});
// 关闭搜索
closeBtn.addEventListener('click', () => {
this.hideSearch();
});
// 自动聚焦
setTimeout(() => input.focus(), 100);
}
performSearch(query, resultsContainer) {
if (!query) {
resultsContainer.innerHTML = '请输入搜索关键词
';
return;
}
const headers = this.getAllSearchableHeaders();
const matches = headers.filter(header =>
header.text.toLowerCase().includes(query.toLowerCase())
);
if (matches.length === 0) {
resultsContainer.innerHTML = '未找到匹配的标题
';
return;
}
const resultHtml = matches.map(header => `
H${header.level}
${this.highlightMatch(header.text, query)}
`).join('');
resultsContainer.innerHTML = resultHtml;
// 添加点击事件
resultsContainer.querySelectorAll('.ghcm-search-result').forEach(result => {
result.addEventListener('click', () => {
const headerId = result.getAttribute('data-header-element');
this.jumpToHeader(headerId);
});
});
}
getAllSearchableHeaders() {
const headers = [];
let index = 0;
CONFIG.selectors.markdownContainers.forEach(container => {
if (container) {
CONFIG.selectors.headers.forEach(headerTag => {
const elements = DOMUtils.$$(`${container} ${headerTag.toLowerCase()}`);
elements.forEach(el => {
headers.push({
element: el,
level: parseInt(headerTag.replace('H', ''), 10),
text: el.textContent.trim(),
id: `search-header-${index++}`
});
el.setAttribute('data-search-id', `search-header-${index - 1}`);
});
});
}
});
return headers;
}
highlightMatch(text, query) {
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '$1');
}
jumpToHeader(headerId) {
const element = document.querySelector(`[data-search-id="${headerId}"]`);
if (element) {
// 展开到该标题
this.collapseManager.expandToHeader(element);
// 隐藏搜索界面
this.hideSearch();
}
}
showSearch() {
if (this.searchContainer) {
this.searchContainer.remove();
}
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;
}
}
toggle() {
if (this.isVisible) {
this.hideSearch();
} else {
this.showSearch();
}
}
}
// DOM 工具类
class DOMUtils {
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();
}
}
}
}
// 样式管理器
class StyleManager {
constructor() {
this.arrowColors = document.createElement("style");
this.init();
}
init() {
this.addBaseStyles();
this.addColorStyles();
document.head.appendChild(this.arrowColors);
}
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: 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.hidden},
.${CONFIG.classes.hiddenByParent} {
display: none !important;
opacity: 0 !important;
}
/* 无内容标题 */
.${CONFIG.classes.noContent}:after {
display: none !important;
}
/* 禁用链接事件 */
.octicon-link,
.octicon-link > * {
pointer-events: none;
}
/* 平滑动画 */
.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:hover {
background: var(--color-neutral-subtle, #f6f8fa);
}
.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-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-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;
}
/* 深色主题适配 */
@media (prefers-color-scheme: dark) {
.${CONFIG.classes.tocContainer},
.${CONFIG.classes.searchContainer} {
background: var(--color-canvas-default, #0d1117);
border-color: var(--color-border-default, #30363d);
}
}
`);
}
generateHeaderSelectors() {
const containers = CONFIG.selectors.markdownContainers;
const headers = CONFIG.selectors.headers.map(h => h.toLowerCase());
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() {
return CONFIG.selectors.headers.map((header, index) => {
const level = index + 1;
const containers = CONFIG.selectors.markdownContainers;
const selectors = [];
containers.forEach(container => {
if (container) {
selectors.push(`${container} ${header.toLowerCase()}:after`);
selectors.push(`${container} ${header.toLowerCase()}.heading-element:after`);
}
});
return `${selectors.join(", ")} { content: "${level}▼"; }`;
}).join("\n");
}
addColorStyles() {
const styles = CONFIG.selectors.headers.map((header, index) => {
const containers = CONFIG.selectors.markdownContainers;
const selectors = [];
containers.forEach(container => {
if (container) {
selectors.push(`${container} ${header.toLowerCase()}:after`);
selectors.push(`${container} ${header.toLowerCase()}.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();
}
}
// 折叠功能核心类
class CollapseManager {
constructor(stateManager) {
this.stateManager = stateManager;
this.animationQueue = new Map();
}
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);
}
}
DOMUtils.clearSelection();
this.dispatchToggleEvent(header, level, isCollapsed);
}
toggleSingle(header, isCollapsed) {
header.classList.toggle(CONFIG.classes.collapsed, isCollapsed);
this.updateContent(header, isCollapsed);
}
toggleAllSameLevel(level, isCollapsed) {
const headerName = CONFIG.selectors.headers[level - 1].toLowerCase();
const selectors = CONFIG.selectors.markdownContainers
.filter(container => container)
.map(container => `${container} ${headerName}, ${container} ${headerName}.heading-element`)
.join(", ");
DOMUtils.$$(selectors).forEach(header => {
if (DOMUtils.isHeader(header)) {
header.classList.toggle(CONFIG.classes.collapsed, isCollapsed);
this.updateContent(header, isCollapsed);
}
});
}
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(CONFIG.selectors.headers.join(","));
return {
element: el,
isHeader: !!childHeader,
childHeader: childHeader,
childHeaderCollapsed: childHeader ? childHeader.classList.contains(CONFIG.classes.collapsed) : false
};
});
// 更新状态
this.stateManager.setHeaderState(headerKey, {
isCollapsed,
elements: analyzedElements
});
// 执行智能动画(考虑子标题状态)
this.animateElementsIntelligent(analyzedElements, isCollapsed, headerKey);
}
getContentElements(header, level) {
const container = DOMUtils.getHeaderContainer(header);
const elements = [];
let nextElement = container.nextElementSibling;
// 构建同级和更高级别的选择器
const higherLevelSelectors = CONFIG.selectors.headers
.slice(0, level)
.map(h => h.toLowerCase())
.join(",");
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) {
// 取消之前的动画
if (this.animationQueue.has(headerKey)) {
clearTimeout(this.animationQueue.get(headerKey));
this.animationQueue.delete(headerKey);
}
// 性能优化:如果元素太多,直接切换而不做动画
if (elements.length > CONFIG.animation.maxAnimatedElements) {
this.toggleElementsInstantly(elements, isCollapsed);
return;
}
// 对于适量元素,使用优化的批量动画
this.animateElementsBatch(elements, isCollapsed, headerKey);
}
// 新的智能动画方法,考虑子标题状态
animateElementsIntelligent(analyzedElements, isCollapsed, headerKey) {
// 取消之前的动画
if (this.animationQueue.has(headerKey)) {
clearTimeout(this.animationQueue.get(headerKey));
this.animationQueue.delete(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.display = 'none';
} else {
// 展开:根据子标题状态决定是否显示
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.display = 'block';
// 如果是子标题且原本是折叠的,需要保持其内容隐藏
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);
} else {
this.expandIntelligentBatch(batch);
}
// 处理下一个批次
if (batchIndex < batches.length - 1) {
const timeout = setTimeout(() => {
processBatch(batchIndex + 1);
}, 30); // 减少延迟,让动画更流畅
this.animationQueue.set(`${headerKey}-batch-${batchIndex}`, 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) {
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)';
});
// 动画完成后隐藏元素
setTimeout(() => {
batch.forEach(({ element }) => {
element.classList.add(CONFIG.classes.hiddenByParent);
element.style.display = 'none';
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.style.removeProperty('transition');
});
Logger.log(`[GHCM] 折叠动画批次完成`);
}, CONFIG.animation.duration);
});
}
expandIntelligentBatch(batch) {
Logger.log(`[GHCM] 展开动画批次: ${batch.length} 个元素`);
// 展开批次:先显示元素但设为初始动画状态
batch.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.display = 'block';
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);
}
});
// 清理样式
setTimeout(() => {
batch.forEach(({ element }) => {
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.style.removeProperty('transition');
});
Logger.log(`[GHCM] 展开动画批次完成`);
}, CONFIG.animation.duration);
});
}
// 确保子标题的内容保持隐藏状态
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.display = 'none';
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.classList.remove('ghcm-transitioning');
});
Logger.log(`[GHCM] 已恢复子标题的折叠状态:`, childHeader.textContent.trim());
}
// 即时切换,无动画
toggleElementsInstantly(elements, isCollapsed) {
// 批量DOM操作,减少重排
const fragment = document.createDocumentFragment();
elements.forEach(element => {
if (isCollapsed) {
element.classList.add(CONFIG.classes.hiddenByParent);
element.style.display = 'none';
} else {
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.display = 'block';
// 清理可能存在的动画样式
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);
} else {
this.expandBatch(batch);
}
completedBatches++;
// 处理下一个批次
if (batchIndex < batches.length - 1) {
const timeout = setTimeout(() => {
processBatch(batchIndex + 1);
}, 50); // 批次间短暂延迟
this.animationQueue.set(`${headerKey}-batch-${batchIndex}`, 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) {
// 先设置初始状态
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';
});
// 动画完成后隐藏
setTimeout(() => {
batch.forEach(element => {
element.classList.add(CONFIG.classes.hiddenByParent);
element.style.display = 'none';
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
});
}, CONFIG.animation.duration);
});
}
expandBatch(batch) {
// 先显示元素但设为透明
batch.forEach(element => {
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.display = 'block';
element.style.opacity = '0';
element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`;
});
// 触发淡入动画
requestAnimationFrame(() => {
batch.forEach(element => {
element.style.opacity = '1';
});
// 清理样式
setTimeout(() => {
batch.forEach(element => {
element.style.removeProperty('opacity');
element.style.removeProperty('transition');
});
}, CONFIG.animation.duration);
});
}
// 展开到指定标题(用于hash导航)
expandToHeader(targetHeader) {
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;
}
// 滚动到目标位置
this.scrollToElement(targetHeader);
}
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;
const targetPosition = element.offsetTop - 100; // 留出一些顶部空间
// 平滑滚动
window.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
// 延迟再次确保位置正确
setTimeout(() => {
if (Math.abs(window.scrollY - targetPosition) > 50) {
window.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
}
}, 500);
}
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;
// 查找所有子标题并恢复其状态
while (nextElement) {
// 停止条件:遇到同级或更高级别的标题
const higherLevelSelectors = CONFIG.selectors.headers
.slice(0, parentLevel)
.map(h => h.toLowerCase())
.join(",");
if (nextElement.matches(higherLevelSelectors) ||
(nextElement.classList?.contains('markdown-heading') &&
nextElement.querySelector(higherLevelSelectors))) {
break;
}
// 检查是否是子标题
const childHeader = DOMUtils.isHeader(nextElement) ?
nextElement : nextElement.querySelector(CONFIG.selectors.headers.join(","));
if (childHeader && childHeader.classList.contains(CONFIG.classes.collapsed)) {
// 确保这个子标题的内容保持隐藏
this.ensureChildHeaderContentHidden(childHeader);
}
nextElement = nextElement.nextElementSibling;
}
}
// 批量操作方法
getAllHeaders() {
const headers = [];
CONFIG.selectors.markdownContainers.forEach(container => {
if (container) {
CONFIG.selectors.headers.forEach(headerTag => {
const elements = DOMUtils.$$(`${container} ${headerTag.toLowerCase()}`);
headers.push(...elements.filter(el => DOMUtils.isHeader(el)));
});
}
});
return headers;
}
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.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.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 headerTag = CONFIG.selectors.headers[level - 1];
if (!headerTag) return;
const headers = [];
CONFIG.selectors.markdownContainers.forEach(container => {
if (container) {
const elements = DOMUtils.$$(`${container} ${headerTag.toLowerCase()}`);
headers.push(...elements.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.updateContent(header, true);
count++;
}
});
Logger.log(`[GHCM] 已折叠 ${count} 个 H${level} 标题`);
this.showNotification(`📁 已折叠 ${count} 个 H${level} 标题`);
}
expandLevel(level) {
const headerTag = CONFIG.selectors.headers[level - 1];
if (!headerTag) return;
const headers = [];
CONFIG.selectors.markdownContainers.forEach(container => {
if (container) {
const elements = DOMUtils.$$(`${container} ${headerTag.toLowerCase()}`);
headers.push(...elements.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.updateContent(header, false);
count++;
}
});
Logger.log(`[GHCM] 已展开 ${count} 个 H${level} 标题`);
this.showNotification(`📂 已展开 ${count} 个 H${level} 标题`);
}
// 通知功能
showNotification(message) {
// 创建通知元素
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);
// 显示动画
requestAnimationFrame(() => {
notification.style.opacity = '1';
});
// 自动消失
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 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.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.updateContent(header, true);
}
});
}
// 代理目录和搜索功能
toggleToc() {
if (this.tocGenerator) {
this.tocGenerator.toggle();
}
}
toggleSearch() {
if (this.searchManager) {
this.searchManager.toggle();
}
}
// 检查标题是否有内容
markEmptyHeaders() {
CONFIG.selectors.markdownContainers.forEach(containerSelector => {
if (!containerSelector) return;
CONFIG.selectors.headers.forEach(headerName => {
const headerSelector = `${containerSelector} ${headerName.toLowerCase()}`;
DOMUtils.$$(headerSelector).concat(
DOMUtils.$$(`${headerSelector}.heading-element`)
).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.setupEventListeners();
}
setupEventListeners() {
// 点击事件
document.addEventListener("click", this.handleClick.bind(this), true);
// Hash 变化事件
window.addEventListener("hashchange", this.handleHashChange.bind(this));
// DOM 变化监听(如果有其他脚本修改DOM)
if (window.ghmo) {
window.addEventListener("ghmo:dom", this.handleDOMChange.bind(this));
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.handleDOMChange.bind(this));
} else {
setTimeout(() => this.handleDOMChange(), 200);
}
}
handleClick(event) {
let target = event.target;
// 处理SVG点击
if (target.nodeName === "path") {
target = target.closest("svg");
}
// 跳过排除的元素
if (!target || this.shouldSkipElement(target)) {
return;
}
// 查找最近的标题元素
const header = target.closest(CONFIG.selectors.headers.map(h => h.toLowerCase()).join(","));
if (header && DOMUtils.isHeader(header) && DOMUtils.isInMarkdown(header)) {
event.preventDefault();
Logger.log("[GHCM] Header clicked:", header);
this.collapseManager.toggle(header, event.shiftKey);
}
}
shouldSkipElement(element) {
const nodeName = element.nodeName?.toLowerCase();
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() {
// 重新标记空标题
this.collapseManager.markEmptyHeaders();
// 处理当前hash
this.handleHashChange();
}
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(CONFIG.selectors.headers.map(h => h.toLowerCase()).join(","));
}
if (header && DOMUtils.isHeader(header)) {
this.collapseManager.expandToHeader(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.menuManager = new MenuManager(this);
this.hotkeyManager = new HotkeyManager(this.collapseManager);
this.eventManager = new EventManager(this.collapseManager);
// 将附加功能关联到折叠管理器
this.collapseManager.tocGenerator = this.tocGenerator;
this.collapseManager.searchManager = this.searchManager;
this.collapseManager.menuManager = this.menuManager;
this.init();
}
init() {
const performanceMode = GM_getValue("ghcm-performance-mode", false);
const memoryEnabled = CONFIG.memory.enabled;
const hotkeysEnabled = CONFIG.hotkeys.enabled;
const animationStatus = performanceMode ? "性能模式 (无动画)" : "标准模式 (有动画)";
Logger.log(`[GHCM] Initializing GitHub Collapse Markdown (Optimized v3.2.3) - ${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();
}
}, 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.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}`);
}
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();
}
}
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() {
const helpContent = `
GitHub Collapse Markdown - 使用说明
✨ 快捷键:
• ${CONFIG.hotkeys.collapseAll} - 折叠所有标题
• ${CONFIG.hotkeys.expandAll} - 展开所有标题
• ${CONFIG.hotkeys.toggleAll} - 智能切换
• ${CONFIG.hotkeys.showToc} - 目录导航
• ${CONFIG.hotkeys.search} - 搜索标题
• ${CONFIG.hotkeys.menu} - 显示/隐藏菜单
🖱️ 鼠标操作:
• 点击标题 - 折叠/展开该标题
• Shift + 点击 - 折叠/展开同级别所有标题
• 点击右下角设置按钮 - 打开GUI菜单
🔥 核心功能:
• 🧠 智能嵌套状态 - 展开父标题时保持子标题折叠状态
• 💾 状态记忆 - 自动记住每个页面的折叠状态
• ⚡ 性能优化 - 大量内容时自动优化动画
• 📑 目录导航 - 快速跳转到任意标题
• 🔍 实时搜索 - 搜索标题内容并快速定位
• 🎨 现代GUI界面 - 美观简约的设置面板
⚙️ 设置说明:
• 点击右下角浮动按钮打开现代化GUI菜单
• 所有功能都支持实时切换,无需刷新页面
• 性能模式可在长文档中提高响应速度
• 状态记忆和快捷键都支持自由切换
`.trim();
alert(helpContent);
}
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);
});
// 显示所有隐藏的内容
DOMUtils.$$(".ghcm-hidden-by-parent").forEach(element => {
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.display = '';
element.style.opacity = '';
element.style.transform = '';
});
// 清空状态
this.stateManager.clear();
Logger.log("[GHCM] 已重置所有折叠状态");
}
}
// 启动应用
window.ghcmInstance = new GitHubCollapseMarkdown();
})();