// ==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.');
}
})();