您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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(); })();