GitHub Project Filter Formatter (Responsive Button Inside)

Adds a responsive button inside and to the right of GitHub project filter input to format text as title:*XX*.

目前為 2025-06-03 提交的版本,檢視 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GitHub Project Filter Formatter (Responsive Button Inside)
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Adds a responsive button inside and to the right of GitHub project filter input to format text as title:*XX*.
// @author       xiaohaoxing
// @match        https://github.com/orgs/.*/projects/.*
// @match        https://github.com/*/*/projects/*
// @grant        none
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const INPUT_ID = 'filter-bar-component-input';
    const BUTTON_ID = 'tampermonkey-format-title-button';
    const BUTTON_TEXT = '模糊';
    const WRAPPER_CLASS = 'tampermonkey-input-wrapper';
    const MIN_INPUT_TEXT_AREA_WIDTH = 50; // Minimum pixels for text before button (if button shown)

    let filterInput = null;
    let formatButton = null;
    let inputWrapper = null;
    let resizeObserver = null; // To observe wrapper/input size changes

    function createFormatButton() {
        const existingButton = document.getElementById(BUTTON_ID);
        if (existingButton) {
            return existingButton;
        }

        const button = document.createElement('button');
        button.id = BUTTON_ID;
        button.textContent = BUTTON_TEXT;
        button.type = 'button';

        button.style.position = 'absolute';
        button.style.top = '50%';
        button.style.right = '25px'; // Gap from the right edge of the wrapper
        button.style.transform = 'translateY(-50%)';
        button.style.zIndex = '10';
        button.style.padding = '2px 8px';
        button.style.fontSize = '12px';
        button.style.height = 'calc(100% - 8px)'; // Fit nicely, allowing 4px top/bottom margin in wrapper
        button.style.maxHeight = '26px'; // Max height
        button.style.lineHeight = '1';
        button.style.display = 'inline-flex';
        button.style.alignItems = 'center';
        button.style.justifyContent = 'center';
        button.style.border = '1px solid var(--color-btn-border, #606771)';
        button.style.borderRadius = '4px';
        button.style.backgroundColor = 'var(--color-btn-bg, #f6f8fa)';
        button.style.color = 'var(--color-btn-text, #24292f)';
        button.style.cursor = 'pointer';
        button.style.fontWeight = '500';
        button.style.whiteSpace = 'nowrap'; // Prevent text wrapping in button

        button.addEventListener('mouseenter', () => {
            button.style.backgroundColor = 'var(--color-btn-hover-bg, #f3f4f6)';
            button.style.borderColor = 'var(--color-btn-hover-border, #505761)';
        });
        button.addEventListener('mouseleave', () => {
            button.style.backgroundColor = 'var(--color-btn-bg, #f6f8fa)';
            button.style.borderColor = 'var(--color-btn-border, #606771)';
        });

        button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            const currentDomInput = document.getElementById(INPUT_ID);
            if (!currentDomInput) return;
            filterInput = currentDomInput;

            if (filterInput.value.trim() !== '') {
                const originalValue = filterInput.value;
                const newValue = `title:*${originalValue}*`;
                const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set ||
                                    Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
                if (valueSetter) {
                    valueSetter.call(filterInput, newValue);
                } else {
                    filterInput.value = newValue;
                }
                const inputEvent = new Event('input', { bubbles: true, cancelable: true });
                filterInput.dispatchEvent(inputEvent);
                filterInput.focus();
            }
        });
        return button;
    }

    function adjustLayout() {
        if (!filterInput || !inputWrapper || !formatButton) return;

        // Ensure input fills the wrapper and has box-sizing: border-box
        filterInput.style.width = '100%';
        filterInput.style.boxSizing = 'border-box';

        // Temporarily show button to measure its width if it was hidden
        const wasButtonHidden = formatButton.style.display === 'none';
        if (wasButtonHidden) {
            formatButton.style.visibility = 'hidden'; // Keep it hidden but allow measurement
            formatButton.style.display = 'inline-flex';
        }

        const buttonActualWidth = formatButton.offsetWidth;

        if (wasButtonHidden) { // Restore original hidden state if needed
            formatButton.style.display = 'none';
            formatButton.style.visibility = 'visible';
        }

        const wrapperInnerWidth = inputWrapper.clientWidth -
                                 (parseFloat(getComputedStyle(inputWrapper).paddingLeft) || 0) -
                                 (parseFloat(getComputedStyle(inputWrapper).paddingRight) || 0);

        const spaceForButtonAndGap = buttonActualWidth + 10; // 5px on each side of button within input padding

        // Check if there's enough space for the button AND a minimum text area
        if (buttonActualWidth > 0 && (wrapperInnerWidth - spaceForButtonAndGap) >= MIN_INPUT_TEXT_AREA_WIDTH) {
            filterInput.style.paddingRight = `${spaceForButtonAndGap}px`;
            formatButton.style.display = 'inline-flex'; // Show button
        } else {
            // Not enough space, hide button and reset padding
            filterInput.style.paddingRight = '8px'; // Or original padding if you stored it
            formatButton.style.display = 'none';    // Hide button
        }
    }


    function setupInputAndButton() {
        const currentDomInput = document.getElementById(INPUT_ID);

        if (!currentDomInput) { // Input disappeared, cleanup
            if (resizeObserver && inputWrapper) {
                resizeObserver.unobserve(inputWrapper);
            }
            if (formatButton && formatButton.parentNode) formatButton.parentNode.removeChild(formatButton);
            if (inputWrapper && inputWrapper.parentNode && inputWrapper.classList.contains(WRAPPER_CLASS)) {
                const originalParent = inputWrapper.parentNode;
                if (filterInput && document.body.contains(filterInput) && filterInput.parentElement !== originalParent) {
                    originalParent.insertBefore(filterInput, inputWrapper); // Restore original input
                    filterInput.style.paddingRight = ''; // Reset padding
                    filterInput.style.width = '';      // Reset width
                }
                originalParent.removeChild(inputWrapper);
            }
            filterInput = null; inputWrapper = null; formatButton = null;
            return;
        }

        filterInput = currentDomInput;

        if (filterInput.parentElement && filterInput.parentElement.classList.contains(WRAPPER_CLASS)) {
            inputWrapper = filterInput.parentElement;
        } else {
            inputWrapper = document.createElement('div');
            inputWrapper.classList.add(WRAPPER_CLASS);
            inputWrapper.style.position = 'relative'; // For absolute positioned button

            // Mimic original input's display and allow it to take available space
            const originalInputComputedStyle = getComputedStyle(filterInput);
            inputWrapper.style.display = originalInputComputedStyle.display;
            // If the input was block or flex, it likely took full width of its context
            if (originalInputComputedStyle.display === 'block' || originalInputComputedStyle.display === 'flex') {
                 inputWrapper.style.width = '100%'; // Or copy originalInputComputedStyle.width if it was specific
            } else if (originalInputComputedStyle.display === 'inline-block') {
                // For inline-block, width might be content-based or explicitly set.
                // We let it be content-based unless original input had a specific width.
                if (originalInputComputedStyle.width !== 'auto' && !originalInputComputedStyle.width.includes('%')) {
                    inputWrapper.style.width = originalInputComputedStyle.width;
                }
            }
            // Handle case where input might be directly in a flex container
            if (filterInput.parentElement && getComputedStyle(filterInput.parentElement).display.includes('flex')) {
                inputWrapper.style.flexGrow = originalInputComputedStyle.flexGrow;
                inputWrapper.style.flexShrink = originalInputComputedStyle.flexShrink;
                inputWrapper.style.flexBasis = originalInputComputedStyle.flexBasis;
            }


            if (filterInput.parentNode) {
                filterInput.parentNode.insertBefore(inputWrapper, filterInput);
                inputWrapper.appendChild(filterInput);
            } else {
                console.warn("[Tampermonkey] Filter input has no parent, cannot wrap.");
                return;
            }
        }

        if (!formatButton || !inputWrapper.contains(formatButton)) {
            formatButton = createFormatButton();
            inputWrapper.appendChild(formatButton);
        }

        // Initial layout adjustment
        adjustLayout();

        // Observe wrapper for size changes to re-adjust layout
        if (typeof ResizeObserver !== 'undefined') {
            if (resizeObserver) { // Disconnect from old wrapper if any
                resizeObserver.disconnect();
            }
            resizeObserver = new ResizeObserver(entries => {
                // We are observing the wrapper, so direct call to adjustLayout
                requestAnimationFrame(adjustLayout); // Debounce with requestAnimationFrame
            });
            if (inputWrapper) {
                resizeObserver.observe(inputWrapper);
            }
        } else {
            // Fallback for browsers without ResizeObserver (less ideal)
            // window.addEventListener('resize', adjustLayout); // This is too broad
            // console.warn("[Tampermonkey] ResizeObserver not supported. Layout might not be perfectly responsive to container changes.");
        }
    }

    const mutationObserver = new MutationObserver(() => {
        const currentInput = document.getElementById(INPUT_ID);
        if (currentInput) {
            if (!inputWrapper || !inputWrapper.contains(currentInput) || !formatButton || !inputWrapper.contains(formatButton)) {
                setupInputAndButton();
            }
        } else if (filterInput) { // Input was there but now it's gone
            setupInputAndButton(); // This will trigger the cleanup logic
        }
    });

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

    setTimeout(setupInputAndButton, 1000);

})();