Azure DevOps Wiki sidebar with TOC generator

It generates a Table of Contents (TOC) based on the content headings, places it in a right sidebar, updates it in SPA navigation, highlights the visible section with scrollspy, hides in edit mode and regenerates when exiting the editor.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Azure DevOps Wiki sidebar with TOC generator
// @namespace    http://tampermonkey.net/
// @version      2025-10-03
// @description  It generates a Table of Contents (TOC) based on the content headings, places it in a right sidebar, updates it in SPA navigation, highlights the visible section with scrollspy, hides in edit mode and regenerates when exiting the editor.
// @author       Me
// @match        https://dev.azure.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=dev.azure.com
// @grant        none
// @license      CC
// ==/UserScript==

(function() {
    'use strict';

    // Variable global para almacenar el observer del scrollspy
    let scrollspyObserver = null;
    let scrollTimeout = null;
    let wasInEditMode = false; // Track del estado anterior para detectar cambios

    /**
     * Función para generar un slug a partir del texto.
     */
    function slugify(text) {
        return text.toLowerCase()
            .replace(/[^\w ]+/g, '')
            .replace(/ +/g, '-')
            .replace(/^-+|-+$/g, '');
    }

    /**
     * Genera la TOC basada en los encabezados dentro del contenedor markdown.
     */
    function generateTOC() {
        const markdownContent = document.querySelector(".markdown-content");
        if (!markdownContent) return null;

        const headings = markdownContent.querySelectorAll("h1, h2, h3, h4, h5, h6");
        if (headings.length === 0) return null;

        const toc = document.createElement("nav");
        toc.className = "toc-container";
        toc.setAttribute("aria-label", "Table of contents");
        toc.setAttribute("role", "navigation");

        const header = document.createElement("div");
        header.className = "toc-container-header";
        header.textContent = "Contenido";
        toc.appendChild(header);

        const rootUl = document.createElement("ul");
        let currentUl = rootUl;
        let previousLevel = null;
        let ulStack = [rootUl]; // Pila para manejar niveles de forma más robusta

        headings.forEach((heading, index) => {
            const level = parseInt(heading.tagName.substring(1));
            const text = heading.textContent.trim();
            let id = heading.id;

            if (!id) {
                const slug = slugify(text);
                // Asegurar ID único
                let counter = 1;
                id = "user-content-" + slug;
                while (document.getElementById(id)) {
                    id = "user-content-" + slug + "-" + counter;
                    counter++;
                }
                heading.id = id;
            }

            const li = document.createElement("li");
            li.setAttribute('data-heading-id', id);

            const a = document.createElement("a");
            a.href = `#${id}`;
            a.textContent = text;
            a.setAttribute('data-level', level);

            // Prevenir scroll brusco y usar scroll suave
            a.addEventListener('click', function(e) {
                e.preventDefault();
                const targetElement = document.getElementById(id);
                if (targetElement) {
                    targetElement.scrollIntoView({
                        behavior: 'smooth',
                        block: 'start'
                    });
                    // Actualizar URL sin causar scroll
                    history.pushState(null, null, `#${id}`);
                }
            });

            li.appendChild(a);

            if (index === 0) {
                rootUl.appendChild(li);
                previousLevel = level;
                currentUl = rootUl;
                ulStack = [rootUl];
            } else {
                if (level > previousLevel) {
                    // Crear nuevo nivel anidado
                    const newUl = document.createElement("ul");
                    const lastLi = currentUl.lastElementChild;
                    if (lastLi) {
                        lastLi.appendChild(newUl);
                        currentUl = newUl;
                        ulStack.push(newUl);
                    }
                } else if (level < previousLevel) {
                    // Volver a niveles anteriores
                    const levelDiff = previousLevel - level;
                    for (let i = 0; i < levelDiff && ulStack.length > 1; i++) {
                        ulStack.pop();
                    }
                    currentUl = ulStack[ulStack.length - 1];
                }
                currentUl.appendChild(li);
                previousLevel = level;
            }
        });

        toc.appendChild(rootUl);
        return toc;
    }

    /**
     * Detecta si estamos en modo edición
     */
    function isInEditMode() {
        // Detectar si existe el editor de texto o la toolbar de markdown
        const hasEditor = document.querySelector(".we-text") !== null;
        const hasMarkdownToolbar = document.querySelector(".wiki-markdown-toolbar") !== null;
        const hasEditPreviewContainer = document.querySelector(".edit-and-preview") !== null;
        const hasSaveButton = document.querySelector(".we-save-btn") !== null;

        return hasEditor || hasMarkdownToolbar || hasEditPreviewContainer || hasSaveButton;
    }

    /**
     * Actualiza la visibilidad del sidebar basándose en el modo actual
     * y regenera la TOC cuando se sale del modo edición
     */
    function updateSidebarVisibility() {
        const currentEditMode = isInEditMode();
        const sidebarWrapper = document.querySelector(".toc-sidebar");

        // Detectar transición de modo edición a modo lectura
        if (wasInEditMode && !currentEditMode) {
            console.log("Saliendo del modo edición - Regenerando TOC");
            // Pequeño delay para asegurar que el DOM esté actualizado
            setTimeout(() => {
                updateTOC(true); // Forzar regeneración
            }, 100);
        } else if (sidebarWrapper) {
            if (currentEditMode) {
                sidebarWrapper.style.display = 'none';
                console.log("Modo edición detectado - TOC oculta");
            } else {
                sidebarWrapper.style.display = 'block';
                console.log("Modo lectura detectado - TOC visible");
            }
        }

        // Actualizar el estado anterior
        wasInEditMode = currentEditMode;
    }

    /**
     * Actualiza la TOC en el sidebar e inicializa el scrollspy.
     * @param {boolean} forceRegenerate - Forzar regeneración completa de la TOC
     */
    function updateTOC(forceRegenerate = false) {
        // Limpiar observer anterior si existe
        if (scrollspyObserver) {
            scrollspyObserver.disconnect();
            scrollspyObserver = null;
        }

        // Verificar si estamos en modo edición
        if (isInEditMode() && !forceRegenerate) {
            const sidebarWrapper = document.querySelector(".toc-sidebar");
            if (sidebarWrapper) {
                sidebarWrapper.style.display = 'none';
            }
            console.log("En modo edición - TOC no se genera/muestra");
            return;
        }

        const viewContainer = document.querySelector(".wiki-view-container");
        let sidebarWrapper = document.querySelector(".toc-sidebar");

        if (!sidebarWrapper && viewContainer) {
            sidebarWrapper = document.createElement("div");
            sidebarWrapper.className = "toc-sidebar";
            viewContainer.parentNode.insertBefore(sidebarWrapper, viewContainer.nextSibling);
        }

        if (sidebarWrapper) {
            sidebarWrapper.innerHTML = '';
            sidebarWrapper.style.display = 'block'; // Asegurar que esté visible en modo lectura
        }

        const toc = generateTOC();

        if (toc && sidebarWrapper) {
            sidebarWrapper.appendChild(toc);
            initializeScrollspy();
            console.log("TOC generada y colocada en el sidebar con scrollspy mejorado.");
        } else if (sidebarWrapper) {
            const message = document.createElement("div");
            message.className = "toc-missing-message";
            message.textContent = "Agregue encabezados al código markdown para generar la TOC.";
            sidebarWrapper.appendChild(message);
            console.log("No se encontraron encabezados, mensaje mostrado en el sidebar.");
        }
    }

    /**
     * Inicializa el scrollspy mejorado usando Intersection Observer.
     */
    function initializeScrollspy() {
        const headings = document.querySelectorAll(".markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6");
        const tocLinks = document.querySelectorAll(".toc-sidebar .toc-container a");

        if (!headings.length || !tocLinks.length) return;

        // Mapa para tracking rápido
        const headingsMap = new Map();
        const visibleHeadings = new Set();

        headings.forEach(heading => {
            if (heading.id) {
                headingsMap.set(heading.id, heading);
            }
        });

        /**
         * Actualiza el item activo en la TOC basándose en el scroll position
         */
        function updateActiveItem() {
            // Limpiar timeout anterior si existe
            clearTimeout(scrollTimeout);

            scrollTimeout = setTimeout(() => {
                const scrollPosition = window.scrollY || document.documentElement.scrollTop;
                const windowHeight = window.innerHeight;
                let activeHeading = null;
                let minDistance = Infinity;

                // Encontrar el encabezado más cercano al top del viewport
                headings.forEach(heading => {
                    const rect = heading.getBoundingClientRect();
                    const absoluteTop = rect.top + scrollPosition;

                    // Considerar encabezados que están por encima del punto medio del viewport
                    // o que son el último visible antes del scroll actual
                    if (absoluteTop <= scrollPosition + (windowHeight * 0.3)) {
                        const distance = Math.abs(scrollPosition - absoluteTop);
                        if (distance < minDistance) {
                            minDistance = distance;
                            activeHeading = heading;
                        }
                    }
                });

                // Si no hay encabezado activo y estamos cerca del top, activar el primero
                if (!activeHeading && scrollPosition < 100 && headings.length > 0) {
                    activeHeading = headings[0];
                }

                // Actualizar clases active
                tocLinks.forEach(link => {
                    const linkId = link.getAttribute("href").substring(1);
                    const li = link.parentElement;

                    if (activeHeading && linkId === activeHeading.id) {
                        li.classList.add("active");
                        // Asegurar que el item activo sea visible en la TOC si hay scroll
                        ensureTocItemVisible(link);
                    } else {
                        li.classList.remove("active");
                    }
                });
            }, 10); // Pequeño debounce para mejorar performance
        }

        /**
         * Asegura que el item activo de la TOC sea visible si la TOC tiene scroll
         */
        function ensureTocItemVisible(link) {
            const tocContainer = document.querySelector(".toc-container");
            if (tocContainer && tocContainer.scrollHeight > tocContainer.clientHeight) {
                const linkRect = link.getBoundingClientRect();
                const containerRect = tocContainer.getBoundingClientRect();

                if (linkRect.top < containerRect.top || linkRect.bottom > containerRect.bottom) {
                    link.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
                }
            }
        }

        // Configurar Intersection Observer como respaldo y para detección inicial
        scrollspyObserver = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    visibleHeadings.add(entry.target.id);
                } else {
                    visibleHeadings.delete(entry.target.id);
                }
            });

            // Actualizar basándose en los cambios de visibilidad
            updateActiveItem();
        }, {
            root: null,
            rootMargin: '-20% 0px -70% 0px', // Zona activa en el 30% superior del viewport
            threshold: [0, 0.25, 0.5, 0.75, 1] // Múltiples umbrales para mejor precisión
        });

        headings.forEach(heading => {
            if (heading.id) {
                scrollspyObserver.observe(heading);
            }
        });

        // Agregar listener de scroll para actualización más precisa
        let scrollListener = () => updateActiveItem();
        window.addEventListener('scroll', scrollListener, { passive: true });

        // Guardar referencia para poder limpiar después
        window._tocScrollListener = scrollListener;

        // Actualización inicial
        updateActiveItem();
    }

    /**
     * Inicializa el observador para detectar cambios en el contenido de la wiki.
     */
    function initializeObserver() {
        const wikiContainer = document.querySelector(".wiki-view-container");

        if (wikiContainer) {
            let timeout;
            const debouncedUpdate = () => {
                clearTimeout(timeout);
                timeout = setTimeout(() => {
                    // Limpiar listener de scroll anterior si existe
                    if (window._tocScrollListener) {
                        window.removeEventListener('scroll', window._tocScrollListener);
                        window._tocScrollListener = null;
                    }
                    updateTOC();
                    updateSidebarVisibility(); // Verificar visibilidad después de actualizar
                }, 300);
            };

            const observer = new MutationObserver(debouncedUpdate);

            observer.observe(wikiContainer, {
                childList: true,
                subtree: true,
                attributes: false, // Evitar actualizaciones innecesarias
                characterData: false
            });

            // Observador adicional para detectar cambios en el modo de edición
            const bodyObserver = new MutationObserver(() => {
                updateSidebarVisibility();
            });

            bodyObserver.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: false,
                characterData: false
            });

            console.log("Observador de Wiki inicializado con detección de modo edición.");
        } else {
            setTimeout(initializeObserver, 500);
        }
    }

    // Limpiar recursos cuando la página se descarga
    window.addEventListener('beforeunload', () => {
        if (scrollspyObserver) {
            scrollspyObserver.disconnect();
        }
        if (window._tocScrollListener) {
            window.removeEventListener('scroll', window._tocScrollListener);
        }
    });

    // Ejecutar al cargar la página
    setTimeout(() => {
        // Establecer el estado inicial
        wasInEditMode = isInEditMode();
        updateTOC();
        updateSidebarVisibility();
    }, 500);
    initializeObserver();
})();