Settings Tab Manager (STM)

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

Tính đến 23-04-2025. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/533630/1575936/Settings%20Tab%20Manager%20%28STM%29.js

// ==UserScript==
// @name         Settings Tab Manager (STM)
// @namespace    shared-settings-manager
// @version      1.1.3
// @description  Provides an API for other userscripts to add tabs to a site's settings menu, with a single separator and improved interaction logic.
// @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 ... */
        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({ /* ... same ... */
        TAB: 'selectedTab',
        PANEL: 'selectedPanel',
    });
    const ATTRS = Object.freeze({ /* ... same ... */
        SCRIPT_ID: 'data-stm-script-id',
        MANAGED: 'data-stm-managed',
        SEPARATOR: 'data-stm-main-separator',
        ORDER: 'data-stm-order',
    });

    let isInitialized = false;
    let settingsMenuEl = null;
    let tabContainerEl = null;
    let panelContainerEl = null;
    let activeTabId = null; // Holds the scriptId of the currently active STM tab, null otherwise
    const registeredTabs = new Map();
    const pendingRegistrations = [];
    let isSeparatorAdded = false;

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

    const publicApi = Object.freeze({ /* ... same ... */
        ready: readyPromise,
        registerTab: (config) => registerTabImpl(config),
        activateTab: (scriptId) => activateTabImpl(scriptId),
        getPanelElement: (scriptId) => getPanelElementImpl(scriptId),
        getTabElement: (scriptId) => getTabElementImpl(scriptId)
    });

    GM_addStyle(`/* ... same styles ... */
        ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}] { display: none; }
        ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}].${ACTIVE_CLASSES.PANEL} { display: block; }
        ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}] { cursor: pointer; }
        ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.SEPARATOR}] { cursor: default; margin: 0 5px; }
    `);


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

    function findSettingsElements() { /* ... same ... */
        settingsMenuEl = document.querySelector(SELECTORS.SETTINGS_MENU);
        if (!settingsMenuEl) return false;
        tabContainerEl = settingsMenuEl.querySelector(SELECTORS.TAB_CONTAINER);
        panelContainerEl = settingsMenuEl.querySelector(SELECTORS.PANEL_CONTAINER);
        if (!tabContainerEl || !panelContainerEl) { /* ... warning ... */ return false; }
        if (!document.body.contains(settingsMenuEl) || !document.body.contains(tabContainerEl) || !document.body.contains(panelContainerEl)) { /* ... warning ... */ return false; }
        return true;
    }

    /**
     * Deactivates the currently active STM tab (specified by activeTabId).
     * Does NOT interfere with native site tab classes.
     */
    function deactivateCurrentStmTab() { // Renamed for clarity
        if (!activeTabId) return; // No STM tab active

        const config = registeredTabs.get(activeTabId);
        if (!config) {
             warn(`Config not found for supposedly active tab ID: ${activeTabId}`);
             activeTabId = null; // Clear potentially invalid ID
             return;
        }

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

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

        if (panel) {
            panel.classList.remove(ACTIVE_CLASSES.PANEL);
            panel.style.display = 'none'; // Explicitly hide
            // log(`Deactivated panel class/display for: ${activeTabId}`);
        } else {
             warn(`Could not find panel element for ${activeTabId} during deactivation.`);
        }

        // Call the script's deactivate hook
        try {
            config.onDeactivate?.(panel, tab); // Pass potentially null elements if lookup failed
        } catch (e) {
            error(`Error during onDeactivate for ${activeTabId}:`, e);
        }

        activeTabId = null; // Clear the active STM tab ID *after* processing
    }

    /**
     * Activates a specific STM tab. Handles deactivation of any previously active STM tab.
     */
    function activateStmTab(scriptId) { // Renamed for clarity
        if (!registeredTabs.has(scriptId) || !tabContainerEl || !panelContainerEl) {
            error(`Cannot activate tab: ${scriptId}. Not registered or containers not found.`); // Changed from warn to error
            return;
        }

        if (activeTabId === scriptId) {
            // log(`Tab ${scriptId} is already active.`);
            return; // Already active
        }

        // --- Deactivation Phase ---
        // Deactivate the *currently active STM tab* first.
        deactivateCurrentStmTab(); // This now ONLY touches the STM tab defined by previous activeTabId

        // --- Activation Phase ---
        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.`);
             // Attempt to clean up partly activated state? Maybe not needed.
            return; // Cannot proceed
        }

        // **Crucially, ensure native tabs are visually deselected.**
        // We rely on the site's own handler for native tabs, but if switching
        // from native to STM, we need to ensure the native one is visually cleared.
        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 new STM tab/panel
        tab.classList.add(ACTIVE_CLASSES.TAB);
        panel.classList.add(ACTIVE_CLASSES.PANEL);
        panel.style.display = 'block'; // Ensure visible

        const previouslyActiveId = activeTabId; // Store before overwriting
        activeTabId = scriptId; // Set the new active STM tab ID *before* calling onActivate

        // log(`Activated tab/panel for: ${scriptId}`);

        // Call the script's activation hook
        try {
            config.onActivate?.(panel, tab);
        } catch (e) {
            error(`Error during onActivate for ${scriptId}:`, e);
            // Should we revert activation? Tricky. Logged error is usually sufficient.
        }
    }

    /** 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(); // Stop event from bubbling further (like to site handlers)
            const scriptId = clickedStmTab.getAttribute(ATTRS.SCRIPT_ID);
            if (scriptId && scriptId !== activeTabId) {
                // log(`STM tab clicked: ${scriptId}`);
                activateStmTab(scriptId); // Activate the clicked STM tab
            } else if (scriptId && scriptId === activeTabId){
                 // log(`Clicked already active STM tab: ${scriptId}`);
                 // Do nothing if clicking the already active tab
            }
            return; // Handled by STM
        }

        // Check if a native site tab was clicked (and NOT an STM tab)
        const clickedSiteTab = event.target.closest(`${SELECTORS.SITE_TAB}:not([${ATTRS.MANAGED}])`);
        if (clickedSiteTab) {
            // log(`Native site tab clicked.`);
            // If an STM tab was active, deactivate it visually and internally.
            // Let the site's own handler manage the activation of the native tab.
            if (activeTabId) {
                 // log(`Deactivating current STM tab (${activeTabId}) due to native tab click.`);
                 deactivateCurrentStmTab();
            }
            // **Do not** stop propagation here. Let the site's own click handler run.
            return;
        }

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

        // If click was somewhere else within the container but not on a tab, do nothing special.
        // log("Clicked non-tab area within container.");
    }


    function attachTabClickListener() { /* ... same ... */
        if (!tabContainerEl) return;
        tabContainerEl.removeEventListener('click', handleTabClick, true);
        tabContainerEl.addEventListener('click', handleTabClick, true); // Keep capture=true
        log('Tab click listener attached.');
    }

    function createSeparator() { /* ... same ... */
         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(ATTRS.SEPARATOR, 'true');
         separator.textContent = '|';
         return separator;
     }

    function createTabAndPanel(config) { /* ... mostly same ... */
        // ... checks ...
        // ... create Tab element (newTab) ...
        // ... create Panel element (newPanel) ...
        // ... Insertion Logic (Single Separator & Ordered Tabs) ...
        // Find insertBeforeTab based on order...
        // Check isFirstStmTabBeingAdded...
        // Add separatorInstance if needed...
        // Insert separatorInstance and newTab...
        // Append newPanel...
        if (!tabContainerEl || !panelContainerEl) { /* ... error ... */ return; }
        if (tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${config.scriptId}"]`)) { /* ... log skip ... */ return; }

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

        const newTab = document.createElement('span'); /* ... set attributes ... */
        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(ATTRS.ORDER, desiredOrder);

        const newPanel = document.createElement('div'); /* ... set attributes ... */
        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`;

        let insertBeforeTab = null;
        const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));
        existingStmTabs.sort((a, b) => (parseInt(a.getAttribute(ATTRS.ORDER) || Infinity, 10)) - (parseInt(b.getAttribute(ATTRS.ORDER) || Infinity, 10)));
        for (const existingTab of existingStmTabs) {
            if (desiredOrder < (parseInt(existingTab.getAttribute(ATTRS.ORDER) || Infinity, 10))) {
                insertBeforeTab = existingTab; break;
            }
        }

        const isFirstStmTabBeingAdded = existingStmTabs.length === 0;
        let separatorInstance = null;
        if (!isSeparatorAdded && isFirstStmTabBeingAdded) {
            separatorInstance = createSeparator();
            isSeparatorAdded = true;
            log('Adding the main STM separator.');
        }

        if (insertBeforeTab) {
            if (separatorInstance) tabContainerEl.insertBefore(separatorInstance, insertBeforeTab);
            tabContainerEl.insertBefore(newTab, insertBeforeTab);
        } else {
            if (separatorInstance) tabContainerEl.appendChild(separatorInstance);
            tabContainerEl.appendChild(newTab);
        }

        panelContainerEl.appendChild(newPanel);

        // --- Initialize Panel Content --- (Keep Promise.resolve wrapper)
        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>`;
        }
    }

    function processPendingRegistrations() { /* ... same, ensure sorting ... */
        if (!isInitialized) return;
        log(`Processing ${pendingRegistrations.length} pending registrations...`);
        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;
        });
        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.`);
            }
        }
        log('Finished processing pending registrations.');
    }

    // --- Initialization and Observation ---
    function initializeManager() { /* ... same, calls findSettingsElements, attachTabClickListener, processPendingRegistrations ... */
        if (!findSettingsElements()) { return false; }
        if (isInitialized && settingsMenuEl && tabContainerEl && panelContainerEl) {
             attachTabClickListener(); return true;
        }
        log('Initializing Settings Tab Manager...');
        attachTabClickListener();
        isInitialized = true;
        log('Manager is ready.');
        resolveReadyPromise(publicApi);
        processPendingRegistrations();
        return true;
    }

    const observer = new MutationObserver(/* ... same observer logic ... */
        (mutationsList, obs) => {
        let needsReInitCheck = false;
        if (!isInitialized && document.querySelector(SELECTORS.SETTINGS_MENU)) { needsReInitCheck = true; }
        else if (isInitialized && settingsMenuEl && !document.body.contains(settingsMenuEl)) {
            warn('Settings menu seems to have been removed from DOM.');
            isInitialized = false; settingsMenuEl = null; tabContainerEl = null; panelContainerEl = null; isSeparatorAdded = false; activeTabId = null;
            needsReInitCheck = true;
        }
        if (!settingsMenuEl || needsReInitCheck) {
             for (const mutation of mutationsList) { /* ... find menu ... */
                 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) { log('Settings menu detected...'); needsReInitCheck = true; break; }
                         }
                     }
                 }
                 if (needsReInitCheck) break;
             }
        }
        if (needsReInitCheck) { setTimeout(() => { if (initializeManager()) { log('Manager initialized/re-initialized successfully via MutationObserver.'); } }, 0); }
    });
    observer.observe(document.body, { childList: true, subtree: true });
    log('Mutation observer started...');
    setTimeout(initializeManager, 0); // Initial attempt


    // --- API Implementation Functions ---
    function registerTabImpl(config) { /* ... same validation, sorting pending queue ... */
        if (!config || typeof config !== 'object') { error('Registration failed: Invalid config object.'); return false; }
        const { scriptId, tabTitle, onInit } = config; /* ... validate scriptId, tabTitle, onInit, optionals ... */
        if (typeof scriptId !== 'string' || !scriptId.trim()) { error('Reg failed: Invalid scriptId.'); return false; }
        if (typeof tabTitle !== 'string' || !tabTitle.trim()) { error('Reg failed: Invalid tabTitle.'); return false; }
        if (typeof onInit !== 'function') { error('Reg failed: onInit not function.'); return false; }
        if (registeredTabs.has(scriptId) || pendingRegistrations.some(p => p.scriptId === scriptId)) { warn(`Reg failed: ID "${scriptId}" already registered/pending.`); return false; }
        // ... more optional validation ...

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

    // Public API function now calls the renamed internal function
    function activateTabImpl(scriptId) {
        if (typeof scriptId !== 'string' || !scriptId.trim()) { error('activateTab failed: Invalid scriptId.'); return; }
        if (isInitialized) {
            activateStmTab(scriptId); // Call the internal function
        } else {
            warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`);
        }
    }

    function getPanelElementImpl(scriptId) { /* ... same ... */
        if (!isInitialized || !panelContainerEl) return null;
        if (typeof scriptId !== 'string' || !scriptId.trim()) return null;
        return panelContainerEl.querySelector(`div[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}="${scriptId}"]`);
    }
    function getTabElementImpl(scriptId) { /* ... same ... */
        if (!isInitialized || !tabContainerEl) return null;
        if (typeof scriptId !== 'string' || !scriptId.trim()) return null;
        return tabContainerEl.querySelector(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}="${scriptId}"]`);
     }


    // --- Global Exposure ---
    if (window.SettingsTabManager && window.SettingsTabManager !== publicApi) { /* ... warning ... */ }
    else if (!window.SettingsTabManager) { /* ... define property ... */
        Object.defineProperty(window, 'SettingsTabManager', { value: publicApi, writable: false, configurable: true });
        log('SettingsTabManager API exposed on window.');
    }

})();