GitHub 增强:日期高亮 + 图片预览 + 标题折叠
Version au
// ==UserScript==
// @name GitHub Enhancer
// @namespace https://github.com/
// @version 1.0.0
// @description GitHub 增强:日期高亮 + 图片预览 + 标题折叠
// @author orangelckc
// @match https://github.com/*
// @match https://gist.github.com/*
// @match https://help.github.com/*
// @match https://docs.github.com/*
// @run-at document-idle
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @noframes
// ==/UserScript==
(function () {
'use strict';
// ================================================================
// 模块1: 日期高亮 (GitHub Freshness)
// 通过颜色高亮判断仓库更新活跃度
// ================================================================
let HIGHLIGHT_COLOR = '#93EC77';
let GREY_COLOR = '#4DFFFF40';
let TIME_THRESHOLD_MONTHS = GM_getValue('timeThresholdMonths', 2);
let isHighlighting = false;
let currentURL = location.href;
function highlightDates() {
if (isHighlighting) return;
isHighlighting = true;
const now = new Date();
const elements = document.querySelectorAll('relative-time');
if (elements.length === 0) {
isHighlighting = false;
return;
}
elements.forEach(element => {
const datetime = element.getAttribute('datetime');
if (datetime) {
const date = new Date(datetime);
const timeDiff = now - date;
const daysDiff = timeDiff / (1000 * 3600 * 24);
const monthsDiff = daysDiff / 30;
if (monthsDiff <= TIME_THRESHOLD_MONTHS) {
element.style.setProperty('color', HIGHLIGHT_COLOR, 'important');
} else {
element.style.setProperty('color', GREY_COLOR, 'important');
}
}
});
isHighlighting = false;
}
function onUrlChange() {
if (currentURL !== location.href) {
currentURL = location.href;
const regex = /^https:\/\/github\.com\/[^/]+\/[^/]+\/tree\/[^/]+/;
if (regex.test(location.href)) {
const codeTab = document.getElementById('code-tab');
if (codeTab && codeTab.classList.contains('selected')) {
setTimeout(() => { highlightDates(); }, 1000);
}
}
}
}
const freshnessObserver = new MutationObserver(() => {
const codeTab = document.getElementById('code-tab');
if (codeTab && codeTab.classList.contains('selected')) {
highlightDates();
}
});
freshnessObserver.observe(document.body, { childList: true, subtree: true });
setInterval(onUrlChange, 1000);
setTimeout(() => {
const codeTab = document.getElementById('code-tab');
if (codeTab && codeTab.classList.contains('selected')) {
highlightDates();
}
}, 1000);
let isScrolling = false;
window.addEventListener('scroll', () => {
if (!isScrolling) {
isScrolling = true;
setTimeout(() => { isScrolling = false; }, 100);
}
});
setTimeout(highlightDates, 1000);
// ================================================================
// 模块2: 图片预览 (README Image Viewer)
// Markdown 图片查看器:切换/滚轮缩放/拖动平移
// ================================================================
const ROOT_SELECTORS = [
'article.markdown-body',
'.markdown-body',
'.js-comment-body',
'[data-testid="markdown-body"]'
];
const BADGE_HOSTS = ['shields.io', 'badgen.net', 'badge.fury.io', 'poser.pugx.org', 'nodei.co'];
const BADGE_TEXT_PATTERN = /(?:badge|shield|build|status|ci|coverage|version|license|npm|pypi|downloads|release|codecov|coveralls|sonarcloud|quality|dependencies|dependabot)/i;
const PREVIEW_MIN_DIMENSION = 100;
let viewerEl = null;
let scanTimer = 0;
let previousBodyOverflow = '';
let viewerScale = 1;
addGhivStyle(`
.ghiv-ready { cursor: zoom-in !important; }
.ghiv-overlay {
position: fixed; inset: 0; z-index: 2147483647;
display: none; place-items: center;
background: rgba(1, 4, 9, 0.85);
backdrop-filter: blur(6px);
cursor: zoom-out;
}
.ghiv-overlay.ghiv-open { display: grid; }
.ghiv-overlay img {
max-width: 92vw; max-height: 92vh;
object-fit: contain;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
border-radius: 4px;
cursor: zoom-out;
transition: transform 0.1s ease-out;
transform-origin: center center;
}
`);
scheduleScan();
observeGitHubNavigation();
document.addEventListener('click', onImageClick, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && viewerEl) closeViewer();
}, true);
function addGhivStyle(css) {
if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; }
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
function observeGitHubNavigation() {
['turbo:load', 'turbo:render', 'pjax:end'].forEach((eventName) => {
document.addEventListener(eventName, scheduleScan);
});
const observer = new MutationObserver((mutations) => {
const hasPageChange = mutations.some((mutation) => {
if (viewerEl && mutation.target === viewerEl) return false;
if (viewerEl && viewerEl.contains(mutation.target)) return false;
return Array.from(mutation.addedNodes).some((node) => !viewerEl || !viewerEl.contains(node));
});
if (hasPageChange) scheduleScan();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function scheduleScan() {
window.clearTimeout(scanTimer);
scanTimer = window.setTimeout(scanImages, 120);
}
function scanImages() {
const nodes = new Set();
ROOT_SELECTORS.forEach((selector) => {
document.querySelectorAll(`${selector} img`).forEach((img) => nodes.add(img));
});
Array.from(nodes).filter(isPreviewableImage).forEach((img) => {
img.classList.add('ghiv-ready');
if (!img.title) img.title = '点击预览图片';
});
}
function isPreviewableImage(img) {
if (!(img instanceof HTMLImageElement)) return false;
if (!ROOT_SELECTORS.some((selector) => img.closest(selector))) return false;
if (img.closest('g-emoji, .emoji, .avatar, .octicon, .reaction-summary-item')) return false;
const src = img.currentSrc || img.src || img.getAttribute('src') || img.dataset.canonicalSrc || '';
if (!src || src.startsWith('data:')) return false;
const rect = img.getBoundingClientRect();
const width = img.naturalWidth || img.width || rect.width;
const height = img.naturalHeight || img.height || rect.height;
if (width < PREVIEW_MIN_DIMENSION || height < PREVIEW_MIN_DIMENSION) return false;
if (rect.width === 0 && rect.height === 0) return false;
if (isBadgeImage(img, src, width, height)) return false;
return true;
}
function isBadgeImage(img, src, width, height) {
const link = img.closest('a');
const href = link ? (link.getAttribute('href') || '') : '';
const alt = img.getAttribute('alt') || '';
const title = img.getAttribute('title') || '';
const text = `${src} ${href} ${alt} ${title}`;
const badgeShape = width <= 260 && height <= 42;
try {
const url = new URL(src, location.href);
const host = url.hostname.toLowerCase();
const path = url.pathname.toLowerCase();
if (BADGE_HOSTS.some((bh) => host === bh || host.endsWith(`.${bh}`))) return true;
if (/\/actions\/workflows\/[^/]+\/badge\.svg$/i.test(path)) return true;
if (path.endsWith('/badge.svg') && badgeShape) return true;
} catch (_) { }
return badgeShape && BADGE_TEXT_PATTERN.test(text);
}
function onImageClick(event) {
const target = event.target instanceof Element ? event.target : null;
const img = target ? target.closest('img') : null;
if (!img || !ROOT_SELECTORS.some((s) => img.closest(s))) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (!isPreviewableImage(img)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
openViewer(img);
}
function openViewer(img) {
if (!viewerEl) {
viewerEl = document.createElement('div');
viewerEl.className = 'ghiv-overlay';
viewerEl.setAttribute('role', 'dialog');
viewerEl.setAttribute('aria-modal', 'true');
viewerEl.tabIndex = -1;
const previewImg = document.createElement('img');
previewImg.alt = '';
viewerEl.appendChild(previewImg);
viewerEl.addEventListener('click', (e) => {
e.preventDefault();
closeViewer();
});
viewerEl.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.06 : 1 / 1.06;
viewerScale = Math.min(8, Math.max(0.2, viewerScale * factor));
viewerEl.querySelector('img').style.transform = `scale(${viewerScale})`;
}, { passive: false });
document.body.appendChild(viewerEl);
}
const src = img.dataset.canonicalSrc || img.currentSrc || img.src;
viewerScale = 1;
const previewImg = viewerEl.querySelector('img');
previewImg.src = src;
previewImg.style.transform = '';
viewerEl.querySelector('img').alt = (img.getAttribute('alt') || '图片预览');
previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
viewerEl.classList.add('ghiv-open');
viewerEl.focus({ preventScroll: true });
}
function closeViewer() {
if (!viewerEl) return;
viewerEl.classList.remove('ghiv-open');
document.body.style.overflow = previousBodyOverflow;
}
// ================================================================
// 模块3: 标题折叠 (GitHub Collapse Markdown - 精简版)
// 仅保留核心折叠功能,去掉目录/搜索/书签/菜单/快捷键/帮助
// ================================================================
const CONFIG = {
debug: false,
colors: GM_getValue("ghcm-colors", [
"#6778d0", "#ac9c3d", "#b94a73", "#56ae6c", "#9750a1", "#ba543d"
]),
animation: {
duration: 200,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
maxAnimatedElements: GM_getValue("ghcm-performance-mode", false) ? 0 : 20,
batchSize: 10
},
selectors: {
markdownContainers: [".markdown-body", ".comment-body"],
headers: ["H1", "H2", "H3", "H4", "H5", "H6"],
excludeClicks: [".anchor", ".octicon-link", "a", "img"]
},
classes: {
collapsed: "ghcm-collapsed",
hidden: "ghcm-hidden",
hiddenByParent: "ghcm-hidden-by-parent",
noContent: "ghcm-no-content",
activeHeading: "ghcm-active-heading",
hoverHeading: "ghcm-hover-heading"
},
memory: {
enabled: GM_getValue("ghcm-memory-enabled", true),
key: "ghcm-page-states"
},
ui: {
showLevelNumber: GM_getValue('ghcm-show-level-number', true),
arrowSize: GM_getValue('ghcm-arrow-size', '0.8em')
}
};
const Logger = {
log: (...args) => { if (CONFIG.debug) console.log(...args); },
warn: (...args) => { console.warn(...args); },
error: (...args) => { console.error(...args); }
};
// --- StateManager ---
class StateManager {
constructor() {
this.headerStates = new Map();
this.pageUrl = this.getPageKey();
this._saveTimer = null;
this._pendingSave = false;
this._saveDelay = 200;
try { window.addEventListener('beforeunload', () => this.flushPendingSave()); } catch { }
}
getPageKey() {
try { return `${window.location.origin}${window.location.pathname}`; }
catch (e) { return window.location.href; }
}
updatePageKey() {
const newKey = this.getPageKey();
if (newKey !== this.pageUrl) {
this.headerStates.clear();
this.pageUrl = newKey;
}
}
setHeaderState(headerKey, state) {
this.headerStates.set(headerKey, state);
this.scheduleSave();
}
getHeaderState(headerKey) { return this.headerStates.get(headerKey); }
generateHeaderKey(element) {
try {
const normalize = value => (typeof value === 'string' ? value.trim() : '');
const isSynthetic = id => /^ghcm-(?:bookmark|h)-/i.test(id || '');
const stableId = (() => {
const directId = normalize(element.getAttribute?.('id') || element.id);
if (directId && !isSynthetic(directId)) return directId;
const anchor = element.querySelector?.('.anchor');
if (anchor) {
const anchorId = normalize(anchor.getAttribute('id'));
if (anchorId && !isSynthetic(anchorId)) return anchorId;
const hrefId = normalize(anchor.getAttribute('href')?.replace(/^#/, ''));
if (hrefId && !isSynthetic(hrefId)) return hrefId;
}
const anyWithId = element.querySelector?.('[id]');
const childId = normalize(anyWithId?.getAttribute('id'));
if (childId && !isSynthetic(childId)) return childId;
return null;
})();
if (stableId) return `id:${stableId}`;
} catch { }
const level = this.getHeaderLevel(element);
const text = element.textContent?.trim() || "";
const position = Array.from(element.parentElement?.children || []).indexOf(element);
return `${level}-${text}-${position}`;
}
getHeaderLevel(element) { return DOMUtils.getHeadingLevel(element); }
clear() { this.headerStates.clear(); this.scheduleSave({ force: true }); }
scheduleSave({ force = false } = {}) {
if (!CONFIG.memory.enabled) { this.cancelScheduledSave(); return; }
this._pendingSave = true;
if (force) { this.flushPendingSave(); return; }
if (this._saveTimer) return;
this._saveTimer = setTimeout(() => { this.flushPendingSave(); }, this._saveDelay);
}
cancelScheduledSave() {
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
this._pendingSave = false;
}
flushPendingSave() {
if (!this._pendingSave) return;
this._pendingSave = false;
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
if (!CONFIG.memory.enabled) return;
try {
const pageStates = GM_getValue(CONFIG.memory.key, {});
const currentStates = {};
this.headerStates.forEach((state, key) => { currentStates[key] = state.isCollapsed; });
pageStates[this.pageUrl] = currentStates;
GM_setValue(CONFIG.memory.key, pageStates);
} catch (e) { Logger.warn("[GHCM] 保存状态失败:", e); }
}
loadFromMemory() {
if (!CONFIG.memory.enabled) return;
try {
const pageStates = GM_getValue(CONFIG.memory.key, {});
const currentStates = pageStates[this.pageUrl];
if (currentStates) {
Object.entries(currentStates).forEach(([key, isCollapsed]) => {
this.headerStates.set(key, { isCollapsed });
});
}
} catch (e) { Logger.warn("[GHCM] 加载状态失败:", e); }
}
}
// --- DOMUtils ---
class DOMUtils {
static getHeadingTagsLower() {
if (!DOMUtils._headingTagsLower) {
DOMUtils._headingTagsLower = CONFIG.selectors.headers.map(tag => tag.toLowerCase());
}
return DOMUtils._headingTagsLower;
}
static getUpperHeadingSelector() {
if (!DOMUtils._upperHeadingSelector) {
DOMUtils._upperHeadingSelector = CONFIG.selectors.headers.join(',');
}
return DOMUtils._upperHeadingSelector;
}
static getHeadingTags({ level, upToLevel } = {}) {
const tags = DOMUtils.getHeadingTagsLower();
if (typeof level === 'number') { const tag = tags[level - 1]; return tag ? [tag] : []; }
if (typeof upToLevel === 'number') return tags.slice(0, upToLevel);
return tags;
}
static getCachedSelector(key, builder) {
if (!DOMUtils._selectorCache) DOMUtils._selectorCache = new Map();
if (!DOMUtils._selectorCache.has(key)) DOMUtils._selectorCache.set(key, builder());
return DOMUtils._selectorCache.get(key);
}
static buildSelector(tags, { scopedTo, includeWrapper } = {}) {
if (!tags || !tags.length) return '';
const selectors = [];
tags.forEach(tag => {
const base = scopedTo ? `${scopedTo} ${tag}` : tag;
selectors.push(base);
if (includeWrapper) selectors.push(`${base}.heading-element`);
});
return selectors.join(', ');
}
static getHeadingSelector() {
return DOMUtils.getCachedSelector('all-headings', () => DOMUtils.buildSelector(DOMUtils.getHeadingTags()));
}
static getHeadingSelectorUpToLevel(level) {
return DOMUtils.getCachedSelector(`upto-${level}`, () =>
DOMUtils.buildSelector(DOMUtils.getHeadingTags({ upToLevel: level }))
);
}
static getScopedHeadingSelector(container, { includeWrapper = false, level, upToLevel } = {}) {
if (!container) return '';
const key = `scope-${container}|wrap:${includeWrapper}|level:${level ?? 'all'}|upto:${upToLevel ?? 'na'}`;
return DOMUtils.getCachedSelector(key, () =>
DOMUtils.buildSelector(DOMUtils.getHeadingTags({ level, upToLevel }), { scopedTo: container, includeWrapper })
);
}
static collectHeadings(containers = CONFIG.selectors.markdownContainers) {
const useCache = containers === CONFIG.selectors.markdownContainers;
if (useCache && DOMUtils._headingCache) return DOMUtils._headingCache.slice();
const selectors = containers.map(container => DOMUtils.getScopedHeadingSelector(container)).filter(Boolean);
if (!selectors.length) return [];
try {
const list = DOMUtils.$$(selectors.join(', ')).filter(element => DOMUtils.shouldIncludeHeading(element));
if (useCache) { DOMUtils._headingCache = list; return list.slice(); }
return list;
} catch { return []; }
}
static hasMarkdownHeadings() {
return CONFIG.selectors.markdownContainers.some(container => {
try {
const selector = DOMUtils.getScopedHeadingSelector(container);
return selector ? !!document.querySelector(selector) : false;
} catch { return false; }
});
}
static getHeadingLevel(element) {
if (!element || !element.nodeName) return 0;
const match = element.nodeName.match(/h([1-6])/i);
return match ? parseInt(match[1], 10) : 0;
}
static $(selector, parent = document) { return parent.querySelector(selector); }
static $$(selector, parent = document) { return Array.from(parent.querySelectorAll(selector)); }
static isHeader(element) { return CONFIG.selectors.headers.includes(element.nodeName); }
static isInMarkdown(element) {
return CONFIG.selectors.markdownContainers.some(selector => element.closest(selector));
}
static getHeaderContainer(header) {
return header.closest('.markdown-heading') || header;
}
static clearSelection() {
const selection = window.getSelection?.() || document.selection;
if (selection) {
if (selection.removeAllRanges) selection.removeAllRanges();
else if (selection.empty) selection.empty();
}
}
static blurActiveElement() {
try {
const active = document.activeElement;
if (!active || active === document.body) return;
if (typeof active.blur === 'function') active.blur();
} catch { }
}
static isVisible(el) {
try {
if (!el || el.getAttribute('aria-hidden') === 'true' || el.hidden) return false;
const cls = el.className || '';
if (typeof cls === 'string' && /(sr-only|visually-hidden)/i.test(cls)) return false;
const rects = el.getClientRects?.();
if (!rects || rects.length === 0) return false;
return (el.offsetWidth + el.offsetHeight) > 0;
} catch { return true; }
}
static inIgnoredRegion(el) {
try { return !!el.closest('nav, header, footer, aside, [role="navigation"], [role="menu"], [role="menubar"], [role="toolbar"]'); }
catch { return false; }
}
static shouldIncludeHeading(el) {
if (!DOMUtils.isHeader(el)) return false;
if (!DOMUtils.isInMarkdown(el)) return false;
if (DOMUtils.inIgnoredRegion(el)) return false;
if (!DOMUtils.isVisible(el)) return false;
return true;
}
static invalidateHeadingCache() { DOMUtils._headingCache = null; }
}
// --- StyleManager (精简版) ---
class StyleManager {
constructor() {
this.arrowColors = document.createElement("style");
this.arrowContentOverride = document.createElement("style");
this.init();
}
init() {
this.addBaseStyles();
this.addColorStyles();
document.head.appendChild(this.arrowColors);
document.head.appendChild(this.arrowContentOverride);
this.updateArrowContentOverride();
this.applyArrowSize(CONFIG.ui.arrowSize);
}
addBaseStyles() {
const headerSelectors = this.generateHeaderSelectors();
GM_addStyle(`
${headerSelectors.base} {
position: relative;
padding-right: 3em;
cursor: pointer;
transition: all ${CONFIG.animation.duration}ms ${CONFIG.animation.easing};
}
${headerSelectors.after} {
display: inline-block;
position: absolute;
right: 0.5em;
top: 50%;
transform: translateY(-50%);
font-size: var(--ghcm-arrow-size, 0.8em);
font-weight: bold;
pointer-events: none;
transition: transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing};
}
${this.generateArrowContent()}
.${CONFIG.classes.collapsed}:after {
content: "展开" !important;
transform: translateY(-50%);
}
.${CONFIG.classes.activeHeading} {
background: rgba(191, 219, 254, 0.55);
border-radius: 4px;
}
.${CONFIG.classes.hoverHeading} {
background: rgba(107, 114, 128, 0.12);
border-radius: 4px;
}
.ghcm-temp-highlight {
background: rgba(191, 219, 254, 0.4);
transition: background 0.4s ease;
}
.${CONFIG.classes.hidden},
.${CONFIG.classes.hiddenByParent} {
display: none !important;
opacity: 0 !important;
}
.${CONFIG.classes.noContent}:after {
display: none !important;
}
.ghcm-transitioning {
transition: opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing},
transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing};
}
`);
}
generateHeaderSelectors() {
const containers = CONFIG.selectors.markdownContainers;
const headers = DOMUtils.getHeadingTagsLower();
const baseSelectors = [];
const afterSelectors = [];
containers.forEach(container => {
if (container) {
headers.forEach(header => {
baseSelectors.push(`${container} ${header}`);
baseSelectors.push(`${container} ${header}.heading-element`);
afterSelectors.push(`${container} ${header}:after`);
afterSelectors.push(`${container} ${header}.heading-element:after`);
});
}
});
return { base: baseSelectors.join(", "), after: afterSelectors.join(", ") };
}
generateArrowContent() {
const headers = DOMUtils.getHeadingTagsLower();
return headers.map((header, index) => {
const level = index + 1;
const containers = CONFIG.selectors.markdownContainers;
const selectors = [];
containers.forEach(container => {
if (container) {
selectors.push(`${container} ${header}:after`);
selectors.push(`${container} ${header}.heading-element:after`);
}
});
return `${selectors.join(", ")} { content: "收起"; }`;
}).join("\n");
}
addColorStyles() {
const headers = DOMUtils.getHeadingTagsLower();
const styles = headers.map((header, index) => {
const containers = CONFIG.selectors.markdownContainers;
const selectors = [];
containers.forEach(container => {
if (container) {
selectors.push(`${container} ${header}:after`);
selectors.push(`${container} ${header}.heading-element:after`);
}
});
return `${selectors.join(", ")} { color: ${CONFIG.colors[index]}; }`;
}).join("\n");
this.arrowColors.textContent = styles;
}
updateColors(newColors) {
CONFIG.colors = newColors;
GM_setValue("ghcm-colors", newColors);
this.addColorStyles();
}
applyArrowSize(size) {
try { document.documentElement.style.setProperty('--ghcm-arrow-size', size || '0.8em'); } catch { }
}
updateArrowContentOverride() {
const headers = DOMUtils.getHeadingTagsLower();
const rules = headers.map((header, index) => {
const level = index + 1;
const containers = CONFIG.selectors.markdownContainers;
const selectors = [];
containers.forEach(container => {
if (container) {
selectors.push(`${container} ${header}:not(.${CONFIG.classes.collapsed}):after`);
selectors.push(`${container} ${header}.heading-element:not(.${CONFIG.classes.collapsed}):after`);
}
});
const text = '收起';
return `${selectors.join(", ")} { content: "${text}" !important; }`;
}).join("\n");
this.arrowContentOverride.textContent = rules;
}
}
// --- CollapseManager ---
class CollapseManager {
constructor(stateManager) {
this.stateManager = stateManager;
this.animationQueue = new Map();
this._scrollEnsureTimeout = null;
this.activeHeading = null;
this._activeNotification = null;
this.tocGenerator = null;
this.searchManager = null;
this.bookmarkManager = null;
}
trackTimeout(headerKey, timeoutId) {
if (!this.animationQueue.has(headerKey)) this.animationQueue.set(headerKey, new Set());
this.animationQueue.get(headerKey).add(timeoutId);
}
cancelTimeouts(headerKey) {
const set = this.animationQueue.get(headerKey);
if (!set) return;
set.forEach(id => clearTimeout(id));
this.animationQueue.delete(headerKey);
}
clearAllAnimations() {
for (const set of this.animationQueue.values()) set.forEach(id => clearTimeout(id));
this.animationQueue.clear();
}
toggle(header, isShiftClicked = false) {
if (!header || header.classList.contains(CONFIG.classes.noContent)) return;
const startTime = performance.now();
const level = this.stateManager.getHeaderLevel(header);
const isCollapsed = !header.classList.contains(CONFIG.classes.collapsed);
if (isShiftClicked) { this.toggleAllSameLevel(level, isCollapsed); }
else { this.toggleSingle(header, isCollapsed); }
const endTime = performance.now();
if (endTime - startTime > 100 && CONFIG.animation.maxAnimatedElements > 0) {
if (!GM_getValue("ghcm-auto-performance-warned", false)) {
CONFIG.animation.maxAnimatedElements = Math.max(5, CONFIG.animation.maxAnimatedElements / 2);
GM_setValue("ghcm-auto-performance-warned", true);
}
}
this.setActiveHeading(header);
DOMUtils.clearSelection();
DOMUtils.blurActiveElement();
this.dispatchToggleEvent(header, level, isCollapsed);
}
toggleSingle(header, isCollapsed) {
header.classList.toggle(CONFIG.classes.collapsed, isCollapsed);
this.updateAriaExpanded(header);
this.updateContent(header, isCollapsed);
}
toggleAllSameLevel(level, isCollapsed) {
const selectors = CONFIG.selectors.markdownContainers
.map(container => DOMUtils.getScopedHeadingSelector(container, { level, includeWrapper: true }))
.filter(Boolean).join(', ');
if (!selectors) return;
DOMUtils.$$(selectors).forEach(header => {
if (DOMUtils.isHeader(header)) {
header.classList.toggle(CONFIG.classes.collapsed, isCollapsed);
this.updateAriaExpanded(header);
this.updateContent(header, isCollapsed);
}
});
}
updateAriaExpanded(header) {
try { header.setAttribute('aria-expanded', String(!header.classList.contains(CONFIG.classes.collapsed))); } catch { }
}
updateContent(header, isCollapsed) {
const level = this.stateManager.getHeaderLevel(header);
const headerKey = this.stateManager.generateHeaderKey(header);
const elements = this.getContentElements(header, level);
const analyzedElements = elements.map(el => {
const childHeader = DOMUtils.isHeader(el) ? el : el.querySelector(DOMUtils.getUpperHeadingSelector());
return {
element: el,
isHeader: !!childHeader,
childHeader: childHeader,
childHeaderCollapsed: childHeader ? childHeader.classList.contains(CONFIG.classes.collapsed) : false
};
});
this.stateManager.setHeaderState(headerKey, { isCollapsed });
this.animateElementsIntelligent(analyzedElements, isCollapsed, headerKey);
}
getContentElements(header, level) {
const container = DOMUtils.getHeaderContainer(header);
const elements = [];
let nextElement = container.nextElementSibling;
const higherLevelSelectors = DOMUtils.getHeadingSelectorUpToLevel(level);
while (nextElement) {
if (nextElement.matches(higherLevelSelectors) ||
(nextElement.classList?.contains('markdown-heading') &&
nextElement.querySelector(higherLevelSelectors))) break;
elements.push(nextElement);
nextElement = nextElement.nextElementSibling;
}
return elements;
}
animateElementsIntelligent(analyzedElements, isCollapsed, headerKey) {
this.cancelTimeouts(headerKey);
if (analyzedElements.length > CONFIG.animation.maxAnimatedElements) {
this.toggleElementsIntelligentInstantly(analyzedElements, isCollapsed);
return;
}
this.animateElementsIntelligentBatch(analyzedElements, isCollapsed, headerKey);
}
toggleElementsIntelligentInstantly(analyzedElements, isCollapsed) {
analyzedElements.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
if (isCollapsed) {
element.classList.add(CONFIG.classes.hiddenByParent);
element.style.removeProperty('display');
} else {
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.removeProperty('display');
if (isHeader && childHeaderCollapsed) {
setTimeout(() => { this.ensureChildHeaderContentHidden(childHeader); }, 10);
}
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.style.removeProperty('transition');
element.classList.remove('ghcm-transitioning');
}
});
}
animateElementsIntelligentBatch(analyzedElements, isCollapsed, headerKey) {
if (CONFIG.animation.maxAnimatedElements === 0) {
this.toggleElementsIntelligentInstantly(analyzedElements, isCollapsed);
return;
}
const batches = this.createIntelligentBatches(analyzedElements, CONFIG.animation.batchSize);
const processBatch = (batchIndex) => {
if (batchIndex >= batches.length) return;
const batch = batches[batchIndex];
if (isCollapsed) { this.collapseIntelligentBatch(batch, headerKey); }
else { this.expandIntelligentBatch(batch, headerKey); }
if (batchIndex < batches.length - 1) {
const timeout = setTimeout(() => { processBatch(batchIndex + 1); }, 30);
this.trackTimeout(headerKey, timeout);
}
};
processBatch(0);
}
createIntelligentBatches(analyzedElements, batchSize) {
const batches = [];
for (let i = 0; i < analyzedElements.length; i += batchSize) batches.push(analyzedElements.slice(i, i + batchSize));
return batches;
}
collapseIntelligentBatch(batch, headerKey) {
batch.forEach(({ element }) => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}, transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`;
});
requestAnimationFrame(() => {
batch.forEach(({ element }) => {
element.style.opacity = '0';
element.style.transform = 'translateY(-8px)';
});
const t = setTimeout(() => {
batch.forEach(({ element }) => {
element.classList.add(CONFIG.classes.hiddenByParent);
element.style.removeProperty('display');
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.style.removeProperty('transition');
});
}, CONFIG.animation.duration);
this.trackTimeout(headerKey, t);
});
}
expandIntelligentBatch(batch, headerKey) {
batch.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
element.classList.remove(CONFIG.classes.hiddenByParent);
element.style.removeProperty('display');
element.style.opacity = '0';
element.style.transform = 'translateY(-8px)';
element.style.transition = `opacity ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}, transform ${CONFIG.animation.duration}ms ${CONFIG.animation.easing}`;
});
requestAnimationFrame(() => {
batch.forEach(({ element, isHeader, childHeader, childHeaderCollapsed }) => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
if (isHeader && childHeaderCollapsed) {
setTimeout(() => { this.ensureChildHeaderContentHidden(childHeader); }, CONFIG.animation.duration + 50);
}
});
const t = setTimeout(() => {
batch.forEach(({ element }) => {
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.style.removeProperty('transition');
});
}, CONFIG.animation.duration);
this.trackTimeout(headerKey, t);
});
}
ensureChildHeaderContentHidden(childHeader) {
if (!childHeader || !childHeader.classList.contains(CONFIG.classes.collapsed)) return;
const childLevel = this.stateManager.getHeaderLevel(childHeader);
const childElements = this.getContentElements(childHeader, childLevel);
childElements.forEach(element => {
element.classList.add(CONFIG.classes.hiddenByParent);
element.style.removeProperty('display');
element.style.removeProperty('opacity');
element.style.removeProperty('transform');
element.classList.remove('ghcm-transitioning');
});
}
expandToHeader(targetHeader, { scroll = true, setActive = true } = {}) {
if (!targetHeader) return;
const level = this.stateManager.getHeaderLevel(targetHeader);
let current = targetHeader;
while (current) {
const container = DOMUtils.getHeaderContainer(current);
let previous = container.previousElementSibling;
let foundParent = false;
while (previous) {
const parentHeader = this.findHeaderInElement(previous, level - 1);
if (parentHeader) {
if (parentHeader.classList.contains(CONFIG.classes.collapsed)) this.toggleSingle(parentHeader, false);
current = parentHeader;
foundParent = true;
break;
}
previous = previous.previousElementSibling;
}
if (!foundParent) break;
}
if (scroll) this.scrollToElement(targetHeader);
if (setActive) this.setActiveHeading(targetHeader, { scroll: false });
}
findHeaderInElement(element, maxLevel) {
if (DOMUtils.isHeader(element)) {
if (this.stateManager.getHeaderLevel(element) <= maxLevel) return element;
}
for (let i = 1; i < maxLevel; i++) {
const headerName = CONFIG.selectors.headers[i - 1].toLowerCase();
const header = element.querySelector(headerName) || element.querySelector(`${headerName}.heading-element`);
if (header) return header;
}
return null;
}
scrollToElement(element) {
if (!element) return;
const headerEl = document.querySelector('header[role="banner"], .Header, .AppHeader-globalBar');
const headerOffset = (headerEl?.offsetHeight || 80) + 20;
const rect = element.getBoundingClientRect();
const targetPosition = Math.max(0, rect.top + window.pageYOffset - headerOffset);
window.scrollTo({ top: targetPosition, behavior: 'smooth' });
if (this._scrollEnsureTimeout) clearTimeout(this._scrollEnsureTimeout);
this._scrollEnsureTimeout = setTimeout(() => {
if (Math.abs(window.scrollY - targetPosition) > 50) {
window.scrollTo({ top: targetPosition, behavior: 'smooth' });
}
}, 500);
}
setActiveHeading(element, { scroll = false } = {}) {
if (!element) return;
let header = element;
if (!DOMUtils.isHeader(header)) header = header.querySelector(DOMUtils.getUpperHeadingSelector());
if (!header) return;
if (this.activeHeading && this.activeHeading !== header) {
try { this.activeHeading.classList.remove(CONFIG.classes.activeHeading); } catch { }
}
this.activeHeading = header;
try { header.classList.add(CONFIG.classes.activeHeading); } catch { }
if (scroll) this.scrollToElement(header);
}
getActiveHeaderElement(force = false) {
if (!force && this.activeHeading && document.contains(this.activeHeading)) return this.activeHeading;
const headers = this.getAllHeaders();
if (!headers.length) return null;
const headerEl = document.querySelector('header[role="banner"], .Header, .AppHeader-globalBar');
const headerOffset = (headerEl?.offsetHeight || 80) + 20;
const position = window.scrollY + headerOffset + 1;
let active = headers[0];
for (const header of headers) {
const top = header.getBoundingClientRect().top + window.pageYOffset;
if (top <= position) active = header;
else break;
}
if (active) this.setActiveHeading(active);
return active;
}
isHeaderNavigable(header) {
if (!header) return false;
if (header.classList?.contains(CONFIG.classes.hidden) || header.classList?.contains(CONFIG.classes.hiddenByParent)) return false;
try { if (header.closest(`.${CONFIG.classes.hiddenByParent}`)) return false; } catch { }
try {
const style = window.getComputedStyle(header);
if (style.display === 'none' || style.visibility === 'hidden') return false;
} catch { }
return true;
}
dispatchToggleEvent(header, level, isCollapsed) {
document.dispatchEvent(new CustomEvent("ghcm:toggle-complete", { detail: { header, level, isCollapsed } }));
if (!isCollapsed) {
setTimeout(() => { this.checkAndRestoreChildHeaderStates(header, level); }, CONFIG.animation.duration + 100);
}
}
checkAndRestoreChildHeaderStates(parentHeader, parentLevel) {
const container = DOMUtils.getHeaderContainer(parentHeader);
let nextElement = container.nextElementSibling;
const higherLevelSelectors = DOMUtils.getHeadingSelectorUpToLevel(parentLevel);
while (nextElement) {
if (nextElement.matches(higherLevelSelectors) ||
(nextElement.classList?.contains('markdown-heading') && nextElement.querySelector(higherLevelSelectors))) break;
const childHeader = DOMUtils.isHeader(nextElement) ? nextElement : nextElement.querySelector(DOMUtils.getUpperHeadingSelector());
if (childHeader && childHeader.classList.contains(CONFIG.classes.collapsed)) {
this.ensureChildHeaderContentHidden(childHeader);
}
nextElement = nextElement.nextElementSibling;
}
}
getAllHeaders() { return DOMUtils.collectHeadings(); }
syncAriaExpandedForAll() {
try {
this.getAllHeaders().forEach(h => {
h.setAttribute('aria-expanded', String(!h.classList.contains(CONFIG.classes.collapsed)));
});
} catch { }
}
collapseAll() {
let count = 0;
this.getAllHeaders().forEach(header => {
if (!header.classList.contains(CONFIG.classes.collapsed) && !header.classList.contains(CONFIG.classes.noContent)) {
header.classList.add(CONFIG.classes.collapsed);
this.updateAriaExpanded(header);
this.updateContent(header, true);
count++;
}
});
this.showNotification(`📁 已折叠 ${count} 个标题`);
}
expandAll() {
let count = 0;
this.getAllHeaders().forEach(header => {
if (header.classList.contains(CONFIG.classes.collapsed)) {
header.classList.remove(CONFIG.classes.collapsed);
this.updateAriaExpanded(header);
this.updateContent(header, false);
count++;
}
});
this.showNotification(`📂 已展开 ${count} 个标题`);
}
toggleAll() {
const headers = this.getAllHeaders();
const collapsedCount = headers.filter(h => h.classList.contains(CONFIG.classes.collapsed)).length;
const totalCount = headers.filter(h => !h.classList.contains(CONFIG.classes.noContent)).length;
if (collapsedCount > totalCount / 2) this.expandAll();
else this.collapseAll();
}
showNotification(message) {
if (this._activeNotification) { try { this._activeNotification.remove(); } catch { } this._activeNotification = null; }
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: var(--color-canvas-default, #ffffff);
border: 1px solid var(--color-border-default, #d0d7de);
border-radius: 8px; padding: 12px 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10002;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px; color: var(--color-fg-default, #24292f);
opacity: 0; transition: opacity 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
this._activeNotification = notification;
requestAnimationFrame(() => { notification.style.opacity = '1'; });
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) notification.parentNode.removeChild(notification);
if (this._activeNotification === notification) this._activeNotification = null;
}, 300);
}, 2000);
}
loadSavedStates() {
this.stateManager.loadFromMemory();
for (let level = 1; level <= 6; level++) this.applyStatesForLevel(level);
}
applyStatesForLevel(level) {
this.getAllHeaders().filter(h => this.stateManager.getHeaderLevel(h) === level).forEach(header => {
const headerKey = this.stateManager.generateHeaderKey(header);
const savedState = this.stateManager.getHeaderState(headerKey);
if (savedState && savedState.isCollapsed) {
header.classList.add(CONFIG.classes.collapsed);
this.updateAriaExpanded(header);
this.updateContent(header, true);
}
});
}
markEmptyHeaders() {
CONFIG.selectors.markdownContainers.forEach(containerSelector => {
const selector = DOMUtils.getScopedHeadingSelector(containerSelector, { includeWrapper: true });
if (!selector) return;
DOMUtils.$$(selector).forEach(header => {
const level = this.stateManager.getHeaderLevel(header);
const elements = this.getContentElements(header, level);
if (elements.length === 0) header.classList.add(CONFIG.classes.noContent);
else header.classList.remove(CONFIG.classes.noContent);
});
});
}
}
// --- EventManager (精简版) ---
class EventManager {
constructor(collapseManager) {
this.collapseManager = collapseManager;
this.hoverHeader = null;
this.setupEventListeners();
}
setupEventListeners() {
document.addEventListener("click", this.handleClick.bind(this), true);
this._hoverHandler = this.handleHover.bind(this);
this._hoverLeaveHandler = this.handleHoverLeave.bind(this);
document.addEventListener('mouseover', this._hoverHandler, true);
document.addEventListener('mouseout', this._hoverLeaveHandler, true);
window.addEventListener("hashchange", this.handleHashChange.bind(this));
if (window.ghmo) window.addEventListener("ghmo:dom", this.handleDOMChange.bind(this));
document.addEventListener("pjax:end", this.handleNavigation.bind(this));
document.addEventListener("turbo:load", this.handleNavigation.bind(this));
document.addEventListener("turbo:render", this.handleNavigation.bind(this));
window.addEventListener("pageshow", this.handleNavigation.bind(this));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.handleDOMChange.bind(this));
} else {
setTimeout(() => this.handleDOMChange(), 200);
}
}
handleClick(event) {
let target = event.target;
if (event.button !== 0) return;
try {
const sel = window.getSelection?.();
if (sel && sel.toString && sel.toString().trim().length > 0) return;
} catch { }
if (target.nodeName === "path") target = target.closest("svg");
if (!target || this.shouldSkipElement(target)) return;
const header = target.closest(DOMUtils.getHeadingSelector());
if (header && DOMUtils.isHeader(header) && DOMUtils.isInMarkdown(header)) {
this.collapseManager.toggle(header, event.shiftKey);
}
}
handleHover(event) {
const header = event.target.closest(DOMUtils.getHeadingSelector());
if (!header || !DOMUtils.isHeader(header)) return;
if (this.hoverHeader === header) return;
try {
if (this.hoverHeader) this.hoverHeader.classList.remove(CONFIG.classes.hoverHeading);
header.classList.add(CONFIG.classes.hoverHeading);
this.hoverHeader = header;
} catch { }
}
handleHoverLeave(event) {
const header = event.target.closest(DOMUtils.getHeadingSelector());
if (!header || !DOMUtils.isHeader(header)) return;
const related = event.relatedTarget;
if (related && (related === header || related.closest?.(DOMUtils.getHeadingSelector()) === header)) return;
if (this.hoverHeader === header) {
header.classList.remove(CONFIG.classes.hoverHeading);
this.hoverHeader = null;
}
}
shouldSkipElement(element) {
const nodeName = element.nodeName?.toLowerCase();
try {
if (element.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]')) return true;
} catch { }
return CONFIG.selectors.excludeClicks.some(selector => {
if (selector.startsWith('.')) return element.classList.contains(selector.slice(1));
return nodeName === selector;
});
}
handleHashChange() {
const hash = window.location.hash.replace(/#/, "");
if (hash) this.openHashTarget(hash);
}
handleDOMChange() {
DOMUtils.invalidateHeadingCache();
this.collapseManager.markEmptyHeaders();
this.handleHashChange();
try {
const active = this.collapseManager.getActiveHeaderElement();
if (active) this.collapseManager.setActiveHeading(active);
} catch { }
}
handleNavigation() {
DOMUtils.invalidateHeadingCache();
try { this.collapseManager.clearAllAnimations(); } catch { }
try { this.collapseManager.stateManager.updatePageKey(); } catch (e) { }
this.handleDOMChange();
if (CONFIG.memory.enabled) {
setTimeout(() => { try { this.collapseManager.loadSavedStates(); } catch (e) { } }, 300);
}
}
openHashTarget(id) {
const possibleSelectors = [`#user-content-${id}`, `#${id}`, `[id="${id}"]`];
let targetElement = null;
for (const selector of possibleSelectors) {
targetElement = DOMUtils.$(selector);
if (targetElement) break;
}
if (!targetElement) return;
let header = targetElement;
if (!DOMUtils.isHeader(header)) header = targetElement.closest(DOMUtils.getHeadingSelector());
if (header && DOMUtils.isHeader(header)) {
this.collapseManager.expandToHeader(header, { scroll: false, setActive: false });
this.collapseManager.scrollToElement(header);
this.collapseManager.setActiveHeading(header);
}
}
}
// --- 初始化 ---
const stateManager = new StateManager();
const styleManager = new StyleManager();
const collapseManager = new CollapseManager(stateManager);
const eventManager = new EventManager(collapseManager);
// 初始化折叠标记和状态恢复
setTimeout(() => {
collapseManager.markEmptyHeaders();
if (CONFIG.memory.enabled) collapseManager.loadSavedStates();
collapseManager.syncAriaExpandedForAll();
}, 500);
})();