Greasy Fork

来自缓存

Greasy Fork is available in English.

Atlassian Confluence Floating ToC

Adds a floating, hierarchical table of contents to Confluence pages.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Atlassian Confluence Floating ToC
// @namespace    https://gist.github.com/four43/31c68a090142032968c8a2f9a58862c3
// @version      1.0.3
// @description  Adds a floating, hierarchical table of contents to Confluence pages.
// @author       Seth Miller<[email protected]>
// @license      MIT
// @match        https://*.atlassian.net/wiki/spaces/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    const TOC_PANEL_ID = 'gemini-floating-toc-panel';
    const TOC_BUTTON_ID = 'gemini-floating-toc-button';
    const TOC_LINKS_CONTAINER_ID = 'gemini-toc-links-container';
    const STORAGE_KEY = 'gemini-toc-visible';
    const WIDTH_STORAGE_KEY = 'gemini-toc-width';
    const DEFAULT_WIDTH = 280;
    const MIN_WIDTH = 200;
    const MAX_WIDTH = 600;

    // --- Global variables to hold our created elements and observer ---
    // We create these once and re-attach them if they get removed.
    let tocPanel = null;
    let tocButton = null;
    let contentObserver = null;
    let lastObserverTarget = null;
    let isScrollListenerAttached = false;
    let lastUrl = location.href;
    let ensureInterval = null;

    // --- 1. Add All CSS Styles ---
    // (This only needs to run once at the start)
    GM_addStyle(`
        /* 1. Make the main layout a flex container */
        #layout-main-wrapper {
            display: flex;
            flex-direction: row;
            position: relative;
        }

        /* 2. Style the ToC Panel */
        #${TOC_PANEL_ID} {
            width: 280px;
            flex-shrink: 0;
            height: calc(100vh - 100px);
            position: sticky;
            top: 55px; /* Match header height */
            overflow-y: auto;
            padding: 20px 15px;
            margin-left: 24px;
            background: var(--ds-surface-overlay, #FFFFFF);
            border-radius: 0px;
            border: 1px solid var(--ds-border, #0B120E24);
            transition: all 0.1s ease-in-out;
            font-size: 13px;
            box-sizing: border-box;
            opacity: 1;
        }

        /* Resize handle */
        #${TOC_PANEL_ID}::after {
            content: '';
            position: absolute;
            top: 0;
            left: 0;
            width: 3px;
            height: 100%;
            cursor: ew-resize;
            background: transparent;
            transition: background-color 0.2s ease;
        }

        #${TOC_PANEL_ID}:hover::after {
            /* background: var(--ds-border, rgba(11, 18, 14, 0.14)); */
        }

        #${TOC_PANEL_ID}.resizing {
            transition: none;
            user-select: none;
        }

        #${TOC_PANEL_ID}.resizing::after {
            background: var(--ds-border-selected, #2e2e2eff);
        }

        /* 3. Hidden State for ToC */
        #${TOC_PANEL_ID}.hidden {
            width: 0;
            padding: 0;
            margin: 0;
            border: none;
            overflow: hidden;
            opacity: 0;
        }

        /* 4. ToC Header */
        #${TOC_PANEL_ID} h4 {
            margin-top: 0;
            margin-bottom: 10px;
            padding-bottom: 10px;
            border-bottom: 1px solid var(--ds-border, #0B120E24);
            color: var(--ds-text, #292A2E);
            font-size: 16px;
            font-weight: 600;
            white-space: nowrap;
        }

        /* 5. ToC Links */
        #${TOC_LINKS_CONTAINER_ID} a {
            display: block;
            padding: 5px 8px;
            text-decoration: none !important;
            color: var(--ds-link, #1868DB) !important;
            border-radius: 3px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            transition: background-color 0.2s ease;
        }

        #${TOC_LINKS_CONTAINER_ID} a:hover {
            background-color: var(--ds-background-neutral-hovered, #F0F1F2);
            color: var(--ds-link-pressed, #1558BC) !important;
        }

        /* 6. Active Link Highlighting */
        #${TOC_LINKS_CONTAINER_ID} a.active {
            background-color: var(--ds-background-selected, #E9F2FE);
            color: var(--ds-link-pressed, #1558BC) !important;
            font-weight: 600;
        }

        /* 7. Hierarchy Padding */
        #${TOC_LINKS_CONTAINER_ID} .toc-h1 { padding-left: 8px; font-weight: 600; }
        #${TOC_LINKS_CONTAINER_ID} .toc-h2 { padding-left: 16px; }
        #${TOC_LINKS_CONTAINER_ID} .toc-h3 { padding-left: 24px; font-size: 12px; }
        #${TOC_LINKS_CONTAINER_ID} .toc-h4 { padding-left: 32px; font-size: 12px; color: var(--ds-text-subtle, #505258); }

        /* 8. Toggle Button */
        #${TOC_BUTTON_ID} {
            background: transparent;
            border: none;
            border-radius: 3px;
            cursor: pointer;
            height: 32px;
            width: 32px;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            /* Match the fill color of other icons */
            color: var(--ds-text-subtle, #FFF);
        }

        #${TOC_BUTTON_ID} svg path {
             /* Use 'currentColor' to inherit the white from the button's color style */
            fill: currentColor !important;
        }

        #${TOC_BUTTON_ID}:hover {
            background-color: var(--ds-top-bar-button-background-hovered, rgba(255, 255, 255, 0.1));
        }

        #${TOC_BUTTON_ID}.active {
            background-color: var(--ds-top-bar-button-selected-background, rgba(255, 255, 255, 0.15));
        }

        /* 9. Responsive: Hide on smaller screens */
        @media (max-width: 1024px) {
            #${TOC_PANEL_ID}, #${TOC_BUTTON_ID} {
                display: none;
            }
        }
    `);

    // --- 2. Create/Get Panel Element ---
    // Creates the panel in memory once, so we can re-attach it later.
    function getTocPanel() {
        if (!tocPanel) {
            console.log('Floating ToC: Creating panel element in memory.');
            tocPanel = document.createElement('div');
            tocPanel.id = TOC_PANEL_ID;
            tocPanel.innerHTML = `<h4>Table of Contents</h4><div id="${TOC_LINKS_CONTAINER_ID}"></div>`;

            // Apply saved width
            const savedWidth = GM_getValue(WIDTH_STORAGE_KEY, DEFAULT_WIDTH);
            tocPanel.style.width = `${savedWidth}px`;

            // Add resize functionality
            setupResize(tocPanel);
        }
        return tocPanel;
    }

    // --- Helper function to setup resize functionality ---
    function setupResize(panel) {
        let isResizing = false;
        let startX = 0;
        let startWidth = 0;

        const onMouseDown = (e) => {
            const rect = panel.getBoundingClientRect();
            const handleArea = 8; // Match the ::after width

            // Check if click is in the resize handle area (left side)
            if (e.clientX >= rect.left && e.clientX <= rect.left + handleArea) {
                e.preventDefault();
                isResizing = true;
                startX = e.clientX;
                startWidth = panel.offsetWidth;
                panel.classList.add('resizing');

                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mouseup', onMouseUp);
            }
        };

        const onMouseMove = (e) => {
            if (!isResizing) return;

            const delta = startX - e.clientX; // Reversed: moving left increases width
            const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
            panel.style.width = `${newWidth}px`;
        };

        const onMouseUp = () => {
            if (!isResizing) return;

            isResizing = false;
            panel.classList.remove('resizing');

            // Save the new width
            const currentWidth = panel.offsetWidth;
            GM_setValue(WIDTH_STORAGE_KEY, currentWidth);
            console.log('Floating ToC: Saved width:', currentWidth);

            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
        };

        panel.addEventListener('mousedown', onMouseDown);
    }

    // --- 3. Create/Get Button Element ---
    // Creates the button in memory once, so we can re-attach it later.
    function getTocButton() {
        if (!tocButton) {
            console.log('Floating ToC: Creating button element in memory.');
            tocButton = document.createElement('button');
            tocButton.id = TOC_BUTTON_ID;
            tocButton.title = 'Toggle Table of Contents';
            // Start with closed state icon
            tocButton.innerHTML = `
                <svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24"><path fill="#fdfdfd" d="M11.5 16V8l-4 4zm4.5 3h3V5h-3zM5 19h9V5H5zm11 0h3zM3 21V3h18v18z"/></svg>
            `;

            // Add click listener ONCE
            tocButton.addEventListener('click', () => {
                const panel = getTocPanel(); // Get the panel
                const isCurrentlyVisible = !panel.classList.contains('hidden');
                toggleTOC(panel, tocButton, !isCurrentlyVisible);
            });
        }
        return tocButton;
    }

    // --- 4. Populate the ToC Panel ---
    function populateTOC() {
        const panel = getTocPanel();
        const linksContainer = panel.querySelector(`#${TOC_LINKS_CONTAINER_ID}`);
        if (!linksContainer) return;

        linksContainer.innerHTML = '';
        const headings = document.querySelectorAll('#main-content.wiki-content h1, #main-content.wiki-content h2, #main-content.wiki-content h3, #main-content.wiki-content h4');

        if (headings.length === 0) {
            linksContainer.innerHTML = 'No headings found on this page.';
            return;
        }

        headings.forEach(heading => {
            if (!heading.id) {
                return; // Skip headings without IDs
            }

            const link = document.createElement('a');
            link.href = '#' + heading.id;
            link.textContent = heading.textContent;
            const level = heading.tagName.toLowerCase();
            link.className = 'toc-' + level;

            // Smooth scroll
            link.onclick = (e) => {
                e.preventDefault();
                heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
                if (history.pushState) {
                    history.pushState(null, null, '#' + heading.id);
                } else {
                    location.hash = '#' + heading.id;
                }
            };

            linksContainer.appendChild(link);
        });
    }

    // --- 5. Toggle Visibility ---
    function toggleTOC(panel, button, isVisible) {
        if (panel) {
            panel.classList.toggle('hidden', !isVisible);
        }
        if (button) {
            button.classList.toggle('active', isVisible);
            // Update button icon based on state
            updateButtonIcon(button, isVisible);
        }
        GM_setValue(STORAGE_KEY, isVisible);
    }

    // --- Helper function to update button icon ---
    function updateButtonIcon(button, isOpen) {
        if (!button) return;

        if (isOpen) {
            // Open state icon
            button.innerHTML = `
                <svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24"><path fill="#fdfdfd" d="M11.5 16V8l-4 4zM5 19h9V5H5zm-2 2V3h18v18z"/></svg>
            `;
        } else {
            // Closed state icon
            button.innerHTML = `
                <svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 24 24"><path fill="#fdfdfd" d="M11.5 16V8l-4 4zm4.5 3h3V5h-3zM5 19h9V5H5zm11 0h3zM3 21V3h18v18z"/></svg>
            `;
        }
    }

    // --- 6. Highlight Active Link on Scroll ---
    let scrollTimeout;
    function onScroll() {
        console.debug('Floating ToC (Scroll): onScroll triggered');

        if (scrollTimeout) {
            cancelAnimationFrame(scrollTimeout);
        }

        scrollTimeout = requestAnimationFrame(() => {
            const headings = document.querySelectorAll('#main-content.wiki-content h1[id], #main-content.wiki-content h2[id], #main-content.wiki-content h3[id], #main-content.wiki-content h4[id]');
            const tocLinks = document.querySelectorAll(`#${TOC_LINKS_CONTAINER_ID} a`);

            console.debug('Floating ToC (Scroll): Found', headings.length, 'headings and', tocLinks.length, 'links');

            const scrollOffset = 70; // 60px header height + 10px buffer
            let activeHeadingId = null;

            for (let i = headings.length - 1; i >= 0; i--) {
                const heading = headings[i];
                const rect = heading.getBoundingClientRect();

                console.debug(`Floating ToC (Scroll): Heading "${heading.textContent.substring(0, 20)}..." - top: ${rect.top}, scrollOffset: ${scrollOffset}`);

                if (rect.top <= scrollOffset) {
                    activeHeadingId = heading.id;
                    console.debug('Floating ToC (Scroll): Active heading ID:', activeHeadingId);
                    break;
                }
            }

            tocLinks.forEach(link => {
                const wasActive = link.classList.contains('active');
                const shouldBeActive = link.getAttribute('href') === '#' + activeHeadingId;
                link.classList.toggle('active', shouldBeActive);

                if (shouldBeActive && !wasActive) {
                    console.debug('Floating ToC (Scroll): Activated link:', link.textContent);
                }
            });
        });
    }

    // --- Helper function to find the actual scrollable container ---
    function findScrollContainer() {
        // Try common Confluence scroll containers
        const candidates = [
            document.querySelector('#AkMainContent'), // Primary scroll container in Confluence
            document.querySelector('#ak-main-content'),
            document.querySelector('[data-test-id="content-body"]'),
            document.querySelector('.wiki-page-content'),
            document.documentElement,
            document.body
        ];

        for (const element of candidates) {
            if (element && element.scrollHeight > element.clientHeight) {
                console.log('Floating ToC (Init): Found scroll container:', element.id || element.className || element.tagName);
                return element;
            }
        }

        console.log('Floating ToC (Init): Using window as scroll container');
        return window;
    }

    // --- 7. Check if on Edit Page ---
    function isEditPage() {
        return location.href.includes('/pages/edit-v2/');
    }

    // --- 8. Remove All Elements ---
    function removeElements() {
        console.log('Floating ToC: Removing elements (edit page detected)');

        // Remove panel
        const panel = document.getElementById(TOC_PANEL_ID);
        if (panel) {
            panel.remove();
        }

        // Remove button
        const button = document.getElementById(TOC_BUTTON_ID);
        if (button) {
            button.remove();
        }

        // Disconnect observer
        if (contentObserver) {
            contentObserver.disconnect();
            contentObserver = null;
            lastObserverTarget = null;
        }

        // Remove scroll listener
        if (isScrollListenerAttached) {
            const scrollContainer = findScrollContainer();
            scrollContainer.removeEventListener('scroll', onScroll);
            isScrollListenerAttached = false;
        }
    }

    // --- 9. Main "Ensurer" Loop ---
    // This runs periodically to make sure our elements haven't been wiped out by SPA re-renders
    function ensureElements() {
        const mainWrapper = document.querySelector('#layout-main-wrapper');
        const shareRestrictButton = document.querySelector('div[data-vc="share-restrict-and-copy-button"]');
        const contentContainer = document.querySelector('#main-content.wiki-content');

        // Wait for all key elements to be on the page
        if (!mainWrapper || !shareRestrictButton || !contentContainer) {
            console.log('Floating ToC (Ensurer): Waiting for core layout elements...');
            return;
        }

        // Get saved state early so we can use it for both new and existing elements
        const savedState = GM_getValue(STORAGE_KEY, true);

        // --- Ensure Panel ---
        let panel = document.getElementById(TOC_PANEL_ID);
        if (!panel) {
            console.log('Floating ToC (Ensurer): Panel not found. Injecting...');
            panel = getTocPanel(); // Get or create
            mainWrapper.appendChild(panel);
            toggleTOC(panel, null, savedState);
        } else {
            // Panel exists, ensure it matches saved state
            const isCurrentlyVisible = !panel.classList.contains('hidden');
            if (isCurrentlyVisible !== savedState) {
                console.log('Floating ToC (Ensurer): Syncing panel state to saved state:', savedState);
                toggleTOC(panel, null, savedState);
            }
            // Ensure width is applied (in case panel was recreated by Confluence)
            const savedWidth = GM_getValue(WIDTH_STORAGE_KEY, DEFAULT_WIDTH);
            if (panel.style.width !== `${savedWidth}px`) {
                panel.style.width = `${savedWidth}px`;
            }
        }

        // --- Ensure Button ---
        let button = document.getElementById(TOC_BUTTON_ID);
        if (!button) {
            console.log('Floating ToC (Ensurer): Button not found. Injecting...');
            const shareButtonContainer = shareRestrictButton.closest('div[data-testid="share-action-container-without-separator"]');
            if (shareButtonContainer && shareButtonContainer.parentElement) {
                const buttonContainer = shareButtonContainer.parentElement;
                button = getTocButton(); // Get or create
                buttonContainer.insertBefore(button, shareButtonContainer);
                toggleTOC(null, button, savedState);
            } else {
                 console.log('Floating ToC (Ensurer): Could not find share button parent.');
            }
        } else {
            // Button exists, ensure it matches saved state
            const isCurrentlyActive = button.classList.contains('active');
            if (isCurrentlyActive !== savedState) {
                console.log('Floating ToC (Ensurer): Syncing button state to saved state:', savedState);
                toggleTOC(null, button, savedState);
            }
        }

        // --- Ensure Observer & Content ---
        // Only attach observer if it's not attached or if the content element has changed
        if (!contentObserver || lastObserverTarget !== contentContainer) {
            console.log('Floating ToC (Ensurer): Attaching observer and populating ToC.');

            // Disconnect old one if it exists
            if (contentObserver) {
                contentObserver.disconnect();
            }

            populateTOC();

            let populateDebounce;
            contentObserver = new MutationObserver(() => {
                console.log('Floating ToC (Observer): Content change detected, repopulating.');
                clearTimeout(populateDebounce);
                populateDebounce = setTimeout(populateTOC, 300);
            });
            contentObserver.observe(contentContainer, { childList: true, subtree: true });
            lastObserverTarget = contentContainer;

            // Add scroll listener (only needs to be done once)
            if (!isScrollListenerAttached) {
                console.log('Floating ToC (Ensurer): Attaching scroll listener.');
                const scrollContainer = findScrollContainer();
                scrollContainer.addEventListener('scroll', onScroll, { passive: true });
                isScrollListenerAttached = true;

                // Trigger once to set initial state
                onScroll();
            }
        }
    }

    // --- 10. Monitor URL Changes ---
    function checkUrlChange() {
        const currentUrl = location.href;
        if (currentUrl !== lastUrl) {
            console.log('Floating ToC: URL changed from', lastUrl, 'to', currentUrl);
            lastUrl = currentUrl;

            if (isEditPage()) {
                // Stop the ensurer and remove elements
                if (ensureInterval) {
                    clearInterval(ensureInterval);
                    ensureInterval = null;
                }
                removeElements();
            } else {
                // Start the ensurer if not already running
                if (!ensureInterval) {
                    console.log('Floating ToC: Starting ensurer (view page detected)');
                    ensureInterval = setInterval(ensureElements, 1000);
                }
            }
        }
    }

    // --- 11. Start Everything ---
    console.log('Floating ToC: Starting script...');

    // Check URL changes frequently
    setInterval(checkUrlChange, 500);

    // Only start ensurer if not on edit page
    if (!isEditPage()) {
        console.log('Floating ToC: Initial load - starting ensurer');
        ensureInterval = setInterval(ensureElements, 1000);
    } else {
        console.log('Floating ToC: Initial load - edit page detected, not starting');
    }

})();