- // ==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,GRAD@20..48,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 });
-
- })();