Greasy Fork is available in English.

8chan Settings Tab Manager (STM) [Core API]

Core API - Provides window.SettingsTabManager for other scripts to add settings tabs.

  1. // ==UserScript==
  2. // @name 8chan Settings Tab Manager (STM) [Core API]
  3. // @namespace nipah-scripts-8chan
  4. // @version 1.2.0
  5. // @description Core API - Provides window.SettingsTabManager for other scripts to add settings tabs.
  6. // @author nipah, Gemini
  7. // @license MIT
  8. // @match https://8chan.moe/*
  9. // @match https://8chan.se/*
  10. // @grant GM_addStyle
  11. // @run-at document-idle
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // --- Constants ---
  18. const MANAGER_ID = 'SettingsTabManager';
  19. const log = (...args) => console.log(`[${MANAGER_ID}]`, ...args);
  20. const warn = (...args) => console.warn(`[${MANAGER_ID}]`, ...args);
  21. const error = (...args) => console.error(`[${MANAGER_ID}]`, ...args);
  22.  
  23. const SELECTORS = Object.freeze({
  24. SETTINGS_MENU: '#settingsMenu',
  25. TAB_CONTAINER: '#settingsMenu .floatingContainer > div:first-child',
  26. PANEL_CONTAINER: '#settingsMenu .menuContentPanel',
  27. SITE_TAB: '.settingsTab', // Generic class for any tab (native or STM)
  28. SITE_PANEL: '.panelContents', // Generic class for any panel (native or STM)
  29. SITE_SEPARATOR: '.settingsTabSeparator', // Class for separators (native or STM)
  30. });
  31. const ACTIVE_CLASSES = Object.freeze({
  32. TAB: 'selectedTab',
  33. PANEL: 'selectedPanel',
  34. });
  35. const ATTRS = Object.freeze({
  36. SCRIPT_ID: 'data-stm-script-id', // Identifies the script managing the tab/panel
  37. MANAGED: 'data-stm-managed', // Marks elements managed by STM
  38. SEPARATOR: 'data-stm-main-separator', // Marks the STM main separator
  39. ORDER: 'data-stm-order', // Stores the desired display order for STM tabs
  40. });
  41.  
  42. // --- State ---
  43. let isInitialized = false;
  44. let settingsMenuEl = null;
  45. let tabContainerEl = null;
  46. let panelContainerEl = null;
  47. /** @type {string | null} Tracks the scriptId of the *currently active* STM tab. Null if a native tab is active or no tab is active. */
  48. let activeStmTabId = null;
  49. /** @type {Map<string, object>} Stores registered tab configurations: { scriptId: config } */
  50. const registeredTabs = new Map();
  51. /** @type {Array<object>} Stores configurations for tabs registered before the manager is initialized. */
  52. const pendingRegistrations = [];
  53. let isSeparatorAdded = false; // Flag to ensure only one main separator is added.
  54.  
  55. // --- Readiness Promise ---
  56. let resolveReadyPromise;
  57. /** @type {Promise<object>} A promise that resolves with the public API when the manager is ready. */
  58. const readyPromise = new Promise(resolve => { resolveReadyPromise = resolve; });
  59.  
  60. // --- Public API Definition ---
  61. const publicApi = Object.freeze({
  62. /** A Promise that resolves with the API object itself once the manager is initialized and ready. */
  63. ready: readyPromise,
  64. /**
  65. * Registers a new settings tab.
  66. * @param {object} config - The configuration object for the tab.
  67. * @param {string} config.scriptId - A unique identifier for the script registering the tab.
  68. * @param {string} config.tabTitle - The text displayed on the tab.
  69. * @param {(panelElement: HTMLDivElement, tabElement: HTMLSpanElement) => void | Promise<void>} config.onInit - Callback function executed once when the tab and panel are first created. Receives the panel's content div and the tab span element. Can be async.
  70. * @param {number} [config.order] - Optional number to control the tab's position relative to other STM tabs (lower numbers appear first). Defaults to appearing last.
  71. * @param {(panelElement: HTMLDivElement | null, tabElement: HTMLSpanElement | null) => void} [config.onActivate] - Optional callback executed when this tab becomes active. Receives the panel and tab elements (or null if lookup fails).
  72. * @param {(panelElement: HTMLDivElement | null, tabElement: HTMLSpanElement | null) => void} [config.onDeactivate] - Optional callback executed when this tab becomes inactive. Receives the panel and tab elements (or null if lookup fails).
  73. * @returns {boolean} True if the registration was accepted (either processed immediately or queued), false if validation failed.
  74. */
  75. registerTab: (config) => registerTabImpl(config),
  76. /**
  77. * Programmatically activates a registered STM tab.
  78. * @param {string} scriptId - The unique scriptId of the tab to activate.
  79. */
  80. activateTab: (scriptId) => activateTabImpl(scriptId),
  81. /**
  82. * Retrieves the DOM element for a registered tab's content panel.
  83. * @param {string} scriptId - The scriptId of the tab.
  84. * @returns {HTMLDivElement | null} The panel element, or null if not found or not initialized.
  85. */
  86. getPanelElement: (scriptId) => getPanelElementImpl(scriptId),
  87. /**
  88. * Retrieves the DOM element for a registered tab's clickable tab element.
  89. * @param {string} scriptId - The scriptId of the tab.
  90. * @returns {HTMLSpanElement | null} The tab element, or null if not found or not initialized.
  91. */
  92. getTabElement: (scriptId) => getTabElementImpl(scriptId)
  93. });
  94.  
  95. // --- Styling ---
  96. GM_addStyle(`
  97. /* Ensure panels added by STM behave like native ones */
  98. ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}] {
  99. display: none; /* Hide inactive panels */
  100. }
  101. ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}].${ACTIVE_CLASSES.PANEL} {
  102. display: block; /* Show active panel */
  103. }
  104. /* Basic styling for the added tabs */
  105. ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}] {
  106. cursor: pointer;
  107. }
  108. /* Styling for the single separator */
  109. ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.SEPARATOR}] {
  110. cursor: default;
  111. margin: 0 5px; /* Add some spacing around the separator */
  112. user-select: none; /* Prevent text selection */
  113. -webkit-user-select: none;
  114. }
  115. `);
  116.  
  117. // --- Core Logic Implementation Functions ---
  118.  
  119. /**
  120. * Finds and caches the essential DOM elements for the settings UI.
  121. * @returns {boolean} True if all required elements are found and attached to the DOM, false otherwise.
  122. */
  123. function findSettingsElements() {
  124. settingsMenuEl = document.querySelector(SELECTORS.SETTINGS_MENU);
  125. if (!settingsMenuEl) return false;
  126.  
  127. tabContainerEl = settingsMenuEl.querySelector(SELECTORS.TAB_CONTAINER);
  128. panelContainerEl = settingsMenuEl.querySelector(SELECTORS.PANEL_CONTAINER);
  129.  
  130. if (!tabContainerEl) {
  131. warn('Tab container not found within settings menu using selector:', SELECTORS.TAB_CONTAINER);
  132. return false;
  133. }
  134. if (!panelContainerEl) {
  135. warn('Panel container not found within settings menu using selector:', SELECTORS.PANEL_CONTAINER);
  136. return false;
  137. }
  138. // Ensure the elements are still in the document (relevant for re-init checks)
  139. if (!document.body.contains(settingsMenuEl) || !document.body.contains(tabContainerEl) || !document.body.contains(panelContainerEl)) {
  140. warn('Found settings elements are detached from the DOM.');
  141. settingsMenuEl = null;
  142. tabContainerEl = null;
  143. panelContainerEl = null;
  144. isSeparatorAdded = false; // Reset separator if containers are gone
  145. activeStmTabId = null; // Reset active tab state
  146. return false;
  147. }
  148. return true;
  149. }
  150.  
  151. /**
  152. * Internal helper: Deactivates the specified STM tab visually and triggers its onDeactivate callback.
  153. * Does *not* change `activeStmTabId`.
  154. * @param {string} scriptId The ID of the STM tab to deactivate.
  155. */
  156. function _deactivateStmTabVisualsAndCallback(scriptId) {
  157. if (!scriptId) return;
  158.  
  159. const config = registeredTabs.get(scriptId);
  160. const tab = getTabElementImpl(scriptId);
  161. const panel = getPanelElementImpl(scriptId);
  162.  
  163. tab?.classList.remove(ACTIVE_CLASSES.TAB);
  164. if (panel) {
  165. panel.classList.remove(ACTIVE_CLASSES.PANEL);
  166. // Ensure display is none, overriding potential inline styles from activation
  167. panel.style.display = 'none';
  168. }
  169.  
  170. // Call the script's deactivate hook only if config exists
  171. if (config?.onDeactivate) {
  172. try {
  173. config.onDeactivate(panel, tab); // Pass potentially null elements
  174. } catch (e) {
  175. error(`Error during onDeactivate for ${scriptId}:`, e);
  176. }
  177. }
  178. }
  179.  
  180. /**
  181. * Internal helper: Activates the specified STM tab visually and triggers its onActivate callback.
  182. * Does *not* change `activeStmTabId` or deactivate other tabs.
  183. * @param {string} scriptId The ID of the STM tab to activate.
  184. * @returns {boolean} True if activation visuals/callback were attempted successfully (elements found), false otherwise.
  185. */
  186. function _activateStmTabVisualsAndCallback(scriptId) {
  187. const config = registeredTabs.get(scriptId);
  188. if (!config) {
  189. error(`Cannot activate visuals: Config not found for ${scriptId}.`);
  190. return false;
  191. }
  192.  
  193. const tab = getTabElementImpl(scriptId);
  194. const panel = getPanelElementImpl(scriptId);
  195.  
  196. if (!tab || !panel) {
  197. error(`Cannot activate visuals: Tab or Panel element not found for ${scriptId}.`);
  198. return false;
  199. }
  200.  
  201. // Activate the new STM tab/panel visuals
  202. tab.classList.add(ACTIVE_CLASSES.TAB);
  203. panel.classList.add(ACTIVE_CLASSES.PANEL);
  204. // Explicitly set display to block, ensuring visibility even if default CSS is overridden elsewhere
  205. panel.style.display = 'block';
  206.  
  207. // Call the script's activation hook
  208. if (config.onActivate) {
  209. try {
  210. config.onActivate(panel, tab);
  211. } catch (e) {
  212. error(`Error during onActivate for ${scriptId}:`, e);
  213. // Activation visuals already applied, difficult to revert cleanly. Error logged.
  214. }
  215. }
  216. return true; // Activation attempted
  217. }
  218.  
  219. /**
  220. * Deactivates all *native* (non-STM) tabs and panels.
  221. * Used when switching from a native tab to an STM tab.
  222. */
  223. function _deactivateNativeTabs() {
  224. if (!tabContainerEl || !panelContainerEl) return;
  225.  
  226. panelContainerEl.querySelectorAll(`:scope > ${SELECTORS.SITE_PANEL}.${ACTIVE_CLASSES.PANEL}:not([${ATTRS.MANAGED}])`)
  227. .forEach(p => p.classList.remove(ACTIVE_CLASSES.PANEL));
  228. tabContainerEl.querySelectorAll(`:scope > ${SELECTORS.SITE_TAB}.${ACTIVE_CLASSES.TAB}:not([${ATTRS.MANAGED}])`)
  229. .forEach(t => t.classList.remove(ACTIVE_CLASSES.TAB));
  230. // log('Deactivated native tabs/panels.');
  231. }
  232.  
  233. /**
  234. * Handles clicks within the tab container to switch between native and STM tabs.
  235. * Uses event delegation on the tab container.
  236. * @param {MouseEvent} event - The click event.
  237. */
  238. function handleTabClick(event) {
  239. // Ensure containers are still valid before proceeding
  240. if (!tabContainerEl || !panelContainerEl || !event.target) {
  241. return;
  242. }
  243.  
  244. // Find the closest ancestor that is a tab element (native or STM)
  245. const clickedTabElement = event.target.closest(SELECTORS.SITE_TAB);
  246. if (!clickedTabElement) {
  247. // Clicked outside any tab (e.g., empty space, maybe separator if not matched by SITE_TAB)
  248. // Check specifically for our separator
  249. if (event.target.closest(`span[${ATTRS.SEPARATOR}]`)) {
  250. event.stopPropagation(); // Prevent any site action if separator is clicked
  251. return;
  252. }
  253. return; // Not a relevant click
  254. }
  255.  
  256. const isStmTab = clickedTabElement.matches(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`);
  257. const clickedStmScriptId = isStmTab ? clickedTabElement.getAttribute(ATTRS.SCRIPT_ID) : null;
  258.  
  259. // --- Case 1: Clicked an STM Tab ---
  260. if (isStmTab && clickedStmScriptId) {
  261. event.stopPropagation(); // Prevent site's default handler for tabs
  262.  
  263. if (clickedStmScriptId === activeStmTabId) {
  264. // log(`Clicked already active STM tab: ${clickedStmScriptId}`);
  265. return; // Already active, do nothing
  266. }
  267.  
  268. // Deactivate previously active tab (could be native or another STM tab)
  269. if (activeStmTabId) {
  270. _deactivateStmTabVisualsAndCallback(activeStmTabId); // Deactivate previous STM tab
  271. } else {
  272. _deactivateNativeTabs(); // Deactivate native tabs if one was active
  273. }
  274.  
  275. // Activate the clicked STM tab
  276. if (_activateStmTabVisualsAndCallback(clickedStmScriptId)) {
  277. activeStmTabId = clickedStmScriptId; // Update state *after* successful activation attempt
  278. } else {
  279. // Activation failed (elements missing?) - log already happened inside helper.
  280. // Reset state to avoid inconsistent UI.
  281. activeStmTabId = null;
  282. }
  283. return; // Handled by STM
  284. }
  285.  
  286. // --- Case 2: Clicked a Native Site Tab ---
  287. // Check it's specifically a non-managed tab to avoid accidental matches if selectors overlap badly
  288. if (clickedTabElement.matches(`${SELECTORS.SITE_TAB}:not([${ATTRS.MANAGED}])`)) {
  289. // If an STM tab *was* active, deactivate it visually and clear STM's active state.
  290. if (activeStmTabId) {
  291. // log(`Deactivating current STM tab (${activeStmTabId}) due to native tab click.`);
  292. _deactivateStmTabVisualsAndCallback(activeStmTabId);
  293. activeStmTabId = null; // Clear STM state, site handler will manage native state
  294. }
  295.  
  296. // **Allow event propagation** - Let the site's own click handler manage the native tab switch.
  297. // log("Allowing event propagation for native tab handler.");
  298. return;
  299. }
  300.  
  301. // --- Case 3: Clicked something else matching SITE_TAB (e.g., STM separator if it has SITE_TAB class) ---
  302. // If it's our separator, stop propagation. (This check might be redundant if separator doesn't have SITE_TAB class)
  303. if (clickedTabElement.matches(`span[${ATTRS.SEPARATOR}]`)) {
  304. event.stopPropagation();
  305. return;
  306. }
  307.  
  308. // Otherwise, ignore (shouldn't happen often if selectors are correct).
  309. }
  310.  
  311. /** Attaches the main click listener to the tab container using the capture phase. */
  312. function attachTabClickListener() {
  313. if (!tabContainerEl) return;
  314. // Remove existing listener before adding to prevent duplicates if re-initialized
  315. tabContainerEl.removeEventListener('click', handleTabClick, true);
  316. tabContainerEl.addEventListener('click', handleTabClick, true); // Use capture phase to run before site's potential handlers
  317. log('Tab click listener attached.');
  318. }
  319.  
  320. /**
  321. * Creates the single separator span element used between native and STM tabs.
  322. * @returns {HTMLSpanElement} The separator element.
  323. */
  324. function createSeparator() {
  325. const separator = document.createElement('span');
  326. // Use the site's separator class if defined, otherwise a fallback
  327. separator.className = SELECTORS.SITE_SEPARATOR.startsWith('.')
  328. ? SELECTORS.SITE_SEPARATOR.substring(1)
  329. : 'settings-tab-separator-fallback'; // Basic fallback class name
  330. separator.setAttribute(ATTRS.MANAGED, 'true');
  331. separator.setAttribute(ATTRS.SEPARATOR, 'true');
  332. separator.textContent = '|'; // Simple visual separator
  333. return separator;
  334. }
  335.  
  336. /**
  337. * Creates and inserts the tab and panel DOM elements for a given script configuration.
  338. * Handles ordering and the single separator insertion.
  339. * @param {object} config - The registration configuration object for the tab.
  340. */
  341. function createTabAndPanel(config) {
  342. if (!tabContainerEl || !panelContainerEl) {
  343. error(`Cannot create tab/panel for ${config.scriptId}: Containers not found.`);
  344. return;
  345. }
  346. // Avoid creating duplicates if called multiple times (e.g., during re-init)
  347. if (getTabElementImpl(config.scriptId) || getPanelElementImpl(config.scriptId)) {
  348. log(`Tab or panel element already exists for ${config.scriptId}, skipping creation.`);
  349. return;
  350. }
  351.  
  352. log(`Creating tab/panel for: ${config.scriptId}`);
  353.  
  354. // --- Create Tab Element ---
  355. const newTab = document.createElement('span');
  356. newTab.className = SELECTORS.SITE_TAB.substring(1); // Use site's tab class
  357. newTab.textContent = config.tabTitle;
  358. newTab.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
  359. newTab.setAttribute(ATTRS.MANAGED, 'true');
  360. newTab.setAttribute('title', `${config.tabTitle} (Settings by ${config.scriptId})`);
  361. const desiredOrder = config.order ?? Infinity; // Use nullish coalescing for default
  362. newTab.setAttribute(ATTRS.ORDER, desiredOrder);
  363.  
  364. // --- Create Panel Element ---
  365. const newPanel = document.createElement('div');
  366. newPanel.className = SELECTORS.SITE_PANEL.substring(1); // Use site's panel class
  367. newPanel.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
  368. newPanel.setAttribute(ATTRS.MANAGED, 'true');
  369. newPanel.id = `${MANAGER_ID}-${config.scriptId}-panel`; // Unique ID for the panel
  370.  
  371. // --- Insertion Logic (Separator & Ordering) ---
  372. let insertBeforeTab = null;
  373. // Get existing STM tabs, convert NodeList to Array for sorting
  374. const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));
  375.  
  376. // Sort existing tabs by their order attribute to find the correct insertion point
  377. existingStmTabs.sort((a, b) => (parseFloat(a.getAttribute(ATTRS.ORDER) ?? Infinity)) - (parseFloat(b.getAttribute(ATTRS.ORDER) ?? Infinity)));
  378.  
  379. // Find the first existing STM tab with a higher order number
  380. for (const existingTab of existingStmTabs) {
  381. if (desiredOrder < (parseFloat(existingTab.getAttribute(ATTRS.ORDER) ?? Infinity))) {
  382. insertBeforeTab = existingTab;
  383. break;
  384. }
  385. }
  386.  
  387. // Add the separator only once, before the *first* STM tab is added
  388. let separatorInstance = null;
  389. if (!isSeparatorAdded && existingStmTabs.length === 0) {
  390. separatorInstance = createSeparator();
  391. isSeparatorAdded = true; // Set flag
  392. log('Adding the main STM separator.');
  393. }
  394.  
  395. // Perform the insertion
  396. if (insertBeforeTab) {
  397. // Insert separator (if created) and new tab before the found element
  398. if (separatorInstance) tabContainerEl.insertBefore(separatorInstance, insertBeforeTab);
  399. tabContainerEl.insertBefore(newTab, insertBeforeTab);
  400. } else {
  401. // Append separator (if created) and new tab to the end
  402. if (separatorInstance) tabContainerEl.appendChild(separatorInstance);
  403. tabContainerEl.appendChild(newTab);
  404. }
  405. // Append the panel to the panel container (order doesn't matter visually here)
  406. panelContainerEl.appendChild(newPanel);
  407.  
  408. // --- Initialize Panel Content (Safely) ---
  409. try {
  410. // Use Promise.resolve to handle both sync and async onInit functions gracefully
  411. Promise.resolve(config.onInit(newPanel, newTab)).catch(e => {
  412. error(`Error during async onInit for ${config.scriptId}:`, e);
  413. newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
  414. });
  415. } catch (e) {
  416. // Catch synchronous errors from onInit
  417. error(`Error during sync onInit for ${config.scriptId}:`, e);
  418. newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
  419. }
  420. }
  421.  
  422. /** Processes all pending registrations that arrived before initialization was complete. */
  423. function processPendingRegistrations() {
  424. if (!isInitialized) return; // Should not happen if called correctly, but safe check
  425. if (pendingRegistrations.length === 0) return; // Nothing to process
  426.  
  427. log(`Processing ${pendingRegistrations.length} pending registrations...`);
  428.  
  429. // Sort the pending queue by order *before* processing
  430. pendingRegistrations.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
  431.  
  432. while (pendingRegistrations.length > 0) {
  433. const config = pendingRegistrations.shift(); // Process in sorted order
  434. // Double-check it wasn't somehow registered already (e.g., edge case with re-init)
  435. if (!registeredTabs.has(config.scriptId)) {
  436. registeredTabs.set(config.scriptId, config);
  437. createTabAndPanel(config); // This function handles ordering relative to existing tabs
  438. } else {
  439. warn(`Script ID ${config.scriptId} was already registered. Skipping pending registration.`);
  440. }
  441. }
  442. log('Finished processing pending registrations.');
  443. }
  444.  
  445. // --- Initialization and Observation ---
  446.  
  447. /**
  448. * Main initialization routine. Finds elements, attaches listener, processes queue, resolves ready promise.
  449. * @returns {boolean} True if initialization was successful, false otherwise.
  450. */
  451. function initializeManager() {
  452. // Attempt to find the core elements
  453. if (!findSettingsElements()) {
  454. // log('Settings elements not found yet.');
  455. return false; // Cannot initialize
  456. }
  457.  
  458. // If already initialized AND the elements are still valid, just ensure listener is attached and exit.
  459. // This handles cases where the observer might trigger initialize multiple times without DOM changes.
  460. if (isInitialized && settingsMenuEl && tabContainerEl && panelContainerEl) {
  461. // log('Manager already initialized and elements are valid. Ensuring listener is attached.');
  462. attachTabClickListener(); // Re-attach listener just in case it was somehow removed
  463. return true;
  464. }
  465.  
  466. log('Initializing Settings Tab Manager...');
  467. attachTabClickListener();
  468. isInitialized = true;
  469. log('Manager is now initialized and ready.');
  470.  
  471. // Process any registrations that occurred before init completed
  472. processPendingRegistrations();
  473.  
  474. // Resolve the public promise to signal readiness to consumer scripts
  475. resolveReadyPromise(publicApi);
  476.  
  477. return true;
  478. }
  479.  
  480. /**
  481. * MutationObserver callback to detect when the settings menu is added to or removed from the DOM.
  482. * @param {MutationRecord[]} mutationsList - List of mutations that occurred.
  483. * @param {MutationObserver} obs - The observer instance.
  484. */
  485. function handleDOMChanges(mutationsList, obs) {
  486. let needsReInitCheck = false;
  487. let settingsMenuFoundInMutation = false;
  488.  
  489. // Scenario 1: Manager isn't initialized yet, check if the settings menu was added.
  490. if (!isInitialized) {
  491. if (document.querySelector(SELECTORS.SETTINGS_MENU)) {
  492. // Menu exists in the DOM now, maybe added before observer caught it or by this mutation.
  493. needsReInitCheck = true;
  494. settingsMenuFoundInMutation = true; // Assume it might have been added
  495. } else {
  496. // Menu doesn't exist, check if it was added in this specific mutation batch
  497. for (const mutation of mutationsList) {
  498. if (mutation.addedNodes) {
  499. for (const node of mutation.addedNodes) {
  500. if (node.nodeType === Node.ELEMENT_NODE) {
  501. // Check if the added node itself is the menu, or if it contains the menu
  502. const menu = (node.matches?.(SELECTORS.SETTINGS_MENU)) ? node : node.querySelector?.(SELECTORS.SETTINGS_MENU);
  503. if (menu) {
  504. log('Settings menu detected in addedNodes via MutationObserver.');
  505. needsReInitCheck = true;
  506. settingsMenuFoundInMutation = true;
  507. break; // Found it
  508. }
  509. }
  510. }
  511. }
  512. if (settingsMenuFoundInMutation) break; // Stop checking mutations if found
  513. }
  514. }
  515. }
  516. // Scenario 2: Manager *was* initialized, check if the menu element was removed.
  517. else if (isInitialized && settingsMenuEl && !document.body.contains(settingsMenuEl)) {
  518. warn('Settings menu seems to have been removed from the DOM. De-initializing.');
  519. // Reset state
  520. isInitialized = false;
  521. settingsMenuEl = null;
  522. tabContainerEl = null;
  523. panelContainerEl = null;
  524. isSeparatorAdded = false;
  525. activeStmTabId = null;
  526. // Don't clear registeredTabs or pendingRegistrations, they might be needed if menu reappears
  527. // We need to check again later if the menu reappears
  528. needsReInitCheck = true; // Trigger a check in case it was immediately re-added
  529. }
  530.  
  531. // If a check is needed (menu added or removed), attempt initialization asynchronously.
  532. // Using setTimeout defers execution slightly, allowing the DOM to stabilize after mutations.
  533. if (needsReInitCheck) {
  534. // log('Mutation detected, scheduling initialization/re-check.'); // Can be verbose
  535. setTimeout(() => {
  536. if (initializeManager()) {
  537. // Successfully initialized or re-initialized
  538. if (settingsMenuFoundInMutation) {
  539. log('Manager initialized successfully following menu detection.');
  540. } else {
  541. // This case might occur if the menu was removed and re-added quickly,
  542. // or if initializeManager was called due to state reset.
  543. log('Manager state updated/re-initialized.');
  544. }
  545. } else {
  546. // Initialization failed (e.g., menu found but internal structure missing)
  547. // log('Initialization attempt after mutation failed.'); // Can be verbose
  548. }
  549. }, 0);
  550. }
  551. }
  552.  
  553. // --- Start Observer ---
  554. const observer = new MutationObserver(handleDOMChanges);
  555. observer.observe(document.body, {
  556. childList: true, // Watch for addition/removal of child nodes
  557. subtree: true // Watch descendants as well
  558. });
  559. log('Mutation observer started for settings menu detection.');
  560.  
  561. // --- Initial Initialization Attempt ---
  562. // Try to initialize immediately after script runs, in case the menu is already present.
  563. // Use setTimeout to ensure it runs after the current execution context.
  564. setTimeout(initializeManager, 0);
  565.  
  566.  
  567. // --- API Implementation Functions ---
  568.  
  569. /** @inheritdoc */
  570. function registerTabImpl(config) {
  571. // --- Input Validation ---
  572. if (!config || typeof config !== 'object') { error('Registration failed: Invalid config object provided.'); return false; }
  573. const { scriptId, tabTitle, onInit } = config; // Destructure required fields for checks
  574.  
  575. // Validate required fields
  576. if (typeof scriptId !== 'string' || !scriptId.trim()) { error('Registration failed: Invalid or missing scriptId (string).', config); return false; }
  577. if (typeof tabTitle !== 'string' || !tabTitle.trim()) { error(`Registration failed for ${scriptId}: Invalid or missing tabTitle (string).`, config); return false; }
  578. if (typeof onInit !== 'function') { error(`Registration failed for ${scriptId}: onInit callback must be a function.`, config); return false; }
  579.  
  580. // Validate optional fields
  581. if (config.onActivate !== undefined && typeof config.onActivate !== 'function') { error(`Registration for ${scriptId} failed: onActivate (if provided) must be a function.`); return false; }
  582. if (config.onDeactivate !== undefined && typeof config.onDeactivate !== 'function') { error(`Registration for ${scriptId} failed: onDeactivate (if provided) must be a function.`); return false; }
  583. if (config.order !== undefined && (typeof config.order !== 'number' || !isFinite(config.order))) {
  584. warn(`Registration for ${scriptId}: Invalid 'order' value provided (must be a finite number). Using default order.`, config);
  585. delete config.order; // Remove invalid order so default (Infinity) applies
  586. }
  587.  
  588. // Check for duplicates
  589. if (registeredTabs.has(scriptId) || pendingRegistrations.some(p => p.scriptId === scriptId)) {
  590. warn(`Registration failed: Script ID "${scriptId}" is already registered or pending registration.`);
  591. return false;
  592. }
  593. // --- End Validation ---
  594.  
  595. log(`Registration accepted for: ${scriptId}`);
  596. const registrationData = { ...config }; // Shallow clone to avoid external modification
  597.  
  598. if (isInitialized) {
  599. // Manager is ready, register and create elements immediately
  600. registeredTabs.set(scriptId, registrationData);
  601. createTabAndPanel(registrationData); // Handles insertion order
  602. } else {
  603. // Manager not ready, add to the pending queue
  604. log(`Manager not ready, queueing registration for ${scriptId}`);
  605. pendingRegistrations.push(registrationData);
  606. // Keep queue sorted for predictable processing later (though createTabAndPanel handles final order)
  607. pendingRegistrations.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
  608. }
  609. return true; // Registration accepted (processed or queued)
  610. }
  611.  
  612. /** @inheritdoc */
  613. function activateTabImpl(scriptId) {
  614. // console.trace(`[${MANAGER_ID}] activateTabImpl called with ID: ${scriptId}`); // Uncomment for deep debugging
  615.  
  616. if (typeof scriptId !== 'string' || !scriptId.trim()) {
  617. error('activateTab failed: Invalid scriptId provided (must be a non-empty string).'); return;
  618. }
  619. if (!isInitialized) {
  620. warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`); return;
  621. }
  622. if (!registeredTabs.has(scriptId)) {
  623. error(`activateTab failed: Script ID "${scriptId}" is not registered.`); return;
  624. }
  625. if (scriptId === activeStmTabId) {
  626. // log(`activateTab: Tab ${scriptId} is already active.`);
  627. return; // Already active, do nothing
  628. }
  629.  
  630. // Deactivate previously active tab (could be native or another STM tab)
  631. if (activeStmTabId) {
  632. _deactivateStmTabVisualsAndCallback(activeStmTabId); // Deactivate previous STM tab
  633. } else {
  634. // If no STM tab was active, assume a native tab might be. Deactivate native tabs.
  635. _deactivateNativeTabs();
  636. }
  637.  
  638. // Activate the requested STM tab
  639. if (_activateStmTabVisualsAndCallback(scriptId)) {
  640. activeStmTabId = scriptId; // Update the state *after* successful activation attempt
  641. log(`Programmatically activated tab: ${scriptId}`);
  642. } else {
  643. // Activation failed (elements missing?) - logs already happened inside helper.
  644. warn(`Programmatic activation failed for tab: ${scriptId}. Resetting active tab state.`);
  645. activeStmTabId = null; // Reset state to avoid inconsistency
  646. }
  647. }
  648.  
  649. /** @inheritdoc */
  650. function getPanelElementImpl(scriptId) {
  651. if (!isInitialized || !panelContainerEl || typeof scriptId !== 'string' || !scriptId.trim()) {
  652. return null;
  653. }
  654. // Use optional chaining for safety, though panelContainerEl is checked above
  655. return panelContainerEl?.querySelector(`div[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  656. }
  657.  
  658. /** @inheritdoc */
  659. function getTabElementImpl(scriptId) {
  660. if (!isInitialized || !tabContainerEl || typeof scriptId !== 'string' || !scriptId.trim()) {
  661. return null;
  662. }
  663. // Use optional chaining for safety
  664. return tabContainerEl?.querySelector(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  665. }
  666.  
  667. // --- Global Exposure ---
  668. /**
  669. * Exposes the public API on `unsafeWindow` for cross-script communication.
  670. * `unsafeWindow` bypasses the userscript sandbox, allowing different scripts
  671. * managed by the same userscript engine instance to access the same object.
  672. */
  673. function exposeApiGlobally() {
  674. log('Attempting to expose API globally on unsafeWindow...');
  675. try {
  676. // `unsafeWindow` is provided by some userscript managers (e.g., Tampermonkey, Violentmonkey)
  677. // It represents the raw page `window` object.
  678. if (typeof unsafeWindow === 'undefined') {
  679. throw new Error('`unsafeWindow` is not available. Cannot guarantee cross-script communication.');
  680. }
  681.  
  682. // Check if the API is already defined, possibly by a duplicate script instance.
  683. if (typeof unsafeWindow.SettingsTabManager !== 'undefined') {
  684. // Avoid overwriting if it's the exact same object (e.g., script ran twice weirdly but points to same instance)
  685. // Though realistically, multiple instances would create distinct objects.
  686. if (unsafeWindow.SettingsTabManager !== publicApi) {
  687. warn('`unsafeWindow.SettingsTabManager` is already defined by potentially another script or instance! Overwriting. Check for duplicate STM scripts.');
  688. } else {
  689. warn('`unsafeWindow.SettingsTabManager` seems to be defined already *as this exact object*. This might indicate the script ran multiple times.');
  690. // No need to redefine if it's the same object.
  691. return;
  692. }
  693. // Attempt to remove the old property before redefining. Might fail if non-configurable.
  694. try {
  695. delete unsafeWindow.SettingsTabManager;
  696. } catch (deleteErr) {
  697. warn('Could not delete previous unsafeWindow.SettingsTabManager definition.', deleteErr);
  698. }
  699. }
  700.  
  701. // Define the property on unsafeWindow.
  702. Object.defineProperty(unsafeWindow, 'SettingsTabManager', {
  703. value: publicApi,
  704. writable: false, // Prevent accidental reassignment by page scripts or basic user errors.
  705. configurable: true // Allow removal/redefinition by other userscripts or the manager if needed.
  706. });
  707.  
  708. // Verify that the definition succeeded.
  709. if (unsafeWindow.SettingsTabManager === publicApi) {
  710. log('SettingsTabManager API exposed successfully on unsafeWindow.');
  711. } else {
  712. // This should ideally not happen if defineProperty doesn't throw.
  713. throw new Error('Failed to verify API definition on unsafeWindow after defineProperty.');
  714. }
  715.  
  716. } catch (e) {
  717. error('Fatal error exposing SettingsTabManager API:', e);
  718. // Attempt to alert the user, as dependent scripts will likely fail.
  719. try {
  720. alert(`${MANAGER_ID} Error: Failed to expose API globally on unsafeWindow. Other scripts depending on STM may fail. Check the browser console (F12) for details.`);
  721. } catch (alertErr) {
  722. error('Failed to show alert about API exposure failure.');
  723. }
  724. }
  725. }
  726.  
  727. exposeApiGlobally(); // Expose the API
  728.  
  729. })(); // End of IIFE