評論區摺疊小工具

在畫面右下角增加一工具箱,用以摺疊Discussion區塊中的comment卡片。

目前為 2025-10-14 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         評論區摺疊小工具
// @namespace    https://github.com/lavonzux/BetterAzureDevOps
// @version      v0.9.8-alpha
// @description  在畫面右下角增加一工具箱,用以摺疊Discussion區塊中的comment卡片。
// @author       Anthony.Mai
// @match        https://dev.azure.com/fubonfinance/SYS_GA/_workitems/edit*
// @icon         https://cdn.vsassets.io/content/icons/favicon.ico
// @grant        none
// @license      Apache License 2.0
// ==/UserScript==

// 工具盤預設打開
const TRAY_OPEN_BY_DEFAULT = true;
// 工具盤背景顏色
const TRAY_BACKGROUND_COLOR = `#adfe`;
// 工具箱開關按鈕顏色
const TRAY_TOGGLE_COLOR = `#f9a`;
// 工具箱按鈕文字顏色
const TOOL_BUTTON_TEXT_COLOR = 'white';
// 工具箱按鈕背景顏色
const TOOL_BUTTON_BG_COLOR = '#0078d4';
// 工具箱按鈕背景:hover顏色
const TOOL_BUTTON_BG_HOVER_COLOR = '#005a9e';

// 未回應評論的摺疊按鈕
const NOT_REACTED_COLLAPSE_BTN_CONTENT = '🔥';
// 已回應評論的摺疊按鈕
const REACTED_COLLAPSE_BTN_CONTENT = '↕️';

// 版面控制開關相關設定
// 切換速度
const SWITCH_TRANSITION_DURATION = `0.2s`;
// 開啟時背景顏色
const SWITCH_ON_BACKGROUND_COLOR = `2196F3`;
// 關閉時背景顏色
const SWITCH_OFF_BACKGROUND_COLOR = `f7581a`;



(function() {
    'use strict';
    const style = document.createElement('style');
    style.innerHTML = `
        :root {
            --tray-width: 28rem;
            --tray-height: 17rem;
            --corner-size: 2rem;

            /* CSS variables for the toggle switch */
            --switch-width: 4rem;
            --switch-height: 2rem;
            --switch-transition: ${SWITCH_TRANSITION_DURATION};
            --knob-gap: 4px;
        }

        /* CSS classes for my toolbox tray */
        .my-tray {
            background-color: ${TRAY_BACKGROUND_COLOR};
            position: absolute;
            bottom: 1rem;
            right: 1rem;

            display: grid;
            grid-template-columns: repeat(2, 1fr);
            grid-auto-rows: 3rem;
            gap: 0.25rem;

            border-radius: 0 1rem 1rem 1rem;
            padding: 0.5rem;
            transition: transform ease-in-out 0.4s;
            cursor: auto;
            width: var(--tray-width);
            height: var(--tray-height);

            border: 0px solid #333;
            overflow: hidden;
            transform-origin: bottom right;
            z-index: 1;
            box-sizing: border-box;
        }

        /* Tray collapsed state */
        .my-tray.my-tray-shrunk {
            overflow: hidden;
            width: var(--corner-size);
            height: var(--corner-size);
            animation: collapse 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
        }

        /* Tray expanded state */
        .my-tray.my-tray-expand {
            overflow: visible;
            border-radius: 1rem 1rem 0 1rem;
            width: var(--tray-width);
            height: var(--tray-height);
            animation: expand 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
        }

        /* Expand animation: Y-axis first, then X-axis */
        @keyframes expand {
          0% {
            width: var(--corner-size);
            height: var(--corner-size);
          }
          50% {
            width: var(--corner-size);
            height: var(--tray-height);
          }
          100% {
            width: var(--tray-width);
            height: var(--tray-height);
          }
        }

        /* Collapse animation: X-axis first, then Y-axis */
        @keyframes collapse {
          0% {
            width: var(--tray-width);
            height: var(--tray-height);
          }
          50% {
            width: var(--corner-size);
            height: var(--tray-height);
          }
          100% {
            width: var(--corner-size);
            height: var(--corner-size);
          }
        }


        /* CSS class for General tray item */
        .my-tray .tray-item {
          transition: transform 0.2s ease-in-out 0.4s;
          transform-origin: top left;
        }
        .my-tray.my-tray-shrunk .tray-item {
          transform: scale(0);
        }

        /* CSS class for different elements in the tray */
        .my-tray .tray-item.refresh-div {
          grid-column-start: 1;
          grid-column-end: 3;
        }
        .my-tray .tray-item.search-div {
          grid-column-start: 1;
          grid-column-end: 3;
          display: grid;
          grid-template-columns: 3fr 1fr;
          gap: 0.25rem;
        }
        .my-tray .tray-item.switch-div {
          grid-column-start: 1;
          grid-column-end: 3;
          display: grid;
          grid-template-columns: repeat(8, 1fr);
          gap: 0.25rem;
        }



        .my-tool-button{
          width: 100%;
          height: 100%;
          padding: 6px 12px;
          font-size: 1rem;
          border: 0;
          border-radius: 1rem;
          color: ${TOOL_BUTTON_TEXT_COLOR};
          cursor: pointer;
          transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out 0.4s;
          transform-origin: top left;
          background-color: ${TOOL_BUTTON_BG_COLOR};
          white-space: nowrap;
          display: flex;
          justify-content: center;
          align-items: center;
          overflow: hidden;
        }
        .my-tool-button:hover {
          background-color: ${TOOL_BUTTON_BG_HOVER_COLOR};
        }

        .my-tray-shrunk .my-tool-button {
          transform: scale(0);
        }

        .my-tooltip{
          position: relative;
          height: 100%;
        }
        .my-tooltip .my-tooltiptext {
          visibility: hidden;
          width: 150px;
          background-color: #000c;
          color: #fff;
          text-align: center;
          border-radius: 6px;
          padding: 5px 0;
          position: absolute;
          z-index: 1;
          bottom: 150%;
          left: 50%;
          margin-left: -75px;
        }
        .my-tooltip:hover .my-tooltiptext {
          visibility: visible;
        }

        /* CSS for the shrinking btn */
        .my-expand-button-div {
          position: sticky;
          top: 0.5rem;
          display: flex;
          justify-content: center;
        }
        .my-expand-button {
          border: none;
          background: none;
          font-size: 1.25rem;
          cursor: pointer;
        }

        /* CSS for shrinking */
        .my-shrinkable {
          transition: max-height 0.8s ease-in-out;
          max-height: 9999px;
          overflow: hidden;
        }
        .my-shrunk {
          max-height: 0px;
        }

        /* CSS for my searching tool */
        input.my-search-input {
          width: 100%;
          height: 100%;
          border: none;
          border-radius: 1rem;
          font-size: clamp(12px, 1.25rem, 24px);
          padding: 0;
          text-align: center;
        }




        .my-tray .my-tray-toggle {
            background-color: ${TRAY_TOGGLE_COLOR};
            position: absolute;
            width: var(--corner-size);
            height: var(--corner-size);
            bottom: 0;
            right: 0;
            border-radius: 0 1rem 1rem 1rem;
            cursor: pointer;
            z-index: 100;
            transition: border-radius ease-in-out 0.2s;
        }
        .my-tray-expand .my-tray-toggle {
            border-radius: 1rem 1rem 0 1rem;
        }
        .my-tray-shrunk .my-tooltip {
            transform: scale(0);
        }
        .my-tray div {
            transition: transform 0.4s ease-in-out 0.3s;
            transform-origin: top left;
        }

        .my-last-clicked {
          box-shadow: 0 0 1rem 0.5rem #009fffb0;
        }


        /* CSS for toggle switches */
        .my-switch {
          position: relative;
          display: inline-block;
          width: var(--switch-width);
          height: var(--switch-height);
        }

        .my-switch input {
          opacity: 0;
          width: 0;
          height: 0;
        }

        .my-slider {
          position: absolute;
          cursor: pointer;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background-color: #ccc;
          transition: var(--switch-transition);
          border-radius: var(--switch-height);
        }

        .my-slider:before {
          position: absolute;
          content: "";
          height: calc(var(--switch-height) - 2* var(--knob-gap));
          width: calc(var(--switch-height) - 2* var(--knob-gap));
          left: var(--knob-gap);
          bottom: var(--knob-gap);
          background-color: white;
          transition: var(--switch-transition);
          border-radius: 50%;
        }

        input:checked + .my-slider {
          background-color: #2196F3;
        }
        input:not(:checked) + .slider {
          background-color: #f7581a;
        }

        input:checked + .my-slider:before {
          transform: translateX(calc(var(--switch-width) - var(--switch-height)));
        }
    `;
    document.head.appendChild(style);


    let lastClickedComment = null;
    const observer = new MutationObserver((_record, observer) => {

        // Early return if the tray was already there
        const myTray = document.body.querySelector('div.my-tray');
        if (myTray) return;

        const tray = document.createElement('div');
        tray.classList.add('my-tray', 'my-tray-shrunk');
        document.body.appendChild(tray);
        if (TRAY_OPEN_BY_DEFAULT) toggleTray(tray);

        const trayToggle = document.createElement('div');
        trayToggle.classList.add('my-tray-toggle');
        trayToggle.addEventListener('click', (event) => {
            event.stopPropagation();
            toggleTray(event.target.parentNode);
        });
        tray.appendChild(trayToggle);

        tray.appendChild(wrapIntoTrayItem(createRefreshButton(), TRAY_ITEM_TYPE.REFRESH_DIV));
        tray.appendChild(wrapIntoTrayItem(createExpandAllButton()));
        tray.appendChild(wrapIntoTrayItem(createShrinkAllButton()));
        tray.appendChild(wrapIntoTrayItem(createExpandReactedButton()));
        tray.appendChild(wrapIntoTrayItem(createShrinkReactedButton()));
        tray.appendChild(createSearchTool());


        // [Test] Create switches
        const testSw = createSwitch((event) => {
            const checked = event.target.checked;
            switchWideLayout(checked);
        });
        const warppedSw = wrapIntoTooltip(testSw, '切換為左側寬版排版');
        const switchDiv = createSwitchDiv([warppedSw]);
        tray.appendChild(switchDiv);

    });

    // Function universally used
    function findCommentCards(type) {
        // If discussion section or comment cards are null, early return
        const discussionSection = document.querySelector('div.work-item-form-discussion div.work-item-form-collapsible-section-content');
        if (!discussionSection) return[];
        const commentCards = discussionSection.querySelectorAll('div.comment-item.displayed-comment');
        if (commentCards.length <= 0) return[];

        // Return all cards if no type specified
        if (!type) return commentCards;

        const groupByReacted = Object.groupBy(commentCards, card => card.querySelector('.reaction-statusbar-placeholder') !== null );
        if (type === 'reacted') return groupByReacted.true;
        if (type === 'notReacted') return groupByReacted.false;

        // Fallback if given type is invalid
        return commentCards;
    }

    /**
     * Find comment cards and group them into two groups by given predicate
     */
    function findCommentCardsByPredicate(groupingPredicate = (_card) => true) {
        // If discussion section or comment cards are null, early return
        const discussionSection = document.querySelector('div.work-item-form-discussion div.work-item-form-collapsible-section-content');
        if (!discussionSection) return[];
        const commentCards = discussionSection.querySelectorAll('div.comment-item.displayed-comment');
        if (commentCards.length <= 0) return[];

        return Object.groupBy(commentCards, (card) => groupingPredicate(card));
    }


    const GROUPIND_PREDICATE = Object.freeze({
        BY_STRING_IGNORE_CASE: (stringToFind) => (commentCard) => !commentCard.textContent.toLowerCase().includes(stringToFind?.trim() || ''),
        BY_REACTION_EXIST: (commentCard) => commentCard.querySelector('.reaction-statusbar-placeholder') !== null
    });

    function toggleTray(tray) {
        if (tray.classList.contains('my-tray-shrunk')) {
            tray.classList.remove('my-tray-shrunk');
            tray.classList.add('my-tray-expand');
        } else {
            tray.classList.remove('my-tray-expand');
            tray.classList.add('my-tray-shrunk');
        }
    }
    function toggleButtonCallback(controlledDivs, event) {
        lastClickedComment?.classList.remove('my-last-clicked');
        lastClickedComment = event.target.parentElement.parentElement.parentElement;
        lastClickedComment?.classList.add('my-last-clicked');

        controlledDivs.forEach(div => {
            div.classList.toggle('my-shrunk');
        });

        const card = event.target.parentElement.parentElement.parentElement;
        scrollToCommentCard(card);
    }
    function scrollToCommentCard(card) {
        const workItemContainer = document.querySelector('div.work-item-form-page-content.page-content.page-content-top');
        const offset = card.offsetTop - workItemContainer.offsetTop - 12;
        workItemContainer.scroll({top: offset, behavior: 'smooth'});
    };


    // Functions for creating my elements

    function createRefreshButton() {
        const refreshButton = createToolButtonDiv(
            '🔃 更新摺疊按鈕狀態',
            function () {
                event.stopPropagation();
                const groupByReacted = findCommentCardsByPredicate(GROUPIND_PREDICATE.BY_REACTION_EXIST);
                updateReactedCommentCards(groupByReacted.true);
                updateNotReactedCommentCards(groupByReacted.false);
            },
            '更新評論卡片中摺疊按鈕的狀態,初次載入頁面時建議等完全載入後再按'
        );
        refreshButton.classList.add('refresh');
        return refreshButton;
    }
    function createShrinkAllButton() {
        const btnDiv = createToolButtonDiv(
            '📁 全部摺疊',
            function () {
                event.stopPropagation();
                const allTrue = findCommentCardsByPredicate();
                shrinkByCondition(allTrue);
            },
            '摺疊全部評論卡片'
        );
        return btnDiv;
    }
    function createExpandAllButton() {
        const btnDiv = createToolButtonDiv(
            '📂 全部展開',
            function () {
                event.stopPropagation();
                const allFalse = findCommentCardsByPredicate(() => false);
                shrinkByCondition(allFalse);
            },
            '展開全部評論卡片'
        );
        return btnDiv;
    }
    function createShrinkReactedButton() {
        const btnDiv = createToolButtonDiv(
            '⏫ 關已回應',
            function () {
                event.stopPropagation();
                const predicate = GROUPIND_PREDICATE.BY_REACTION_EXIST;
                const groupedCommentCards = findCommentCardsByPredicate(predicate);
                shrinkByCondition(groupedCommentCards);
            },
            '摺疊已反應的評論卡'
        );
        return btnDiv;
    }
    function createExpandReactedButton() {
        const btnDiv = createToolButtonDiv(
            '⏬ 開已回應',
            function () {
                event.stopPropagation();
                const predicate = (card) => !(GROUPIND_PREDICATE.BY_REACTION_EXIST(card));
                const groupedCommentCards = findCommentCardsByPredicate(predicate);
                shrinkByCondition(groupedCommentCards);
            },
            '打開已反應的評論卡'
        );
        return btnDiv;
    }

    function createSearchTool() {
        const searchDiv = document.createElement('div');
        searchDiv.classList.add('tray-item', 'search-div');

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.placeholder = '輸入欲搜尋的文字';
        searchInput.classList.add('my-search-input');
        const searchTextBoxWithTooltip = wrapIntoTooltip(
            searchInput,
            '輸入欲搜尋的文字,會展開所有包含該文字的評論,並摺疊不包含該字串的評論。'
        );

        const searchBtn = createToolButton('🔍 搜尋', function() {
            event.stopPropagation();
            const targetString = document.querySelector('div.my-tray .tray-item.search-div input.my-search-input').value;
            const predicate = GROUPIND_PREDICATE.BY_STRING_IGNORE_CASE(targetString);
            const groupedCommentCards = findCommentCardsByPredicate(predicate);
            shrinkByCondition(groupedCommentCards);
        });

        searchDiv.appendChild(searchTextBoxWithTooltip);
        searchDiv.appendChild(searchBtn);

        return searchDiv;
    }
    function createSwitchDiv(switches) {
        const switchDiv = document.createElement('div');
        switchDiv.classList.add('tray-item', 'switch-div');
        switches.forEach(sw => {
            switchDiv.appendChild(sw);
        });
        return switchDiv;
    }
    function createSwitch(switchCallback) {
        const label = document.createElement('label');
        label.classList.add('my-switch');

        const checkbox = document.createElement('input');
        checkbox.setAttribute("type", "checkbox");
        checkbox.addEventListener('change', switchCallback);

        const slider = document.createElement('div');
        slider.classList.add('my-slider');

        label.appendChild(checkbox);
        label.appendChild(slider);

        return label;
    }

    // Function for creating tool buttons
    function createToolButtonDiv(btnText, btnCallback, tooltipText) {
        return wrapIntoTooltip(
            createToolButton(btnText, btnCallback),
            tooltipText
        );
    }
    function createToolButton(text, callback) {
        const btn = document.createElement('button');
        btn.innerText = text;
        btn.type = 'button';
        btn.addEventListener('click', callback);
        btn.classList.add('my-tool-button');
        return btn;
    }
    function createCollapseButton() {
        const btnDiv = document.createElement('div');
        btnDiv.classList.add('my-expand-button-div');

        const btn = document.createElement('button');
        btn.innerText = '↕️';
        btn.classList.add('my-expand-button');
        btnDiv.appendChild(btn);

        return btnDiv;
    }
    function createReactedCollapseButton(type) {
        const btnDiv = document.createElement('div');
        btnDiv.classList.add('my-expand-button-div');

        const btn = document.createElement('button');
        if (type && type === 'reacted') {
            btn.innerText = REACTED_COLLAPSE_BTN_CONTENT;
        } else {
            btn.innerText = NOT_REACTED_COLLAPSE_BTN_CONTENT;
        }

        btn.classList.add('my-expand-button');
        btnDiv.appendChild(btn);

        return btnDiv;
    }


    // Functions to wrap elements into Util elements

    function wrapIntoTooltip(node, tooltipText) {
        const tooltipDiv = document.createElement('div');
        tooltipDiv.classList.add('my-tooltip');
        tooltipDiv.appendChild(node);

        const tooltipSpan = document.createElement('span');
        tooltipSpan.innerText = tooltipText;
        tooltipSpan.classList.add('my-tooltiptext');

        tooltipDiv.appendChild(tooltipSpan);
        return tooltipDiv;
    }

    const TRAY_ITEM_TYPE = {
        REFRESH_DIV: 'refresh-div',
        SEARCH_DIV: 'search-div',
        SWITCH_DIV: 'switch-div'
    };
    function wrapIntoTrayItem(node, type) {
        const trayItem = document.createElement('div');
        trayItem.classList.add('tray-item');
        if (type) {
            trayItem.classList.add(type);
        }
        trayItem.appendChild(node);
        return trayItem;
    }

    // Functions for finding the first pure text div
    function isPureTextElement(node) {
        return node.nodeType === Node.TEXT_NODE
            || node.nodeType === Node.ELEMENT_NODE
            && !node.querySelector('img')
            && node.innerHTML !== '<br>';
    }


    // ========== ========== ========== ========== ========== ========== ==========
    // ========== ========== === Spec of each tool button === ========== ==========
    // ========== ========== ========== ========== ========== ========== ==========

    // Functions for the UPDATE tool button
    function updateReactedCommentCards(commentCards) {
        commentCards.forEach(node=> {
            node.querySelector('div.my-expand-button-div')?.remove(); // Remove existing button if found

            const contentDivs = node.querySelector('div.comment-content').childNodes;
            const shrinkableDivs = [];
            let noFirstPureTextNode = true;
            for (const contentDiv of contentDivs) {
                if (noFirstPureTextNode && isPureTextElement(contentDiv)) {
                    noFirstPureTextNode = false;
                    continue;
                }
                contentDiv.classList.add('my-shrinkable', 'my-shrunk');
                shrinkableDivs.push(contentDiv);
            }
            if (noFirstPureTextNode) shrinkableDivs.shift(); // Remove the first one if really no any pure text div

            // Append the shrink fold button
            const toggleButton = createReactedCollapseButton('reacted').cloneNode('deep');
            toggleButton.addEventListener('click', (event) => toggleButtonCallback(shrinkableDivs, event));
            node.querySelector('div.comment-item-left').appendChild(toggleButton);
        });
    };
    function updateNotReactedCommentCards(commentCards) {
        commentCards.forEach(node=> {
            node.querySelector('div.my-expand-button-div')?.remove(); // Remove existing button if found

            const contentDivs = node.querySelector('div.comment-content').childNodes;
            const shrinkableDivs = [];
            let noFirstPureTextNode = true;
            for (const contentDiv of contentDivs) {
                if (noFirstPureTextNode && isPureTextElement(contentDiv)) {
                    noFirstPureTextNode = false;
                    continue;
                }
                contentDiv.classList.add('my-shrinkable');
                shrinkableDivs.push(contentDiv);
            }
            if (noFirstPureTextNode) shrinkableDivs.shift(); // Remove the first one if really no any pure text div

            // Append the shrink fold button
            const toggleButton = createReactedCollapseButton().cloneNode('deep');
            toggleButton.addEventListener('click', (event) => toggleButtonCallback(shrinkableDivs, event));
            node.querySelector('div.comment-item-left').appendChild(toggleButton);
        });
    };


    function shrinkByCondition(commentCardsGroupByTrueFalse) {
        for (const truthyCard of commentCardsGroupByTrueFalse.true ?? []) {
            const shrinkableDivs = truthyCard.querySelectorAll('.my-shrinkable');
            shrinkableDivs.forEach(d => d.classList.add('my-shrunk'));
        }
        for (const falsyCard of commentCardsGroupByTrueFalse.false ?? []) {
            const shrinkableDivs = falsyCard.querySelectorAll('.my-shrinkable');
            shrinkableDivs.forEach(d => d.classList.remove('my-shrunk'));
        }

    }

    function switchWideLayout(setToWide = true) {
        const gridContainer = document.querySelector('div.work-item-grid.first-column-wide');
        const rightSection = document.querySelector('div.work-item-form-right');
        if (!gridContainer || !rightSection) return;

        if (setToWide) {
            document.querySelector('div.work-item-grid.first-column-wide').style.gridTemplateColumns = '5fr 2fr';
            document.querySelector('div.work-item-form-right').style.gridArea = '1/2/2/3';
        } else {
            document.querySelector('div.work-item-grid.first-column-wide').style.gridTemplateColumns = null;
            document.querySelector('div.work-item-form-right').style.gridArea = null;
        }

    }

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();