Nested Outline Headings

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

目前为 2025-03-31 提交的版本。查看 最新版本

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

})();

QingJ © 2025

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