Pages View Google Docs

Adds a thumbnail view similar to MS Word, including both heading and section grouping

// ==UserScript==
// @name         Pages View Google Docs
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds a thumbnail view similar to MS Word, including both heading and section grouping
// @match        https://docs.google.com/document/d/*
// @license      MIT
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    // ---------------------------
    // 1. Utility Functions
    // ---------------------------
    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

    const clickElement = element => {
        ['mousedown', 'mouseup', 'click'].forEach(type => {
            element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
        });
        console.log('Simulated click on', element);
    };

    const waitForHeaderBubbles = async (timeout = 3000) => {
        const startTime = Date.now();
        let bubbles = [];
        while (Date.now() - startTime < timeout) {
            bubbles = Array.from(document.querySelectorAll('div.docs-bubble.kix-header-footer-bubble'))
                .filter(bubble => window.getComputedStyle(bubble).display !== 'none');
            if (bubbles.length) break;
            await sleep(300);
        }
        return bubbles;
    };

    // New function to load header bubble using a chain of fallbacks.
    function clickHeaderChain() {
        // Attempt 1: Try to click the main Header option.
        const mainHeader = document.evaluate(
            "//div[contains(@class, 'goog-menuitem') and contains(@class, 'apps-menuitem') and not(contains(@class, 'goog-submenu'))]//span[contains(@aria-label, 'Header') and not(contains(@aria-label, 'Headers'))]",
            document,
            null,
            XPathResult.FIRST_ORDERED_NODE_TYPE,
            null
        ).singleNodeValue;
        if (mainHeader) {
            clickElement(mainHeader.closest('div.goog-menuitem'));
            // Close any lingering menus.
            document.querySelectorAll('div.goog-menu').forEach(menu => menu.style.display = 'none');
            console.log("Main Header clicked immediately.");
            return;
        }

        // Attempt 2: Try the fallback "Headers & footers" option.
        const fallbackHeader = document.evaluate(
            "//div[contains(@class, 'goog-menuitem') and contains(@class, 'apps-menuitem') and contains(@class, 'goog-submenu')]//span[contains(@aria-label, 'Headers') and contains(@aria-label, 'footers')]",
            document,
            null,
            XPathResult.FIRST_ORDERED_NODE_TYPE,
            null
        ).singleNodeValue;
        if (fallbackHeader) {
            clickElement(fallbackHeader.closest('div.goog-menuitem'));
            console.log("Fallback 'Headers & footers' clicked.");
            // Wait a bit then try for the main header.
            setTimeout(() => {
                const mainHeaderRetry = document.evaluate(
                    "//div[contains(@class, 'goog-menuitem') and contains(@class, 'apps-menuitem') and not(contains(@class, 'goog-submenu'))]//span[contains(@aria-label, 'Header') and not(contains(@aria-label, 'Headers'))]",
                    document,
                    null,
                    XPathResult.FIRST_ORDERED_NODE_TYPE,
                    null
                ).singleNodeValue;
                if (mainHeaderRetry) {
                    clickElement(mainHeaderRetry.closest('div.goog-menuitem'));
                    document.querySelectorAll('div.goog-menu').forEach(menu => menu.style.display = 'none');
                    console.log("Main Header clicked after fallback.");
                } else {
                    console.log("Main Header did not appear after clicking fallback.");
                }
            }, 500);
            return;
        }

        // Attempt 3: Use the Insert menu as a last resort.
        const insertMenu = document.querySelector('div#docs-insert-menu');
        if (insertMenu) {
            clickElement(insertMenu);
            console.log("Insert menu clicked as fallback.");
            setTimeout(() => {
                const fallbackHeaderRetry = document.evaluate(
                    "//div[contains(@class, 'goog-menuitem') and contains(@class, 'apps-menuitem') and contains(@class, 'goog-submenu')]//span[contains(@aria-label, 'Headers') and contains(@aria-label, 'footers')]",
                    document,
                    null,
                    XPathResult.FIRST_ORDERED_NODE_TYPE,
                    null
                ).singleNodeValue;
                if (fallbackHeaderRetry) {
                    clickElement(fallbackHeaderRetry.closest('div.goog-menuitem'));
                    console.log("'Headers & footers' clicked from Insert menu.");
                    setTimeout(() => {
                        const mainHeaderFinal = document.evaluate(
                            "//div[contains(@class, 'goog-menuitem') and contains(@class, 'apps-menuitem') and not(contains(@class, 'goog-submenu'))]//span[contains(@aria-label, 'Header') and not(contains(@aria-label, 'Headers'))]",
                            document,
                            null,
                            XPathResult.FIRST_ORDERED_NODE_TYPE,
                            null
                        ).singleNodeValue;
                        if (mainHeaderFinal) {
                            clickElement(mainHeaderFinal.closest('div.goog-menuitem'));
                            document.querySelectorAll('div.goog-menu').forEach(menu => menu.style.display = 'none');
                            console.log("Main Header clicked after Insert chain.");
                        } else {
                            console.log("Main Header did not appear after Insert chain.");
                        }
                    }, 500);
                } else {
                    console.log("'Headers & footers' did not appear after clicking Insert menu.");
                }
            }, 500);
        } else {
            console.log("Insert menu is missing.");
        }
    }

    const disableDocumentTabs = () => {
        const tabsElem = document.querySelector(
            'div.navigation-widget.navigation-widget-unified-styling.docs-material.navigation-widget-floating-navigation-button.navigation-location-indicator.outline-refresh.navigation-widget-hoverable.navigation-widget-chaptered.left-sidebar-container-content-child'
        );
        if (tabsElem) {
            tabsElem.style.pointerEvents = 'none';
            console.log("Document tabs disabled.");
        }
    };

    const enableDocumentTabs = () => {
        const tabsElem = document.querySelector(
            'div.navigation-widget.navigation-widget-unified-styling.docs-material.navigation-widget-floating-navigation-button.navigation-location-indicator.outline-refresh.navigation-widget-hoverable.navigation-widget-chaptered.left-sidebar-container-content-child'
        );
        if (tabsElem) {
            tabsElem.style.pointerEvents = '';
            console.log("Document tabs re-enabled.");
        }
    };


    // ---------------------------
    // 2. Global State & Constants
    // ---------------------------
    let currentTextDirection = 'ltr';
    let thumbnailOverlay = null;
    let overlayPositionObserver = null;
    let thumbnailZoomFactor = 1;
    const ZOOM_STEP = 0.1, MIN_ZOOM = 0.5, MAX_ZOOM = 2.0;
    const capturedPages = new Set();
    let captureTimeoutId = null;
    let mutationObserver = null;
    let isGroupingEnabled = false; // For heading grouping
    let isSectionGroupingEnabled = false; // New flag for section grouping
    const capturedPageData = {}; // { pageNumber: { thumbEntry, headings: Set() } }
    let headingGroupsArr = [];
    let cancelScrollSequence = false;
    let isThumbnailViewActive = false;
    let pagesViewButton = null;
    let progressBarContainer = null;
    let progressBarInner = null;
    let tabSelectionIntervalId = null;
    let cancelHeadingClicks = false;
    let initialSelectedTab = null; // Store the tab element that was selected when opening overlay

    // ---------------------------
    // 3. Document & Scroll Utilities
    // ---------------------------
    const getDocumentId = () =>
    (window.location.href.match(/\/d\/([a-zA-Z0-9_-]+)/) || [])[1] || 'default';

    const getScrollableElement = () => document.querySelector('.kix-appview-editor');

    const saveScrollPosition = () => {
        const scrollable = getScrollableElement();
        if (scrollable) {
            const scrollData = JSON.parse(localStorage.getItem('googleDocsScrollData') || '{}');
            scrollData[getDocumentId()] = scrollable.scrollTop;
            localStorage.setItem('googleDocsScrollData', JSON.stringify(scrollData));
        }
    };

    const restoreScrollPosition = () => {
        const scrollable = getScrollableElement();
        const scrollData = JSON.parse(localStorage.getItem('googleDocsScrollData') || '{}');
        const pos = scrollData[getDocumentId()];
        if (scrollable && pos !== undefined) {
            scrollable.scrollTop = parseInt(pos, 10);
        }
    };

    // ---------------------------
    // 4. Thumbnail Overlay Management
    // ---------------------------
    const updateOverlayPosition = () => {
        let topOffset = '50px';
        let ruler = document.getElementById('kix-horizontal-ruler') ||
            document.querySelector('div#docs-chrome[aria-label="Menu bar"]');
        if (ruler) topOffset = `${ruler.getBoundingClientRect().bottom}px`;

        let leftOffset = '10px';
        const sidebar = document.querySelector('.left-sidebar-container');
        if (sidebar) leftOffset = `${sidebar.getBoundingClientRect().right}px`;

        if (thumbnailOverlay) {
            Object.assign(thumbnailOverlay.style, { top: topOffset, left: leftOffset });
        }
    };

    const startOverlayPositionObserver = () => {
        const targets = [];
        const sidebar = document.querySelector('.left-sidebar-container');
        if (sidebar) targets.push(sidebar);
        let ruler = document.getElementById('kix-horizontal-ruler') ||
            document.querySelector('div#docs-chrome[aria-label="Menu bar"]');
        if (ruler) targets.push(ruler);
        if (!targets.length) return;
        overlayPositionObserver = new MutationObserver(updateOverlayPosition);
        targets.forEach(target =>
                        overlayPositionObserver.observe(target, { attributes: true, attributeFilter: ['style', 'class'] })
                       );
    };

    const stopOverlayPositionObserver = () => {
        if (overlayPositionObserver) {
            overlayPositionObserver.disconnect();
            overlayPositionObserver = null;
        }
    };

    const createThumbnailOverlay = () => {
        thumbnailOverlay = document.createElement('div');
        thumbnailOverlay.id = 'thumbnailOverlay';
        updateOverlayPosition();
        Object.assign(thumbnailOverlay.style, {
            position: 'fixed',
            right: '0',
            bottom: '0',
            background: '#f9fbfd',
            zIndex: '10000',
            overflowY: 'auto',
            display: 'flex',
            flexWrap: 'wrap',
            padding: '10px',
            alignContent: 'flex-start'
        });
        document.body.appendChild(thumbnailOverlay);
        startOverlayPositionObserver();
    };

    const removeThumbnailOverlay = () => {
        if (thumbnailOverlay) {
            thumbnailOverlay.remove();
            thumbnailOverlay = null;
        }
        stopOverlayPositionObserver();
    };

    // ---------------------------
    // 5. Thumbnail Display & Zoom
    // ---------------------------
    const insertThumbnailInOrder = (thumbElement, pageNumber) => {
        if (!thumbnailOverlay) return;
        const thumbnails = Array.from(thumbnailOverlay.querySelectorAll('.thumbnail-entry'));
        const index = thumbnails.findIndex(el => parseInt(el.dataset.pageNumber, 10) > pageNumber);
        if (index >= 0) {
            thumbnailOverlay.insertBefore(thumbElement, thumbnails[index]);
        } else {
            thumbnailOverlay.appendChild(thumbElement);
        }
    };

    const updateThumbnailZoom = () => {
        if (!thumbnailOverlay) return;
        thumbnailOverlay.querySelectorAll('.thumbnail-entry img').forEach(img => {
            img.style.width = `${200 * thumbnailZoomFactor}px`;
        });
    };

    const handleCtrlZoom = event => {
        if (!isThumbnailViewActive || !event.ctrlKey) return;
        if (['=', 'Add', 'NumpadAdd'].includes(event.key) && thumbnailZoomFactor < MAX_ZOOM) {
            event.preventDefault();
            event.stopImmediatePropagation();
            thumbnailZoomFactor += ZOOM_STEP;
            updateThumbnailZoom();
        } else if (['-', 'Subtract', 'NumpadSubtract'].includes(event.key) && thumbnailZoomFactor > MIN_ZOOM) {
            event.preventDefault();
            event.stopImmediatePropagation();
            thumbnailZoomFactor -= ZOOM_STEP;
            updateThumbnailZoom();
        }
    };

    const attachZoomListeners = () => {
        window.addEventListener('keydown', handleCtrlZoom, true);
        document.querySelectorAll('iframe').forEach(iframe => {
            try {
                (iframe.contentDocument || iframe.contentWindow.document)
                    .addEventListener('keydown', handleCtrlZoom, true);
            } catch (err) {
                console.error('Error attaching zoom listener:', err);
            }
        });
    };

    const detachZoomListeners = () => {
        window.removeEventListener('keydown', handleCtrlZoom, true);
        document.querySelectorAll('iframe').forEach(iframe => {
            try {
                (iframe.contentDocument || iframe.contentWindow.document)
                    .removeEventListener('keydown', handleCtrlZoom, true);
            } catch (err) {
                console.error('Error detaching zoom listener:', err);
            }
        });
    };

    // Update text direction and re-render thumbnails (flat or grouped)
    const setTextDirection = direction => {
        currentTextDirection = direction;
        if (thumbnailOverlay) {
            thumbnailOverlay.style.direction = direction;
            updateOverlayPosition();
            // When RTL, add extra padding on the right.
            if (direction === 'rtl') {
                thumbnailOverlay.style.padding = '10px 20px 10px 10px'; // top, right, bottom, left
            } else {
                thumbnailOverlay.style.padding = '10px';
            }
        }
        if (!isGroupingEnabled && !isSectionGroupingEnabled) {
            thumbnailOverlay.replaceChildren();
            Object.keys(capturedPageData)
                .map(Number)
                .sort((a, b) => a - b)
                .forEach(pageNum => {
                const data = capturedPageData[pageNum];
                if (data?.thumbEntry) {
                    thumbnailOverlay.appendChild(data.thumbEntry);
                }
            });
        } else if (isGroupingEnabled) {
            renderGroupedThumbnails();
        } else if (isSectionGroupingEnabled) {
            renderSectionGroupedPages();
        }
    };

    // ---------------------------
    // 6. Page Capture Module
    // ---------------------------
    const getCurrentPageNumber = () => {
        const tooltip = document.querySelector('div.jfk-tooltip-contentId[style*="direction: ltr"]');
        if (tooltip) {
            const match = tooltip.textContent.match(/(\d+)\s+of/);
            return match ? parseInt(match[1], 10) : null;
        }
        return null;
    };

    const getCurrentSelectedHeading = () => {
        const headingElem = document.querySelector(
            '#chapter-container-t\\.wf0m5iat3jku > div.chapter-item.chapter-item-subchapters-indent-enabled > div.updating-navigation-item-list > div.navigation-item-list.goog-container .navigation-item.location-indicator-highlight'
        );
        if (headingElem) {
            const content = headingElem.querySelector('.navigation-item-content');
            const headingText = content ? content.textContent.trim() : 'Unknown';
            const levelMatch = content ? content.className.match(/navigation-item-level-(\d+)/) : null;
            return { headingElem, headingText, navLevel: levelMatch ? parseInt(levelMatch[1], 10) : 0 };
        }
        return null;
    };

    const getHighestParentHeading = currentHeadingElem => {
        const parentContainer = currentHeadingElem.parentNode;
        const allHeadings = Array.from(parentContainer.querySelectorAll('.navigation-item'));
        const currentIndex = allHeadings.indexOf(currentHeadingElem);
        let highestHeading = currentHeadingElem, highestLevel = Infinity;
        for (let i = currentIndex - 1; i >= 0; i--) {
            const elem = allHeadings[i];
            const content = elem.querySelector('.navigation-item-content');
            if (content) {
                const level = (content.className.match(/navigation-item-level-(\d+)/) || [])[1] || 0;
                if (level < highestLevel) {
                    highestLevel = level;
                    highestHeading = elem;
                }
            }
        }
        const content = highestHeading.querySelector('.navigation-item-content');
        return {
            headingElem: highestHeading,
            headingText: content ? content.textContent.trim() : 'Unknown',
            navLevel: highestLevel === Infinity ? 0 : highestLevel
        };
    };

    // ---------------------------
    // 7. Grouping Functions (Heading Grouping)
    // ---------------------------
    const recordHeadingStartingPages = async () => {
        // Disable document tabs while heading clicks are active.
        disableDocumentTabs();

        // Reset grouping array and ensure heading clicks can run.
        headingGroupsArr = [];
        cancelHeadingClicks = false;
        const selectedTab = document.querySelector('.chapter-item-label-and-buttons-container-selected');
        if (!selectedTab) {
            console.warn("No selected tab found.");
            enableDocumentTabs();
            return;
        }
        const chapterItem = selectedTab.closest('.chapter-item');
        if (!chapterItem) {
            console.warn("Selected tab does not have a chapter-item parent.");
            enableDocumentTabs();
            return;
        }
        const navList = chapterItem.querySelector('.updating-navigation-item-list');
        if (!navList) {
            console.warn("No navigation item list found under the selected tab.");
            enableDocumentTabs();
            return;
        }

        const topLevelHeadings = Array.from(navList.querySelectorAll('.navigation-item')).filter(item => {
            const content = item.querySelector('.navigation-item-content');
            return content?.classList.contains('navigation-item-level-0');
        });
        const totalHeadings = topLevelHeadings.length;
        let clickedCount = 0;
        const progressBar = document.getElementById('headingProgressBar');
        if (progressBar) {
            progressBar.style.width = `0%`;
        }

        try {
            for (const item of topLevelHeadings) {
                if (cancelHeadingClicks) {
                    console.log("Heading click loop cancelled.");
                    break;
                }
                clickElement(item);
                clickedCount++;
                if (progressBar) {
                    progressBar.style.width = `${(clickedCount / totalHeadings) * 100}%`;
                }
                await sleep(500);
                const currentPage = getCurrentPageNumber();
                if (currentPage) {
                    const headingText = item.querySelector('.navigation-item-content')?.textContent.trim() || 'Unknown';
                    headingGroupsArr.push({ headingText, navLevel: 0, startingPage: currentPage, pages: new Set() });
                }
            }
        } finally {
            // Always re-enable document tabs after the loop completes or is interrupted.
            enableDocumentTabs();
        }
    };


    const assignPagesToGroups = () => {
        headingGroupsArr.sort((a, b) => a.startingPage - b.startingPage);
        const capturedPageNumbers = Object.keys(capturedPageData).map(Number).sort((a, b) => a - b);
        const groupsByStartingPage = {};
        headingGroupsArr.forEach(group => {
            groupsByStartingPage[group.startingPage] = groupsByStartingPage[group.startingPage] || [];
            groupsByStartingPage[group.startingPage].push(group);
        });
        const distinctStartPages = Object.keys(groupsByStartingPage).map(Number).sort((a, b) => a - b);
        distinctStartPages.forEach((sp, index) => {
            const nextSP = index < distinctStartPages.length - 1 ? distinctStartPages[index + 1] : Infinity;
            const pagesInRange = capturedPageNumbers.filter(pageNum => pageNum >= sp && pageNum < nextSP);
            const groups = groupsByStartingPage[sp];
            groups.forEach((group, idx) => {
                group.pages = (idx < groups.length - 1)
                    ? new Set(capturedPageNumbers.includes(sp) ? [sp] : [])
                : new Set(pagesInRange);
            });
        });
    };

    const capturePages = () => {
        if (!thumbnailOverlay) return;
        const pages = Array.from(document.querySelectorAll('.kix-page-paginated'));
        const scrollable = getScrollableElement();
        pages.forEach((page, index) => {
            const rotatingTileManager = page.closest('.kix-rotatingtilemanager.docs-ui-hit-region-surface');
            if (rotatingTileManager && rotatingTileManager.parentElement &&
                window.getComputedStyle(rotatingTileManager.parentElement).display === 'none') return;
            let pageNumber = parseInt(page.style.zIndex, 10);
            pageNumber = !isNaN(pageNumber) ? pageNumber + 1 : index + 1;
            if (capturedPages.has(pageNumber)) return;
            const canvas = page.querySelector('canvas.kix-canvas-tile-content');
            if (!canvas) return;
            // Force a reflow/repaint on the canvas.
            canvas.style.display = 'none';
            void canvas.offsetHeight;
            canvas.style.display = '';
            let dataUrl;
            try {
                dataUrl = canvas.toDataURL();
            } catch (err) {
                console.error('Error converting canvas to image:', err);
                return;
            }
            let pageScrollPos = 0;
            if (scrollable) {
                const containerRect = scrollable.getBoundingClientRect();
                const pageRect = page.getBoundingClientRect();
                pageScrollPos = scrollable.scrollTop + (pageRect.top - containerRect.top);
            }
            const thumbEntry = document.createElement('div');
            thumbEntry.className = 'thumbnail-entry';
            thumbEntry.dataset.pageNumber = pageNumber;
            thumbEntry.dataset.scrollPos = pageScrollPos;
            Object.assign(thumbEntry.style, {
                margin: '10px',
                textAlign: 'center',
                cursor: 'pointer',
                opacity: '0',
                transition: 'opacity 0.5s'
            });
            const img = document.createElement('img');
            img.src = dataUrl;
            img.style.width = `${200 * thumbnailZoomFactor}px`;
            img.style.height = 'auto';
            img.name = `page_${pageNumber}`;
            thumbEntry.appendChild(img);
            const pageLabel = document.createElement('div');
            pageLabel.innerText = `Page ${pageNumber}`;
            pageLabel.style.marginTop = '5px';
            thumbEntry.appendChild(pageLabel);
            thumbEntry.addEventListener('click', () => {
                exitThumbnailView();
                isGroupingEnabled = false;
                isSectionGroupingEnabled = false;
                const targetPos = parseInt(thumbEntry.dataset.scrollPos, 10);
                if (scrollable) scrollable.scrollTop = targetPos;
            });
            if (!isGroupingEnabled && !isSectionGroupingEnabled) {
                insertThumbnailInOrder(thumbEntry, pageNumber);
            }
            capturedPageData[pageNumber] = { thumbEntry, headings: new Set() };
            setTimeout(() => { thumbEntry.style.opacity = '1'; }, 50);
            capturedPages.add(pageNumber);
        });
        updateProgressBar();
    };

    const capturePagesWrapper = () => {
        capturePages();
        if (isGroupingEnabled) {
            renderGroupedThumbnails();
        } else if (isSectionGroupingEnabled) {
            // Call without waiting so that scrolling is not blocked.
            renderSectionGroupedPages();
        }
    };

    const startObservingPages = () => {
        const container = document.querySelector('.kix-rotatingtilemanager-content');
        if (!container) return;
        mutationObserver = new MutationObserver(() => {
            clearTimeout(captureTimeoutId);
            captureTimeoutId = setTimeout(capturePagesWrapper, 100);
        });
        mutationObserver.observe(container, { childList: true, subtree: true, attributes: true });
    };

    const stopObservingPages = () => {
        if (mutationObserver) {
            mutationObserver.disconnect();
            mutationObserver = null;
        }
    };

    // ---------------------------
    // 8. Fast Scroll Simulation
    // ---------------------------
    const simulateScrollSequence = async () => {
        const scrollable = getScrollableElement();
        if (!scrollable) return;
        scrollable.scrollTop = 0;
        startObservingPages();
        capturePagesWrapper();
        const intervalId = setInterval(() => {
            if (cancelScrollSequence) {
                clearInterval(intervalId);
                return;
            }
            const { scrollTop, clientHeight, scrollHeight } = scrollable;
            const newScroll = scrollTop + clientHeight;
            if (newScroll >= scrollHeight) {
                scrollable.scrollTop = scrollHeight;
                capturePagesWrapper();
                if (progressBarInner) progressBarInner.style.background = '#2684fc';
                clearInterval(intervalId);
                console.log("Reached bottom of page, scroll sequence complete.");
                recordHeadingStartingPages().then(() => {
                    assignPagesToGroups();
                    if (isGroupingEnabled) {
                        renderGroupedThumbnails();
                    }
                });
            } else {
                scrollable.scrollTop = newScroll;
                capturePagesWrapper();
            }
        }, 100);
    };

    // ---------------------------
    // 9. Thumbnail View Toggle & Cleanup
    // ---------------------------
    const createProgressBar = () => {
        if (!pagesViewButton) return;
        progressBarContainer = document.createElement('div');
        Object.assign(progressBarContainer.style, {
            position: 'absolute',
            top: '0',
            left: '50%',
            transform: 'translateX(-50%) translateY(1px)',
            width: '60%',
            height: '2px',
            background: 'transparent',
            pointerEvents: 'none'
        });
        progressBarInner = document.createElement('div');
        Object.assign(progressBarInner.style, {
            height: '100%',
            width: '0%',
            background: '#555'
        });
        progressBarContainer.appendChild(progressBarInner);
        pagesViewButton.appendChild(progressBarContainer);
    };

    const removeProgressBar = () => {
        progressBarContainer?.remove();
        progressBarContainer = progressBarInner = null;
    };

    const updateProgressBar = () => {
        if (!progressBarInner) return;
        const tooltipElem = document.querySelector('div.jfk-tooltip-contentId[style*="direction: ltr"]');
        if (!tooltipElem) return setTimeout(updateProgressBar, 500);
        const match = tooltipElem.textContent.match(/of\s*(\d+)/);
        if (!match) return;
        const maxPages = parseInt(match[1], 10);
        if (maxPages === 0) return;
        const progressPercent = Math.min((capturedPages.size / maxPages) * 100, 100);
        progressBarInner.style.width = progressPercent + '%';
    };

    const toggleThumbnailView = () => {
        if (!isThumbnailViewActive) {
            cancelScrollSequence = false;
            // Reset heading click cancellation.
            cancelHeadingClicks = false;
            // Store the initially selected tab element.
            initialSelectedTab = document.querySelector('.chapter-item-label-and-buttons-container.chapter-item-label-and-buttons-container-selected');

            // Start monitoring the selected tab.
            tabSelectionIntervalId = setInterval(() => {
                // If the initially selected tab no longer exists or lost the selected class, close overlay.
                if (!initialSelectedTab || !initialSelectedTab.classList.contains('chapter-item-label-and-buttons-container-selected')) {
                    console.log("Current tab is no longer selected. Closing overlay.");
                    exitThumbnailView();
                }
            }, 1000);

            saveScrollPosition();
            createThumbnailOverlay();
            simulateScrollSequence();
            isThumbnailViewActive = true;
            attachZoomListeners();
            createProgressBar();
            createCustomMenu(pagesViewButton);
            pagesViewButton.classList.add('active');
            // Check for header bubble at the start.
            const headerBubbleExists = document.querySelector('div.docs-bubble.kix-header-footer-bubble') &&
                  window.getComputedStyle(document.querySelector('div.docs-bubble.kix-header-footer-bubble')).display !== 'none';
            if (headerBubbleExists) {
                console.log("Header bubble detected on Pages View open; section grouping available.");
            } else {
                console.log("No header bubble detected on Pages View open; section grouping disabled.");
            }
        } else {
            exitThumbnailView();
        }
    };

    const exitThumbnailView = (skipRestore = false) => {
        if (tabSelectionIntervalId) {
            clearInterval(tabSelectionIntervalId);
            tabSelectionIntervalId = null;
        }
        cancelHeadingClicks = true;
        cancelScrollSequence = true;
        removeThumbnailOverlay();
        removeProgressBar();
        document.getElementById('customMenu')?.remove();
        if (!skipRestore) restoreScrollPosition();
        stopObservingPages();
        capturedPages.clear();
        Object.keys(capturedPageData).forEach(key => delete capturedPageData[key]);
        headingGroupsArr = [];
        isThumbnailViewActive = false;
        isGroupingEnabled = false;
        isSectionGroupingEnabled = false;
        detachZoomListeners();
        pagesViewButton?.classList.remove('active');
    };

    // ---------------------------
    // 10. Button Management
    // ---------------------------
    const waitForElement = (selector, timeout = 20000) =>
    new Promise((resolve, reject) => {
        const observer = new MutationObserver((_, obs) => {
            const el = document.querySelector(selector);
            if (el) {
                obs.disconnect();
                resolve(el);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => {
            observer.disconnect();
            reject(new Error(`Timeout waiting for element: ${selector}`));
        }, timeout);
    });

    const addPagesViewButton = referenceElement => {
        const newButton = document.createElement('div');
        newButton.setAttribute('role', 'button');
        newButton.className = 'goog-inline-block jfk-button jfk-button-standard kix-outlines-widget-header-add-chapter-button-icon custom-pages-view-button';
        newButton.tabIndex = 0;
        newButton.setAttribute('data-tooltip-class', 'kix-outlines-widget-header-add-chapter-button-tooltip');
        newButton.setAttribute('aria-label', 'Pages view');
        newButton.setAttribute('data-tooltip', 'Pages view');

        const iconWrapper = document.createElement('div');
        iconWrapper.className = 'docs-icon goog-inline-block';
        const iconInner = document.createElement('div');
        iconInner.className = 'docs-icon-img-container docs-icon-img docs-icon-editors-ia-content-copy';
        iconInner.setAttribute('aria-hidden', 'true');
        iconInner.textContent = '\u00A0';
        iconWrapper.appendChild(iconInner);
        newButton.appendChild(iconWrapper);

        // Inject button styles.
        const style = document.createElement('style');
        style.textContent = `
      .custom-pages-view-button {
        user-select: none;
        direction: ltr;
        visibility: visible;
        position: relative;
        display: inline-block;
        cursor: pointer;
        font-size: 11px;
        text-align: center;
        white-space: nowrap;
        line-height: 27px;
        outline: 0;
        color: #333;
        border: 1px solid rgba(0,0,0,.1);
        font-family: "Google Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif;
        font-weight: 500;
        background-color: transparent;
        background-image: none;
        border-radius: 50%;
        border-width: 0;
        box-shadow: none;
        min-width: unset;
        height: 28px;
        margin: 2px;
        padding: 0;
        width: 28px;
        transition: background-color 0.3s ease;
      }
      .custom-pages-view-button:hover {
        background-color: rgba(0,0,0,0.1);
      }
      .custom-pages-view-button.active,
      .custom-menu-button.active {
        background-color: rgba(0,0,0,0.1);
      }
      .custom-menu-button:focus,
      .custom-menu-button:active {
        outline: none !important;
        border: none !important;
        box-shadow: none !important;
      }
      .custom-menu-button.active {
        outline: none !important;
        border: none !important;
        box-shadow: none !important;
        background-color: rgba(0,0,0,0.1);
      }
    `;
      document.head.appendChild(style);
      referenceElement.parentNode.insertBefore(newButton, referenceElement.nextSibling);
      pagesViewButton = newButton;
      newButton.addEventListener('click', toggleThumbnailView);
  };

    const createCustomMenu = referenceButton => {
        const menu = document.createElement('div');
        menu.id = 'customMenu';
        const rect = referenceButton.getBoundingClientRect();
        Object.assign(menu.style, {
            position: 'absolute',
            left: (rect.left - 180) + 'px',
            top: (rect.top - 55) + 'px',
            width: '200px',
            backgroundColor: '#fff',
            borderRadius: '26px',
            padding: '8px',
            zIndex: '10001',
            boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.2)',
            fontFamily: 'Roboto,RobotoDraft,Helvetica,Arial,sans-serif',
            fontWeight: '400',
            fontSize: '13px',
            color: '#000',
            cursor: 'default',
            userSelect: 'none',
            display: 'flex',
            justifyContent: 'space-around',
            alignItems: 'center'
        });

        for (let i = 1; i <= 4; i++) {
            const btn = document.createElement('div');
            btn.className = 'goog-inline-block jfk-button jfk-button-standard custom-pages-view-button custom-menu-button';
            btn.tabIndex = 0;
            Object.assign(btn.style, {
                width: '28px',
                height: '28px',
                borderRadius: '50%',
                cursor: 'pointer',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center'
            });
            if (i === 1) {
                // Section grouping button
                btn.setAttribute('data-tooltip', 'Section grouping');
            } else if (i === 2) {
                // Heading grouping button
                btn.setAttribute('data-tooltip', 'Heading grouping');
            } else if (i === 3) {
                btn.setAttribute('data-tooltip', 'Left-to-right');
            } else if (i === 4) {
                btn.setAttribute('data-tooltip', 'Right-to-left');
            }
            btn.setAttribute('data-tooltip-class', 'kix-outlines-widget-header-add-chapter-button-tooltip');

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

            const iconInner = document.createElement('div');
            iconInner.className = 'docs-icon-img-container docs-icon-img';
            iconInner.style.userSelect = 'none';

            if (i === 1) {
                // Section Grouping: square grid icon
                iconInner.classList.add('docs-icon-editors-ia-square-grid-view');
                btn.addEventListener('click', async () => {
                    isSectionGroupingEnabled = !isSectionGroupingEnabled;
                    btn.classList.toggle('active', isSectionGroupingEnabled);
                    if (isSectionGroupingEnabled) {
                        // First, check if header bubbles already exist.
                        let headerBubbles = Array.from(document.querySelectorAll('div.docs-bubble.kix-header-footer-bubble'))
                        .filter(bubble => window.getComputedStyle(bubble).display !== 'none');
                        if (!headerBubbles.length) {
                            // If not, trigger the header chain and wait until bubbles appear (up to 3000ms).
                            clickHeaderChain();
                            headerBubbles = await waitForHeaderBubbles(3000);
                        }
                        if (headerBubbles.length) {
                            renderSectionGroupedPages();
                        } else {
                            console.log("Header bubble not loaded; section grouping will not be applied.");
                        }
                    } else {
                        // Revert to flat view.
                        if (thumbnailOverlay) {
                            thumbnailOverlay.replaceChildren();
                            Object.keys(capturedPageData)
                                .map(Number)
                                .sort((a, b) => a - b)
                                .forEach(pageNum => {
                                const data = capturedPageData[pageNum];
                                if (data?.thumbEntry) {
                                    thumbnailOverlay.appendChild(data.thumbEntry);
                                }
                            });
                        }
                    }
                });
            }
            else if (i === 2) {
                // Heading Grouping: header/footer icon
                iconInner.classList.add('docs-icon-editors-ia-header-footer');
                // Ensure the button is positioned relative to its container so the progress bar aligns correctly.
                btn.style.position = 'relative';
                btn.addEventListener('click', () => {
                    isGroupingEnabled = !isGroupingEnabled;
                    btn.classList.toggle('active', isGroupingEnabled);
                    if (isGroupingEnabled) {
                        renderGroupedThumbnails();
                    } else {
                        if (thumbnailOverlay) {
                            thumbnailOverlay.replaceChildren();
                            Object.keys(capturedPageData)
                                .map(Number)
                                .sort((a, b) => a - b)
                                .forEach(pageNum => {
                                const data = capturedPageData[pageNum];
                                if (data?.thumbEntry) {
                                    thumbnailOverlay.appendChild(data.thumbEntry);
                                }
                            });
                        }
                    }
                });
                // Create a progress bar container similar to the pages view progress bar.
                const headingProgressContainer = document.createElement('div');
                Object.assign(headingProgressContainer.style, {
                    position: 'absolute',
                    top: '0',
                    left: '50%',
                    transform: 'translateX(-50%) translateY(1px)',
                    width: '60%',
                    height: '2px',
                    background: 'transparent',
                    pointerEvents: 'none'
                });
                const headingProgressInner = document.createElement('div');
                headingProgressInner.id = 'headingProgressBar';
                Object.assign(headingProgressInner.style, {
                    height: '100%',
                    width: '0%',
                    background: '#2684fc'
                });
                headingProgressContainer.appendChild(headingProgressInner);
                btn.appendChild(headingProgressContainer);
            }

            else if (i === 3) {
                iconInner.classList.add('docs-icon-text-ltr-20');
                btn.addEventListener('click', () => {
                    setTextDirection('ltr');
                    document.querySelectorAll('#customMenu .custom-menu-button[data-tooltip="Left-to-right"], #customMenu .custom-menu-button[data-tooltip="Right-to-left"]')
                        .forEach(btnElem => {
                        btnElem.classList.toggle('active', btnElem.getAttribute('data-tooltip') === 'Left-to-right');
                    });
                });
                if (currentTextDirection === 'ltr') btn.classList.add('active');
            } else if (i === 4) {
                iconInner.classList.add('docs-icon-text-rtl-20');
                btn.addEventListener('click', () => {
                    setTextDirection('rtl');
                    document.querySelectorAll('#customMenu .custom-menu-button[data-tooltip="Left-to-right"], #customMenu .custom-menu-button[data-tooltip="Right-to-left"]')
                        .forEach(btnElem => {
                        btnElem.classList.toggle('active', btnElem.getAttribute('data-tooltip') === 'Right-to-left');
                    });
                });
                if (currentTextDirection === 'rtl') btn.classList.add('active');
            }
            iconWrapper.appendChild(iconInner);
            btn.appendChild(iconWrapper);
            menu.appendChild(btn);
        }
        document.body.appendChild(menu);
        return menu;
    };

    // ---------------------------
    // 11. Grouped Thumbnails Rendering (Heading Grouping)
    // ---------------------------
    const renderGroupedThumbnails = () => {
        if (!thumbnailOverlay) return;
        thumbnailOverlay.replaceChildren();
        headingGroupsArr.sort((a, b) => a.startingPage - b.startingPage).forEach(group => {
            const groupContainer = document.createElement('div');
            Object.assign(groupContainer.style, {
                margin: '10px',
                padding: '10px',
                border: '1px solid #ccc',
                borderRadius: '8px',
                background: '#fff'
            });
            const headingTitle = document.createElement('div');
            headingTitle.textContent = group.headingText;
            headingTitle.style.fontWeight = 'bold';
            headingTitle.style.marginBottom = '5px';
            groupContainer.appendChild(headingTitle);
            const thumbsContainer = document.createElement('div');
            Object.assign(thumbsContainer.style, {
                display: 'flex',
                flexWrap: 'wrap',
                gap: '8px'
            });
            Array.from(group.pages).sort((a, b) => a - b).forEach(pageNum => {
                const data = capturedPageData[pageNum];
                if (data?.thumbEntry) {
                    const thumbClone = data.thumbEntry.cloneNode(true);
                    thumbClone.addEventListener('click', () => {
                        exitThumbnailView();
                        const targetPos = parseInt(thumbClone.dataset.scrollPos, 10);
                        const scrollable = getScrollableElement();
                        if (scrollable) scrollable.scrollTop = targetPos;
                    });
                    thumbsContainer.appendChild(thumbClone);
                }
            });
            groupContainer.appendChild(thumbsContainer);
            thumbnailOverlay.appendChild(groupContainer);
        });
    };

    // ---------------------------
    // 11a. Section Grouping: Render Pages Grouped by Section
    // ---------------------------
    // Helper function to close unwanted menus (mimics the Borders and Shading behavior)
    function closeMenusExcept(exceptionsSelectors = []) {
        const allMenus = document.querySelectorAll(
            'div.goog-menu.goog-menu-vertical.docs-material.shell-menu.goog-menu-noaccel, div.goog-menu.goog-menu-vertical.docs-material'
        );
        allMenus.forEach(menu => {
            if (!exceptionsSelectors.some(selector => menu.matches(selector))) {
                menu.style.display = 'none';
                console.log('Closed unwanted menu:', menu);
            }
        });
        // Additionally, click the document body to clear any overlays.
        document.body.click();
    }

    // Asynchronous helper to ensure header bubbles are loaded
    const ensureHeaderBubblesLoaded = async (pauseScroll = true) => {
        // Check for existing visible header/footer bubbles.
        let headerBubbles = Array.from(document.querySelectorAll('div.docs-bubble.kix-header-footer-bubble'))
        .filter(bubble => window.getComputedStyle(bubble).display !== 'none');
        if (headerBubbles.length) {
            return headerBubbles;
        }

        // If no bubbles are found and pauseScroll is allowed, pause scroll simulation.
        if (pauseScroll) {
            cancelScrollSequence = true;
        }

        // --- Attempt 1: Direct click of "Header" ---
        let headerLabel = document.querySelector('div.goog-menuitem[role="menuitem"] span.goog-menuitem-label[aria-label^="Header"]');
        if (headerLabel) {
            const headerMenuItem = headerLabel.closest('div.goog-menuitem[role="menuitem"]');
            if (headerMenuItem) {
                console.log("Clicking Header menu item.");
                clickElement(headerMenuItem);
                await sleep(300);
                headerBubbles = await waitForHeaderBubbles();
                if (headerBubbles.length) {
                    closeMenusExcept([]); // Remove any menus that remain.
                    return headerBubbles;
                }
            }
        }

        // --- Attempt 2: Click "Header & Footer" then "Header" ---
        let headerFooterLabel = document.querySelector('div.goog-menuitem[role="menuitem"] span.goog-menuitem-label[aria-label*="Header & Footer"]');
        if (headerFooterLabel) {
            const headerFooterMenuItem = headerFooterLabel.closest('div.goog-menuitem[role="menuitem"]');
            if (headerFooterMenuItem) {
                console.log("Clicking Header & Footer menu item.");
                clickElement(headerFooterMenuItem);
                await sleep(300);
                headerLabel = document.querySelector('div.goog-menuitem[role="menuitem"] span.goog-menuitem-label[aria-label^="Header"]');
                if (headerLabel) {
                    const headerMenuItem = headerLabel.closest('div.goog-menuitem[role="menuitem"]');
                    if (headerMenuItem) {
                        console.log("Clicking Header menu item after Header & Footer.");
                        clickElement(headerMenuItem);
                        await sleep(300);
                    }
                }
                headerBubbles = await waitForHeaderBubbles();
                if (headerBubbles.length) {
                    closeMenusExcept([]);
                    return headerBubbles;
                }
            }
        }

        // --- Attempt 3: Use the Insert menu to insert header/footer ---
        const insertMenuButton = document.getElementById('docs-insert-menu');
        if (insertMenuButton) {
            console.log("Clicking Insert menu button to try to insert header and footer.");
            clickElement(insertMenuButton);
            await sleep(300);
            let insertHeaderFooterLabel = document.querySelector('div.goog-menuitem span.goog-menuitem-label[aria-label*="Header & footer"]');
            if (insertHeaderFooterLabel) {
                const insertHeaderFooterMenuItem = insertHeaderFooterLabel.closest('div.goog-menuitem[role="menuitem"]');
                if (insertHeaderFooterMenuItem) {
                    clickElement(insertHeaderFooterMenuItem);
                    await sleep(300);
                    headerLabel = document.querySelector('div.goog-menuitem[role="menuitem"] span.goog-menuitem-label[aria-label^="Header"]');
                    if (headerLabel) {
                        const headerMenuItem = headerLabel.closest('div.goog-menuitem[role="menuitem"]');
                        if (headerMenuItem) {
                            console.log("Clicking Header menu item after Insert menu.");
                            clickElement(headerMenuItem);
                            await sleep(300);
                        }
                    }
                }
                headerBubbles = await waitForHeaderBubbles();
                if (headerBubbles.length) {
                    closeMenusExcept([]);
                    return headerBubbles;
                }
            }
        }

        console.log("Header/footer bubble elements still not found after all attempts.");
        return headerBubbles;
    };

    // Updated renderSectionGroupedPages function that uses the helper above
    const renderSectionGroupedPages = async () => {
        if (!thumbnailOverlay) return;

        // Ensure header bubbles are loaded (only if they aren't already)
        const headerBubbles = await ensureHeaderBubblesLoaded();
        if (!headerBubbles.length) {
            console.log("Cannot proceed with section grouping; header bubbles are unavailable.");
            return;
        }

        // Group pages by section using header bubble descriptions.
        const sectionGroups = {};
        headerBubbles.forEach((bubble, index) => {
            const pageNumber = index + 1;
            const descriptionSpan = bubble.querySelector('.kix-header-footer-bubble-description');
            if (!descriptionSpan) {
                console.log("Header/footer bubble description not found for page " + pageNumber);
                return;
            }
            const text = descriptionSpan.textContent;
            // Capture strings like "Section 1" from descriptions such as "Header - Section 1"
            const match = text.match(/Section\s+\S+/);
            const sectionKey = match ? match[0] : 'Unknown Section';
            if (!sectionGroups[sectionKey]) sectionGroups[sectionKey] = new Set();
            sectionGroups[sectionKey].add(pageNumber);
        });

        // Clear the overlay and build the grouped thumbnails.
        thumbnailOverlay.replaceChildren();
        Object.keys(sectionGroups).sort().forEach(sectionKey => {
            const groupContainer = document.createElement('div');
            Object.assign(groupContainer.style, {
                margin: '10px',
                padding: '10px',
                border: '1px solid #ccc',
                borderRadius: '8px',
                background: '#fff'
            });
            const header = document.createElement('div');
            header.textContent = sectionKey;
            header.style.fontWeight = 'bold';
            header.style.marginBottom = '5px';
            groupContainer.appendChild(header);
            const thumbsContainer = document.createElement('div');
            Object.assign(thumbsContainer.style, {
                display: 'flex',
                flexWrap: 'wrap',
                gap: '8px'
            });
            Array.from(sectionGroups[sectionKey]).sort((a, b) => a - b).forEach(pageNum => {
                const data = capturedPageData[pageNum];
                if (data?.thumbEntry) {
                    const thumbClone = data.thumbEntry.cloneNode(true);
                    thumbClone.addEventListener('click', () => {
                        exitThumbnailView();
                        const targetPos = parseInt(thumbClone.dataset.scrollPos, 10);
                        const scrollable = getScrollableElement();
                        if (scrollable) scrollable.scrollTop = targetPos;
                    });
                    thumbsContainer.appendChild(thumbClone);
                }
            });
            groupContainer.appendChild(thumbsContainer);
            thumbnailOverlay.appendChild(groupContainer);
        });
    };

    // ---------------------------
    // 12. Initialization
    // ---------------------------
    waitForElement('.kix-outlines-widget-header-add-chapter-button')
        .then(addPagesViewButton)
        .catch(console.error);
})();

QingJ © 2025

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