// ==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();
})();