// ==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