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');
    }

})();