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.gf.qytechs.cn/scripts/533630/1575663/Settings%20Tab%20Manager%20%28STM%29.js

  1. // ==UserScript==
  2. // @name Settings Tab Manager (STM)
  3. // @namespace shared-settings-manager
  4. // @version 1.1.2
  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. const scriptId = clickedTab.getAttribute(ATTRS.SCRIPT_ID);
  181. if (scriptId) {
  182. activateTab(scriptId); // Call the internal activate function
  183. }
  184. } else {
  185. if (event.target.closest(SELECTORS.SITE_TAB) && !event.target.closest(`span[${ATTRS.MANAGED}]`)) {
  186. deactivateCurrentTab();
  187. }
  188. }
  189. }
  190.  
  191. /** Attaches the main click listener to the tab container. */
  192. function attachTabClickListener() {
  193. if (!tabContainerEl) return;
  194. tabContainerEl.removeEventListener('click', handleTabClick, true);
  195. tabContainerEl.addEventListener('click', handleTabClick, true);
  196. log('Tab click listener attached.');
  197. }
  198.  
  199. /** Helper to create a separator span */
  200. function createSeparator(scriptId) {
  201. const separator = document.createElement('span');
  202. separator.className = SELECTORS.SITE_SEPARATOR ? SELECTORS.SITE_SEPARATOR.substring(1) : 'settings-tab-separator-fallback';
  203. separator.setAttribute(ATTRS.MANAGED, 'true');
  204. separator.setAttribute('data-stm-separator-for', scriptId);
  205. separator.textContent = '|';
  206. separator.style.cursor = 'default';
  207. return separator;
  208. }
  209.  
  210. /** Creates and inserts the tab and panel elements for a given script config. */
  211. function createTabAndPanel(config) {
  212. if (!tabContainerEl || !panelContainerEl) {
  213. error(`Cannot create tab/panel for ${config.scriptId}: Containers not found.`);
  214. return;
  215. }
  216. if (tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${config.scriptId}"]`)) {
  217. log(`Tab already exists for ${config.scriptId}, skipping creation.`);
  218. return;
  219. }
  220.  
  221. log(`Creating tab/panel for: ${config.scriptId}`);
  222.  
  223. // --- Create Tab ---
  224. const newTab = document.createElement('span');
  225. newTab.className = SELECTORS.SITE_TAB.substring(1);
  226. newTab.textContent = config.tabTitle;
  227. newTab.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
  228. newTab.setAttribute(ATTRS.MANAGED, 'true');
  229. newTab.setAttribute('title', `${config.tabTitle} (Settings by ${config.scriptId})`);
  230. const desiredOrder = typeof config.order === 'number' ? config.order : Infinity;
  231. newTab.setAttribute('data-stm-order', desiredOrder); // Store order attribute
  232.  
  233. // --- Create Panel ---
  234. const newPanel = document.createElement('div');
  235. newPanel.className = SELECTORS.SITE_PANEL.substring(1);
  236. newPanel.setAttribute(ATTRS.SCRIPT_ID, config.scriptId);
  237. newPanel.setAttribute(ATTRS.MANAGED, 'true');
  238. newPanel.id = `${MANAGER_ID}-${config.scriptId}-panel`;
  239.  
  240. // --- Insertion Logic (with basic ordering) ---
  241. let inserted = false;
  242. const existingStmTabs = Array.from(tabContainerEl.querySelectorAll(`span[${ATTRS.MANAGED}][${ATTRS.SCRIPT_ID}]`));
  243.  
  244. // Get combined list of STM tabs and separators for easier sorting/insertion
  245. const elementsToSort = existingStmTabs.map(tab => ({
  246. element: tab,
  247. order: parseInt(tab.getAttribute('data-stm-order') || Infinity, 10),
  248. isSeparator: false,
  249. separatorFor: tab.getAttribute(ATTRS.SCRIPT_ID)
  250. }));
  251.  
  252. // Find separators associated with STM tabs
  253. 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 => {
  254. let associatedTabId = sep.getAttribute('data-stm-separator-for');
  255. if (associatedTabId) {
  256. let associatedTab = elementsToSort.find(item => item.separatorFor === associatedTabId);
  257. if (associatedTab) {
  258. elementsToSort.push({ element: sep, order: associatedTab.order, isSeparator: true, separatorFor: associatedTabId });
  259. }
  260. }
  261. });
  262.  
  263. // Simplified: Find the correct place based on order among existing STM tabs
  264. let insertBeforeElement = null;
  265. for (const existingTab of existingStmTabs.sort((a, b) => parseInt(a.getAttribute('data-stm-order') || Infinity, 10) - parseInt(b.getAttribute('data-stm-order') || Infinity, 10))) {
  266. const existingOrder = parseInt(existingTab.getAttribute('data-stm-order') || Infinity, 10);
  267. if (desiredOrder < existingOrder) {
  268. insertBeforeElement = existingTab;
  269. break;
  270. }
  271. }
  272.  
  273. const newSeparator = createSeparator(config.scriptId); // Create separator regardless
  274.  
  275. if (insertBeforeElement) {
  276. // Check if the element before the target is a separator. If so, insert before that separator.
  277. const prevElement = insertBeforeElement.previousElementSibling;
  278. if (prevElement && prevElement.matches(`${SELECTORS.SITE_SEPARATOR}[${ATTRS.MANAGED}]`)) {
  279. tabContainerEl.insertBefore(newSeparator, prevElement);
  280. tabContainerEl.insertBefore(newTab, prevElement); // Insert tab *after* its separator
  281. } else {
  282. // Insert separator and then tab right before the target element
  283. tabContainerEl.insertBefore(newSeparator, insertBeforeElement);
  284. tabContainerEl.insertBefore(newTab, insertBeforeElement);
  285. }
  286. inserted = true;
  287. }
  288.  
  289.  
  290. if (!inserted) {
  291. // Append at the end of other STM tabs (potentially before site's last tabs)
  292. const lastStmTab = existingStmTabs.pop(); // Get the last one from the originally selected list
  293. if (lastStmTab) {
  294. // Insert after the last STM tab
  295. tabContainerEl.insertBefore(newSeparator, lastStmTab.nextSibling); // Insert separator first
  296. tabContainerEl.insertBefore(newTab, newSeparator.nextSibling); // Insert tab after separator
  297. } else {
  298. // This is the first STM tab being added, append separator and tab
  299. tabContainerEl.appendChild(newSeparator);
  300. tabContainerEl.appendChild(newTab);
  301. }
  302. }
  303.  
  304. // Append Panel
  305. panelContainerEl.appendChild(newPanel);
  306.  
  307. // --- Initialize Panel Content ---
  308. try {
  309. Promise.resolve(config.onInit(newPanel, newTab)).catch(e => {
  310. error(`Error during async onInit for ${config.scriptId}:`, e);
  311. newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
  312. });
  313. } catch (e) {
  314. error(`Error during sync onInit for ${config.scriptId}:`, e);
  315. newPanel.innerHTML = `<p style="color: red;">Error initializing settings panel for ${config.scriptId}. See console.</p>`;
  316. }
  317. }
  318.  
  319. /** Process all pending registrations. */
  320. function processPendingRegistrations() {
  321. if (!isInitialized) return;
  322. log(`Processing ${pendingRegistrations.length} pending registrations...`);
  323. while (pendingRegistrations.length > 0) {
  324. const config = pendingRegistrations.shift();
  325. if (!registeredTabs.has(config.scriptId)) {
  326. registeredTabs.set(config.scriptId, config);
  327. createTabAndPanel(config);
  328. } else {
  329. warn(`Script ID ${config.scriptId} was already registered. Skipping pending registration.`);
  330. }
  331. }
  332. }
  333.  
  334. // --- Initialization and Observation ---
  335.  
  336. /** Main initialization routine. */
  337. function initializeManager() {
  338. if (!findSettingsElements()) {
  339. log('Settings elements not found on init check.');
  340. return false;
  341. }
  342.  
  343. if (isInitialized) {
  344. log('Manager already initialized.');
  345. attachTabClickListener(); // Re-attach listener just in case
  346. return true;
  347. }
  348.  
  349. log('Initializing Settings Tab Manager...');
  350. attachTabClickListener();
  351.  
  352. isInitialized = true;
  353. log('Manager is ready.');
  354. // NOW it's safe to resolve the promise with publicApi
  355. resolveReadyPromise(publicApi);
  356.  
  357. processPendingRegistrations();
  358. return true;
  359. }
  360.  
  361. // Observer
  362. const observer = new MutationObserver((mutationsList, obs) => {
  363. let foundMenu = !!settingsMenuEl;
  364. let potentialReInit = false;
  365.  
  366. if (!foundMenu) {
  367. for (const mutation of mutationsList) { /* ... same logic as before ... */
  368. if (mutation.addedNodes) {
  369. for (const node of mutation.addedNodes) {
  370. if (node.nodeType === Node.ELEMENT_NODE) {
  371. const menu = (node.matches && node.matches(SELECTORS.SETTINGS_MENU))
  372. ? node
  373. : (node.querySelector ? node.querySelector(SELECTORS.SETTINGS_MENU) : null);
  374. if (menu) {
  375. foundMenu = true;
  376. potentialReInit = true;
  377. log('Settings menu detected in DOM.');
  378. break;
  379. }
  380. }
  381. }
  382. }
  383. if (foundMenu) break;
  384. }
  385. }
  386.  
  387. if (foundMenu) {
  388. if (!isInitialized || !settingsMenuEl || !tabContainerEl || !panelContainerEl || potentialReInit) {
  389. if (initializeManager()) {
  390. // Optional: obs.disconnect();
  391. } else {
  392. log('Initialization check failed, observer remains active.');
  393. }
  394. }
  395. }
  396. });
  397.  
  398. observer.observe(document.body, {
  399. childList: true,
  400. subtree: true
  401. });
  402. log('Mutation observer started for settings menu detection.');
  403.  
  404. initializeManager(); // Attempt initial
  405.  
  406.  
  407. // --- API Implementation Functions (linked from publicApi object) ---
  408.  
  409. function registerTabImpl(config) { // Renamed from registerTab
  410. if (!config || typeof config !== 'object') { /* ... validation ... */
  411. error('Registration failed: Invalid config object provided.'); return false;
  412. }
  413. const { scriptId, tabTitle, onInit } = config;
  414. if (typeof scriptId !== 'string' || !scriptId.trim()) { /* ... validation ... */
  415. error('Registration failed: Invalid or missing scriptId.', config); return false;
  416. }
  417. if (typeof tabTitle !== 'string' || !tabTitle.trim()) { /* ... validation ... */
  418. error('Registration failed: Invalid or missing tabTitle.', config); return false;
  419. }
  420. if (typeof onInit !== 'function') { /* ... validation ... */
  421. error('Registration failed: onInit must be a function.', config); return false;
  422. }
  423. if (registeredTabs.has(scriptId)) { /* ... validation ... */
  424. warn(`Registration failed: Script ID "${scriptId}" is already registered.`); return false;
  425. }
  426. if (config.onActivate && typeof config.onActivate !== 'function') { /* ... validation ... */
  427. error(`Registration for ${scriptId} failed: onActivate must be a function.`); return false;
  428. }
  429. if (config.onDeactivate && typeof config.onDeactivate !== 'function') { /* ... validation ... */
  430. error(`Registration for ${scriptId} failed: onDeactivate must be a function.`); return false;
  431. }
  432.  
  433. log(`Registration accepted for: ${scriptId}`);
  434. const registrationData = { ...config };
  435.  
  436. if (isInitialized) {
  437. registeredTabs.set(scriptId, registrationData);
  438. createTabAndPanel(registrationData);
  439. } else {
  440. log(`Manager not ready, queueing registration for ${scriptId}`);
  441. pendingRegistrations.push(registrationData);
  442. }
  443. return true;
  444. }
  445.  
  446. function activateTabImpl(scriptId) { // Renamed from activateTab
  447. if (isInitialized) {
  448. activateTab(scriptId); // Calls the internal function
  449. } else {
  450. warn(`Cannot activate tab ${scriptId} yet, manager not initialized.`);
  451. }
  452. }
  453.  
  454. function getPanelElementImpl(scriptId) { // Renamed from getPanelElement
  455. if (!isInitialized || !panelContainerEl) return null;
  456. return panelContainerEl.querySelector(`div[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  457. }
  458.  
  459. function getTabElementImpl(scriptId) { // Renamed from getTabElement
  460. if (!isInitialized || !tabContainerEl) return null;
  461. return tabContainerEl.querySelector(`span[${ATTRS.SCRIPT_ID}="${scriptId}"]`);
  462. }
  463.  
  464.  
  465. // --- Global Exposure ---
  466. if (window.SettingsTabManager && window.SettingsTabManager !== publicApi) {
  467. warn('window.SettingsTabManager is already defined by another script or instance!');
  468. } else if (!window.SettingsTabManager) {
  469. Object.defineProperty(window, 'SettingsTabManager', {
  470. value: publicApi, // Expose the predefined API object
  471. writable: false,
  472. configurable: true
  473. });
  474. log('SettingsTabManager API exposed on window.');
  475. }
  476.  
  477. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址