您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds nesting functionality to outline headings, in addition to right click menu option to fold/unfold up to desired level.
当前为
// ==UserScript== // @name Nested Outline Headings // @namespace http://tampermonkey.net/ // @version 1.1 // @description Adds nesting functionality to outline headings, in addition to right click menu option to fold/unfold up to desired level. // @match *://docs.google.com/document/* // @match https://docs.google.com/document/d/* // @grant none // @license MIT // @run-at document-end // ==/UserScript== (function() { 'use strict'; // ---------------------------- // Shared utility functions // ---------------------------- // Returns the heading level from an outline item element. function getHeadingLevel(item) { const content = item.querySelector('.navigation-item-content'); if (!content) return null; for (const cls of content.classList) { if (cls.startsWith('navigation-item-level-')) { return parseInt(cls.split('-').pop(), 10); } } return null; } // Updates the inherited selection highlight in the outline. function updateInheritedSelection() { document.querySelectorAll('.navigation-item.inherited-selected').forEach(item => { item.classList.remove('inherited-selected'); }); const selected = document.querySelector('.navigation-item.location-indicator-highlight'); if (!selected) return; if (!selected.classList.contains('folded')) return; const selectedLevel = getHeadingLevel(selected); if (selectedLevel === null) return; const headings = Array.from(document.querySelectorAll('.navigation-item')); const selectedIndex = headings.indexOf(selected); let parentCandidate = null; for (let i = selectedIndex - 1; i >= 0; i--) { const candidate = headings[i]; const candidateLevel = getHeadingLevel(candidate); if (candidateLevel !== null && candidateLevel < selectedLevel && !candidate.classList.contains('folded')) { parentCandidate = candidate; break; } } if (parentCandidate) { parentCandidate.classList.add('inherited-selected'); } } function getActiveTabContainer() { const tabs = document.querySelectorAll('div.chapter-container[id^="chapter-container-"]'); for (const tab of tabs) { const selected = tab.querySelector('.chapter-item-label-and-buttons-container[aria-selected="true"]'); if (selected) return tab; } return null; } // ---------------------------- // Integration: Folding function // ---------------------------- // Global function to fold (collapse) the outline to a given level. // All headings with a level greater than or equal to targetLevel will be folded. window.foldToLevel = function(targetLevel) { const activeTab = getActiveTabContainer(); const headings = activeTab ? activeTab.querySelectorAll('.navigation-item') : []; headings.forEach(item => { const level = getHeadingLevel(item); if (level === null) return; const toggle = item.querySelector('.custom-toggle-button'); // If this heading is exactly one level above the target, // update its toggle button state only. if (level === targetLevel - 1) { if (toggle) { // We want to show its child subheadings (level === targetLevel) as folded, // so mark the toggle as collapsed. toggle.dataset.expanded = 'false'; const inner = toggle.querySelector('.chapterItemArrowContainer'); inner.setAttribute('aria-expanded', 'false'); inner.setAttribute('aria-label', 'Expand subheadings'); const icon = inner.querySelector('.material-symbols-outlined'); icon.style.display = 'inline-block'; icon.style.transformOrigin = 'center center'; icon.style.transform = 'rotate(-90deg)'; // Apply the toggle-on state to indicate a collapsed toggle. item.classList.add("toggle-on"); } // Do not modify the folded state or process children. return; } // For all other headings, use the normal logic. const shouldExpand = level < targetLevel; if (shouldExpand) { // Expanded state. item.classList.remove('folded'); if (toggle) { toggle.dataset.expanded = 'true'; const inner = toggle.querySelector('.chapterItemArrowContainer'); inner.setAttribute('aria-expanded', 'true'); inner.setAttribute('aria-label', 'Collapse subheadings'); const icon = inner.querySelector('.material-symbols-outlined'); icon.style.display = 'inline-block'; icon.style.transformOrigin = 'center center'; icon.style.transform = 'rotate(-45deg)'; item.classList.remove("toggle-on"); } else { // Ensure headings without a toggle button are not marked. item.classList.remove("toggle-on"); } expandChildren(item, level); } else { // Collapsed state. item.classList.add('folded'); if (toggle) { toggle.dataset.expanded = 'false'; const inner = toggle.querySelector('.chapterItemArrowContainer'); inner.setAttribute('aria-expanded', 'false'); inner.setAttribute('aria-label', 'Expand subheadings'); const icon = inner.querySelector('.material-symbols-outlined'); icon.style.display = 'inline-block'; icon.style.transformOrigin = 'center center'; icon.style.transform = 'rotate(-90deg)'; item.classList.add("toggle-on"); } else { item.classList.remove("toggle-on"); } collapseChildren(item, level); } }); updateInheritedSelection(); }; // ---------------------------- // "Show headings" menu (First Script) // ---------------------------- function isCorrectMenu(menu) { const labels = menu.querySelectorAll('.goog-menuitem-label'); return Array.from(labels).some(label => label.textContent.trim() === "Choose emoji"); } function menuHasShowHeadings(menu) { const labels = menu.querySelectorAll('.goog-menuitem-label'); return Array.from(labels).some(label => label.textContent.trim() === "Show headings"); } // Dynamically update the submenu items. function updateSubmenu(submenu) { // Clear any existing items. while (submenu.firstChild) { submenu.removeChild(submenu.firstChild); } // Find all headings and determine the maximum display level. const activeTab = getActiveTabContainer(); const headings = activeTab ? activeTab.querySelectorAll('.navigation-item') : []; let maxDisplayLevel = 0; headings.forEach(heading => { const rawLevel = getHeadingLevel(heading); if (rawLevel !== null) { const displayLevel = rawLevel + 1; // adjust to get the correct display level if (displayLevel > maxDisplayLevel) { maxDisplayLevel = displayLevel; } } }); // If there are no headings, add a disabled "No headings" item. if (maxDisplayLevel === 0) { const item = document.createElement('div'); item.className = "goog-menuitem"; item.style.userSelect = "none"; item.style.fontStyle = "italic"; item.style.color = "#9aa0a6"; const contentDiv = document.createElement('div'); contentDiv.className = "goog-menuitem-content"; const innerDiv = document.createElement('div'); innerDiv.textContent = "No headings"; contentDiv.appendChild(innerDiv); item.appendChild(contentDiv); submenu.appendChild(item); } else { // Create a menu option for each level. for (let i = 1; i <= maxDisplayLevel; i++) { const item = document.createElement('div'); item.className = "goog-menuitem"; item.setAttribute("role", "menuitem"); item.style.userSelect = "none"; const contentDiv = document.createElement('div'); contentDiv.className = "goog-menuitem-content"; const innerDiv = document.createElement('div'); innerDiv.setAttribute("aria-label", `Level ${i}`); innerDiv.textContent = `Level ${i}`; contentDiv.appendChild(innerDiv); item.appendChild(contentDiv); // Add hover highlight. item.addEventListener('mouseenter', function() { item.classList.add('goog-menuitem-highlight'); }); item.addEventListener('mouseleave', function() { item.classList.remove('goog-menuitem-highlight'); }); // On click, call foldToLevel with the chosen display level. item.addEventListener('click', function(e) { window.foldToLevel(i); submenu.style.display = "none"; }); submenu.appendChild(item); } } } // Create an initially empty submenu. function createSubmenu() { const submenu = document.createElement('div'); submenu.className = "goog-menu goog-menu-vertical docs-material shell-menu shell-tight-menu goog-menu-noaccel goog-menu-noicon"; submenu.setAttribute("role", "menu"); submenu.style.userSelect = "none"; submenu.style.position = "absolute"; submenu.style.display = "none"; // Initially hidden. submenu.style.zIndex = 1003; submenu.style.background = "#fff"; submenu.style.border = "1px solid transparent"; submenu.style.borderRadius = "4px"; submenu.style.boxShadow = "0 2px 6px 2px rgba(60,64,67,.15)"; submenu.style.padding = "6px 0"; submenu.style.fontSize = "13px"; submenu.style.margin = "0"; document.body.appendChild(submenu); return submenu; } // Create the "Show headings" menu option and attach the dynamic submenu. function createShowHeadingsOption() { const menuItem = document.createElement('div'); menuItem.className = "goog-menuitem apps-menuitem goog-submenu"; menuItem.setAttribute("role", "menuitem"); menuItem.setAttribute("aria-haspopup", "true"); menuItem.style.userSelect = "none"; menuItem.dataset.showheadings = "true"; const contentDiv = document.createElement('div'); contentDiv.className = "goog-menuitem-content"; contentDiv.style.userSelect = "none"; // Icon container. const iconDiv = document.createElement('div'); iconDiv.className = "docs-icon goog-inline-block goog-menuitem-icon"; iconDiv.setAttribute("aria-hidden", "true"); iconDiv.style.userSelect = "none"; // Inner icon. const innerIconDiv = document.createElement('div'); innerIconDiv.className = "docs-icon-img-container docs-icon-img docs-icon-editors-ia-header-footer"; innerIconDiv.style.userSelect = "none"; iconDiv.appendChild(innerIconDiv); // Label. const labelSpan = document.createElement('span'); labelSpan.className = "goog-menuitem-label"; labelSpan.style.userSelect = "none"; labelSpan.textContent = "Show headings"; // Submenu arrow. const arrowSpan = document.createElement('span'); arrowSpan.className = "goog-submenu-arrow"; arrowSpan.style.userSelect = "none"; arrowSpan.textContent = "►"; contentDiv.appendChild(iconDiv); contentDiv.appendChild(labelSpan); contentDiv.appendChild(arrowSpan); menuItem.appendChild(contentDiv); // Attach and save the submenu. const submenu = createSubmenu(); menuItem._submenu = submenu; // When hovering over the "Show headings" option, update the submenu based on current headings. menuItem.addEventListener('mouseenter', function() { menuItem.classList.add('goog-menuitem-highlight'); updateSubmenu(submenu); const rect = menuItem.getBoundingClientRect(); submenu.style.left = `${rect.right}px`; submenu.style.top = `${rect.top}px`; submenu.style.display = "block"; }); // Add a global click listener to dismiss the submenu if clicking outside. document.addEventListener('click', function(e) { // Check if the submenu is visible and the click target is not inside it. if (submenu.style.display === "block" && !submenu.contains(e.target)) { submenu.style.display = "none"; menuItem.classList.remove('goog-menuitem-highlight'); } }); return menuItem; } function processMenu(menu) { if (!isCorrectMenu(menu)) return; if (menuHasShowHeadings(menu)) return; const newMenuItem = createShowHeadingsOption(); // Insert after the first separator. const firstSeparator = menu.querySelector('.apps-hoverable-menu-separator-container'); if (firstSeparator) { let lastItem = null; let sibling = firstSeparator.nextElementSibling; while (sibling && !sibling.matches('.apps-hoverable-menu-separator-container')) { if (sibling.matches('.goog-menuitem')) { lastItem = sibling; } sibling = sibling.nextElementSibling; } if (lastItem) { if (lastItem.nextElementSibling) { menu.insertBefore(newMenuItem, lastItem.nextElementSibling); } else { menu.appendChild(newMenuItem); } } else { if (firstSeparator.nextSibling) { menu.insertBefore(newMenuItem, firstSeparator.nextSibling); } else { menu.appendChild(newMenuItem); } } } else { menu.appendChild(newMenuItem); } // Hide the submenu when another main menu item is hovered. if (!menu.dataset.showHeadingsListener) { menu.addEventListener('mouseenter', function(e) { const targetMenuItem = e.target.closest('.goog-menuitem'); if (targetMenuItem && targetMenuItem.dataset.showheadings !== "true") { newMenuItem._submenu.style.display = "none"; newMenuItem.classList.remove('goog-menuitem-highlight'); } }, true); menu.dataset.showHeadingsListener = "true"; } } const menuObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches && node.matches('.goog-menu.goog-menu-vertical.docs-material.goog-menu-noaccel')) { processMenu(node); } else { const menus = node.querySelectorAll && node.querySelectorAll('.goog-menu.goog-menu-vertical.docs-material.goog-menu-noaccel'); if (menus && menus.length > 0) { menus.forEach(menu => processMenu(menu)); } } } }); }); }); menuObserver.observe(document.body, {childList: true, subtree: true}); // ---------------------------- // Outline Sidebar Modifications (Second Script) // ---------------------------- // Insert Material Symbols Outlined stylesheet for the arrow icon. const materialLink = document.createElement('link'); materialLink.rel = 'stylesheet'; materialLink.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200'; document.head.appendChild(materialLink); // Inject custom CSS. const style = document.createElement('style'); style.textContent = `.custom-toggle-button { opacity: 0; pointer-events: none; transition: opacity 0.3s; position: absolute; top: 50%; transform: translateY(-50%); cursor: pointer; z-index: 3; } .custom-toggle-button .goog-flat-button { width: 22px !important; height: 22px !important; display: flex !important; align-items: center !important; justify-content: center !important; border-radius: 50% !important; } .custom-toggle-button .material-symbols-outlined { color: #5f6368 !important; } .navigation-item-content-container { position: relative !important; overflow: visible !important; z-index: 0 !important; } .navigation-item-content { position: relative; z-index: 1; } .folded { opacity: 0; height: 0 !important; overflow: hidden; pointer-events: none; margin: 0 !important; padding: 0 !important; } .navigation-item.inherited-selected .navigation-item-content { color: #1967d2 !important; font-weight: 500 !important; } .navigation-item.inherited-selected .navigation-item-vertical-line-middle { background-color: #1967d2 !important; } .navigation-item.toggle-on .navigation-item-content-container::before { content: ""; position: absolute !important; top: 50% !important; left: 5px !important; right: -5px !important; transform: translateY(-50%) !important; height: 80% !important; background-color: #f0f4f9 !important; border-radius: 5px !important; z-index: -1 !important; } .navigation-item-vertical-line { position: relative; z-index: 1; }`; document.head.appendChild(style); function stopEvent(e) { e.stopPropagation(); e.preventDefault(); e.stopImmediatePropagation(); } function createToggleButton(expanded = true) { const btn = document.createElement('div'); btn.className = 'custom-toggle-button'; btn.dataset.expanded = expanded ? 'true' : 'false'; const inner = document.createElement('div'); inner.className = 'goog-inline-block goog-flat-button chapterItemArrowContainer'; inner.setAttribute('role', 'button'); inner.setAttribute('aria-expanded', expanded ? 'true' : 'false'); inner.setAttribute('aria-label', expanded ? 'Collapse subheadings' : 'Expand subheadings'); const icon = document.createElement('span'); icon.className = 'material-symbols-outlined'; icon.textContent = 'arrow_drop_down'; icon.style.display = 'inline-block'; icon.style.transition = 'transform 0.3s'; icon.style.transformOrigin = 'center center'; icon.style.transform = expanded ? 'rotate(-45deg)' : 'rotate(-90deg)'; inner.appendChild(icon); btn.appendChild(inner); return btn; } function expandChildren(item, level) { let sibling = item.nextElementSibling; while (sibling) { const sibLevel = getHeadingLevel(sibling); if (sibLevel === null) { sibling = sibling.nextElementSibling; continue; } if (sibLevel <= level) break; if (sibLevel === level + 1) { sibling.classList.remove('folded'); const childToggle = sibling.querySelector('.custom-toggle-button'); if (childToggle && childToggle.dataset.expanded === 'true') { expandChildren(sibling, sibLevel); } } sibling = sibling.nextElementSibling; } } function collapseChildren(item, level) { let sibling = item.nextElementSibling; while (sibling) { const sibLevel = getHeadingLevel(sibling); if (sibLevel === null) { sibling = sibling.nextElementSibling; continue; } if (sibLevel <= level) break; sibling.classList.add('folded'); sibling = sibling.nextElementSibling; } } function addToggleButtons() { const headings = document.querySelectorAll('.navigation-item'); headings.forEach(heading => { const container = heading.querySelector('.navigation-item-content-container'); if (!container) return; container.style.position = 'relative'; const level = getHeadingLevel(heading); if (level === null) return; let hasChildren = false; let sibling = heading.nextElementSibling; while (sibling) { const sibLevel = getHeadingLevel(sibling); if (sibLevel === null) { sibling = sibling.nextElementSibling; continue; } if (sibLevel > level) { hasChildren = true; break; } else break; } if (hasChildren && !container.querySelector('.custom-toggle-button')) { const toggleBtn = createToggleButton(true); const computedLeft = (-2 + level * 12) + "px"; toggleBtn.style.left = computedLeft; container.insertBefore(toggleBtn, container.firstChild); ['mousedown', 'pointerdown', 'touchstart'].forEach(evt => { toggleBtn.addEventListener(evt, stopEvent, true); }); toggleBtn.addEventListener('click', (e) => { stopEvent(e); const isExpanded = toggleBtn.dataset.expanded === 'true'; toggleBtn.dataset.expanded = (!isExpanded).toString(); const inner = toggleBtn.querySelector('.chapterItemArrowContainer'); inner.setAttribute('aria-expanded', (!isExpanded).toString()); inner.setAttribute('aria-label', !isExpanded ? 'Collapse subheadings' : 'Expand subheadings'); const icon = inner.querySelector('.material-symbols-outlined'); icon.style.display = 'inline-block'; icon.style.transformOrigin = 'center center'; if (!isExpanded) { icon.style.transform = 'rotate(-45deg)'; heading.classList.remove("toggle-on"); expandChildren(heading, level); } else { icon.style.transform = 'rotate(-90deg)'; heading.classList.add("toggle-on"); collapseChildren(heading, level); } updateInheritedSelection(); }, true); } }); } function updateVerticalLineWidth() { const navigationItems = document.querySelectorAll('.navigation-item'); navigationItems.forEach(item => { const verticalLine = item.querySelector('.navigation-item-vertical-line'); if (verticalLine) { const width = verticalLine.offsetWidth; item.style.setProperty('--vertical-line-width', width + 'px'); } }); } function setupToggleVisibility() { function init() { const widget = document.querySelector('.outlines-widget'); if (!widget) { setTimeout(init, 1000); return; } let hideTimer; widget.addEventListener('mouseenter', () => { if (hideTimer) clearTimeout(hideTimer); widget.querySelectorAll('.custom-toggle-button').forEach(btn => { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; }); }); widget.addEventListener('mouseleave', () => { hideTimer = setTimeout(() => { widget.querySelectorAll('.custom-toggle-button').forEach(btn => { if (btn.dataset.expanded === 'true') { btn.style.opacity = '0'; btn.style.pointerEvents = 'none'; } }); }, 3000); }); } init(); } let debounceTimer; function debounceUpdate() { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { addToggleButtons(); updateInheritedSelection(); updateVerticalLineWidth(); }, 100); } const outlineObserver = new MutationObserver(debounceUpdate); outlineObserver.observe(document.body, { childList: true, subtree: true }); // Initial outline setup. addToggleButtons(); updateInheritedSelection(); updateVerticalLineWidth(); setupToggleVisibility(); // Wait for the outlines widget to be ready. const readyObserver = new MutationObserver((mutations, obs) => { if (document.querySelector('#kix-outlines-widget-header-text-chaptered')) { obs.disconnect(); addToggleButtons(); updateInheritedSelection(); updateVerticalLineWidth(); setupToggleVisibility(); } }); readyObserver.observe(document.body, { childList: true, subtree: true }); })();