Settings Tab Manager (STM)

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

目前為 2025-04-22 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.gf.qytechs.cn/scripts/533630/1575663/Settings%20Tab%20Manager%20%28STM%29.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Settings Tab Manager (STM)
// @namespace    shared-settings-manager
// @version      1.1.2
// @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) {
            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.');
    }

})();