ADO Wiki Expanded Pages Path

Shows expanded pages path and vertical line indicator in Azure DevOps Wiki

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         ADO Wiki Expanded Pages Path
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Shows expanded pages path and vertical line indicator in Azure DevOps Wiki
// @author       You
// @match        https://dev.azure.com/*/_wiki/wikis/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const PATH_CONTAINER_ID = 'wiki-expanded-path-container';
    const VERTICAL_LINE_CLASS = 'wiki-expanded-vertical-line';
    const ANCHOR_PAGE_NAME = 'Service Enablement'; // Hardcoded anchor
    const TOGGLE_BTN_ID = 'wiki-sidebar-toggle-btn';
    let updateTimeout = null;
    let savedSidebarWidth = null;
    let isSidebarCollapsed = false;
    const SIDEBAR_STATE_KEY = 'wiki-sidebar-collapsed';

    const LEVEL_COLORS = {
        1: { bright: '#0078d4', dim: 'rgba(0, 120, 212, 0.2)' },
        2: { bright: '#00bcf2', dim: 'rgba(0, 188, 242, 0.2)' },
        3: { bright: '#00b294', dim: 'rgba(0, 178, 148, 0.2)' },
        4: { bright: '#8cbd18', dim: 'rgba(139, 189, 24, 0.2)' },
        5: { bright: '#f7630c', dim: 'rgba(247, 99, 12, 0.2)' },
        6: { bright: '#e81123', dim: 'rgba(232, 17, 35, 0.2)' },
        7: { bright: '#b146c2', dim: 'rgba(177, 70, 194, 0.2)' },
        8: { bright: '#8764b8', dim: 'rgba(135, 100, 184, 0.2)' },
        9: { bright: '#744da9', dim: 'rgba(116, 77, 169, 0.2)' },
        10: { bright: '#5c2d91', dim: 'rgba(92, 45, 145, 0.2)' }
    };

    const styles = `
        /* Path breadcrumb styles */
        #${PATH_CONTAINER_ID} {
            background: linear-gradient(to right, #f3f2f1, #ffffff);
            border-bottom: 1px solid #e1dfdd;
            padding: 8px 12px;
            font-size: 12px;
            color: #323130;
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            gap: 4px;
            min-height: 32px;
            box-sizing: border-box;
        }
        #${PATH_CONTAINER_ID} .path-label {
            font-weight: 600;
            color: #605e5c;
            margin-right: 8px;
        }
        #${PATH_CONTAINER_ID} .path-item {
            display: inline-flex;
            align-items: center;
            background: #ffffff;
            border: 1px solid #e1dfdd;
            border-radius: 3px;
            padding: 2px 8px;
            color: #0078d4;
            cursor: pointer;
            transition: background 0.15s ease, border-color 0.15s ease;
        }
        #${PATH_CONTAINER_ID} .path-item:hover {
            background: #f3f2f1;
            border-color: #0078d4;
        }
        #${PATH_CONTAINER_ID} .path-item.selected {
            background: #0078d4;
            color: #ffffff;
            border-color: #0078d4;
            font-weight: 600;
        }
        #${PATH_CONTAINER_ID} .path-separator {
            color: #a19f9d;
            font-size: 10px;
        }
        #${PATH_CONTAINER_ID}.empty-path::before {
            content: 'No expanded pages';
            color: #a19f9d;
            font-style: italic;
        }

        /* Vertical line styles */
        .${VERTICAL_LINE_CLASS} {
            position: absolute;
            bottom: 0;
            width: 2px;
            pointer-events: none;
            z-index: 1;
        }

        /* Make sure rows have relative positioning for the line */
        tr.bolt-tree-row {
            position: relative;
        }

        /* Sidebar collapse/expand toggle button */
        #${TOGGLE_BTN_ID} {
            position: absolute;
            top: 50%;
            right: -14px;
            transform: translateY(-50%);
            width: 14px;
            height: 40px;
            background: #f3f2f1;
            border: 1px solid #e1dfdd;
            border-left: none;
            border-radius: 0 4px 4px 0;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 1000;
            padding: 0;
            transition: background 0.15s ease;
            color: #605e5c;
            font-size: 10px;
            line-height: 1;
        }
        #${TOGGLE_BTN_ID}:hover {
            background: #e1dfdd;
            color: #0078d4;
        }
        #${TOGGLE_BTN_ID} .toggle-icon {
            display: block;
            pointer-events: none;
        }

        /* Sidebar wrapper for positioning the toggle */
        .wiki-sidebar-wrapper {
            position: relative;
        }

        /* Smooth transition for sidebar collapse */
        .wiki-sidebar-collapsible {
            transition: width 0.2s ease, min-width 0.2s ease;
            overflow: hidden;
        }
        .wiki-sidebar-collapsed {
            width: 0px !important;
            min-width: 0px !important;
            overflow: hidden !important;
        }
        .wiki-sidebar-collapsed > *:not(#${TOGGLE_BTN_ID}):not(.wiki-sidebar-toggle-anchor) {
            visibility: hidden;
        }
    `;

    function injectStyles() {
        if (document.getElementById('wiki-path-styles')) return;
        const style = document.createElement('style');
        style.id = 'wiki-path-styles';
        style.textContent = styles;
        document.head.appendChild(style);
    }

    function createPathContainer() {
        let container = document.getElementById(PATH_CONTAINER_ID);
        if (container) return container;

        container = document.createElement('div');
        container.id = PATH_CONTAINER_ID;

        const treePane = document.querySelector('.wiki-tree-pane');
        if (treePane && treePane.parentNode) {
            treePane.parentNode.insertBefore(container, treePane);
        }
        return container;
    }

    function getSelectedRowIndex(rows) {
        for (let i = 0; i < rows.length; i++) {
            if (rows[i].classList.contains('selected')) {
                return i;
            }
        }
        return -1;
    }

    function getExpandedPath() {
        const pages = [];
        const rows = document.querySelectorAll('tr.bolt-tree-row');

        for (const row of rows) {
            const isExpanded = row.getAttribute('aria-expanded') === 'true';
            const isSelected = row.classList.contains('selected');

            if (!isExpanded && !isSelected) continue;

            const level = parseInt(row.getAttribute('aria-level'), 10);
            const nameEl = row.querySelector('.tree-name-cell.tree-name-text');
            if (!nameEl) continue;

            pages.push({
                name: nameEl.textContent.trim(),
                level,
                isSelected,
                isExpanded,
                element: row
            });
        }

        pages.sort((a, b) => a.level - b.level);
        const path = [];

        for (const page of pages) {
            while (path.length > 0 && path[path.length - 1].level >= page.level) {
                path.pop();
            }
            path.push(page);
        }

        return path;
    }

    function clearVerticalLines() {
        document.querySelectorAll(`.${VERTICAL_LINE_CLASS}`).forEach(el => el.remove());
    }

    function getLineLeftPosition(level) {
        const baseOffset = 24;
        const levelOffset = 16;
        return baseOffset + (level - 1) * levelOffset;
    }

    function findAnchorLevel() {
        const rows = document.querySelectorAll('tr.bolt-tree-row');
        for (const row of rows) {
            const nameEl = row.querySelector('.tree-name-cell.tree-name-text');
            if (nameEl && nameEl.textContent.trim() === ANCHOR_PAGE_NAME) {
                const isExpanded = row.getAttribute('aria-expanded') === 'true';
                if (isExpanded) {
                    return parseInt(row.getAttribute('aria-level'), 10);
                }
            }
        }
        return -1; // Anchor not found or not expanded
    }

    function addVerticalLines() {
        clearVerticalLines();

        const rows = Array.from(document.querySelectorAll('tr.bolt-tree-row'));
        if (rows.length === 0) return;

        // Find the anchor level (Service Enablement)
        const anchorLevel = findAnchorLevel();

        // For each expanded row, draw lines down through its descendants
        rows.forEach((row, index) => {
            const isExpanded = row.getAttribute('aria-expanded') === 'true';
            if (!isExpanded) return;

            const level = parseInt(row.getAttribute('aria-level'), 10);

            // Find last descendant
            let lastDescendantIndex = index;
            for (let i = index + 1; i < rows.length; i++) {
                const childLevel = parseInt(rows[i].getAttribute('aria-level'), 10);
                if (childLevel <= level) break;
                lastDescendantIndex = i;
            }

            // Determine if this ENTIRE line should be dimmed:
            // If anchor is found and expanded:
            //   - Levels <= anchor level → dimmed (parents/ancestors + anchor itself)
            //   - Levels > anchor level → bright (descendants only)
            // If anchor is not found/expanded, everything is bright
            const shouldDim = anchorLevel >= 0 && level <= anchorLevel;

            const colors = LEVEL_COLORS[level] || LEVEL_COLORS[10];
            const lineColor = shouldDim ? colors.dim : colors.bright;

            // Add line to each row from this expanded row to its last descendant
            for (let i = index; i <= lastDescendantIndex; i++) {
                const targetRow = rows[i];
                const isFirstRow = (i === index);

                // Check if line for this level already exists
                if (targetRow.querySelector(`.${VERTICAL_LINE_CLASS}[data-level="${level}"]`)) {
                    continue;
                }

                const line = document.createElement('div');
                line.className = VERTICAL_LINE_CLASS;
                line.setAttribute('data-level', level);
                line.style.left = `${getLineLeftPosition(level)}px`;
                line.style.background = lineColor;

                if (isFirstRow) {
                    line.style.top = '50%';
                    line.style.bottom = '0';
                } else {
                    line.style.top = '0';
                    line.style.bottom = '0';
                }

                targetRow.style.position = 'relative';
                targetRow.appendChild(line);
            }
        });
    }

    function updatePathDisplay() {
        const container = createPathContainer();
        if (!container) return;

        const pages = getExpandedPath();
        container.innerHTML = '';
        container.classList.toggle('empty-path', pages.length === 0);

        if (pages.length === 0) return;

        const label = document.createElement('span');
        label.className = 'path-label';
        label.textContent = '📂 Path:';
        container.appendChild(label);

        pages.forEach((page, i) => {
            if (i > 0) {
                const sep = document.createElement('span');
                sep.className = 'path-separator';
                sep.textContent = ' › ';
                container.appendChild(sep);
            }

            const item = document.createElement('span');
            item.className = 'path-item' + (page.isSelected ? ' selected' : '');
            item.textContent = page.name;
            item.title = `Level ${page.level}: ${page.name}`;
            item.onclick = () => page.element.click();
            container.appendChild(item);
        });
    }

    function updateAll() {
        updatePathDisplay();
        addVerticalLines();
    }

    function scheduleUpdate() {
        if (updateTimeout) return;
        updateTimeout = setTimeout(() => {
            updateTimeout = null;
            updateAll();
        }, 150);
    }

    function getSidebarElement() {
        // The sidebar is the splitter pane that contains .wiki-tree-pane
        const treePane = document.querySelector('.wiki-tree-pane');
        if (!treePane) return null;
        // Walk up to find the splitter's left pane (the resizable sidebar container)
        let el = treePane.closest('.bolt-splitter-main') || treePane.closest('[class*="splitter"]');
        if (el) return el;
        // Fallback: use the parent that has an explicit width style (the resizable pane)
        el = treePane.parentElement;
        while (el) {
            if (el.style && el.style.width) return el;
            if (el.classList && (el.classList.contains('left-hub-content') || el.classList.contains('wiki-nav-content'))) return el;
            el = el.parentElement;
        }
        // Last resort: direct parent of treePane
        return treePane.parentElement;
    }

    function createToggleButton() {
        if (document.getElementById(TOGGLE_BTN_ID)) return;

        const sidebar = getSidebarElement();
        if (!sidebar) return;

        // Ensure sidebar is positioned for the absolute toggle button
        const computedPos = window.getComputedStyle(sidebar).position;
        if (computedPos === 'static') {
            sidebar.style.position = 'relative';
        }

        const btn = document.createElement('button');
        btn.id = TOGGLE_BTN_ID;
        btn.title = 'Collapse sidebar';
        btn.innerHTML = '<span class="toggle-icon">◀</span>';
        btn.addEventListener('click', toggleSidebar);

        sidebar.appendChild(btn);
    }

    function toggleSidebar() {
        const sidebar = getSidebarElement();
        if (!sidebar) return;

        const btn = document.getElementById(TOGGLE_BTN_ID);
        const icon = btn ? btn.querySelector('.toggle-icon') : null;

        if (!isSidebarCollapsed) {
            // Collapse: save current width, then collapse
            savedSidebarWidth = sidebar.style.width || sidebar.getBoundingClientRect().width + 'px';
            sidebar.classList.add('wiki-sidebar-collapsible');

            // Force a reflow so the transition applies
            sidebar.getBoundingClientRect();
            sidebar.classList.add('wiki-sidebar-collapsed');

            // Handle the splitter gutter/handle if present
            const splitterGutter = sidebar.parentElement?.querySelector('.bolt-splitter-gutter, [class*="splitter-gutter"], [class*="handleBar"]');
            if (splitterGutter) {
                splitterGutter.style.display = 'none';
            }

            if (icon) icon.textContent = '▶';
            if (btn) btn.title = 'Expand sidebar';
            isSidebarCollapsed = true;
            localStorage.setItem(SIDEBAR_STATE_KEY, 'true');

            // Move button outside so it's still visible
            repositionToggleWhenCollapsed(sidebar, btn);
        } else {
            // Expand: restore saved width
            sidebar.classList.remove('wiki-sidebar-collapsed');
            if (savedSidebarWidth) {
                sidebar.style.width = savedSidebarWidth;
            }

            // Restore splitter gutter
            const splitterGutter = sidebar.parentElement?.querySelector('.bolt-splitter-gutter, [class*="splitter-gutter"], [class*="handleBar"]');
            if (splitterGutter) {
                splitterGutter.style.display = '';
            }

            if (icon) icon.textContent = '◀';
            if (btn) btn.title = 'Collapse sidebar';
            isSidebarCollapsed = false;
            localStorage.removeItem(SIDEBAR_STATE_KEY);

            // Move button back into sidebar
            repositionToggleWhenExpanded(sidebar, btn);

            // Remove transition class after animation finishes
            setTimeout(() => {
                sidebar.classList.remove('wiki-sidebar-collapsible');
            }, 250);

            // Re-render lines after expand
            setTimeout(updateAll, 300);
        }
    }

    function repositionToggleWhenCollapsed(sidebar, btn) {
        if (!btn) return;
        // Move button to the sidebar's parent so it remains visible when sidebar width is 0
        const parent = sidebar.parentElement;
        if (parent && btn.parentElement !== parent) {
            btn.remove();
            parent.style.position = 'relative';
            btn.style.position = 'absolute';
            btn.style.left = '0px';
            btn.style.right = 'auto';
            parent.insertBefore(btn, parent.firstChild);
        }
    }

    function repositionToggleWhenExpanded(sidebar, btn) {
        if (!btn) return;
        // Move button back into sidebar
        if (btn.parentElement !== sidebar) {
            btn.remove();
            btn.style.position = 'absolute';
            btn.style.left = '';
            btn.style.right = '-14px';
            sidebar.appendChild(btn);
        }
    }

    function init() {
        const treePane = document.querySelector('.wiki-tree-pane');
        if (!treePane) {
            setTimeout(init, 500);
            return;
        }

        injectStyles();
        createPathContainer();
        createToggleButton();

        // Restore sidebar collapsed state from previous session
        if (localStorage.getItem(SIDEBAR_STATE_KEY) === 'true') {
            toggleSidebar();
        }

        updateAll();

        treePane.addEventListener('click', scheduleUpdate);

        treePane.addEventListener('keydown', (e) => {
            if (['Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
                scheduleUpdate();
            }
        });

        let lastUrl = location.href;
        const urlObserver = new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                setTimeout(updateAll, 300);
            }
        });
        urlObserver.observe(document.body, { childList: true, subtree: true });

        const tableContainer = document.querySelector('.bolt-table-container');
        if (tableContainer) {
            let scrollTimeout = null;
            tableContainer.addEventListener('scroll', () => {
                if (scrollTimeout) clearTimeout(scrollTimeout);
                scrollTimeout = setTimeout(() => {
                    addVerticalLines();
                }, 100);
            });
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();