Confluence Floating TOC

在 Confluence 文章页面上浮动展示文章目录,并支持展开和折叠功能,自动适应暗色/亮色模式

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         Confluence Floating TOC
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  在 Confluence 文章页面上浮动展示文章目录,并支持展开和折叠功能,自动适应暗色/亮色模式
// @author       mkdir700
// @match        https://*.atlassian.net/wiki/*
// @grant        none
// @license      MIT
// ==/UserScript==


// 递归处理已有的 TOC,重新生成新的 TOC
function genertateTOCFromExistingToc(toc) {
    if (toc.textContent === '') {
        return;
    }
    let currUl = document.createElement('ul');
    currUl.id = 'floating-toc-ul';
    for (let i = 0; i < toc.children.length; i++) {
        // li > span > a
        var a = toc.children[i].querySelector('span > a');
        var headerTextElement = toc.children[i].querySelector('span > a > span > span');
        if (!headerTextElement) {
            continue;
        }

        var headerText = headerTextElement.textContent;

        // 创建目录项
        var tocItem = document.createElement('li');

        // 创建链接
        var tocLink = document.createElement('a');
        tocLink.textContent = headerText;

        // 使用标题的 id 作为 URL 片段
        // 标题中的空格需要替换为 -,并且转为小写
        tocLink.href = a.href;
        tocItem.appendChild(tocLink);

        // 如果有子目录,递归处理
        var childUl = toc.children[i].querySelector('ul');
        if (childUl) {
            var newUl = genertateTOCFromExistingToc(childUl);
            if (newUl) {
                tocItem.appendChild(newUl);
            }
        }
        currUl.appendChild(tocItem);
    }

    return currUl;
}


function getExistingToc() {
    return document.querySelector('[data-testid="list-style-toc-level-container"]');
}

function generateTOCFormPage() {
    // 创建目录列表
    var tocList = document.createElement('ul');
    tocList.id = 'floating-toc-ul';
    // 获取所有标题
    var headers = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
    headers.forEach(function (header) {
        // 过滤掉 id 为空的标题
        if (header.textContent === '') {
            return;
        }
        // 检查是否有属性 data-item-title
        if (header.hasAttribute('data-item-title')) {
            return;
        }
        // 检查属性 data-testid 是否等于 title-text
        if (header.getAttribute('data-testid') === 'title-text') {
            return;
        }
        if (header.id === 'floating-toc-title') {
            return;
        }
        // class 为 'cc-te0214' 的标题不需要显示在目录中
        if (header.className === 'cc-te0214') {
            return;
        }
        // 排除特定的 h2 标签
        if (header.tagName === 'H2' && header.closest('[data-vc="end-of-page-recommendation-component"]')) {
            return;
        }
        // 排除 "快速入门" 标题
        if (header.closest('[data-test-id="onboarding-quickstart-experience"]')) {
            return;
        }

        if (header.closest('[data-test-id="flag-visibility-wrapper"]')) {
            return;
        }

        if (header.tagName === 'H2' && header.closest('[class="atlaskit-portal-container"]')) {
            return;
        }

        // 创建目录项
        var tocItem = document.createElement('li');
        tocItem.style.marginLeft = (parseInt(header.tagName[1]) - 1) * 10 + 'px'; // 根据标题级别缩进

        // 创建链接
        var tocLink = document.createElement('a');
        tocLink.textContent = header.textContent;

        // 使用标题作为 URL 片段
        tocLink.href = '#' + header.textContent.replace(/\s/g, '-');
        tocItem.appendChild(tocLink);

        // 将目录项添加到目录列表中
        tocList.appendChild(tocItem);
    });

    return tocList;
}

function buildToggleButton() {
    // 检查当前颜色模式
    const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                       (document.documentElement.getAttribute('data-color-mode') === 'light' && 
                        document.documentElement.getAttribute('data-theme') && 
                        document.documentElement.getAttribute('data-theme').includes('light:dark'));
    
    var toggleButton = document.createElement('div');
    toggleButton.id = 'floating-toc-toggle';
    toggleButton.innerHTML = '&#9654;'; // 右箭头 Unicode 字符
    toggleButton.style.position = 'fixed';
    toggleButton.style.top = '200px';
    toggleButton.style.right = '0';
    toggleButton.style.backgroundColor = isDarkMode ? '#4688ec' : '#007bff';
    toggleButton.style.color = '#fff';
    toggleButton.style.width = '20px';
    toggleButton.style.height = '40px';
    toggleButton.style.display = 'flex';
    toggleButton.style.justifyContent = 'center';
    toggleButton.style.alignItems = 'center';
    toggleButton.style.cursor = 'pointer';
    toggleButton.style.userSelect = 'none';
    toggleButton.style.borderRadius = '5px 0 0 5px';
    toggleButton.style.zIndex = '1000';
    toggleButton.style.transition = 'all 0.3s ease-in-out';
    toggleButton.style.fontSize = '14px';

    var isCollapsed = false;
    toggleButton.addEventListener('click', function () {
        var tocContainer = document.getElementById('floating-toc-container');
        if (isCollapsed) {
            tocContainer.style.right = '0';
            toggleButton.innerHTML = '&#9654;'; // 右箭头
            toggleButton.style.right = '220px'; // 调整按钮位置
        } else {
            tocContainer.style.right = '-220px'; // 完全隐藏目录
            toggleButton.innerHTML = '&#9664;'; // 左箭头
            toggleButton.style.right = '-10px';
        }
        isCollapsed = !isCollapsed;
    });

    toggleButton.addEventListener('mouseenter', function() {
        if (isCollapsed) {
            toggleButton.style.right = '0';
        }
    });

    toggleButton.addEventListener('mouseleave', function() {
        if (isCollapsed) {
            toggleButton.style.right = '-10px';
        }
    });

    return toggleButton;
}


function buildToc() {
    // 检查当前颜色模式
    const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                       (document.documentElement.getAttribute('data-color-mode') === 'light' && 
                        document.documentElement.getAttribute('data-theme') && 
                        document.documentElement.getAttribute('data-theme').includes('light:dark'));
    
    var tocContainer = document.createElement('div');
    tocContainer.id = 'floating-toc-container';
    tocContainer.style.width = '220px'; // 增加宽度以包含padding
    tocContainer.style.backgroundColor = isDarkMode ? '#2c2c2e' : '#fff';
    tocContainer.style.border = isDarkMode ? '1px solid #444' : '1px solid #ccc';
    tocContainer.style.padding = '10px';
    tocContainer.style.boxSizing = 'border-box'; // 确保padding包含在宽度内
    tocContainer.style.boxShadow = isDarkMode ? '0 0 10px rgba(0,0,0,0.3)' : '0 0 10px rgba(0,0,0,0.1)';
    tocContainer.style.position = 'fixed';
    tocContainer.style.top = '200px';
    tocContainer.style.right = '0';
    tocContainer.style.maxHeight = 'calc(100vh - 300px)';
    tocContainer.style.overflowY = 'auto';
    tocContainer.style.transition = 'right 0.3s ease-in-out';
    tocContainer.style.zIndex = '999';
    tocContainer.style.scrollbarWidth = 'none';
    tocContainer.style.msOverflowStyle = 'none';

    var style = document.createElement('style');
    style.textContent = `
        #floating-toc-container::-webkit-scrollbar {
            display: none;
        }
    `;
    document.head.appendChild(style);

    var tocTitle = document.createElement('div');
    tocTitle.textContent = '目录';
    tocTitle.style.marginTop = '0';
    tocTitle.style.marginBottom = '10px';
    tocTitle.style.textAlign = 'center';
    tocTitle.style.fontSize = '16px';
    tocTitle.style.fontWeight = 'bold';
    tocTitle.style.color = isDarkMode ? '#bfc1c4' : '#000';
    tocContainer.appendChild(tocTitle);

    return tocContainer;
}

function generateTOC() {
    // var existingTOC = getExistingToc();
    // var toc;

    // if (existingTOC) {
        // toc = genertateTOCFromExistingToc(existingTOC);
    // } else {
        // toc = generateTOCFormPage();
    // }
    var toc = generateTOCFormPage();

    if (!toc || toc.children.length === 0) {
        var emptyMessage = document.createElement('div');
        emptyMessage.id = 'floating-toc-empty-message';
        emptyMessage.textContent = '当前页面没有可用的目录内容';
        emptyMessage.style.color = '#666';
        emptyMessage.style.fontStyle = 'italic';
        emptyMessage.style.textAlign = 'center';
        emptyMessage.style.padding = '20px 0';
        return emptyMessage;
    }

    toc.style.listStyle = 'none';
    toc.style.padding = '0';
    toc.style.margin = '0';

    // 检查当前颜色模式
    const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                       (document.documentElement.getAttribute('data-color-mode') === 'light' && 
                        document.documentElement.getAttribute('data-theme') && 
                        document.documentElement.getAttribute('data-theme').includes('light:dark'));

    // 优化目录列表样式
    var listItems = toc.querySelectorAll('li');
    listItems.forEach(function(item, index) {
        item.style.marginBottom = '5px';
        var link = item.querySelector('a');
        if (link) {
            link.style.textDecoration = 'none';
            link.style.color = isDarkMode ? '#a9abaf' : '#333';
            link.style.display = 'block';
            link.style.padding = '3px 5px';
            link.style.borderRadius = '3px';
            link.style.transition = 'background-color 0.2s';
            link.style.whiteSpace = 'nowrap';
            link.style.overflow = 'hidden';
            link.style.textOverflow = 'ellipsis';
            link.style.maxWidth = '180px';  // 减小最大宽度

            // 设置标题完整内容为title属性
            link.title = link.textContent;

            // 截断长标题
            if (link.textContent.length > 25) {
                link.textContent = link.textContent.substring(0, 22) + '...';
            }

            // 根据颜色模式设置悬停效果
            const hoverBgColor = isDarkMode ? '#3a3a3c' : '#f0f0f0';
            link.addEventListener('mouseover', function() {
                this.style.backgroundColor = hoverBgColor;
            });
            link.addEventListener('mouseout', function() {
                this.style.backgroundColor = 'transparent';
            });

            // 优化缩进
            var level = parseInt(item.style.marginLeft) / 10;
            item.style.paddingLeft = (level * 15) + 'px';  // 使用 padding 代替 margin
            item.style.marginLeft = '0';  // 移除左边距

            // 为第三级及以下的标题添加折叠功能
            if (level > 2) {
                item.style.display = 'none';
                var parentLi = item.parentElement.closest('li');
                if (parentLi && !parentLi.classList.contains('has-submenu')) {
                    parentLi.classList.add('has-submenu');
                    var toggleBtn = document.createElement('span');
                    toggleBtn.textContent = '▶';
                    toggleBtn.style.cursor = 'pointer';
                    toggleBtn.style.marginRight = '5px';
                    toggleBtn.style.fontSize = '10px';  // 减小箭头大小
                    toggleBtn.style.color = isDarkMode ? '#a9abaf' : '#333'; // 根据颜色模式设置颜色
                    parentLi.insertBefore(toggleBtn, parentLi.firstChild);

                    toggleBtn.addEventListener('click', function(e) {
                        e.stopPropagation();  // 防止点击事件冒泡
                        var subItems = this.parentElement.querySelectorAll('li');
                        subItems.forEach(function(subItem) {
                            subItem.style.display = subItem.style.display === 'none' ? 'block' : 'none';
                        });
                        this.textContent = this.textContent === '▶' ? '▼' : '▶';
                    });
                }
            }
        }
    });

    // 添加平滑滚动
    toc.style.scrollBehavior = 'smooth';

    return toc;
}

function updateMaxHeight(tocContainer) {
    const viewportHeight = window.innerHeight;
    const topOffset = parseFloat(tocContainer.style.top);
    tocContainer.style.maxHeight = (viewportHeight - topOffset - 20) + 'px'; // 20px 为一些额外的间距
}

// 检测 Confluence 页面的颜色模式并相应地调整插件样式
function detectColorModeAndApplyStyles() {
    // 检查 HTML 元素的 data-color-mode 属性
    const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                       (document.documentElement.getAttribute('data-color-mode') === 'light' && 
                        document.documentElement.getAttribute('data-theme') && 
                        document.documentElement.getAttribute('data-theme').includes('light:dark'));
    
    // 获取插件元素
    const tocContainer = document.getElementById('floating-toc-container');
    const toggleButton = document.getElementById('floating-toc-toggle');
    const backToTopButton = document.getElementById('back-to-top-button');
    
    if (isDarkMode) {
        // 暗色模式样式
        if (tocContainer) {
            tocContainer.style.backgroundColor = '#2c2c2e'; // 深色背景
            tocContainer.style.border = '1px solid #444';
            tocContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.3)';
            
            // 修改目录标题和链接颜色
            const tocTitle = tocContainer.querySelector('div');
            if (tocTitle) {
                tocTitle.style.color = '#bfc1c4'; // 使用 --ds-text 变量值
            }
            
            // 修改所有链接颜色
            const links = tocContainer.querySelectorAll('a');
            links.forEach(link => {
                link.style.color = '#a9abaf'; // 使用 --ds-text-subtle 变量值
                
                // 修改鼠标悬停效果
                link.addEventListener('mouseover', function() {
                    this.style.backgroundColor = '#3a3a3c';
                });
                link.addEventListener('mouseout', function() {
                    this.style.backgroundColor = 'transparent';
                });
            });
        }
        
        if (toggleButton) {
            toggleButton.style.backgroundColor = '#4688ec'; // 使用 --ds-icon-accent-blue 变量值
        }
        
        if (backToTopButton) {
            backToTopButton.style.backgroundColor = '#4688ec'; // 使用 --ds-icon-accent-blue 变量值
        }
    } else {
        // 亮色模式样式(恢复默认)
        if (tocContainer) {
            tocContainer.style.backgroundColor = '#fff';
            tocContainer.style.border = '1px solid #ccc';
            tocContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.1)';
            
            // 恢复目录标题颜色
            const tocTitle = tocContainer.querySelector('div');
            if (tocTitle) {
                tocTitle.style.color = '#000';
            }
            
            // 恢复所有链接颜色
            const links = tocContainer.querySelectorAll('a');
            links.forEach(link => {
                link.style.color = '#333';
                
                // 恢复鼠标悬停效果
                link.addEventListener('mouseover', function() {
                    this.style.backgroundColor = '#f0f0f0';
                });
                link.addEventListener('mouseout', function() {
                    this.style.backgroundColor = 'transparent';
                });
            });
        }
        
        if (toggleButton) {
            toggleButton.style.backgroundColor = '#007bff';
        }
        
        if (backToTopButton) {
            backToTopButton.style.backgroundColor = '#007bff';
        }
    }
}

function buildBackToTopButton() {
    // 检查当前颜色模式
    const isDarkMode = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                       (document.documentElement.getAttribute('data-color-mode') === 'light' && 
                        document.documentElement.getAttribute('data-theme') && 
                        document.documentElement.getAttribute('data-theme').includes('light:dark'));
    
    var backToTopButton = document.createElement('div');
    backToTopButton.id = 'back-to-top-button';
    backToTopButton.innerHTML = '&#9650;'; // 上箭头 Unicode 字符
    backToTopButton.style.position = 'fixed';
    backToTopButton.style.bottom = '30px';
    backToTopButton.style.right = '220px'; // 调整位置,使其位于目录左侧
    backToTopButton.style.backgroundColor = isDarkMode ? '#4688ec' : '#007bff';
    backToTopButton.style.color = '#fff';
    backToTopButton.style.width = '40px';
    backToTopButton.style.height = '40px';
    backToTopButton.style.borderRadius = '50%';
    backToTopButton.style.display = 'flex';
    backToTopButton.style.justifyContent = 'center';
    backToTopButton.style.alignItems = 'center';
    backToTopButton.style.cursor = 'pointer';
    backToTopButton.style.fontSize = '20px';
    backToTopButton.style.boxShadow = isDarkMode ? '0 2px 5px rgba(0,0,0,0.3)' : '0 2px 5px rgba(0,0,0,0.2)';
    backToTopButton.style.transition = 'opacity 0.3s';
    backToTopButton.style.opacity = '0';
    backToTopButton.style.zIndex = '1000';

    backToTopButton.addEventListener('click', function() {
        window.scrollTo({top: 0, behavior: 'smooth'});
    });

    window.addEventListener('scroll', function() {
        if (window.pageYOffset > 100) {
            backToTopButton.style.opacity = '1';
        } else {
            backToTopButton.style.opacity = '0';
        }
    });

    return backToTopButton;
}


(function () {
    'use strict';

    var tocContainer = buildToc();
    document.body.appendChild(tocContainer);

    var toggleButton = buildToggleButton();
    document.body.appendChild(toggleButton);

    // 初始化按钮位置
    toggleButton.style.right = '220px';

    var backToTopButton = buildBackToTopButton();
    document.body.appendChild(backToTopButton);

    function updateTOC() {
        var existingContent = document.getElementById('floating-toc-ul') || document.getElementById('floating-toc-empty-message');
        if (existingContent) {
            existingContent.remove();
        }

        var newContent = generateTOC();
        tocContainer.appendChild(newContent);
    }

    // 使用防抖函数来限制更新频率
    function debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }

    // 防抖处理的更新函数
    const debouncedUpdateTOC = debounce(updateTOC, 300);

    // 初始化目录
    updateTOC();

    // 监听 URL 变化
    var lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            setTimeout(updateTOC, 1000); // 延迟 1 秒更新目录,确保页面内容已加载
        }
    }).observe(document, {subtree: true, childList: true});

    // 监听页面内容变化,包括编辑状态下的变化
    var contentObserver = new MutationObserver(function(mutations) {
        let shouldUpdate = false;
        mutations.forEach(function(mutation) {
            if (mutation.type === 'childList') {
                // 检查是否有新的标题元素被添加或删除
                mutation.addedNodes.forEach(function(node) {
                    if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
                        shouldUpdate = true;
                    }
                });
                mutation.removedNodes.forEach(function(node) {
                    if (node.nodeType === 1 && /^H[1-6]$/i.test(node.tagName)) {
                        shouldUpdate = true;
                    }
                });
            } else if (mutation.type === 'characterData') {
                // 检查文本内容的变化
                let node = mutation.target.parentNode;
                while (node && node !== document.body) {
                    if (/^H[1-6]$/i.test(node.tagName)) {
                        shouldUpdate = true;
                        break;
                    }
                    node = node.parentNode;
                }
            }
        });

        if (shouldUpdate) {
            debouncedUpdateTOC();
        }
    });

    contentObserver.observe(document.body, {
        childList: true,
        subtree: true,
        characterData: true
    });

    // 检测颜色模式并应用样式
    detectColorModeAndApplyStyles();

    // 监听颜色模式变化
    const colorModeObserver = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            if (mutation.type === 'attributes' && 
                (mutation.attributeName === 'data-color-mode' || mutation.attributeName === 'data-theme')) {
                detectColorModeAndApplyStyles();
            }
        });
    });

    colorModeObserver.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['data-color-mode', 'data-theme']
    });

    // ... 其他现有代码 ...
})();