// ==UserScript==
// @name Settings Tab Manager (STM)
// @namespace shared-settings-manager
// @version 1.1.1
// @description Provides an API for other userscripts to add tabs to a site's settings menu.
// @author Gemini
// @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',
});
const ACTIVE_CLASSES = Object.freeze({
TAB: 'selectedTab',
PANEL: 'selectedPanel',
});
const ATTRS = Object.freeze({
SCRIPT_ID: 'data-stm-script-id',
MANAGED: 'data-stm-managed',
});
// --- State ---
let isInitialized = false;
let settingsMenuEl = null;
let tabContainerEl = null;
let panelContainerEl = null;
let activeTabId = null;
const registeredTabs = new Map();
const pendingRegistrations = [];
// --- Readiness Promise ---
let resolveReadyPromise;
const readyPromise = new Promise(resolve => {
resolveReadyPromise = resolve;
});
// --- Public API Definition (MOVED EARLIER) ---
// Define the API object that will be exposed and resolved by the promise.
// Functions it references must be defined *before* they are called by client scripts,
// but the functions themselves can be defined later in this script, thanks to hoisting.
const publicApi = Object.freeze({
ready: readyPromise,
registerTab: (config) => {
// Implementation uses functions defined later (registerTabImpl)
return registerTabImpl(config);
},
activateTab: (scriptId) => {
// Implementation uses functions defined later (activateTabImpl)
activateTabImpl(scriptId);
},
getPanelElement: (scriptId) => {
// Implementation uses functions defined later (getPanelElementImpl)
return getPanelElementImpl(scriptId);
},
getTabElement: (scriptId) => {
// Implementation uses functions defined later (getTabElementImpl)
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}] {
cursor: pointer;
}
`);
// --- Core Logic Implementation Functions ---
// (Functions like findSettingsElements, deactivateCurrentTab, activateTab, handleTabClick, etc.)
/** 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;
}
return true;
}
/** Deactivates the currently active STM tab (if any). */
function deactivateCurrentTab() {
if (activeTabId && registeredTabs.has(activeTabId)) {
const config = registeredTabs.get(activeTabId);
const tab = tabContainerEl?.querySelector(`span[${ATTRS.SCRIPT_ID}="${activeTabId}"]`);
const panel = panelContainerEl?.querySelector(`div[${ATTRS.SCRIPT_ID}="${activeTabId}"]`);
tab?.classList.remove(ACTIVE_CLASSES.TAB);
panel?.classList.remove(ACTIVE_CLASSES.PANEL);
if (panel) panel.style.display = 'none';
try {
config.onDeactivate?.(panel, tab);
} catch (e) {
error(`Error during onDeactivate for ${activeTabId}:`, e);
}
activeTabId = null;
}
// Also 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 tab */
function activateTab(scriptId) { // Renamed from activateTabImpl for clarity within scope
if (!registeredTabs.has(scriptId) || !tabContainerEl || !panelContainerEl) {
warn(`Cannot activate tab: ${scriptId}. Not registered or containers not found.`);
return;
}
if (activeTabId === scriptId) return; // Already active
deactivateCurrentTab(); // Deactivate previous one first
const config = registeredTabs.get(scriptId);
const tab = tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
const panel = panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${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 it's visible
activeTabId = scriptId;
try {
config.onActivate?.(panel, tab);
} catch (e) {
error(`Error during onActivate for ${scriptId}:`, e);
}
}
/** Handles clicks within the tab container. */
function handleTabClick(event) {
const clickedTab = event.target.closest(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`);
if (clickedTab) {
event.stopPropagation();
const scriptId = clickedTab.getAttribute(ATTRS.SCRIPT_ID);
if (scriptId) {
activateTab(scriptId); // Call the internal activate function
}
} else {
if (event.target.closest(SELECTORS.SITE_TAB) && !event.target.closest(`span[${ATTRS.MANAGED}]`)) {
deactivateCurrentTab();
}
}
}
/** Attaches the main click listener to the tab container. */
function attachTabClickListener() {
if (!tabContainerEl) return;
tabContainerEl.removeEventListener('click', handleTabClick, true);
tabContainerEl.addEventListener('click', handleTabClick, true);
log('Tab click listener attached.');
}
/** Helper to create a separator span */
function createSeparator(scriptId) {
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('data-stm-separator-for', scriptId);
separator.textContent = '|';
separator.style.cursor = 'default';
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;
}
if (tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${config.scriptId}"]`)) {
log(`Tab already exists for ${config.scriptId}, skipping creation.`);
return;
}
log(`Creating tab/panel for: ${config.scriptId}`);
// --- Create Tab ---
const newTab = document.createElement('span');
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('data-stm-order', desiredOrder); // Store order attribute
// --- Create Panel ---
const newPanel = document.createElement('div');
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`;
// --- Insertion Logic (with basic ordering) ---
let inserted = false;
const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));
// Get combined list of STM tabs and separators for easier sorting/insertion
const elementsToSort = existingStmTabs.map(tab => ({
element: tab,
order: parseInt(tab.getAttribute('data-stm-order') || Infinity, 10),
isSeparator: false,
separatorFor: tab.getAttribute(ATTRS.SCRIPT_ID)
}));
// Find separators associated with STM tabs
tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}] + ${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}], ${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}] + span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`).forEach(sep => {
let associatedTabId = sep.getAttribute('data-stm-separator-for');
if (associatedTabId) {
let associatedTab = elementsToSort.find(item => item.separatorFor === associatedTabId);
if (associatedTab) {
elementsToSort.push({ element: sep, order: associatedTab.order, isSeparator: true, separatorFor: associatedTabId });
}
}
});
// Simplified: Find the correct place based on order among existing STM tabs
let insertBeforeElement = null;
for (const existingTab of existingStmTabs.sort((a, b) => parseInt(a.getAttribute('data-stm-order') || Infinity, 10) - parseInt(b.getAttribute('data-stm-order') || Infinity, 10))) {
const existingOrder = parseInt(existingTab.getAttribute('data-stm-order') || Infinity, 10);
if (desiredOrder < existingOrder) {
insertBeforeElement = existingTab;
break;
}
}
const newSeparator = createSeparator(config.scriptId); // Create separator regardless
if (insertBeforeElement) {
// Check if the element before the target is a separator. If so, insert before that separator.
const prevElement = insertBeforeElement.previousElementSibling;
if (prevElement && prevElement.matches(`${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}]`)) {
tabContainerEl.insertBefore(newSeparator, prevElement);
tabContainerEl.insertBefore(newTab, prevElement); // Insert tab *after* its separator
} else {
// Insert separator and then tab right before the target element
tabContainerEl.insertBefore(newSeparator, insertBeforeElement);
tabContainerEl.insertBefore(newTab, insertBeforeElement);
}
inserted = true;
}
if (!inserted) {
// Append at the end of other STM tabs (potentially before site's last tabs)
const lastStmTab = existingStmTabs.pop(); // Get the last one from the originally selected list
if (lastStmTab) {
// Insert after the last STM tab
tabContainerEl.insertBefore(newSeparator, lastStmTab.nextSibling); // Insert separator first
tabContainerEl.insertBefore(newTab, newSeparator.nextSibling); // Insert tab after separator
} else {
// This is the first STM tab being added, append separator and tab
tabContainerEl.appendChild(newSeparator);
tabContainerEl.appendChild(newTab);
}
}
// Append Panel
panelContainerEl.appendChild(newPanel);
// --- Initialize Panel Content ---
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>`;
}
}
/** Process all pending registrations. */
function processPendingRegistrations() {
if (!isInitialized) return;
log(`Processing ${pendingRegistrations.length} pending registrations...`);
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.`);
}
}
}
// --- Initialization and Observation ---
/** Main initialization routine. */
function initializeManager() {
if (!findSettingsElements()) {
log('Settings elements not found on init check.');
return false;
}
if (isInitialized) {
log('Manager already initialized.');
attachTabClickListener(); // Re-attach listener just in case
return true;
}
log('Initializing Settings Tab Manager...');
attachTabClickListener();
isInitialized = true;
log('Manager is ready.');
// NOW it's safe to resolve the promise with publicApi
resolveReadyPromise(publicApi);
processPendingRegistrations();
return true;
}
// Observer
const observer = new MutationObserver((mutationsList, obs) => {
let foundMenu = !!settingsMenuEl;
let potentialReInit = false;
if (!foundMenu) {
for (const mutation of mutationsList) { /* ... same logic as before ... */
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) {
foundMenu = true;
potentialReInit = true;
log('Settings menu detected in DOM.');
break;
}
}
}
}
if (foundMenu) break;
}
}
if (foundMenu) {
if (!isInitialized || !settingsMenuEl || !tabContainerEl || !panelContainerEl || potentialReInit) {
if (initializeManager()) {
// Optional: obs.disconnect();
} else {
log('Initialization check failed, observer remains active.');
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
log('Mutation observer started for settings menu detection.');
initializeManager(); // Attempt initial
// --- API Implementation Functions (linked from publicApi object) ---
function registerTabImpl(config) { // Renamed from registerTab
if (!config || typeof config !== 'object') { /* ... validation ... */
error('Registration failed: Invalid config object provided.'); return false;
}
const { scriptId, tabTitle, onInit } = config;
if (typeof scriptId !== 'string' || !scriptId.trim()) { /* ... validation ... */
error('Registration failed: Invalid or missing scriptId.', config); return false;
}
if (typeof tabTitle !== 'string' || !tabTitle.trim()) { /* ... validation ... */
error('Registration failed: Invalid or missing tabTitle.', config); return false;
}
if (typeof onInit !== 'function') { /* ... validation ... */
error('Registration failed: onInit must be a function.', config); return false;
}
if (registeredTabs.has(scriptId)) { /* ... validation ... */
warn(`Registration failed: Script ID "${scriptId}" is already registered.`); return false;
}
if (config.onActivate && typeof config.onActivate !== 'function') { /* ... validation ... */
error(`Registration for ${scriptId} failed: onActivate must be a function.`); return false;
}
if (config.onDeactivate && typeof config.onDeactivate !== 'function') { /* ... validation ... */
error(`Registration for ${scriptId} failed: onDeactivate must be a function.`); return false;
}
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);
}
return true;
}
function activateTabImpl(scriptId) { // Renamed from activateTab
if (isInitialized) {
activateTab(scriptId); // Calls the internal function
} else {
warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`);
}
}
function getPanelElementImpl(scriptId) { // Renamed from getPanelElement
if (!isInitialized || !panelContainerEl) return null;
return panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
}
function getTabElementImpl(scriptId) { // Renamed from getTabElement
if (!isInitialized || !tabContainerEl) return null;
return tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
}
// --- Global Exposure ---
if (window.SettingsTabManager && window.SettingsTabManager !== publicApi) {
warn('window.SettingsTabManager is already defined by another script or instance!');
} else if (!window.SettingsTabManager) {
Object.defineProperty(window, 'SettingsTabManager', {
value: publicApi, // Expose the predefined API object
writable: false,
configurable: true
});
log('SettingsTabManager API exposed on window.');
}
})();