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 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();