Settings Tab Manager (STM)

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

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

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

// ==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, with a single separator.
// @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';

    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', // Keep this if the site uses it visually
    });
    const ACTIVE_CLASSES = Object.freeze({
        TAB: 'selectedTab',
        PANEL: 'selectedPanel',
    });
    const ATTRS = Object.freeze({
        SCRIPT_ID: 'data-stm-script-id',
        MANAGED: 'data-stm-managed',
        SEPARATOR: 'data-stm-main-separator', // Attribute for the single separator
        ORDER: 'data-stm-order', // Attribute to store the order on the tab element
    });

    // --- State ---
    let isInitialized = false;
    let settingsMenuEl = null;
    let tabContainerEl = null;
    let panelContainerEl = null;
    let activeTabId = null;
    const registeredTabs = new Map(); // Stores { scriptId: config } for registered tabs
    const pendingRegistrations = []; // Stores { config } for tabs registered before init
    let isSeparatorAdded = false; // Flag to ensure only one separator is added

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

    // --- Public API Definition ---
    const publicApi = Object.freeze({
        ready: readyPromise,
        registerTab: (config) => {
            return registerTabImpl(config);
        },
        activateTab: (scriptId) => {
            activateTabImpl(scriptId);
        },
        getPanelElement: (scriptId) => {
            return getPanelElementImpl(scriptId);
        },
        getTabElement: (scriptId) => {
            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}][${ATTRS.SCRIPT_ID}] {
           cursor: pointer;
        }
        /* Styling for the single separator */
        ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.SEPARATOR}] {
            cursor: default;
            margin: 0 5px; /* Add some spacing around the separator */
        }
    `);

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

    /** 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;
        }
        // Ensure the elements are still in the document (relevant for re-init checks)
        if (!document.body.contains(settingsMenuEl) || !document.body.contains(tabContainerEl) || !document.body.contains(panelContainerEl)) {
             warn('Found settings elements are detached from the DOM.');
             settingsMenuEl = null;
             tabContainerEl = null;
             panelContainerEl = null;
             isSeparatorAdded = false; // Reset separator if containers are gone
             return false;
        }
        return true;
    }

    /** Deactivates the currently active STM tab (if any) and ensures native tabs are also visually deactivated. */
    function deactivateCurrentTab() {
        // Deactivate STM-managed tab
        if (activeTabId && registeredTabs.has(activeTabId)) {
            const config = registeredTabs.get(activeTabId);
            const tab = getTabElementImpl(activeTabId); // Use API getter
            const panel = getPanelElementImpl(activeTabId); // Use API getter

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

            try {
                config.onDeactivate?.(panel, tab);
            } catch (e) {
                error(`Error during onDeactivate for ${activeTabId}:`, e);
            }
        }
        activeTabId = null; // Clear active STM tab ID

        // 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 specific STM tab */
    function activateTab(scriptId) {
        if (!registeredTabs.has(scriptId) || !tabContainerEl || !panelContainerEl) {
            warn(`Cannot activate tab: ${scriptId}. Not registered or containers not found.`);
            return;
        }

        if (activeTabId === scriptId) return; // Already active, do nothing

        deactivateCurrentTab(); // Deactivate previous one first (handles both STM and native)

        const config = registeredTabs.get(scriptId);
        const tab = getTabElementImpl(scriptId);
        const panel = getPanelElementImpl(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 panel is visible via style

        activeTabId = scriptId; // Set the new active STM tab ID

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

    /** Handles clicks within the tab container to switch tabs. */
    function handleTabClick(event) {
        // Check if an STM-managed tab was clicked
        const clickedStmTab = event.target.closest(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`);
        if (clickedStmTab) {
            event.stopPropagation(); // Prevent potential conflicts with site listeners
            const scriptId = clickedStmTab.getAttribute(ATTRS.SCRIPT_ID);
            if (scriptId && scriptId !== activeTabId) {
                activateTab(scriptId); // Activate the clicked STM tab
            }
            return; // Handled
        }

        // Check if a native site tab was clicked (that isn't also an STM tab)
        const clickedSiteTab = event.target.closest(`${SELECTORS.SITE_TAB}:not([${ATTRS.MANAGED}])`);
        if (clickedSiteTab) {
             // If a native tab is clicked, ensure any active STM tab is deactivated
            if (activeTabId) {
                 deactivateCurrentTab();
                 // Note: We rely on the site's own JS to handle activating the native tab/panel visually
            }
            return; // Let the site handle its own tab activation
        }

        // Check if the separator was clicked (do nothing)
        if (event.target.closest(`span[${ATTRS.SEPARATOR}]`)) {
             event.stopPropagation();
             return;
        }
    }


    /** Attaches the main click listener to the tab container. */
    function attachTabClickListener() {
        if (!tabContainerEl) return;
        // Use capture phase to potentially handle clicks before site's listeners if needed
        tabContainerEl.removeEventListener('click', handleTabClick, true);
        tabContainerEl.addEventListener('click', handleTabClick, true);
        log('Tab click listener attached.');
    }

     /** Helper to create the SINGLE separator span */
    function createSeparator() {
         const separator = document.createElement('span');
         // Use the site's separator class if defined, otherwise a fallback
         separator.className = SELECTORS.SITE_SEPARATOR ? SELECTORS.SITE_SEPARATOR.substring(1) : 'settings-tab-separator-fallback';
         separator.setAttribute(ATTRS.MANAGED, 'true'); // Mark as managed by STM (helps selection)
         separator.setAttribute(ATTRS.SEPARATOR, 'true'); // Mark as the main separator
         separator.textContent = '|'; // Or any desired visual separator
         // Style is handled by GM_addStyle now
         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;
        }
        // Check if tab *element* exists already
        if (tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${config.scriptId}"]`)) {
            log(`Tab element already exists for ${config.scriptId}, skipping creation.`);
            return; // Avoid duplicates if registration happens multiple times somehow
        }

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

        // --- Create Tab ---
        const newTab = document.createElement('span');
        newTab.className = SELECTORS.SITE_TAB.substring(1); // Use site's base tab class
        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(ATTRS.ORDER, desiredOrder); // Store order attribute

        // --- Create Panel ---
        const newPanel = document.createElement('div');
        newPanel.className = SELECTORS.SITE_PANEL.substring(1); // Use site's base panel class
        newPanel.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
        newPanel.setAttribute(ATTRS.MANAGED, 'true');
        newPanel.id = `${MANAGER_ID}-${config.scriptId}-panel`; // Unique ID for the panel

        // --- Insertion Logic (Single Separator & Ordered Tabs) ---
        let insertBeforeTab = null; // The specific STM tab element to insert *before*
        const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));

        // Sort existing STM tabs DOM elements by their order attribute to find the correct insertion point
        existingStmTabs.sort((a, b) => {
            const orderA = parseInt(a.getAttribute(ATTRS.ORDER) || Infinity, 10);
            const orderB = parseInt(b.getAttribute(ATTRS.ORDER) || Infinity, 10);
            return orderA - orderB;
        });

        // Find the first existing STM tab with a higher order number than the new tab
        for (const existingTab of existingStmTabs) {
            const existingOrder = parseInt(existingTab.getAttribute(ATTRS.ORDER) || Infinity, 10);
            if (desiredOrder < existingOrder) {
                insertBeforeTab = existingTab;
                break;
            }
        }

        // Check if this is the very first STM tab being added *to the DOM*
        const isFirstStmTabBeingAdded = existingStmTabs.length === 0;
        let separatorInstance = null;

        // Add the single separator *only* if it hasn't been added yet AND this is the first STM tab going into the DOM
        if (!isSeparatorAdded && isFirstStmTabBeingAdded) {
            separatorInstance = createSeparator(); // Create the single separator instance
            isSeparatorAdded = true; // Mark it as added so it doesn't happen again
            log('Adding the main STM separator.');
        }

        // Insert the separator (if created) and then the tab
        if (insertBeforeTab) {
            // Insert before a specific existing STM tab
            if (separatorInstance) {
                // This case should technically not happen if separator is only added for the *first* tab,
                // but we keep it for robustness. It means we are inserting the *first* tab before another.
                tabContainerEl.insertBefore(separatorInstance, insertBeforeTab);
            }
            tabContainerEl.insertBefore(newTab, insertBeforeTab);
        } else {
            // Append after all existing STM tabs (or as the very first STM elements)
            if (separatorInstance) {
                // Append the separator first because this is the first STM tab
                tabContainerEl.appendChild(separatorInstance);
            }
            // Append the new tab (either after the new separator or after the last existing STM tab)
            tabContainerEl.appendChild(newTab);
        }

        // Append Panel to the panel container
        panelContainerEl.appendChild(newPanel);

        // --- Initialize Panel Content ---
        // Use Promise.resolve to handle both sync and async onInit functions gracefully
        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) { // Catch synchronous errors from onInit
            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>`;
        }
    }

    /** Sorts and processes all pending registrations once the manager is initialized. */
    function processPendingRegistrations() {
        if (!isInitialized) return; // Should not happen if called correctly, but safety check
        log(`Processing ${pendingRegistrations.length} pending registrations...`);

        // Sort pending registrations by 'order' BEFORE creating elements
        pendingRegistrations.sort((a, b) => {
             const orderA = typeof a.order === 'number' ? a.order : Infinity;
             const orderB = typeof b.order === 'number' ? b.order : Infinity;
             // If orders are equal, maintain original registration order (stable sort behavior desired)
             return orderA - orderB;
        });

        // Process the sorted queue
        while (pendingRegistrations.length > 0) {
            const config = pendingRegistrations.shift(); // Get the next config from the front
            if (!registeredTabs.has(config.scriptId)) {
                registeredTabs.set(config.scriptId, config); // Add to map first
                // createTabAndPanel will handle DOM insertion and separator logic
                createTabAndPanel(config);
            } else {
                // This case should be rare now due to checks in registerTabImpl, but good to keep
                warn(`Script ID ${config.scriptId} was already registered. Skipping pending registration.`);
            }
        }
        log('Finished processing pending registrations.');
    }

    // --- Initialization and Observation ---

    /** Main initialization routine. Finds elements, attaches listener, processes queue. */
    function initializeManager() {
        if (!findSettingsElements()) {
            // log('Settings elements not found or invalid on init check.');
            return false; // Keep observer active
        }

        // Check if already initialized *and* elements are still valid
        if (isInitialized && settingsMenuEl && tabContainerEl && panelContainerEl) {
            // log('Manager already initialized and elements seem valid.');
            attachTabClickListener(); // Re-attach listener just in case it got removed
            return true;
        }

        log('Initializing Settings Tab Manager...');
        attachTabClickListener(); // Attach the main click listener

        isInitialized = true; // Set flag *before* resolving promise and processing queue
        log('Manager is ready.');

        // Resolve the public promise *after* setting isInitialized flag
        // This signals to waiting scripts that the manager is ready
        resolveReadyPromise(publicApi);

        // Process any registrations that occurred before initialization was complete
        processPendingRegistrations();

        return true; // Initialization successful
    }

    // --- Mutation Observer ---
    // Observes the body for the appearance of the settings menu
    const observer = new MutationObserver((mutationsList, obs) => {
        let needsReInitCheck = false;

        // 1. Check if the menu exists but we aren't initialized yet
        if (!isInitialized && document.querySelector(SELECTORS.SETTINGS_MENU)) {
            needsReInitCheck = true;
        }
        // 2. Check if the menu *was* found previously but is no longer in the DOM
        else if (isInitialized && settingsMenuEl && !document.body.contains(settingsMenuEl)) {
            warn('Settings menu seems to have been removed from DOM.');
            isInitialized = false; // Force re-initialization if it reappears
            settingsMenuEl = null;
            tabContainerEl = null;
            panelContainerEl = null;
            isSeparatorAdded = false; // Reset separator flag
            activeTabId = null;
            // Don't resolve the promise again, but new scripts might await it
            // Reset the promise state if needed (more complex, usually not necessary)
            // readyPromise = new Promise(resolve => { resolveReadyPromise = resolve; });
            needsReInitCheck = true; // Check if it got immediately re-added
        }

        // 3. Scan mutations if we haven't found the menu yet or if it was removed
        if (!settingsMenuEl || needsReInitCheck) {
             for (const mutation of mutationsList) {
                if (mutation.addedNodes) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if the added node *is* the menu or *contains* the menu
                             const menu = (node.matches && node.matches(SELECTORS.SETTINGS_MENU))
                                            ? node
                                            : (node.querySelector ? node.querySelector(SELECTORS.SETTINGS_MENU) : null);
                            if (menu) {
                                log('Settings menu detected in DOM via MutationObserver.');
                                needsReInitCheck = true;
                                break; // Found it in this mutation
                            }
                        }
                    }
                }
                if (needsReInitCheck) break; // Found it, no need to check other mutations
            }
        }

        // 4. Attempt initialization if needed
        if (needsReInitCheck) {
            // Use setTimeout to allow the DOM to potentially stabilize after the mutation
            setTimeout(() => {
                if (initializeManager()) {
                    log('Manager initialized/re-initialized successfully via MutationObserver.');
                    // Optional: If the settings menu is known to be stable once added,
                    // you *could* disconnect the observer here to save resources.
                    // However, leaving it active is safer if the menu might be rebuilt.
                    // obs.disconnect();
                    // log('Mutation observer disconnected.');
                } else {
                    // log('Initialization check failed after mutation, observer remains active.');
                }
            }, 0); // Delay slightly
         }
    });

    // Start observing the body for additions/removals in the subtree
    observer.observe(document.body, {
        childList: true, // Watch for direct children changes (adding/removing nodes)
        subtree: true    // Watch the entire subtree under document.body
    });
    log('Mutation observer started for settings menu detection.');

    // --- Attempt initial initialization on script load ---
    // Use setTimeout to ensure this runs after the current execution context,
    // allowing the global API exposure to happen first.
    setTimeout(initializeManager, 0);


    // --- API Implementation Functions ---

    /** Public API function to register a new settings tab. */
    function registerTabImpl(config) {
        // --- Input Validation ---
        if (!config || typeof config !== 'object') {
            error('Registration failed: Invalid config object provided.'); return false;
        }
        const { scriptId, tabTitle, onInit } = config;
        if (typeof scriptId !== 'string' || !scriptId.trim()) {
             error('Registration failed: Invalid or missing scriptId (string).', config); return false;
        }
        if (typeof tabTitle !== 'string' || !tabTitle.trim()) {
             error('Registration failed: Invalid or missing tabTitle (string).', config); return false;
        }
        if (typeof onInit !== 'function') {
             error('Registration failed: onInit callback must be a function.', config); return false;
        }
        // Optional callbacks validation
        if (config.onActivate && typeof config.onActivate !== 'function') {
             error(`Registration for ${scriptId} failed: onActivate (if provided) must be a function.`); return false;
        }
        if (config.onDeactivate && typeof config.onDeactivate !== 'function') {
             error(`Registration for ${scriptId} failed: onDeactivate (if provided) must be a function.`); return false;
        }
        // Optional order validation
        if (config.order !== undefined && typeof config.order !== 'number') {
            warn(`Registration for ${scriptId}: Invalid order value provided (must be a number). Defaulting to end.`, config);
            // Allow registration but ignore invalid order
            delete config.order;
        }

        // Prevent duplicate registrations by scriptId
        if (registeredTabs.has(scriptId) || pendingRegistrations.some(p => p.scriptId === scriptId)) {
             warn(`Registration failed: Script ID "${scriptId}" is already registered or pending.`); return false;
        }

        // --- Registration Logic ---
        log(`Registration accepted for: ${scriptId}`);
        // Clone config to avoid external modification? (Shallow clone is usually sufficient)
        const registrationData = { ...config };

        if (isInitialized) {
            // Manager ready: Register immediately and create elements
            registeredTabs.set(scriptId, registrationData);
            // createTabAndPanel handles sorting/insertion/separator logic
            createTabAndPanel(registrationData);
        } else {
            // Manager not ready: Add to pending queue
            log(`Manager not ready, queueing registration for ${scriptId}`);
            pendingRegistrations.push(registrationData);
             // Sort pending queue immediately upon adding to maintain correct insertion order later
             pendingRegistrations.sort((a, b) => {
                 const orderA = typeof a.order === 'number' ? a.order : Infinity;
                 const orderB = typeof b.order === 'number' ? b.order : Infinity;
                 return orderA - orderB;
             });
        }
        return true; // Indicate successful acceptance of registration (not necessarily immediate creation)
    }

    /** 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) {
            // Call the internal function which handles the logic
            activateTab(scriptId);
        } else {
            // Queue activation? Or just warn? Warning is simpler.
            warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`);
            // Could potentially store a desired initial tab and activate it in processPendingRegistrations
        }
    }

    /** Public API function to get the DOM element for a tab's panel. */
    function getPanelElementImpl(scriptId) {
        if (!isInitialized || !panelContainerEl) return null;
        if (typeof scriptId !== 'string' || !scriptId.trim()) return null;
        // Use attribute selector for robustness
        return panelContainerEl.querySelector(`div[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}="${scriptId}"]`);
    }

    /** Public API function to get the DOM element for a tab's clickable part. */
    function getTabElementImpl(scriptId) {
        if (!isInitialized || !tabContainerEl) return null;
        if (typeof scriptId !== 'string' || !scriptId.trim()) return null;
        // Use attribute selector for robustness
        return tabContainerEl.querySelector(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}="${scriptId}"]`);
    }


    // --- Global Exposure ---
    // Expose the public API on the window object, checking for conflicts.
    if (window.SettingsTabManager && window.SettingsTabManager !== publicApi) {
        // A different instance or script already exists. Log a warning.
        // Depending on requirements, could throw an error, merge APIs, or namespace.
        warn('window.SettingsTabManager is already defined by another script or instance! Potential conflict.');
    } else if (!window.SettingsTabManager) {
        // Define the API object on the window, making it non-writable but configurable.
        Object.defineProperty(window, 'SettingsTabManager', {
            value: publicApi,
            writable: false,      // Prevents accidental overwriting of the API object itself
            configurable: true    // Allows users to delete or redefine it if necessary (e.g., for debugging)
        });
        log('SettingsTabManager API exposed on window.');
    }

})(); // End of IIFE