Nested Outline Headings

Adds nesting functionality to outline headings, in addition to right click menu option to fold/unfold up to desired level.

安裝腳本?
作者推薦腳本

您可能也會喜歡 Google Docs Outline Expander

安裝腳本
// ==UserScript==
// @name         Nested Outline Headings
// @namespace    http://tampermonkey.net/
// @version      1.2
// @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;
    }

    // Returns the container that holds the headings (the updating-navigation-item-list)
    // for the currently selected chapter item (subtab).
    function getActiveHeadingsContainer() {
        // Find the chapter header that is marked as selected.
        const selectedHeader = document.querySelector('.chapter-item-label-and-buttons-container[aria-selected="true"]');
        if (selectedHeader) {
            // Locate the parent chapter item.
            const chapterItem = selectedHeader.closest('.chapter-item');
            if (chapterItem) {
                // Return its associated headings container.
                return chapterItem.querySelector('.updating-navigation-item-list');
            }
        }
        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');
        }
    }

    // (Legacy) This function still returns the container for top-level tabs.
    // It is kept here if needed for other purposes.
    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 (within the active subtab's headings list)
    // will be folded.
    window.foldToLevel = function(targetLevel) {
        const headingsContainer = getActiveHeadingsContainer();
        const headings = headingsContainer ? headingsContainer.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) {
                    // 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)';
                    item.classList.add("toggle-on");
                }
                return;
            }

            // For all other headings, use the normal logic.
            const shouldExpand = level < targetLevel;
            if (shouldExpand) {
                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 {
                    item.classList.remove("toggle-on");
                }
                expandChildren(item, level);
            } else {
                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) {
        while (submenu.firstChild) {
            submenu.removeChild(submenu.firstChild);
        }
        // Use the headings only from the active subtab's headings container.
        const headingsContainer = getActiveHeadingsContainer();
        const headings = headingsContainer ? headingsContainer.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 (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 {
            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);

                item.addEventListener('mouseenter', function() {
                    item.classList.add('goog-menuitem-highlight');
                });
                item.addEventListener('mouseleave', function() {
                    item.classList.remove('goog-menuitem-highlight');
                });

                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";
        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";

        const iconDiv = document.createElement('div');
        iconDiv.className = "docs-icon goog-inline-block goog-menuitem-icon";
        iconDiv.setAttribute("aria-hidden", "true");
        iconDiv.style.userSelect = "none";

        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);

        const labelSpan = document.createElement('span');
        labelSpan.className = "goog-menuitem-label";
        labelSpan.style.userSelect = "none";
        labelSpan.textContent = "Show headings";

        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);

        const submenu = createSubmenu();
        menuItem._submenu = submenu;

        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";
        });

        document.addEventListener('click', function(e) {
            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();

        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);
        }

        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)
    // ----------------------------

    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);

    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;
      }
    }

    // --------------------------------------------
    // New: Recursively toggle descendant headings
    // --------------------------------------------
    function simulateToggleForDescendants(parentHeading, parentOldState) {
      const parentLevel = getHeadingLevel(parentHeading);
      let sibling = parentHeading.nextElementSibling;
      while (sibling) {
          const sibLevel = getHeadingLevel(sibling);
          if (sibLevel === null) {
              sibling = sibling.nextElementSibling;
              continue;
          }
          if (sibLevel <= parentLevel) break;
          const childToggle = sibling.querySelector('.custom-toggle-button');
          if (childToggle && childToggle.dataset.expanded === parentOldState.toString()) {
              // Dispatch a normal click event (without ctrl) on the child's toggle button.
              childToggle.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: false }));
          }
          sibling = sibling.nextElementSibling;
      }
    }

    // Update addToggleButtons to operate only on headings in the active headings container.
    function addToggleButtons() {
      const headingsContainer = getActiveHeadingsContainer();
      if (!headingsContainer) return;
      const headings = headingsContainer.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);
            // Store the parent heading's old toggle state.
            const parentOldState = toggleBtn.dataset.expanded === 'true';
            // Toggle the parent's state.
            toggleBtn.dataset.expanded = (!parentOldState).toString();
            const inner = toggleBtn.querySelector('.chapterItemArrowContainer');
            inner.setAttribute('aria-expanded', (!parentOldState).toString());
            inner.setAttribute('aria-label', !parentOldState ? 'Collapse subheadings' : 'Expand subheadings');
            const icon = inner.querySelector('.material-symbols-outlined');
            icon.style.display = 'inline-block';
            icon.style.transformOrigin = 'center center';
            if (!parentOldState) {
              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();
            // If ctrl key is pressed, simulate a click on all descendant toggles
            // that have the same state as the parent's old state.
            if (e.ctrlKey) {
              simulateToggleForDescendants(heading, parentOldState);
            }
          }, true);
        }
      });
    }

    // Update updateVerticalLineWidth to only update headings from the active headings container.
    function updateVerticalLineWidth() {
      const headingsContainer = getActiveHeadingsContainer();
      if (!headingsContainer) return;
      const navigationItems = headingsContainer.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();

    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 });

    // ----------------------------
    // Adjust Chapter Overflow Menu Position
    // ----------------------------
    // This function continuously checks the position of the chapter overflow menu
    // and, if it is visible and its top is above the top of the navigation widget hat,
    // resets its top so that it is never displayed above the hat.
    function adjustChapterOverflowMenuPosition() {
        const menu = document.querySelector('div.chapter-overflow-menu');
        if (!menu) return;
        if (window.getComputedStyle(menu).display === 'none') return;
        const menuRect = menu.getBoundingClientRect();
        const hat = document.querySelector('.navigation-widget-hat');
        if (!hat) return;
        const hatRect = hat.getBoundingClientRect();
        if (menuRect.top < hatRect.top) {
            menu.style.top = `${hatRect.top}px`;
        }
    }
    setInterval(adjustChapterOverflowMenuPosition, 100);

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址