Settings Tab Manager (STM)

Provides an API for other userscripts to add tabs to a site's settings menu.

目前为 2025-04-22 提交的版本,查看 最新版本

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

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

(function() {
    'use strict';

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

    // --- Configuration ---
    const SELECTORS = Object.freeze({
        SETTINGS_MENU: '#settingsMenu',
        TAB_CONTAINER: '#settingsMenu .floatingContainer > div:first-child',
        PANEL_CONTAINER: '#settingsMenu .menuContentPanel',
        SITE_TAB: '.settingsTab',
        SITE_PANEL: '.panelContents',
        SITE_SEPARATOR: '.settingsTabSeparator',
    });
    const ACTIVE_CLASSES = Object.freeze({
        TAB: 'selectedTab',
        PANEL: 'selectedPanel',
    });
    const ATTRS = Object.freeze({
        SCRIPT_ID: 'data-stm-script-id',
        MANAGED: 'data-stm-managed',
    });

    // --- State ---
    let isInitialized = false;
    let settingsMenuEl = null;
    let tabContainerEl = null;
    let panelContainerEl = null;
    let activeTabId = null;
    const registeredTabs = new Map();
    const pendingRegistrations = [];

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

    // --- Public API Definition (MOVED EARLIER) ---
    // Define the API object that will be exposed and resolved by the promise.
    // Functions it references must be defined *before* they are called by client scripts,
    // but the functions themselves can be defined later in this script, thanks to hoisting.
    const publicApi = Object.freeze({
        ready: readyPromise,
        registerTab: (config) => {
            // Implementation uses functions defined later (registerTabImpl)
            return registerTabImpl(config);
        },
        activateTab: (scriptId) => {
            // Implementation uses functions defined later (activateTabImpl)
            activateTabImpl(scriptId);
        },
        getPanelElement: (scriptId) => {
            // Implementation uses functions defined later (getPanelElementImpl)
            return getPanelElementImpl(scriptId);
        },
        getTabElement: (scriptId) => {
            // Implementation uses functions defined later (getTabElementImpl)
            return getTabElementImpl(scriptId);
        }
    });

    // --- Styling ---
    GM_addStyle(`
        /* Ensure panels added by STM behave like native ones */
        ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}] {
            display: none; /* Hide inactive panels */
        }
        ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}].${ACTIVE_CLASSES.PANEL} {
            display: block; /* Show active panel */
        }
        /* Optional: Basic styling for the added tabs */
        ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.MANAGED}] {
           cursor: pointer;
        }
    `);

    // --- Core Logic Implementation Functions ---
    // (Functions like findSettingsElements, deactivateCurrentTab, activateTab, handleTabClick, etc.)

    /** Finds the essential DOM elements for the settings UI. Returns true if all found. */
    function findSettingsElements() {
        settingsMenuEl = document.querySelector(SELECTORS.SETTINGS_MENU);
        if (!settingsMenuEl) return false;

        tabContainerEl = settingsMenuEl.querySelector(SELECTORS.TAB_CONTAINER);
        panelContainerEl = settingsMenuEl.querySelector(SELECTORS.PANEL_CONTAINER);

        if (!tabContainerEl) {
            warn('Tab container not found within settings menu using selector:', SELECTORS.TAB_CONTAINER);
            return false;
        }
        if (!panelContainerEl) {
            warn('Panel container not found within settings menu using selector:', SELECTORS.PANEL_CONTAINER);
            return false;
        }
        return true;
    }

    /** Deactivates the currently active STM tab (if any). */
    function deactivateCurrentTab() {
        if (activeTabId && registeredTabs.has(activeTabId)) {
            const config = registeredTabs.get(activeTabId);
            const tab = tabContainerEl?.querySelector(`span[${ATTRS.SCRIPT_ID}="${activeTabId}"]`);
            const panel = panelContainerEl?.querySelector(`div[${ATTRS.SCRIPT_ID}="${activeTabId}"]`);

            tab?.classList.remove(ACTIVE_CLASSES.TAB);
            panel?.classList.remove(ACTIVE_CLASSES.PANEL);
            if (panel) panel.style.display = 'none';

            try {
                config.onDeactivate?.(panel, tab);
            } catch (e) {
                error(`Error during onDeactivate for ${activeTabId}:`, e);
            }
            activeTabId = null;
        }
        // Also remove active class from any site tabs/panels managed outside STM
        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));

    }

    /** Internal implementation for activating a tab */
    function activateTab(scriptId) { // Renamed from activateTabImpl for clarity within scope
        if (!registeredTabs.has(scriptId) || !tabContainerEl || !panelContainerEl) {
            warn(`Cannot activate tab: ${scriptId}. Not registered or containers not found.`);
            return;
        }

        if (activeTabId === scriptId) return; // Already active

        deactivateCurrentTab(); // Deactivate previous one first

        const config = registeredTabs.get(scriptId);
        const tab = tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
        const panel = panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${scriptId}"]`);

        if (!tab || !panel) {
            error(`Tab or Panel element not found for ${scriptId} during activation.`);
            return;
        }

        tab.classList.add(ACTIVE_CLASSES.TAB);
        panel.classList.add(ACTIVE_CLASSES.PANEL);
        panel.style.display = 'block'; // Ensure it's visible

        activeTabId = scriptId;

        try {
            config.onActivate?.(panel, tab);
        } catch (e) {
            error(`Error during onActivate for ${scriptId}:`, e);
        }
    }

    /** Handles clicks within the tab container. */
    function handleTabClick(event) {
        const clickedTab = event.target.closest(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`);

        if (clickedTab) {
            event.stopPropagation();
            const scriptId = clickedTab.getAttribute(ATTRS.SCRIPT_ID);
            if (scriptId) {
                activateTab(scriptId); // Call the internal activate function
            }
        } else {
            if (event.target.closest(SELECTORS.SITE_TAB) && !event.target.closest(`span[${ATTRS.MANAGED}]`)) {
                deactivateCurrentTab();
            }
        }
    }

    /** Attaches the main click listener to the tab container. */
    function attachTabClickListener() {
        if (!tabContainerEl) return;
        tabContainerEl.removeEventListener('click', handleTabClick, true);
        tabContainerEl.addEventListener('click', handleTabClick, true);
        log('Tab click listener attached.');
    }

     /** Helper to create a separator span */
    function createSeparator(scriptId) {
         const separator = document.createElement('span');
         separator.className = SELECTORS.SITE_SEPARATOR ? SELECTORS.SITE_SEPARATOR.substring(1) : 'settings-tab-separator-fallback';
         separator.setAttribute(ATTRS.MANAGED, 'true');
         separator.setAttribute('data-stm-separator-for', scriptId);
         separator.textContent = '|';
         separator.style.cursor = 'default';
         return separator;
     }

    /** Creates and inserts the tab and panel elements for a given script config. */
    function createTabAndPanel(config) {
        if (!tabContainerEl || !panelContainerEl) {
            error(`Cannot create tab/panel for ${config.scriptId}: Containers not found.`);
            return;
        }
        if (tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${config.scriptId}"]`)) {
            log(`Tab already exists for ${config.scriptId}, skipping creation.`);
            return;
        }

        log(`Creating tab/panel for: ${config.scriptId}`);

        // --- Create Tab ---
        const newTab = document.createElement('span');
        newTab.className = SELECTORS.SITE_TAB.substring(1);
        newTab.textContent = config.tabTitle;
        newTab.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
        newTab.setAttribute(ATTRS.MANAGED, 'true');
        newTab.setAttribute('title', `${config.tabTitle} (Settings by ${config.scriptId})`);
        const desiredOrder = typeof config.order === 'number' ? config.order : Infinity;
        newTab.setAttribute('data-stm-order', desiredOrder); // Store order attribute

        // --- Create Panel ---
        const newPanel = document.createElement('div');
        newPanel.className = SELECTORS.SITE_PANEL.substring(1);
        newPanel.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
        newPanel.setAttribute(ATTRS.MANAGED, 'true');
        newPanel.id = `${MANAGER_ID}-${config.scriptId}-panel`;

        // --- Insertion Logic (with basic ordering) ---
        let inserted = false;
        const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));

        // Get combined list of STM tabs and separators for easier sorting/insertion
        const elementsToSort = existingStmTabs.map(tab => ({
            element: tab,
            order: parseInt(tab.getAttribute('data-stm-order') || Infinity, 10),
            isSeparator: false,
            separatorFor: tab.getAttribute(ATTRS.SCRIPT_ID)
        }));

        // Find separators associated with STM tabs
        tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}] + ${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}], ${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}] + span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`).forEach(sep => {
             let associatedTabId = sep.getAttribute('data-stm-separator-for');
             if (associatedTabId) {
                 let associatedTab = elementsToSort.find(item => item.separatorFor === associatedTabId);
                 if (associatedTab) {
                     elementsToSort.push({ element: sep, order: associatedTab.order, isSeparator: true, separatorFor: associatedTabId });
                 }
             }
         });

        // Simplified: Find the correct place based on order among existing STM tabs
        let insertBeforeElement = null;
        for (const existingTab of existingStmTabs.sort((a, b) => parseInt(a.getAttribute('data-stm-order') || Infinity, 10) - parseInt(b.getAttribute('data-stm-order') || Infinity, 10))) {
             const existingOrder = parseInt(existingTab.getAttribute('data-stm-order') || Infinity, 10);
             if (desiredOrder < existingOrder) {
                 insertBeforeElement = existingTab;
                 break;
             }
         }

         const newSeparator = createSeparator(config.scriptId); // Create separator regardless

         if (insertBeforeElement) {
              // Check if the element before the target is a separator. If so, insert before that separator.
             const prevElement = insertBeforeElement.previousElementSibling;
              if (prevElement && prevElement.matches(`${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}]`)) {
                 tabContainerEl.insertBefore(newSeparator, prevElement);
                  tabContainerEl.insertBefore(newTab, prevElement); // Insert tab *after* its separator
              } else {
                   // Insert separator and then tab right before the target element
                  tabContainerEl.insertBefore(newSeparator, insertBeforeElement);
                  tabContainerEl.insertBefore(newTab, insertBeforeElement);
              }
              inserted = true;
         }


        if (!inserted) {
            // Append at the end of other STM tabs (potentially before site's last tabs)
            const lastStmTab = existingStmTabs.pop(); // Get the last one from the originally selected list
            if (lastStmTab) {
                // Insert after the last STM tab
                 tabContainerEl.insertBefore(newSeparator, lastStmTab.nextSibling); // Insert separator first
                 tabContainerEl.insertBefore(newTab, newSeparator.nextSibling); // Insert tab after separator
            } else {
                // This is the first STM tab being added, append separator and tab
                tabContainerEl.appendChild(newSeparator);
                tabContainerEl.appendChild(newTab);
            }
        }

        // Append Panel
        panelContainerEl.appendChild(newPanel);

        // --- Initialize Panel Content ---
        try {
            Promise.resolve(config.onInit(newPanel, newTab)).catch(e => {
                error(`Error during async onInit for ${config.scriptId}:`, e);
                newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
            });
        } catch (e) {
            error(`Error during sync onInit for ${config.scriptId}:`, e);
            newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
        }
    }

    /** Process all pending registrations. */
    function processPendingRegistrations() {
        if (!isInitialized) return;
        log(`Processing ${pendingRegistrations.length} pending registrations...`);
        while (pendingRegistrations.length > 0) {
            const config = pendingRegistrations.shift();
            if (!registeredTabs.has(config.scriptId)) {
                registeredTabs.set(config.scriptId, config);
                createTabAndPanel(config);
            } else {
                warn(`Script ID ${config.scriptId} was already registered. Skipping pending registration.`);
            }
        }
    }

    // --- Initialization and Observation ---

    /** Main initialization routine. */
    function initializeManager() {
        if (!findSettingsElements()) {
            log('Settings elements not found on init check.');
            return false;
        }

        if (isInitialized) {
            log('Manager already initialized.');
            attachTabClickListener(); // Re-attach listener just in case
            return true;
        }

        log('Initializing Settings Tab Manager...');
        attachTabClickListener();

        isInitialized = true;
        log('Manager is ready.');
        // NOW it's safe to resolve the promise with publicApi
        resolveReadyPromise(publicApi);

        processPendingRegistrations();
        return true;
    }

    // Observer
    const observer = new MutationObserver((mutationsList, obs) => {
        let foundMenu = !!settingsMenuEl;
        let potentialReInit = false;

        if (!foundMenu) {
             for (const mutation of mutationsList) { /* ... same logic as before ... */
                if (mutation.addedNodes) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                             const menu = (node.matches && node.matches(SELECTORS.SETTINGS_MENU))
                                            ? node
                                            : (node.querySelector ? node.querySelector(SELECTORS.SETTINGS_MENU) : null);
                            if (menu) {
                                foundMenu = true;
                                potentialReInit = true;
                                log('Settings menu detected in DOM.');
                                break;
                            }
                        }
                    }
                }
                if (foundMenu) break;
            }
        }

        if (foundMenu) {
            if (!isInitialized || !settingsMenuEl || !tabContainerEl || !panelContainerEl || potentialReInit) {
                 if (initializeManager()) {
                    // Optional: obs.disconnect();
                 } else {
                    log('Initialization check failed, observer remains active.');
                 }
             }
         }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
    log('Mutation observer started for settings menu detection.');

    initializeManager(); // Attempt initial


    // --- API Implementation Functions (linked from publicApi object) ---

    function registerTabImpl(config) { // Renamed from registerTab
        if (!config || typeof config !== 'object') { /* ... validation ... */
            error('Registration failed: Invalid config object provided.'); return false;
        }
        const { scriptId, tabTitle, onInit } = config;
        if (typeof scriptId !== 'string' || !scriptId.trim()) { /* ... validation ... */
             error('Registration failed: Invalid or missing scriptId.', config); return false;
        }
        if (typeof tabTitle !== 'string' || !tabTitle.trim()) { /* ... validation ... */
             error('Registration failed: Invalid or missing tabTitle.', config); return false;
        }
        if (typeof onInit !== 'function') { /* ... validation ... */
             error('Registration failed: onInit must be a function.', config); return false;
        }
        if (registeredTabs.has(scriptId)) { /* ... validation ... */
             warn(`Registration failed: Script ID "${scriptId}" is already registered.`); return false;
        }
        if (config.onActivate && typeof config.onActivate !== 'function') { /* ... validation ... */
             error(`Registration for ${scriptId} failed: onActivate must be a function.`); return false;
        }
        if (config.onDeactivate && typeof config.onDeactivate !== 'function') { /* ... validation ... */
             error(`Registration for ${scriptId} failed: onDeactivate must be a function.`); return false;
        }

        log(`Registration accepted for: ${scriptId}`);
        const registrationData = { ...config };

        if (isInitialized) {
            registeredTabs.set(scriptId, registrationData);
            createTabAndPanel(registrationData);
        } else {
            log(`Manager not ready, queueing registration for ${scriptId}`);
            pendingRegistrations.push(registrationData);
        }
        return true;
    }

    function activateTabImpl(scriptId) { // Renamed from activateTab
        if (isInitialized) {
            activateTab(scriptId); // Calls the internal function
        } else {
            warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`);
        }
    }

    function getPanelElementImpl(scriptId) { // Renamed from getPanelElement
        if (!isInitialized || !panelContainerEl) return null;
        return panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
    }

    function getTabElementImpl(scriptId) { // Renamed from getTabElement
        if (!isInitialized || !tabContainerEl) return null;
        return tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
    }


    // --- Global Exposure ---
    if (window.SettingsTabManager && window.SettingsTabManager !== publicApi) {
        warn('window.SettingsTabManager is already defined by another script or instance!');
    } else if (!window.SettingsTabManager) {
        Object.defineProperty(window, 'SettingsTabManager', {
            value: publicApi, // Expose the predefined API object
            writable: false,
            configurable: true
        });
        log('SettingsTabManager API exposed on window.');
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址