Greasy Fork

Settings Tab Manager (STM)

Provides an API for userscripts to add tabs to a site's settings menu, with improved state handling.

目前为 2025-04-23 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.cloud/scripts/533630/1575940/Settings%20Tab%20Manager%20%28STM%29.js

// ==UserScript==
// @name         Settings Tab Manager (STM)
// @namespace    shared-settings-manager
// @version      1.1.4
// @description  Provides an API for userscripts to add tabs to a site's settings menu, with improved state handling.
// @author       Gemini & User Input
// @license      MIT
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Keep Constants, State (isSeparatorAdded etc.), Promise, publicApi, Styling the same ---
    const MANAGER_ID = 'SettingsTabManager';
    const log = (...args) => console.log(`[${MANAGER_ID}]`, ...args);
    const warn = (...args) => console.warn(`[${MANAGER_ID}]`, ...args);
    const error = (...args) => console.error(`[${MANAGER_ID}]`, ...args);

    const SELECTORS = Object.freeze({ /* ... same ... */ });
    const ACTIVE_CLASSES = Object.freeze({ /* ... same ... */ });
    const ATTRS = Object.freeze({ /* ... same ... */ });

    let isInitialized = false;
    let settingsMenuEl = null;
    let tabContainerEl = null;
    let panelContainerEl = null;
    let activeStmTabId = null; // Renamed for clarity: Tracks the ID of the *STM* tab that is active
    const registeredTabs = new Map();
    const pendingRegistrations = [];
    let isSeparatorAdded = false;

    let resolveReadyPromise;
    const readyPromise = new Promise(resolve => { resolveReadyPromise = resolve; });

    const publicApi = Object.freeze({ /* ... same ... */ });

    GM_addStyle(`/* ... same styles ... */`);

    // --- Core Logic Implementation Functions ---

    function findSettingsElements() { /* ... same ... */ }

    /**
     * Deactivates the STM tab specified by the scriptId.
     * Removes active classes and calls the onDeactivate callback.
     * Does NOT change activeStmTabId itself.
     * @param {string} scriptId The ID of the STM tab to deactivate visuals/callbacks for.
     */
    function _deactivateStmTabVisualsAndCallback(scriptId) {
        if (!scriptId) return; // Nothing to deactivate

        const config = registeredTabs.get(scriptId);
        // Don't warn if config not found, might be called defensively
        // if (!config) { warn(`Config not found for tab ID during deactivation: ${scriptId}`); }

        const tab = getTabElementImpl(scriptId);
        const panel = getPanelElementImpl(scriptId);

        if (tab) tab.classList.remove(ACTIVE_CLASSES.TAB);
        // else { warn(`Could not find tab element for ${scriptId} during deactivation.`); }

        if (panel) {
            panel.classList.remove(ACTIVE_CLASSES.PANEL);
            panel.style.display = 'none';
        }
        // else { warn(`Could not find panel element for ${scriptId} during deactivation.`); }

        // Call the script's deactivate hook if config exists
        if (config) {
            try {
                config.onDeactivate?.(panel, tab);
            } catch (e) {
                error(`Error during onDeactivate for ${scriptId}:`, e);
            }
        }
    }

    /**
     * Activates the STM tab specified by the scriptId.
     * Adds active classes, ensures panel display, and calls the onActivate callback.
     * Does NOT change activeStmTabId itself.
     * Does NOT deactivate other tabs (STM or native).
     * @param {string} scriptId The ID of the STM tab to activate visuals/callbacks for.
     */
    function _activateStmTabVisualsAndCallback(scriptId) {
        const config = registeredTabs.get(scriptId);
        if (!config) {
            error(`Cannot activate tab: ${scriptId}. Config not found.`);
            return;
        }

        const tab = getTabElementImpl(scriptId);
        const panel = getPanelElementImpl(scriptId);

        if (!tab || !panel) {
            error(`Cannot activate tab: ${scriptId}. Tab or Panel element not found.`);
            return;
        }

        // Activate the new STM tab/panel
        tab.classList.add(ACTIVE_CLASSES.TAB);
        panel.classList.add(ACTIVE_CLASSES.PANEL);
        panel.style.display = 'block';

        // Call the script's activation hook
        try {
            config.onActivate?.(panel, tab);
        } catch (e) {
            error(`Error during onActivate for ${scriptId}:`, e);
            // Consider reverting visual activation on error? Maybe too complex.
        }
    }

    /** Handles clicks within the tab container to switch tabs. */
    function handleTabClick(event) {
        const clickedTabElement = event.target.closest(SELECTORS.SITE_TAB); // Get the clicked tab element
        if (!clickedTabElement) return; // Clicked outside any tab

        const isStmTab = clickedTabElement.matches(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`);
        const clickedStmScriptId = isStmTab ? clickedTabElement.getAttribute(ATTRS.SCRIPT_ID) : null;

        // --- Case 1: Clicked an STM Tab ---
        if (isStmTab && clickedStmScriptId) {
            event.stopPropagation(); // Prevent site handler from running

            if (clickedStmScriptId === activeStmTabId) {
                // log(`Clicked already active STM tab: ${clickedStmScriptId}`);
                return; // Do nothing
            }

            // --- Deactivate previous tab (if any) ---
            const previousActiveStmId = activeStmTabId;
            if (previousActiveStmId) {
                _deactivateStmTabVisualsAndCallback(previousActiveStmId);
            } else {
                // If no STM tab was active, ensure any *native* tab is visually deactivated
                panelContainerEl?.querySelectorAll(`:scope > ${SELECTORS.SITE_PANEL}.${ACTIVE_CLASSES.PANEL}:not([${ATTRS.MANAGED}])`)
                    .forEach(p => p.classList.remove(ACTIVE_CLASSES.PANEL));
                tabContainerEl?.querySelectorAll(`:scope > ${SELECTORS.SITE_TAB}.${ACTIVE_CLASSES.TAB}:not([${ATTRS.MANAGED}])`)
                    .forEach(t => t.classList.remove(ACTIVE_CLASSES.TAB));
            }

            // --- Activate the clicked STM tab ---
            _activateStmTabVisualsAndCallback(clickedStmScriptId);
            activeStmTabId = clickedStmScriptId; // Update the state *after* successful activation steps

            return; // Handled
        }

        // --- Case 2: Clicked a Native Site Tab ---
        if (!isStmTab && clickedTabElement.matches(`${SELECTORS.SITE_TAB}:not([${ATTRS.MANAGED}])`)) {
            // log(`Native site tab clicked.`);

            // If an STM tab was active, deactivate it visually and clear STM state.
            if (activeStmTabId) {
                _deactivateStmTabVisualsAndCallback(activeStmTabId);
                activeStmTabId = null; // Clear STM state
            }

            // **Allow propagation** - Let the site's own click handler manage activating the native tab.
            return;
        }

        // --- Case 3: Clicked the STM Separator ---
        if (clickedTabElement.matches(`span[${ATTRS.SEPARATOR}]`)) {
            event.stopPropagation(); // Do nothing, prevent site handler
            return;
        }
    }


    function attachTabClickListener() { /* ... same ... */ }
    function createSeparator() { /* ... same ... */ }
    function createTabAndPanel(config) { /* ... same ... */ }
    function processPendingRegistrations() { /* ... same, ensure sorting ... */ }
    function initializeManager() { /* ... same ... */ }
    const observer = new MutationObserver(/* ... same observer logic ... */);
    // observer.observe(...)
    // setTimeout(initializeManager, 0);

    // --- API Implementation Functions ---
    function registerTabImpl(config) { /* ... same validation, sorting pending queue ... */ }

    /** Public API function to programmatically activate a registered tab. */
    function activateTabImpl(scriptId) {
        if (typeof scriptId !== 'string' || !scriptId.trim()) {
            error('activateTab failed: Invalid scriptId provided.'); return;
        }
        if (!isInitialized) {
            warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`); return;
        }
        if (!registeredTabs.has(scriptId)) {
            error(`activateTab failed: Script ID "${scriptId}" is not registered.`); return;
        }
        if (scriptId === activeStmTabId) {
            log(`activateTab: Tab ${scriptId} is already active.`); return; // Already active
        }

        // --- Deactivate previous tab (if any) ---
        const previousActiveStmId = activeStmTabId;
        if (previousActiveStmId) {
            _deactivateStmTabVisualsAndCallback(previousActiveStmId);
        } else {
            // Clear any active native tab visuals
            panelContainerEl?.querySelectorAll(`:scope > ${SELECTORS.SITE_PANEL}.${ACTIVE_CLASSES.PANEL}:not([${ATTRS.MANAGED}])`).forEach(p => p.classList.remove(ACTIVE_CLASSES.PANEL));
            tabContainerEl?.querySelectorAll(`:scope > ${SELECTORS.SITE_TAB}.${ACTIVE_CLASSES.TAB}:not([${ATTRS.MANAGED}])`).forEach(t => t.classList.remove(ACTIVE_CLASSES.TAB));
        }

        // --- Activate the requested STM tab ---
        _activateStmTabVisualsAndCallback(scriptId);
        activeStmTabId = scriptId; // Update the state

        log(`Programmatically activated tab: ${scriptId}`);
    }

    function getPanelElementImpl(scriptId) { /* ... same ... */ }
    function getTabElementImpl(scriptId) { /* ... same ... */ }

    // --- Global Exposure ---
    // ... same ...
})();