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

})();