您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Provides an API for other userscripts to add tabs to a site's settings menu, with a single separator.
当前为
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.gf.qytechs.cn/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
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址