Settings Tab Manager (STM)

Provides an API for other userscripts to add tabs to a site's settings menu.

目前为 2025-04-22 提交的版本,查看 最新版本

此脚本不应直接安装,它是供其他脚本使用的外部库。如果你需要使用该库,请在脚本元属性加入:// @require https://update.cn-greasyfork.org/scripts/533630/1575650/Settings%20Tab%20Manager%20%28STM%29.js

  1. // ==UserScript==
  2. // @name Settings Tab Manager (STM)
  3. // @namespace shared-settings-manager
  4. // @version 1.1.1
  5. // @description Provides an API for other userscripts to add tabs to a site's settings menu.
  6. // @author 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. const MANAGER_ID = 'SettingsTabManager';
  18. const log = (...args) => console.log(`[${MANAGER_ID}]`, ...args);
  19. const warn = (...args) => console.warn(`[${MANAGER_ID}]`, ...args);
  20. const error = (...args) => console.error(`[${MANAGER_ID}]`, ...args);
  21.  
  22. // --- Configuration ---
  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',
  28. SITE_PANEL: '.panelContents',
  29. SITE_SEPARATOR: '.settingsTabSeparator',
  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',
  37. MANAGED: 'data-stm-managed',
  38. });
  39.  
  40. // --- State ---
  41. let isInitialized = false;
  42. let settingsMenuEl = null;
  43. let tabContainerEl = null;
  44. let panelContainerEl = null;
  45. let activeTabId = null;
  46. const registeredTabs = new Map();
  47. const pendingRegistrations = [];
  48.  
  49. // --- Readiness Promise ---
  50. let resolveReadyPromise;
  51. const readyPromise = new Promise(resolve => {
  52. resolveReadyPromise = resolve;
  53. });
  54.  
  55. // --- Public API Definition (MOVED EARLIER) ---
  56. // Define the API object that will be exposed and resolved by the promise.
  57. // Functions it references must be defined *before* they are called by client scripts,
  58. // but the functions themselves can be defined later in this script, thanks to hoisting.
  59. const publicApi = Object.freeze({
  60. ready: readyPromise,
  61. registerTab: (config) => {
  62. // Implementation uses functions defined later (registerTabImpl)
  63. return registerTabImpl(config);
  64. },
  65. activateTab: (scriptId) => {
  66. // Implementation uses functions defined later (activateTabImpl)
  67. activateTabImpl(scriptId);
  68. },
  69. getPanelElement: (scriptId) => {
  70. // Implementation uses functions defined later (getPanelElementImpl)
  71. return getPanelElementImpl(scriptId);
  72. },
  73. getTabElement: (scriptId) => {
  74. // Implementation uses functions defined later (getTabElementImpl)
  75. return getTabElementImpl(scriptId);
  76. }
  77. });
  78.  
  79. // --- Styling ---
  80. GM_addStyle(`
  81. /* Ensure panels added by STM behave like native ones */
  82. ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}] {
  83. display: none; /* Hide inactive panels */
  84. }
  85. ${SELECTORS.PANEL_CONTAINER} > div[${ATTRS.MANAGED}].${ACTIVE_CLASSES.PANEL} {
  86. display: block; /* Show active panel */
  87. }
  88. /* Optional: Basic styling for the added tabs */
  89. ${SELECTORS.TAB_CONTAINER} > span[${ATTRS.MANAGED}] {
  90. cursor: pointer;
  91. }
  92. `);
  93.  
  94. // --- Core Logic Implementation Functions ---
  95. // (Functions like findSettingsElements, deactivateCurrentTab, activateTab, handleTabClick, etc.)
  96.  
  97. /** Finds the essential DOM elements for the settings UI. Returns true if all found. */
  98. function findSettingsElements() {
  99. settingsMenuEl = document.querySelector(SELECTORS.SETTINGS_MENU);
  100. if (!settingsMenuEl) return false;
  101.  
  102. tabContainerEl = settingsMenuEl.querySelector(SELECTORS.TAB_CONTAINER);
  103. panelContainerEl = settingsMenuEl.querySelector(SELECTORS.PANEL_CONTAINER);
  104.  
  105. if (!tabContainerEl) {
  106. warn('Tab container not found within settings menu using selector:', SELECTORS.TAB_CONTAINER);
  107. return false;
  108. }
  109. if (!panelContainerEl) {
  110. warn('Panel container not found within settings menu using selector:', SELECTORS.PANEL_CONTAINER);
  111. return false;
  112. }
  113. return true;
  114. }
  115.  
  116. /** Deactivates the currently active STM tab (if any). */
  117. function deactivateCurrentTab() {
  118. if (activeTabId && registeredTabs.has(activeTabId)) {
  119. const config = registeredTabs.get(activeTabId);
  120. const tab = tabContainerEl?.querySelector(`span[${ATTRS.SCRIPT_ID}="${activeTabId}"]`);
  121. const panel = panelContainerEl?.querySelector(`div[${ATTRS.SCRIPT_ID}="${activeTabId}"]`);
  122.  
  123. tab?.classList.remove(ACTIVE_CLASSES.TAB);
  124. panel?.classList.remove(ACTIVE_CLASSES.PANEL);
  125. if (panel) panel.style.display = 'none';
  126.  
  127. try {
  128. config.onDeactivate?.(panel, tab);
  129. } catch (e) {
  130. error(`Error during onDeactivate for ${activeTabId}:`, e);
  131. }
  132. activeTabId = null;
  133. }
  134. // Also remove active class from any site tabs/panels managed outside STM
  135. panelContainerEl?.querySelectorAll(`:scope > ${SELECTORS.SITE_PANEL}.${ACTIVE_CLASSES.PANEL}:not([${ATTRS.MANAGED}])`)
  136. .forEach(p => p.classList.remove(ACTIVE_CLASSES.PANEL));
  137. tabContainerEl?.querySelectorAll(`:scope > ${SELECTORS.SITE_TAB}.${ACTIVE_CLASSES.TAB}:not([${ATTRS.MANAGED}])`)
  138. .forEach(t => t.classList.remove(ACTIVE_CLASSES.TAB));
  139.  
  140. }
  141.  
  142. /** Internal implementation for activating a tab */
  143. function activateTab(scriptId) { // Renamed from activateTabImpl for clarity within scope
  144. if (!registeredTabs.has(scriptId) || !tabContainerEl || !panelContainerEl) {
  145. warn(`Cannot activate tab: ${scriptId}. Not registered or containers not found.`);
  146. return;
  147. }
  148.  
  149. if (activeTabId === scriptId) return; // Already active
  150.  
  151. deactivateCurrentTab(); // Deactivate previous one first
  152.  
  153. const config = registeredTabs.get(scriptId);
  154. const tab = tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  155. const panel = panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  156.  
  157. if (!tab || !panel) {
  158. error(`Tab or Panel element not found for ${scriptId} during activation.`);
  159. return;
  160. }
  161.  
  162. tab.classList.add(ACTIVE_CLASSES.TAB);
  163. panel.classList.add(ACTIVE_CLASSES.PANEL);
  164. panel.style.display = 'block'; // Ensure it's visible
  165.  
  166. activeTabId = scriptId;
  167.  
  168. try {
  169. config.onActivate?.(panel, tab);
  170. } catch (e) {
  171. error(`Error during onActivate for ${scriptId}:`, e);
  172. }
  173. }
  174.  
  175. /** Handles clicks within the tab container. */
  176. function handleTabClick(event) {
  177. const clickedTab = event.target.closest(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`);
  178.  
  179. if (clickedTab) {
  180. event.stopPropagation();
  181. const scriptId = clickedTab.getAttribute(ATTRS.SCRIPT_ID);
  182. if (scriptId) {
  183. activateTab(scriptId); // Call the internal activate function
  184. }
  185. } else {
  186. if (event.target.closest(SELECTORS.SITE_TAB) && !event.target.closest(`span[${ATTRS.MANAGED}]`)) {
  187. deactivateCurrentTab();
  188. }
  189. }
  190. }
  191.  
  192. /** Attaches the main click listener to the tab container. */
  193. function attachTabClickListener() {
  194. if (!tabContainerEl) return;
  195. tabContainerEl.removeEventListener('click', handleTabClick, true);
  196. tabContainerEl.addEventListener('click', handleTabClick, true);
  197. log('Tab click listener attached.');
  198. }
  199.  
  200. /** Helper to create a separator span */
  201. function createSeparator(scriptId) {
  202. const separator = document.createElement('span');
  203. separator.className = SELECTORS.SITE_SEPARATOR ? SELECTORS.SITE_SEPARATOR.substring(1) : 'settings-tab-separator-fallback';
  204. separator.setAttribute(ATTRS.MANAGED, 'true');
  205. separator.setAttribute('data-stm-separator-for', scriptId);
  206. separator.textContent = '|';
  207. separator.style.cursor = 'default';
  208. return separator;
  209. }
  210.  
  211. /** Creates and inserts the tab and panel elements for a given script config. */
  212. function createTabAndPanel(config) {
  213. if (!tabContainerEl || !panelContainerEl) {
  214. error(`Cannot create tab/panel for ${config.scriptId}: Containers not found.`);
  215. return;
  216. }
  217. if (tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${config.scriptId}"]`)) {
  218. log(`Tab already exists for ${config.scriptId}, skipping creation.`);
  219. return;
  220. }
  221.  
  222. log(`Creating tab/panel for: ${config.scriptId}`);
  223.  
  224. // --- Create Tab ---
  225. const newTab = document.createElement('span');
  226. newTab.className = SELECTORS.SITE_TAB.substring(1);
  227. newTab.textContent = config.tabTitle;
  228. newTab.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
  229. newTab.setAttribute(ATTRS.MANAGED, 'true');
  230. newTab.setAttribute('title', `${config.tabTitle} (Settings by ${config.scriptId})`);
  231. const desiredOrder = typeof config.order === 'number' ? config.order : Infinity;
  232. newTab.setAttribute('data-stm-order', desiredOrder); // Store order attribute
  233.  
  234. // --- Create Panel ---
  235. const newPanel = document.createElement('div');
  236. newPanel.className = SELECTORS.SITE_PANEL.substring(1);
  237. newPanel.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
  238. newPanel.setAttribute(ATTRS.MANAGED, 'true');
  239. newPanel.id = `${MANAGER_ID}-${config.scriptId}-panel`;
  240.  
  241. // --- Insertion Logic (with basic ordering) ---
  242. let inserted = false;
  243. const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));
  244.  
  245. // Get combined list of STM tabs and separators for easier sorting/insertion
  246. const elementsToSort = existingStmTabs.map(tab => ({
  247. element: tab,
  248. order: parseInt(tab.getAttribute('data-stm-order') || Infinity, 10),
  249. isSeparator: false,
  250. separatorFor: tab.getAttribute(ATTRS.SCRIPT_ID)
  251. }));
  252.  
  253. // Find separators associated with STM tabs
  254. 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 => {
  255. let associatedTabId = sep.getAttribute('data-stm-separator-for');
  256. if (associatedTabId) {
  257. let associatedTab = elementsToSort.find(item => item.separatorFor === associatedTabId);
  258. if (associatedTab) {
  259. elementsToSort.push({ element: sep, order: associatedTab.order, isSeparator: true, separatorFor: associatedTabId });
  260. }
  261. }
  262. });
  263.  
  264. // Simplified: Find the correct place based on order among existing STM tabs
  265. let insertBeforeElement = null;
  266. for (const existingTab of existingStmTabs.sort((a, b) => parseInt(a.getAttribute('data-stm-order') || Infinity, 10) - parseInt(b.getAttribute('data-stm-order') || Infinity, 10))) {
  267. const existingOrder = parseInt(existingTab.getAttribute('data-stm-order') || Infinity, 10);
  268. if (desiredOrder < existingOrder) {
  269. insertBeforeElement = existingTab;
  270. break;
  271. }
  272. }
  273.  
  274. const newSeparator = createSeparator(config.scriptId); // Create separator regardless
  275.  
  276. if (insertBeforeElement) {
  277. // Check if the element before the target is a separator. If so, insert before that separator.
  278. const prevElement = insertBeforeElement.previousElementSibling;
  279. if (prevElement && prevElement.matches(`${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}]`)) {
  280. tabContainerEl.insertBefore(newSeparator, prevElement);
  281. tabContainerEl.insertBefore(newTab, prevElement); // Insert tab *after* its separator
  282. } else {
  283. // Insert separator and then tab right before the target element
  284. tabContainerEl.insertBefore(newSeparator, insertBeforeElement);
  285. tabContainerEl.insertBefore(newTab, insertBeforeElement);
  286. }
  287. inserted = true;
  288. }
  289.  
  290.  
  291. if (!inserted) {
  292. // Append at the end of other STM tabs (potentially before site's last tabs)
  293. const lastStmTab = existingStmTabs.pop(); // Get the last one from the originally selected list
  294. if (lastStmTab) {
  295. // Insert after the last STM tab
  296. tabContainerEl.insertBefore(newSeparator, lastStmTab.nextSibling); // Insert separator first
  297. tabContainerEl.insertBefore(newTab, newSeparator.nextSibling); // Insert tab after separator
  298. } else {
  299. // This is the first STM tab being added, append separator and tab
  300. tabContainerEl.appendChild(newSeparator);
  301. tabContainerEl.appendChild(newTab);
  302. }
  303. }
  304.  
  305. // Append Panel
  306. panelContainerEl.appendChild(newPanel);
  307.  
  308. // --- Initialize Panel Content ---
  309. try {
  310. Promise.resolve(config.onInit(newPanel, newTab)).catch(e => {
  311. error(`Error during async onInit for ${config.scriptId}:`, e);
  312. newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
  313. });
  314. } catch (e) {
  315. error(`Error during sync onInit for ${config.scriptId}:`, e);
  316. newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
  317. }
  318. }
  319.  
  320. /** Process all pending registrations. */
  321. function processPendingRegistrations() {
  322. if (!isInitialized) return;
  323. log(`Processing ${pendingRegistrations.length} pending registrations...`);
  324. while (pendingRegistrations.length > 0) {
  325. const config = pendingRegistrations.shift();
  326. if (!registeredTabs.has(config.scriptId)) {
  327. registeredTabs.set(config.scriptId, config);
  328. createTabAndPanel(config);
  329. } else {
  330. warn(`Script ID ${config.scriptId} was already registered. Skipping pending registration.`);
  331. }
  332. }
  333. }
  334.  
  335. // --- Initialization and Observation ---
  336.  
  337. /** Main initialization routine. */
  338. function initializeManager() {
  339. if (!findSettingsElements()) {
  340. log('Settings elements not found on init check.');
  341. return false;
  342. }
  343.  
  344. if (isInitialized) {
  345. log('Manager already initialized.');
  346. attachTabClickListener(); // Re-attach listener just in case
  347. return true;
  348. }
  349.  
  350. log('Initializing Settings Tab Manager...');
  351. attachTabClickListener();
  352.  
  353. isInitialized = true;
  354. log('Manager is ready.');
  355. // NOW it's safe to resolve the promise with publicApi
  356. resolveReadyPromise(publicApi);
  357.  
  358. processPendingRegistrations();
  359. return true;
  360. }
  361.  
  362. // Observer
  363. const observer = new MutationObserver((mutationsList, obs) => {
  364. let foundMenu = !!settingsMenuEl;
  365. let potentialReInit = false;
  366.  
  367. if (!foundMenu) {
  368. for (const mutation of mutationsList) { /* ... same logic as before ... */
  369. if (mutation.addedNodes) {
  370. for (const node of mutation.addedNodes) {
  371. if (node.nodeType === Node.ELEMENT_NODE) {
  372. const menu = (node.matches && node.matches(SELECTORS.SETTINGS_MENU))
  373. ? node
  374. : (node.querySelector ? node.querySelector(SELECTORS.SETTINGS_MENU) : null);
  375. if (menu) {
  376. foundMenu = true;
  377. potentialReInit = true;
  378. log('Settings menu detected in DOM.');
  379. break;
  380. }
  381. }
  382. }
  383. }
  384. if (foundMenu) break;
  385. }
  386. }
  387.  
  388. if (foundMenu) {
  389. if (!isInitialized || !settingsMenuEl || !tabContainerEl || !panelContainerEl || potentialReInit) {
  390. if (initializeManager()) {
  391. // Optional: obs.disconnect();
  392. } else {
  393. log('Initialization check failed, observer remains active.');
  394. }
  395. }
  396. }
  397. });
  398.  
  399. observer.observe(document.body, {
  400. childList: true,
  401. subtree: true
  402. });
  403. log('Mutation observer started for settings menu detection.');
  404.  
  405. initializeManager(); // Attempt initial
  406.  
  407.  
  408. // --- API Implementation Functions (linked from publicApi object) ---
  409.  
  410. function registerTabImpl(config) { // Renamed from registerTab
  411. if (!config || typeof config !== 'object') { /* ... validation ... */
  412. error('Registration failed: Invalid config object provided.'); return false;
  413. }
  414. const { scriptId, tabTitle, onInit } = config;
  415. if (typeof scriptId !== 'string' || !scriptId.trim()) { /* ... validation ... */
  416. error('Registration failed: Invalid or missing scriptId.', config); return false;
  417. }
  418. if (typeof tabTitle !== 'string' || !tabTitle.trim()) { /* ... validation ... */
  419. error('Registration failed: Invalid or missing tabTitle.', config); return false;
  420. }
  421. if (typeof onInit !== 'function') { /* ... validation ... */
  422. error('Registration failed: onInit must be a function.', config); return false;
  423. }
  424. if (registeredTabs.has(scriptId)) { /* ... validation ... */
  425. warn(`Registration failed: Script ID "${scriptId}" is already registered.`); return false;
  426. }
  427. if (config.onActivate && typeof config.onActivate !== 'function') { /* ... validation ... */
  428. error(`Registration for ${scriptId} failed: onActivate must be a function.`); return false;
  429. }
  430. if (config.onDeactivate && typeof config.onDeactivate !== 'function') { /* ... validation ... */
  431. error(`Registration for ${scriptId} failed: onDeactivate must be a function.`); return false;
  432. }
  433.  
  434. log(`Registration accepted for: ${scriptId}`);
  435. const registrationData = { ...config };
  436.  
  437. if (isInitialized) {
  438. registeredTabs.set(scriptId, registrationData);
  439. createTabAndPanel(registrationData);
  440. } else {
  441. log(`Manager not ready, queueing registration for ${scriptId}`);
  442. pendingRegistrations.push(registrationData);
  443. }
  444. return true;
  445. }
  446.  
  447. function activateTabImpl(scriptId) { // Renamed from activateTab
  448. if (isInitialized) {
  449. activateTab(scriptId); // Calls the internal function
  450. } else {
  451. warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`);
  452. }
  453. }
  454.  
  455. function getPanelElementImpl(scriptId) { // Renamed from getPanelElement
  456. if (!isInitialized || !panelContainerEl) return null;
  457. return panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  458. }
  459.  
  460. function getTabElementImpl(scriptId) { // Renamed from getTabElement
  461. if (!isInitialized || !tabContainerEl) return null;
  462. return tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  463. }
  464.  
  465.  
  466. // --- Global Exposure ---
  467. if (window.SettingsTabManager && window.SettingsTabManager !== publicApi) {
  468. warn('window.SettingsTabManager is already defined by another script or instance!');
  469. } else if (!window.SettingsTabManager) {
  470. Object.defineProperty(window, 'SettingsTabManager', {
  471. value: publicApi, // Expose the predefined API object
  472. writable: false,
  473. configurable: true
  474. });
  475. log('SettingsTabManager API exposed on window.');
  476. }
  477.  
  478. })();