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