您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A highly performant, beautiful, and dynamic heading navigation menu with virtualization.
// ==UserScript== // @name Feasibly Use the Web // @namespace http://github.com/Echoinbyte/ // @version 3.0 // @description A highly performant, beautiful, and dynamic heading navigation menu with virtualization. // @author Echoinbyte // @match *://*/* // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; // --- Configuration --- const CONFIG = { debounceDelay: 750, virtualization: { itemHeight: 38, buffer: 8, }, colors: { menuBackground: "rgba(30, 41, 59, 0.85)", menuBorder: "#475569", menuText: "#e2e8f0", menuTextSecondary: "#94a3b8", menuHover: "rgba(51, 65, 85, 0.9)", menuActive: "#1e293b", focusOutline: "#60a5fa", shadow: "rgba(0, 0, 0, 0.3)", accent: "#60a5fa", scrollbar: "#475569", scrollbarHover: "#64748b", }, }; // --- Core Logic: Manages state, data, and page observation --- const CoreLogic = { headings: [], filteredHeadings: [], mutationObserver: null, scrollObserver: null, updateTimeout: null, currentUrl: window.location.href, init() { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => this.run()); } else { this.run(); } }, run() { UIManager.init(this); this.discoverAndUpdateHeadings(); this.setupObservers(); this.setupEventListeners(); }, discoverAndUpdateHeadings(force = false) { const newHeadings = this.collectHeadings(); const hasChanged = force || this.headings.length !== newHeadings.length || JSON.stringify(this.headings.map((h) => h.id)) !== JSON.stringify(newHeadings.map((h) => h.id)); if (hasChanged) { this.headings = newHeadings; this.filteredHeadings = newHeadings; UIManager.render(this.filteredHeadings); this.observeVisibleHeadings(); } }, collectHeadings() { const headingNodes = document.querySelectorAll("h1, h2, h3, h4, h5, h6"); const headings = []; const counters = [0, 0, 0, 0, 0, 0]; headingNodes.forEach((node, index) => { if (!node.textContent.trim() || node.closest("#feasible-heading-nav")) { return; } // TODO: Add support for custom heading selectors if ( !node.id || document.querySelector( `[id="${node.id}"]:not([data-heading-processed])` ) !== node ) { const baseId = "feasible-h-" + (node.textContent .trim() .toLowerCase() .replace(/[^a-z0-9\s]/g, "") .replace(/\s+/g, "-") .substring(0, 50) || index); let finalId = baseId; let counter = 2; while ( document.getElementById(finalId) && document.getElementById(finalId) !== node ) { finalId = `${baseId}-${counter++}`; } node.id = finalId; } node.setAttribute("data-heading-processed", "true"); const level = parseInt(node.tagName.substring(1)); // TODO: Add support for nested numbering schemes for (let i = 0; i < level - 1; i++) { if (counters[i] === 0) { counters[i] = 1; } } counters[level - 1]++; for (let i = level; i < 6; i++) { counters[i] = 0; } const numberLabel = counters .slice(0, level) .filter((c) => c > 0) .join("."); headings.push({ id: node.id, text: node.textContent.trim(), level: level, number: numberLabel, element: node, }); }); return headings; }, setupObservers() { this.mutationObserver = new MutationObserver(() => this.scheduleUpdate()); this.mutationObserver.observe(document.body, { childList: true, subtree: true, }); }, observeVisibleHeadings() { if (this.scrollObserver) this.scrollObserver.disconnect(); if (this.headings.length === 0) return; this.scrollObserver = new IntersectionObserver( (entries) => { // Find the most relevant heading to highlight let topMostEntry = null; let topMostPosition = Infinity; entries.forEach((entry) => { if (entry.isIntersecting) { const rect = entry.boundingClientRect; // Prioritize headings that are closer to the top of the viewport if (rect.top < topMostPosition && rect.top >= 0) { topMostPosition = rect.top; topMostEntry = entry; } } }); // Only update the active link if we found a valid entry if (topMostEntry) { UIManager.updateActiveLink(topMostEntry.target.id); } }, { rootMargin: "0px 0px -80% 0px", threshold: 0.1 } ); this.headings.forEach((h) => this.scrollObserver.observe(h.element)); }, scheduleUpdate() { clearTimeout(this.updateTimeout); this.updateTimeout = setTimeout(() => { if (window.location.href !== this.currentUrl) { this.currentUrl = window.location.href; this.discoverAndUpdateHeadings(true); // Force update on URL change } else { this.discoverAndUpdateHeadings(); } }, CONFIG.debounceDelay); }, setupEventListeners() { const schedule = () => this.scheduleUpdate(); window.addEventListener("popstate", schedule); window.addEventListener("hashchange", schedule); // TODO: Add support for custom keyboard shortcuts document.addEventListener("keydown", (e) => { if (e.altKey && e.key.toLowerCase() === "h") { e.preventDefault(); if (UIManager.elements.nav) { if ( !UIManager.isCollapsed && UIManager.core.filteredHeadings.length > 0 ) { UIManager.elements.list.focus(); UIManager.virtualScroll.focusedIndex = 0; UIManager.ensureIndexIsVisible(0); UIManager.updateVirtualScroll(); } else { UIManager.elements.nav.focus(); } } } if (e.altKey && e.key.toLowerCase() === "n") { e.preventDefault(); if (UIManager.elements.filterInput && !UIManager.isCollapsed) { UIManager.elements.filterInput.focus(); } } if (e.altKey && e.key.toLowerCase() === "t") { e.preventDefault(); if (UIManager.elements.toggleBtn) { UIManager.elements.toggleBtn.click(); } } }); const originalPushState = history.pushState; history.pushState = function (...args) { originalPushState.apply(history, args); schedule(); }; const originalReplaceState = history.replaceState; history.replaceState = function (...args) { originalReplaceState.apply(history, args); schedule(); }; }, filterHeadings(query) { const lowerQuery = query.toLowerCase(); this.filteredHeadings = this.headings.filter((h) => h.text.toLowerCase().includes(lowerQuery) ); UIManager.render(this.filteredHeadings); }, }; // --- UI Manager: Manages all DOM elements and interactions --- const UIManager = { core: null, elements: {}, isCollapsed: false, dragState: { isDragging: false, x: 0, y: 0, initialX: 0, initialY: 0 }, virtualScroll: { scrollTop: 0, focusedIndex: -1 }, init(coreInstance) { this.core = coreInstance; this.isCollapsed = localStorage.getItem("feasible-nav-collapsed") === "true"; this.createStyles(); this.createContainer(); this.setupCoreEventListeners(); this.applyInitialState(); this.applyPersistedState(); }, render(headings) { this.virtualScroll.itemCount = headings.length; this.elements.listSizer.style.height = `${ headings.length * CONFIG.virtualization.itemHeight }px`; this.updateVirtualScroll(); }, getStyleSheet(colors) { return ` #feasible-heading-nav { position: fixed !important; top: 20px; right: 20px; width: 320px; max-height: 85vh; background: ${colors.menuBackground}; border: 1px solid ${colors.menuBorder}; border-radius: 12px; box-shadow: 0 8px 32px ${colors.shadow}; z-index: 999999 !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; transition: width 0.3s ease, min-width 0.3s ease; backdrop-filter: blur(10px); user-select: none; pointer-events: auto !important; display: flex; flex-direction: column; color: ${colors.menuText}; } .feasible-header { padding: 12px 16px; border-bottom: 1px solid ${colors.menuBorder}; display: flex; justify-content: space-between; align-items: center; cursor: grab; flex-shrink: 0; } .feasible-title-container { display: flex; align-items: center; gap: 8px; overflow: hidden; } .feasible-title-icon { font-size: 20px; user-select: none; } .feasible-title { margin: 0; font-size: 16px; font-weight: 600; color: ${colors.menuText}; white-space: nowrap; } .feasible-header button { color: ${colors.menuTextSecondary}; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; line-height: 1; } .feasible-filter-input { width: calc(100% - 32px); padding: 8px 12px; margin: 8px 16px; border-radius: 6px; border: 1px solid ${colors.menuBorder}; background-color: ${colors.menuBackground}; color: ${colors.menuText}; font-size: 13px; transition: border-color 0.2s ease; box-sizing: border-box; flex-shrink: 0; } .feasible-filter-input:focus { border-color: ${colors.accent}; outline: none; } .feasible-content { flex-grow: 1; overflow-y: auto; scroll-behavior: smooth; position: relative; } .feasible-content::-webkit-scrollbar { width: 8px; } .feasible-content::-webkit-scrollbar-track { background: transparent; } .feasible-content::-webkit-scrollbar-thumb { background: ${colors.scrollbar}; border-radius: 4px; } .feasible-content::-webkit-scrollbar-thumb:hover { background: ${colors.scrollbarHover}; } .feasible-list { list-style: none; margin: 0; padding: 0; position: relative; } .feasible-list-item { position: absolute; top: 0; left: 0; width: 100%; height: ${CONFIG.virtualization.itemHeight}px; display: flex; align-items: center; padding: 0 16px; color: ${colors.menuText}; text-decoration: none; transition: background-color 0.2s ease, border 0.2s ease; border: 1px solid transparent; cursor: pointer; box-sizing: border-box; } .feasible-list-item.active { background-color: ${colors.menuActive}; font-weight: 600; } .feasible-list-item.focused { border-color: ${colors.focusOutline}; } .feasible-list-item:hover { background-color: ${colors.menuHover}; } .item-number { font-size: 11px; font-weight: bold; text-align: center; line-height: 18px; border-radius: 4px; margin-right: 10px; color: white; padding: 0 6px; } .item-text { flex: 1; min-width: 0; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; font-size: 13px; } `; }, createStyles() { this.elements.style = document.createElement("style"); this.elements.style.textContent = this.getStyleSheet(CONFIG.colors); document.head.appendChild(this.elements.style); }, createContainer() { const nav = document.createElement("nav"); nav.id = "feasible-heading-nav"; nav.setAttribute("tabindex", "1"); // Make it first in tab order nav.setAttribute("role", "navigation"); nav.setAttribute("aria-label", "Page headings navigation"); this.elements.nav = nav; const header = this.createHeader(); const filter = this.createFilterInput(); const content = this.createContentArea(); nav.appendChild(header); nav.appendChild(filter); nav.appendChild(content); document.body.appendChild(nav); }, createHeader() { const header = document.createElement("div"); header.className = "feasible-header"; this.elements.header = header; const titleContainer = document.createElement("div"); titleContainer.className = "feasible-title-container"; titleContainer.innerHTML = `<span class="feasible-title-icon">🧭</span><h2 class="feasible-title">Feasibley Navigate</h2>`; this.elements.titleContainer = titleContainer; const toggleBtn = document.createElement("button"); toggleBtn.setAttribute("aria-label", "Collapse navigation"); toggleBtn.setAttribute("tabindex", "2"); toggleBtn.setAttribute("title", "Toggle navigation (Alt+T)"); toggleBtn.style.cssText = `background: transparent; border: none; font-size: 24px; cursor: pointer; padding: 0 4px;`; this.elements.toggleBtn = toggleBtn; header.appendChild(titleContainer); header.appendChild(toggleBtn); return header; }, createFilterInput() { const filterInput = document.createElement("input"); filterInput.type = "text"; filterInput.placeholder = "Navigate to..."; filterInput.className = "feasible-filter-input"; filterInput.setAttribute("tabindex", "3"); filterInput.setAttribute("aria-label", "Filter headings"); filterInput.setAttribute("title", "Search headings (Alt+N)"); this.elements.filterInput = filterInput; return filterInput; }, createContentArea() { const content = document.createElement("div"); content.className = "feasible-content"; this.elements.content = content; const listSizer = document.createElement("div"); listSizer.style.cssText = "position: relative; width: 100%; height: 0; z-index: 0;"; this.elements.listSizer = listSizer; const list = document.createElement("ul"); list.className = "feasible-list"; list.setAttribute("role", "menu"); list.setAttribute("tabindex", "4"); list.setAttribute("aria-label", "Headings list"); list.setAttribute("title", "Navigate headings (Alt+H)"); this.elements.list = list; listSizer.appendChild(list); content.appendChild(listSizer); return content; }, updateVirtualScroll() { const { content, list } = this.elements; const { itemHeight, buffer } = CONFIG.virtualization; const itemCount = this.core.filteredHeadings.length; const startIndex = Math.max( 0, Math.floor(this.virtualScroll.scrollTop / itemHeight) - buffer ); const endIndex = Math.min( itemCount, Math.ceil( (this.virtualScroll.scrollTop + content.clientHeight) / itemHeight ) + buffer ); const visibleItems = this.core.filteredHeadings.slice( startIndex, endIndex ); list.innerHTML = ""; // Clear for simplicity, advanced recycling is more complex visibleItems.forEach((heading, i) => { const index = startIndex + i; const top = index * itemHeight; const li = this.createListItem(heading); li.style.transform = `translateY(${top}px)`; if (index === this.virtualScroll.focusedIndex) { li.classList.add("focused"); li.setAttribute("aria-selected", "true"); } else { li.setAttribute("aria-selected", "false"); } list.appendChild(li); }); }, createListItem(heading) { const li = document.createElement("li"); li.className = "feasible-list-item"; li.dataset.id = heading.id; li.setAttribute("role", "menuitem"); li.setAttribute("tabindex", "-1"); li.setAttribute("title", `${heading.text} (Level ${heading.level})`); const level = heading.level; const indent = (level - 1) * 15; li.style.paddingLeft = `${16 + indent}px`; const colors = [ "#3182ce", "#38a169", "#d69e2e", "#e53e3e", "#805ad5", "#dd6b20", ]; const color = colors[level - 1] || colors[5]; li.innerHTML = ` <span class="item-number" style="background-color: ${color};">${heading.number}</span> <span class="item-text">${heading.text}</span> `; return li; }, setupCoreEventListeners() { // Dragging this.elements.header.addEventListener("mousedown", (e) => { if (e.target.tagName === "BUTTON") return; e.preventDefault(); this.dragState.isDragging = true; this.dragState.initialX = e.clientX - this.dragState.x; this.dragState.initialY = e.clientY - this.dragState.y; this.elements.header.style.cursor = "grabbing"; document.body.style.userSelect = "none"; }); document.addEventListener("mousemove", (e) => { if (!this.dragState.isDragging) return; this.dragState.x = e.clientX - this.dragState.initialX; this.dragState.y = e.clientY - this.dragState.initialY; this.elements.nav.style.transform = `translate(${this.dragState.x}px, ${this.dragState.y}px)`; }); document.addEventListener("mouseup", () => { if (this.dragState.isDragging) { this.dragState.isDragging = false; this.elements.header.style.cursor = "grab"; document.body.style.userSelect = ""; localStorage.setItem( "feasible-nav-position", JSON.stringify({ x: this.dragState.x, y: this.dragState.y }) ); } }); // Collapse this.elements.toggleBtn.addEventListener("click", () => { this.isCollapsed = !this.isCollapsed; localStorage.setItem("feasible-nav-collapsed", this.isCollapsed); this.applyCollapseState(); }); // Main navigation keyboard controls this.elements.nav.addEventListener("keydown", (e) => { const { key } = e; // Handle Escape key to focus the navigation if (key === "Escape") { e.preventDefault(); this.elements.nav.focus(); return; } // Handle arrow keys to navigate to list when on nav if (key === "ArrowDown" && this.core.filteredHeadings.length > 0) { e.preventDefault(); this.elements.list.focus(); if (this.virtualScroll.focusedIndex === -1) { this.virtualScroll.focusedIndex = 0; this.ensureIndexIsVisible(0); this.updateVirtualScroll(); } return; } }); // Toggle button keyboard controls this.elements.toggleBtn.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this.elements.toggleBtn.click(); } }); // TODO: Add support for custom filter shortcuts this.elements.filterInput.addEventListener("keydown", (e) => { if (e.key === "Tab" && !e.shiftKey) { return; } if (e.key === "ArrowDown") { e.preventDefault(); this.elements.list.focus(); if ( this.virtualScroll.focusedIndex === -1 && this.core.filteredHeadings.length > 0 ) { this.virtualScroll.focusedIndex = 0; this.ensureIndexIsVisible(0); this.updateVirtualScroll(); } } if (e.key === "Enter") { e.preventDefault(); if (this.core.filteredHeadings.length > 0) { const firstHeading = this.core.filteredHeadings[0]; const targetElement = document.getElementById(firstHeading.id); if (targetElement) { this.updateActiveLink(firstHeading.id); this.scrollToAndHighlight(targetElement); } } } if (e.key === "Escape") { e.preventDefault(); this.elements.filterInput.value = ""; this.core.filterHeadings(""); this.elements.nav.focus(); } }); // Virtual Scroll this.elements.content.addEventListener( "scroll", (e) => { this.virtualScroll.scrollTop = e.target.scrollTop; requestAnimationFrame(() => this.updateVirtualScroll()); }, { passive: true } ); // Filter this.elements.filterInput.addEventListener("input", (e) => { this.core.filterHeadings(e.target.value); }); // Keyboard navigation for headings list this.elements.list.addEventListener("keydown", (e) => { const { key } = e; const allowedKeys = [ "ArrowUp", "ArrowDown", "Enter", " ", "Home", "End", "PageUp", "PageDown", "Escape", "Tab", ]; if (!allowedKeys.includes(key)) return; e.preventDefault(); const count = this.core.filteredHeadings.length; if (count === 0) return; let { focusedIndex } = this.virtualScroll; // Initialize focused index if not set if (focusedIndex === -1) { focusedIndex = 0; } switch (key) { case "ArrowDown": focusedIndex = (focusedIndex + 1) % count; break; case "ArrowUp": focusedIndex = (focusedIndex - 1 + count) % count; break; case "Home": focusedIndex = 0; break; case "End": focusedIndex = count - 1; break; case "PageDown": focusedIndex = Math.min(count - 1, focusedIndex + 5); break; case "PageUp": focusedIndex = Math.max(0, focusedIndex - 5); break; case "Enter": case " ": if (focusedIndex !== -1) { const heading = this.core.filteredHeadings[focusedIndex]; const targetElement = document.getElementById(heading.id); if (targetElement) { this.updateActiveLink(heading.id); this.scrollToAndHighlight(targetElement); } } return; case "Escape": this.elements.nav.focus(); return; case "Tab": if (e.shiftKey) { this.elements.filterInput.focus(); } else { // Allow tab to leave the navigation this.elements.nav.blur(); } return; } this.virtualScroll.focusedIndex = focusedIndex; this.ensureIndexIsVisible(focusedIndex); this.updateVirtualScroll(); }); // Event Delegation for list items this.elements.list.addEventListener("click", (e) => { const item = e.target.closest(".feasible-list-item"); if (!item) return; const targetId = item.dataset.id; const targetElement = document.getElementById(targetId); if (targetElement) { // Immediately update the active link to provide instant feedback this.updateActiveLink(targetId); this.scrollToAndHighlight(targetElement); } }); }, scrollToAndHighlight(element) { const originalStyles = { textDecoration: element.style.textDecoration, textDecorationColor: element.style.textDecorationColor, transition: element.style.transition, }; element.style.transition = "text-decoration-color 2s ease-out"; element.style.textDecoration = `underline solid ${CONFIG.colors.accent} 2px`; setTimeout(() => { element.style.textDecorationColor = "transparent"; }, 200); setTimeout(() => { element.style.textDecoration = originalStyles.textDecoration; element.style.textDecorationColor = originalStyles.textDecorationColor; element.style.transition = originalStyles.transition; }, 2200); element.scrollIntoView({ behavior: "auto", block: "start" }); const observer = new IntersectionObserver( (entries) => { observer.disconnect(); const entry = entries[0]; if (entry.isIntersecting && entry.boundingClientRect.top < 120) { const offset = 140 - entry.boundingClientRect.top; window.scrollBy({ top: -offset, behavior: "smooth" }); } }, { rootMargin: "0px 0px -90% 0px" } ); observer.observe(element); }, ensureIndexIsVisible(index) { const { content } = this.elements; const { itemHeight } = CONFIG.virtualization; const scrollTop = this.virtualScroll.scrollTop; const listHeight = content.clientHeight; const itemTop = index * itemHeight; const itemBottom = itemTop + itemHeight; if (itemTop < scrollTop) { content.scrollTop = itemTop; } else if (itemBottom > scrollTop + listHeight) { content.scrollTop = itemBottom - listHeight; } }, applyPersistedState() { const savedPosition = localStorage.getItem("feasible-nav-position"); if (savedPosition) { try { const { x, y } = JSON.parse(savedPosition); if (typeof x === "number" && typeof y === "number") { this.dragState.x = x; this.dragState.y = y; this.elements.nav.style.transform = `translate(${x}px, ${y}px)`; } } catch (e) { console.error("FeasibleNav: Could not parse saved position.", e); localStorage.removeItem("feasible-nav-position"); } } }, applyInitialState() { this.applyCollapseState(); }, applyCollapseState() { const { nav, titleContainer, content, toggleBtn, filterInput } = this.elements; if (this.isCollapsed) { titleContainer.style.display = "none"; content.style.display = "none"; filterInput.style.display = "none"; toggleBtn.innerHTML = "+"; nav.style.width = "auto"; nav.style.minWidth = "48px"; } else { titleContainer.style.display = "flex"; content.style.display = "block"; filterInput.style.display = "block"; toggleBtn.innerHTML = "−"; nav.style.width = "320px"; } }, updateActiveLink(id) { this.elements.list .querySelectorAll(".feasible-list-item") .forEach((item) => { item.classList.toggle("active", item.dataset.id === id); }); }, }; CoreLogic.init(); })();