8chan Spoiler Thumbnail Enhancer

Pre-sizes spoiler images, shows thumbnail (original on hover, or blurred/unblurred on hover), with dynamic settings updates via SettingsTabManager.

当前为 2025-04-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name 8chan Spoiler Thumbnail Enhancer
  3. // @namespace nipah-scripts-8chan
  4. // @version 2.4.0
  5. // @description Pre-sizes spoiler images, shows thumbnail (original on hover, or blurred/unblurred on hover), with dynamic settings updates via SettingsTabManager.
  6. // @author nipah, Gemini
  7. // @license MIT
  8. // @match https://8chan.moe/*
  9. // @match https://8chan.se/*
  10. // @grant GM.setValue
  11. // @grant GM.getValue
  12. // @grant GM_addStyle
  13. // @run-at document-idle
  14. // ==/UserScript==
  15.  
  16. (async function() {
  17. 'use strict';
  18.  
  19. // --- Configuration ---
  20. const SCRIPT_ID = 'SpoilerEnh'; // Unique ID for settings, attributes, classes
  21. const SCRIPT_VERSION = '2.2.0';
  22. const DEBUG_MODE = false; // Set to true for more verbose logging
  23.  
  24. // --- Constants ---
  25. const DEFAULT_SETTINGS = Object.freeze({
  26. thumbnailMode: 'spoiler', // 'spoiler' or 'blurred'
  27. blurAmount: 5, // Pixels for blur effect
  28. disableHoverWhenBlurred: false, // Prevent unblurring on hover in blurred mode
  29. });
  30. const GM_SETTINGS_KEY = `${SCRIPT_ID}_Settings`;
  31.  
  32. // --- Data Attributes ---
  33. // Tracks the overall processing state of an image link
  34. const ATTR_PROCESSED_STATE = `data-${SCRIPT_ID.toLowerCase()}-processed`;
  35. // Tracks the state of fetching spoiler dimensions from its thumbnail
  36. const ATTR_DIMENSION_STATE = `data-${SCRIPT_ID.toLowerCase()}-dims-state`;
  37. // Stores the calculated thumbnail URL directly on the link element
  38. const ATTR_THUMBNAIL_URL = `data-${SCRIPT_ID.toLowerCase()}-thumb-url`;
  39. // Tracks if event listeners have been attached to avoid duplicates
  40. const ATTR_LISTENERS_ATTACHED = `data-${SCRIPT_ID.toLowerCase()}-listeners`;
  41.  
  42. // --- CSS Classes ---
  43. const CLASS_REVEAL_THUMBNAIL = `${SCRIPT_ID}-revealThumbnail`; // Temporary thumbnail shown on hover (spoiler mode) or blurred preview
  44. const CLASS_BLUR_WRAPPER = `${SCRIPT_ID}-blurWrapper`; // Wrapper for the blurred thumbnail to handle sizing and overflow
  45.  
  46. // --- Selectors ---
  47. const SELECTORS = Object.freeze({
  48. // Matches standard 8chan spoiler images and common custom spoiler names
  49. SPOILER_IMG: `img[src="/spoiler.png"], img[src$="/custom.spoiler"]`,
  50. // The anchor tag wrapping the spoiler image
  51. IMG_LINK: 'a.imgLink',
  52. // Selector for the dynamically created blur wrapper div
  53. BLUR_WRAPPER: `.${CLASS_BLUR_WRAPPER}`,
  54. // Selector for the thumbnail image (used in both modes, potentially temporarily)
  55. REVEAL_THUMBNAIL: `img.${CLASS_REVEAL_THUMBNAIL}`, // More specific selector using tag + class
  56. });
  57.  
  58. // --- Global State ---
  59. let scriptSettings = { ...DEFAULT_SETTINGS };
  60.  
  61. // --- Utility Functions ---
  62. const log = (...args) => console.log(`[${SCRIPT_ID}]`, ...args);
  63. const debugLog = (...args) => DEBUG_MODE && console.log(`[${SCRIPT_ID} Debug]`, ...args);
  64. const warn = (...args) => console.warn(`[${SCRIPT_ID}]`, ...args);
  65. const error = (...args) => console.error(`[${SCRIPT_ID}]`, ...args);
  66.  
  67. /**
  68. * Extracts the image hash from a full image URL.
  69. * @param {string | null} imageUrl The full URL of the image.
  70. * @returns {string | null} The extracted hash or null if parsing fails.
  71. */
  72. function getHashFromImageUrl(imageUrl) {
  73. if (!imageUrl) return null;
  74. try {
  75. // Prefer URL parsing for robustness
  76. const url = new URL(imageUrl);
  77. const filename = url.pathname.split('/').pop();
  78. if (!filename) return null;
  79. // Hash is typically the part before the first dot
  80. const hash = filename.split('.')[0];
  81. return hash || null;
  82. } catch (e) {
  83. // Fallback for potentially invalid URLs or non-standard paths
  84. warn("Could not parse image URL with URL API, falling back:", imageUrl, e);
  85. const parts = imageUrl.split('/');
  86. const filename = parts.pop();
  87. if (!filename) return null;
  88. const hash = filename.split('.')[0];
  89. return hash || null;
  90. }
  91. }
  92.  
  93. /**
  94. * Constructs the thumbnail URL based on the full image URL and hash.
  95. * Assumes 8chan's '/path/to/image/HASH.ext' and '/path/to/image/t_HASH' structure.
  96. * @param {string | null} fullImageUrl The full URL of the image.
  97. * @param {string | null} hash The image hash.
  98. * @returns {string | null} The constructed thumbnail URL or null.
  99. */
  100. function getThumbnailUrl(fullImageUrl, hash) {
  101. if (!fullImageUrl || !hash) return null;
  102. try {
  103. // Prefer URL parsing
  104. const url = new URL(fullImageUrl);
  105. const pathParts = url.pathname.split('/');
  106. pathParts.pop(); // Remove filename
  107. const basePath = pathParts.join('/') + '/';
  108. // Construct new URL relative to the origin
  109. return new URL(basePath + 't_' + hash, url.origin).toString();
  110. } catch (e) {
  111. // Fallback for potentially invalid URLs
  112. warn("Could not construct thumbnail URL with URL API, falling back:", fullImageUrl, hash, e);
  113. const parts = fullImageUrl.split('/');
  114. parts.pop(); // Remove filename
  115. const basePath = parts.join('/') + '/';
  116. // Basic string concatenation fallback (might lack origin if relative)
  117. return basePath + 't_' + hash;
  118. }
  119. }
  120.  
  121. /**
  122. * Validates raw settings data against defaults, ensuring correct types and ranges.
  123. * @param {object} settingsToValidate - The raw settings object (e.g., from GM.getValue).
  124. * @returns {object} A validated settings object.
  125. */
  126. function validateSettings(settingsToValidate) {
  127. const validated = {};
  128. const source = { ...DEFAULT_SETTINGS, ...settingsToValidate }; // Merge with defaults first
  129.  
  130. validated.thumbnailMode = (source.thumbnailMode === 'spoiler' || source.thumbnailMode === 'blurred')
  131. ? source.thumbnailMode
  132. : DEFAULT_SETTINGS.thumbnailMode;
  133.  
  134. validated.blurAmount = (typeof source.blurAmount === 'number' && source.blurAmount >= 0 && source.blurAmount <= 50) // Increased max blur slightly
  135. ? source.blurAmount
  136. : DEFAULT_SETTINGS.blurAmount;
  137.  
  138. validated.disableHoverWhenBlurred = (typeof source.disableHoverWhenBlurred === 'boolean')
  139. ? source.disableHoverWhenBlurred
  140. : DEFAULT_SETTINGS.disableHoverWhenBlurred;
  141.  
  142. return validated;
  143. }
  144.  
  145.  
  146. // --- Settings Module ---
  147. // Manages loading, saving, and accessing script settings.
  148. const Settings = {
  149. /** Loads settings from storage, validates them, and updates the global state. */
  150. async load() {
  151. try {
  152. const storedSettings = await GM.getValue(GM_SETTINGS_KEY, {});
  153. scriptSettings = validateSettings(storedSettings);
  154. log('Settings loaded:', scriptSettings);
  155. } catch (e) {
  156. warn('Failed to load settings, using defaults.', e);
  157. scriptSettings = { ...DEFAULT_SETTINGS }; // Reset to defaults on error
  158. }
  159. },
  160.  
  161. /** Saves the current global settings state to storage after validation. */
  162. async save() {
  163. try {
  164. // Always validate before saving
  165. const settingsToSave = validateSettings(scriptSettings);
  166. await GM.setValue(GM_SETTINGS_KEY, settingsToSave);
  167. log('Settings saved.');
  168. } catch (e) {
  169. error('Failed to save settings.', e);
  170. // Consider notifying the user here if appropriate
  171. throw e; // Re-throw for the caller (e.g., save button handler)
  172. }
  173. },
  174.  
  175. // --- Getters for accessing current settings ---
  176. getThumbnailMode: () => scriptSettings.thumbnailMode,
  177. getBlurAmount: () => scriptSettings.blurAmount,
  178. getDisableHoverWhenBlurred: () => scriptSettings.disableHoverWhenBlurred,
  179.  
  180. // --- Setters for updating global settings state (used by UI before saving) ---
  181. setThumbnailMode: (mode) => { scriptSettings.thumbnailMode = mode; },
  182. setBlurAmount: (amount) => { scriptSettings.blurAmount = amount; },
  183. setDisableHoverWhenBlurred: (isDisabled) => { scriptSettings.disableHoverWhenBlurred = isDisabled; },
  184. };
  185.  
  186.  
  187. // --- Image Style Manipulation ---
  188.  
  189. /**
  190. * Applies the current blur setting to an element.
  191. * @param {HTMLElement} element - The element to blur.
  192. */
  193. function applyBlur(element) {
  194. const blurAmount = Settings.getBlurAmount();
  195. element.style.filter = `blur(${blurAmount}px)`;
  196. element.style.willChange = 'filter'; // Hint for performance
  197. debugLog('Applied blur:', blurAmount, element);
  198. }
  199.  
  200. /**
  201. * Removes blur from an element.
  202. * @param {HTMLElement} element - The element to unblur.
  203. */
  204. function removeBlur(element) {
  205. element.style.filter = 'none';
  206. element.style.willChange = 'auto';
  207. debugLog('Removed blur:', element);
  208. }
  209.  
  210.  
  211. // --- Image Structure Management ---
  212.  
  213. /**
  214. * Fetches thumbnail dimensions and applies them to the spoiler image.
  215. * Avoids layout shifts by pre-sizing the spoiler placeholder.
  216. * @param {HTMLImageElement} spoilerImg - The original spoiler image element.
  217. * @param {string} thumbnailUrl - The URL of the corresponding thumbnail.
  218. */
  219. function setSpoilerDimensionsFromThumbnail(spoilerImg, thumbnailUrl) {
  220. // Use a more descriptive attribute name if possible, but keep current for compatibility
  221. const currentState = spoilerImg.getAttribute(ATTR_DIMENSION_STATE);
  222. if (!spoilerImg || currentState === 'success' || currentState === 'pending') {
  223. debugLog('Skipping dimension setting (already done or pending):', spoilerImg);
  224. return; // Avoid redundant work or race conditions
  225. }
  226.  
  227. if (!thumbnailUrl) {
  228. spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-no-thumb-url');
  229. warn('Cannot set dimensions: no thumbnail URL provided for spoiler:', spoilerImg.closest('a')?.href);
  230. return;
  231. }
  232.  
  233. spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'pending');
  234. debugLog('Attempting to set dimensions from thumbnail:', thumbnailUrl);
  235.  
  236. const tempImg = new Image();
  237.  
  238. const cleanup = () => {
  239. tempImg.removeEventListener('load', loadHandler);
  240. tempImg.removeEventListener('error', errorHandler);
  241. };
  242.  
  243. const loadHandler = () => {
  244. if (tempImg.naturalWidth > 0 && tempImg.naturalHeight > 0) {
  245. spoilerImg.width = tempImg.naturalWidth; // Set explicit dimensions
  246. spoilerImg.height = tempImg.naturalHeight;
  247. spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'success');
  248. log('Spoiler dimensions set from thumbnail:', spoilerImg.width, 'x', spoilerImg.height);
  249. } else {
  250. warn(`Thumbnail loaded with zero dimensions: ${thumbnailUrl}`);
  251. spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-zero-dim');
  252. }
  253. cleanup();
  254. };
  255.  
  256. const errorHandler = (errEvent) => {
  257. warn(`Failed to load thumbnail for dimension setting: ${thumbnailUrl}`, errEvent);
  258. spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-load-error');
  259. cleanup();
  260. };
  261.  
  262. tempImg.addEventListener('load', loadHandler);
  263. tempImg.addEventListener('error', errorHandler);
  264.  
  265. try {
  266. // Set src to start loading
  267. tempImg.src = thumbnailUrl;
  268. } catch (e) {
  269. error("Error assigning src for dimension check:", thumbnailUrl, e);
  270. spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-src-assign');
  271. cleanup(); // Ensure cleanup even if src assignment fails
  272. }
  273. }
  274.  
  275. /**
  276. * Creates or updates the necessary DOM structure for the 'blurred' mode.
  277. * Hides the original spoiler and shows a blurred thumbnail.
  278. * @param {HTMLAnchorElement} imgLink - The parent anchor element.
  279. * @param {HTMLImageElement} spoilerImg - The original spoiler image.
  280. * @param {string} thumbnailUrl - The thumbnail URL.
  281. */
  282. function ensureBlurredStructure(imgLink, spoilerImg, thumbnailUrl) {
  283. let blurWrapper = imgLink.querySelector(SELECTORS.BLUR_WRAPPER);
  284. let revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
  285.  
  286. // --- Structure Check and Cleanup ---
  287. // If elements exist but aren't nested correctly, remove them to rebuild
  288. if (revealThumbnail && (!blurWrapper || !blurWrapper.contains(revealThumbnail))) {
  289. debugLog('Incorrect blurred structure found, removing orphan thumbnail.');
  290. revealThumbnail.remove();
  291. revealThumbnail = null; // Reset variable
  292. }
  293. if (blurWrapper && !revealThumbnail) { // Wrapper exists but no image inside? Rebuild.
  294. debugLog('Incorrect blurred structure found, removing empty wrapper.');
  295. blurWrapper.remove();
  296. blurWrapper = null; // Reset variable
  297. }
  298.  
  299. // --- Create or Update Structure ---
  300. if (!blurWrapper) {
  301. debugLog('Creating blur wrapper and thumbnail for:', imgLink.href);
  302. blurWrapper = document.createElement('div');
  303. blurWrapper.className = CLASS_BLUR_WRAPPER;
  304. blurWrapper.style.overflow = 'hidden';
  305. blurWrapper.style.display = 'inline-block'; // Match image display
  306. blurWrapper.style.lineHeight = '0'; // Prevent extra space below image
  307. blurWrapper.style.visibility = 'hidden'; // Hide until loaded and sized
  308.  
  309. revealThumbnail = document.createElement('img');
  310. revealThumbnail.className = CLASS_REVEAL_THUMBNAIL;
  311. revealThumbnail.style.display = 'block'; // Ensure it fills wrapper correctly
  312.  
  313. const cleanup = () => {
  314. revealThumbnail.removeEventListener('load', loadHandler);
  315. revealThumbnail.removeEventListener('error', errorHandler);
  316. };
  317.  
  318. const loadHandler = () => {
  319. if (revealThumbnail.naturalWidth > 0 && revealThumbnail.naturalHeight > 0) {
  320. const w = revealThumbnail.naturalWidth;
  321. const h = revealThumbnail.naturalHeight;
  322.  
  323. // Set size on wrapper and image
  324. blurWrapper.style.width = `${w}px`;
  325. blurWrapper.style.height = `${h}px`;
  326. revealThumbnail.width = w;
  327. revealThumbnail.height = h;
  328.  
  329. applyBlur(revealThumbnail); // Apply blur *after* loading and sizing
  330.  
  331. blurWrapper.style.visibility = 'visible'; // Show it now
  332. spoilerImg.style.display = 'none'; // Hide original spoiler
  333. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-blurred');
  334. debugLog('Blurred thumbnail structure created successfully.');
  335. } else {
  336. warn('Blurred thumbnail loaded with zero dimensions:', thumbnailUrl);
  337. blurWrapper.remove(); // Clean up failed elements
  338. spoilerImg.style.display = ''; // Show spoiler again
  339. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-blurred-zero-dims');
  340. }
  341. cleanup();
  342. };
  343.  
  344. const errorHandler = () => {
  345. warn(`Failed to load blurred thumbnail: ${thumbnailUrl}`);
  346. blurWrapper.remove(); // Clean up failed elements
  347. spoilerImg.style.display = ''; // Show spoiler again
  348. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-blurred-thumb-load');
  349. cleanup();
  350. };
  351.  
  352. revealThumbnail.addEventListener('load', loadHandler);
  353. revealThumbnail.addEventListener('error', errorHandler);
  354.  
  355. blurWrapper.appendChild(revealThumbnail);
  356. // Insert the wrapper before the original spoiler image
  357. imgLink.insertBefore(blurWrapper, spoilerImg);
  358.  
  359. try {
  360. revealThumbnail.src = thumbnailUrl;
  361. } catch (e) {
  362. error("Error assigning src to blurred thumbnail:", thumbnailUrl, e);
  363. errorHandler(); // Trigger error handling manually
  364. }
  365.  
  366. } else {
  367. // Structure exists, just ensure blur is correct and elements are visible
  368. debugLog('Blurred structure already exists, ensuring blur and visibility.');
  369. if (revealThumbnail) applyBlur(revealThumbnail); // Re-apply current blur amount
  370. spoilerImg.style.display = 'none';
  371. blurWrapper.style.display = 'inline-block';
  372. // Ensure state attribute reflects current mode
  373. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-blurred');
  374. }
  375. }
  376.  
  377. /**
  378. * Ensures the 'spoiler' mode structure is active.
  379. * Removes any blurred elements and ensures the original spoiler image is visible.
  380. * Also triggers dimension setting if needed.
  381. * @param {HTMLAnchorElement} imgLink - The parent anchor element.
  382. * @param {HTMLImageElement} spoilerImg - The original spoiler image.
  383. * @param {string} thumbnailUrl - The thumbnail URL (needed for dimension setting).
  384. */
  385. function ensureSpoilerStructure(imgLink, spoilerImg, thumbnailUrl) {
  386. const blurWrapper = imgLink.querySelector(SELECTORS.BLUR_WRAPPER);
  387. if (blurWrapper) {
  388. debugLog('Removing blurred structure for:', imgLink.href);
  389. blurWrapper.remove(); // Removes wrapper and its contents (revealThumbnail)
  390. }
  391.  
  392. // Ensure the original spoiler image is visible
  393. spoilerImg.style.display = ''; // Reset to default display
  394.  
  395. // Ensure dimensions are set (might switch before initial dimension setting completed)
  396. // This function has internal checks to prevent redundant work.
  397. setSpoilerDimensionsFromThumbnail(spoilerImg, thumbnailUrl);
  398.  
  399. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-spoiler');
  400. debugLog('Ensured spoiler structure for:', imgLink.href);
  401. }
  402.  
  403. /**
  404. * Dynamically updates the visual appearance of a single image link
  405. * based on the current script settings (mode, blur amount).
  406. * This is called during initial processing and when settings change.
  407. * @param {HTMLAnchorElement} imgLink - The image link element to update.
  408. */
  409. function updateImageAppearance(imgLink) {
  410. if (!imgLink || !imgLink.matches(SELECTORS.IMG_LINK)) return;
  411.  
  412. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  413. if (!spoilerImg) {
  414. // This link doesn't have a spoiler, state should reflect this
  415. if (!imgLink.hasAttribute(ATTR_PROCESSED_STATE)) {
  416. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler');
  417. }
  418. return;
  419. }
  420.  
  421. const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL);
  422. if (!thumbnailUrl) {
  423. // This is unexpected if processing reached this point, but handle defensively
  424. warn("Cannot update appearance, missing thumbnail URL attribute on:", imgLink.href);
  425. // Mark as failed if not already processed otherwise
  426. if (!imgLink.hasAttribute(ATTR_PROCESSED_STATE) || imgLink.getAttribute(ATTR_PROCESSED_STATE) === 'processing') {
  427. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-missing-thumb-attr');
  428. }
  429. return;
  430. }
  431.  
  432. const currentMode = Settings.getThumbnailMode();
  433. debugLog(`Updating appearance for ${imgLink.href} to mode: ${currentMode}`);
  434.  
  435. if (currentMode === 'blurred') {
  436. ensureBlurredStructure(imgLink, spoilerImg, thumbnailUrl);
  437. } else { // mode === 'spoiler'
  438. ensureSpoilerStructure(imgLink, spoilerImg, thumbnailUrl);
  439. }
  440.  
  441. // If switching TO blurred mode OR blur amount changed while blurred, ensure blur is applied.
  442. // The ensureBlurredStructure function already calls applyBlur, so this check might be slightly redundant,
  443. // but it catches cases where the user is hovering WHILE changing settings.
  444. const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
  445. if (currentMode === 'blurred' && revealThumbnail) {
  446. // Re-apply blur in case it was removed by a hover event that hasn't triggered mouseleave yet
  447. applyBlur(revealThumbnail);
  448. }
  449. }
  450.  
  451.  
  452. // --- Event Handlers ---
  453.  
  454. /** Handles mouse entering the image link area. */
  455. function handleLinkMouseEnter(event) {
  456. const imgLink = event.currentTarget; // `this` can be unreliable depending on context
  457. const mode = Settings.getThumbnailMode();
  458. const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL);
  459. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  460.  
  461. // Essential elements must exist
  462. if (!thumbnailUrl || !spoilerImg) return;
  463.  
  464. debugLog('Mouse Enter:', imgLink.href, 'Mode:', mode);
  465.  
  466. if (mode === 'spoiler') {
  467. // Show original thumbnail temporarily
  468. // Avoid creating if one already exists (e.g., rapid hover)
  469. if (imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL)) return;
  470.  
  471. const revealThumbnail = document.createElement('img');
  472. revealThumbnail.src = thumbnailUrl;
  473. revealThumbnail.className = CLASS_REVEAL_THUMBNAIL; // Use class for identification
  474. revealThumbnail.style.display = 'block'; // Match spoiler image display style
  475.  
  476. // Use dimensions from the pre-sized spoiler image if available and successful
  477. if (spoilerImg.width > 0 && spoilerImg.getAttribute(ATTR_DIMENSION_STATE) === 'success') {
  478. revealThumbnail.width = spoilerImg.width;
  479. revealThumbnail.height = spoilerImg.height;
  480. debugLog('Applying spoiler dims to hover thumb:', spoilerImg.width, spoilerImg.height);
  481. } else {
  482. // Fallback: Use spoiler's current offset dimensions if available
  483. if (spoilerImg.offsetWidth > 0) {
  484. revealThumbnail.style.width = `${spoilerImg.offsetWidth}px`;
  485. revealThumbnail.style.height = `${spoilerImg.offsetHeight}px`;
  486. debugLog('Applying spoiler offset dims to hover thumb:', spoilerImg.offsetWidth, spoilerImg.offsetHeight);
  487. }
  488. // else: let the browser determine size based on loaded image
  489. }
  490.  
  491. imgLink.insertBefore(revealThumbnail, spoilerImg);
  492. spoilerImg.style.display = 'none'; // Hide original spoiler
  493.  
  494. } else if (mode === 'blurred') {
  495. // Unblur the existing thumbnail if hover is enabled
  496. if (Settings.getDisableHoverWhenBlurred()) return;
  497.  
  498. const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); // Should be inside blur wrapper
  499. if (revealThumbnail) {
  500. removeBlur(revealThumbnail);
  501. }
  502. }
  503. }
  504.  
  505. /** Handles mouse leaving the image link area. */
  506. function handleLinkMouseLeave(event) {
  507. const imgLink = event.currentTarget;
  508. const mode = Settings.getThumbnailMode();
  509.  
  510. debugLog('Mouse Leave:', imgLink.href, 'Mode:', mode);
  511.  
  512. if (mode === 'spoiler') {
  513. // Remove the temporary hover thumbnail
  514. const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
  515. if (revealThumbnail) {
  516. revealThumbnail.remove();
  517. }
  518. // Ensure original spoiler is visible again
  519. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  520. if (spoilerImg) {
  521. spoilerImg.style.display = ''; // Reset display
  522. }
  523.  
  524. } else if (mode === 'blurred') {
  525. // Re-apply blur (no need to check disableHoverWhenBlurred, if disabled, blur was never removed)
  526. const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
  527. if (revealThumbnail) {
  528. applyBlur(revealThumbnail); // Uses current blur amount setting
  529. }
  530. }
  531. }
  532.  
  533. // --- Content Processing & Observation ---
  534.  
  535. /**
  536. * Processes a single image link element if it hasn't been processed yet.
  537. * Fetches metadata, attaches listeners, and sets initial appearance.
  538. * @param {HTMLAnchorElement} imgLink - The image link element.
  539. */
  540. function processImgLink(imgLink) {
  541. // Check if already processed or currently processing
  542. if (!imgLink || imgLink.hasAttribute(ATTR_PROCESSED_STATE)) {
  543. // Allow re-running updateImageAppearance even if processed
  544. if (imgLink?.getAttribute(ATTR_PROCESSED_STATE)?.startsWith('processed-')) {
  545. debugLog('Link already processed, potentially re-applying appearance:', imgLink.href);
  546. updateImageAppearance(imgLink); // Ensure appearance matches current settings
  547. }
  548. return;
  549. }
  550.  
  551. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  552. if (!spoilerImg) {
  553. // Mark as skipped only if it wasn't processed before
  554. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler');
  555. return;
  556. }
  557.  
  558. // Mark as processing to prevent duplicate runs from observer/initial scan
  559. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processing');
  560. debugLog('Processing link:', imgLink.href);
  561.  
  562. // --- Metadata Acquisition (Done only once) ---
  563. const fullImageUrl = imgLink.href;
  564. const hash = getHashFromImageUrl(fullImageUrl);
  565. if (!hash) {
  566. warn('Failed to get hash for:', fullImageUrl);
  567. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-hash');
  568. return;
  569. }
  570.  
  571. const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash);
  572. if (!thumbnailUrl) {
  573. warn('Failed to get thumbnail URL for:', fullImageUrl, hash);
  574. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-thumb-url');
  575. return;
  576. }
  577.  
  578. // Store the thumbnail URL on the element for easy access later
  579. imgLink.setAttribute(ATTR_THUMBNAIL_URL, thumbnailUrl);
  580. debugLog(`Stored thumb URL: ${thumbnailUrl}`);
  581.  
  582. // --- Attach Event Listeners (Done only once) ---
  583. if (!imgLink.hasAttribute(ATTR_LISTENERS_ATTACHED)) {
  584. imgLink.addEventListener('mouseenter', handleLinkMouseEnter);
  585. imgLink.addEventListener('mouseleave', handleLinkMouseLeave);
  586. imgLink.setAttribute(ATTR_LISTENERS_ATTACHED, 'true');
  587. debugLog('Attached event listeners.');
  588. }
  589.  
  590. // --- Set Initial Appearance based on current settings ---
  591. // This function also sets the final 'processed-*' state attribute
  592. updateImageAppearance(imgLink);
  593.  
  594. // Dimension setting is triggered within updateImageAppearance -> ensureSpoilerStructure if needed
  595. }
  596.  
  597. /**
  598. * Scans a container element for unprocessed spoiler image links and processes them.
  599. * @param {Node} container - The DOM node (usually an Element) to scan within.
  600. */
  601. function processContainer(container) {
  602. if (!container || typeof container.querySelectorAll !== 'function') return;
  603.  
  604. // Select links that contain a spoiler image and are *not yet processed*
  605. // This selector is more specific upfront.
  606. const imgLinks = container.querySelectorAll(
  607. `${SELECTORS.IMG_LINK}:not([${ATTR_PROCESSED_STATE}]) ${SELECTORS.SPOILER_IMG}`
  608. );
  609.  
  610. if (imgLinks.length > 0) {
  611. debugLog(`Found ${imgLinks.length} potential new spoilers in container:`, container.nodeName);
  612. // Get the parent link element for each found spoiler image
  613. imgLinks.forEach(spoiler => {
  614. const link = spoiler.closest(SELECTORS.IMG_LINK);
  615. if (link) {
  616. processImgLink(link);
  617. } else {
  618. warn("Found spoiler image without parent imgLink:", spoiler);
  619. }
  620. });
  621. }
  622. // Additionally, check links that might have failed processing previously and could be retried
  623. // (Example: maybe a network error prevented thumb loading before) - This might be too aggressive.
  624. // For now, stick to processing only newly added/unprocessed links.
  625. }
  626.  
  627. // --- Settings Panel UI (STM Integration) ---
  628.  
  629. // Cache for panel DOM elements to avoid repeated queries
  630. let panelElementsCache = {};
  631.  
  632. // Unique IDs for elements within the settings panel
  633. const PANEL_IDS = Object.freeze({
  634. MODE_SPOILER: `${SCRIPT_ID}-mode-spoiler`,
  635. MODE_BLURRED: `${SCRIPT_ID}-mode-blurred`,
  636. BLUR_OPTIONS: `${SCRIPT_ID}-blur-options`,
  637. BLUR_AMOUNT_LABEL: `${SCRIPT_ID}-blur-amount-label`,
  638. BLUR_SLIDER: `${SCRIPT_ID}-blur-amount`,
  639. BLUR_VALUE: `${SCRIPT_ID}-blur-value`,
  640. DISABLE_HOVER_CHECKBOX: `${SCRIPT_ID}-disable-hover`,
  641. DISABLE_HOVER_LABEL: `${SCRIPT_ID}-disable-hover-label`,
  642. SAVE_BUTTON: `${SCRIPT_ID}-save-settings`,
  643. SAVE_STATUS: `${SCRIPT_ID}-save-status`,
  644. });
  645.  
  646. // CSS for the settings panel (scoped via STM panel ID)
  647. function getSettingsPanelCSS(stmPanelId) {
  648. return `
  649. #${stmPanelId} > div { margin-bottom: 12px; }
  650. #${stmPanelId} label { display: inline-block; margin-right: 10px; vertical-align: middle; cursor: pointer; }
  651. #${stmPanelId} input[type="radio"], #${stmPanelId} input[type="checkbox"] { vertical-align: middle; margin-right: 3px; cursor: pointer; }
  652. #${stmPanelId} input[type="range"] { vertical-align: middle; width: 180px; margin-left: 5px; cursor: pointer; }
  653. #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} { /* Use class selector for options div */
  654. margin-left: 20px; padding-left: 15px; border-left: 1px solid #ccc;
  655. margin-top: 8px; transition: opacity 0.3s ease, filter 0.3s ease;
  656. }
  657. #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS}.disabled { opacity: 0.5; filter: grayscale(50%); pointer-events: none; }
  658. #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} > div { margin-bottom: 8px; }
  659. #${stmPanelId} #${PANEL_IDS.BLUR_VALUE} { display: inline-block; min-width: 25px; text-align: right; margin-left: 5px; font-family: monospace; font-weight: bold; }
  660. #${stmPanelId} button { margin-top: 15px; padding: 5px 10px; cursor: pointer; }
  661. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS} { margin-left: 10px; font-size: 0.9em; font-style: italic; }
  662. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.success { color: green; }
  663. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.error { color: red; }
  664. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.info { color: #555; }
  665. `;
  666. }
  667.  
  668. // HTML structure for the settings panel
  669. const settingsPanelHTML = `
  670. <div>
  671. <strong>Thumbnail Mode:</strong><br>
  672. <input type="radio" id="${PANEL_IDS.MODE_SPOILER}" name="${SCRIPT_ID}-mode" value="spoiler">
  673. <label for="${PANEL_IDS.MODE_SPOILER}">Show Original Thumbnail on Hover</label><br>
  674. <input type="radio" id="${PANEL_IDS.MODE_BLURRED}" name="${SCRIPT_ID}-mode" value="blurred">
  675. <label for="${PANEL_IDS.MODE_BLURRED}">Show Blurred Thumbnail</label>
  676. </div>
  677. <div class="${PANEL_IDS.BLUR_OPTIONS}" id="${PANEL_IDS.BLUR_OPTIONS}"> <!-- Use class and ID -->
  678. <div>
  679. <label for="${PANEL_IDS.BLUR_SLIDER}" id="${PANEL_IDS.BLUR_AMOUNT_LABEL}">Blur Amount:</label>
  680. <input type="range" id="${PANEL_IDS.BLUR_SLIDER}" min="1" max="50" step="1"> <!-- Max 50 -->
  681. <span id="${PANEL_IDS.BLUR_VALUE}"></span>px
  682. </div>
  683. <div>
  684. <input type="checkbox" id="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}">
  685. <label for="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}" id="${PANEL_IDS.DISABLE_HOVER_LABEL}">Disable Unblur on Hover</label>
  686. </div>
  687. </div>
  688. <hr>
  689. <div>
  690. <button id="${PANEL_IDS.SAVE_BUTTON}">Save & Apply Settings</button>
  691. <span id="${PANEL_IDS.SAVE_STATUS}"></span>
  692. </div>`;
  693.  
  694. /** Caches references to panel elements for quick access. */
  695. function cachePanelElements(panelElement) {
  696. panelElementsCache = { // Store references in the scoped cache
  697. panel: panelElement,
  698. modeSpoilerRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_SPOILER}`),
  699. modeBlurredRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_BLURRED}`),
  700. blurOptionsDiv: panelElement.querySelector(`#${PANEL_IDS.BLUR_OPTIONS}`), // Query by ID is fine here
  701. blurSlider: panelElement.querySelector(`#${PANEL_IDS.BLUR_SLIDER}`),
  702. blurValueSpan: panelElement.querySelector(`#${PANEL_IDS.BLUR_VALUE}`),
  703. disableHoverCheckbox: panelElement.querySelector(`#${PANEL_IDS.DISABLE_HOVER_CHECKBOX}`),
  704. saveButton: panelElement.querySelector(`#${PANEL_IDS.SAVE_BUTTON}`),
  705. saveStatusSpan: panelElement.querySelector(`#${PANEL_IDS.SAVE_STATUS}`),
  706. };
  707. // Basic check for essential elements
  708. if (!panelElementsCache.modeSpoilerRadio || !panelElementsCache.saveButton || !panelElementsCache.blurOptionsDiv) {
  709. error("Failed to cache essential panel elements. UI may not function correctly.");
  710. return false;
  711. }
  712. debugLog("Panel elements cached.");
  713. return true;
  714. }
  715.  
  716. /** Updates the enabled/disabled state and appearance of blur options based on mode selection. */
  717. function updateBlurOptionsStateUI() {
  718. const elements = panelElementsCache; // Use cached elements
  719. if (!elements.blurOptionsDiv) return;
  720.  
  721. const isBlurredMode = elements.modeBlurredRadio?.checked;
  722. const isDisabled = !isBlurredMode;
  723.  
  724. // Toggle visual state class
  725. elements.blurOptionsDiv.classList.toggle('disabled', isDisabled);
  726.  
  727. // Toggle disabled attribute for form elements
  728. if (elements.blurSlider) elements.blurSlider.disabled = isDisabled;
  729. if (elements.disableHoverCheckbox) elements.disableHoverCheckbox.disabled = isDisabled;
  730.  
  731. debugLog("Blur options UI state updated. Disabled:", isDisabled);
  732. }
  733.  
  734. /** Populates the settings controls with current values from the Settings module. */
  735. function populateControlsUI() {
  736. const elements = panelElementsCache;
  737. if (!elements.panel) {
  738. warn("Cannot populate controls, panel elements not cached/ready.");
  739. return;
  740. }
  741.  
  742. try {
  743. const mode = Settings.getThumbnailMode();
  744. if (elements.modeSpoilerRadio) elements.modeSpoilerRadio.checked = (mode === 'spoiler');
  745. if (elements.modeBlurredRadio) elements.modeBlurredRadio.checked = (mode === 'blurred');
  746.  
  747. const blurAmount = Settings.getBlurAmount();
  748. if (elements.blurSlider) elements.blurSlider.value = blurAmount;
  749. if (elements.blurValueSpan) elements.blurValueSpan.textContent = blurAmount;
  750.  
  751. if (elements.disableHoverCheckbox) {
  752. elements.disableHoverCheckbox.checked = Settings.getDisableHoverWhenBlurred();
  753. }
  754.  
  755. updateBlurOptionsStateUI(); // Ensure blur options state is correct on population
  756. debugLog("Settings panel UI populated with current settings.");
  757.  
  758. } catch (err) {
  759. error("Error populating settings controls:", err);
  760. }
  761. }
  762.  
  763. /** Sets the status message in the settings panel. */
  764. function setStatusMessage(message, type = 'info', duration = 3000) {
  765. const statusSpan = panelElementsCache.saveStatusSpan;
  766. if (!statusSpan) return;
  767.  
  768. statusSpan.textContent = message;
  769. statusSpan.className = type; // Add class for styling (success, error, info)
  770.  
  771. // Clear message after duration (if duration > 0)
  772. if (duration > 0) {
  773. setTimeout(() => {
  774. if (statusSpan.textContent === message) { // Avoid clearing newer messages
  775. statusSpan.textContent = '';
  776. statusSpan.className = '';
  777. }
  778. }, duration);
  779. }
  780. }
  781.  
  782. /** Handles the click on the 'Save Settings' button in the panel. */
  783. async function handleSaveClickUI() {
  784. const elements = panelElementsCache;
  785. if (!elements.saveButton || !elements.modeSpoilerRadio) return;
  786.  
  787. setStatusMessage('Saving...', 'info', 0); // Indicate saving (no timeout)
  788.  
  789. try {
  790. // --- 1. Read new values from UI ---
  791. const newMode = elements.modeSpoilerRadio.checked ? 'spoiler' : 'blurred';
  792. const newBlurAmount = parseInt(elements.blurSlider.value, 10);
  793. const newDisableHover = elements.disableHoverCheckbox.checked;
  794.  
  795. // Client-side validation (redundant with Settings.validate, but good UX)
  796. if (isNaN(newBlurAmount) || newBlurAmount < 1 || newBlurAmount > 50) {
  797. throw new Error(`Invalid blur amount: ${newBlurAmount}. Must be between 1 and 50.`);
  798. }
  799.  
  800. // --- 2. Update settings in the Settings module ---
  801. // This updates the global `scriptSettings` object
  802. Settings.setThumbnailMode(newMode);
  803. Settings.setBlurAmount(newBlurAmount);
  804. Settings.setDisableHoverWhenBlurred(newDisableHover);
  805.  
  806. // --- 3. Save persistently ---
  807. await Settings.save(); // This also validates internally
  808.  
  809. // --- 4. Apply changes dynamically to existing elements ---
  810. setStatusMessage('Applying changes...', 'info', 0);
  811. log(`Applying settings dynamically: Mode=${newMode}, Blur=${newBlurAmount}, DisableHover=${newDisableHover}`);
  812.  
  813. // Select all links that have been successfully processed previously
  814. const processedLinks = document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}^="processed-"]`);
  815. log(`Found ${processedLinks.length} elements to update dynamically.`);
  816.  
  817. processedLinks.forEach(link => {
  818. try {
  819. // This function handles switching between modes or updating blur amount
  820. updateImageAppearance(link);
  821. } catch (updateErr) {
  822. // Log error for specific link but continue with others
  823. error(`Error updating appearance for ${link.href}:`, updateErr);
  824. }
  825. });
  826.  
  827. // --- 5. Final status update ---
  828. setStatusMessage('Saved & Applied!', 'success', 3000);
  829. log('Settings saved and changes applied dynamically.');
  830.  
  831. } catch (err) {
  832. error('Failed to save or apply settings:', err);
  833. setStatusMessage(`Error: ${err.message || 'Could not save/apply.'}`, 'error', 5000);
  834. }
  835. }
  836.  
  837. /** Attaches event listeners to the controls *within* the settings panel. */
  838. function addPanelEventListeners() {
  839. const elements = panelElementsCache;
  840. if (!elements.panel) {
  841. error("Cannot add panel listeners, panel elements not cached.");
  842. return;
  843. }
  844.  
  845. // Debounce function to prevent rapid firing during slider drag
  846. let debounceTimer;
  847. const debounce = (func, delay = 50) => {
  848. return (...args) => {
  849. clearTimeout(debounceTimer);
  850. debounceTimer = setTimeout(() => { func.apply(this, args); }, delay);
  851. };
  852. };
  853.  
  854. // Save Button
  855. elements.saveButton?.addEventListener('click', handleSaveClickUI);
  856.  
  857. // Mode Radio Buttons (update blur options enable/disable state)
  858. const modeChangeHandler = () => updateBlurOptionsStateUI();
  859. elements.modeSpoilerRadio?.addEventListener('change', modeChangeHandler);
  860. elements.modeBlurredRadio?.addEventListener('change', modeChangeHandler);
  861.  
  862. // Blur Slider Input (update value display in real-time)
  863. elements.blurSlider?.addEventListener('input', (event) => {
  864. if (elements.blurValueSpan) {
  865. elements.blurValueSpan.textContent = event.target.value;
  866. }
  867. // Optional: Apply blur change dynamically while dragging (might be slow)
  868. // const applyLiveBlur = debounce(() => {
  869. // if (elements.modeBlurredRadio?.checked) {
  870. // Settings.setBlurAmount(parseInt(event.target.value, 10));
  871. // document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}="processed-blurred"] ${SELECTORS.REVEAL_THUMBNAIL}`)
  872. // .forEach(thumb => applyBlur(thumb));
  873. // }
  874. // });
  875. // applyLiveBlur();
  876. });
  877.  
  878. log("Settings panel event listeners added.");
  879. }
  880.  
  881. // --- STM Integration Callbacks ---
  882.  
  883. /** `onInit` callback for SettingsTabManager. Called once when the panel is first created. */
  884. function initializeSettingsPanel(panelElement, tabElement) {
  885. log(`STM initializing panel: #${panelElement.id}`);
  886. try {
  887. // Inject CSS scoped to this panel
  888. GM_addStyle(getSettingsPanelCSS(panelElement.id));
  889.  
  890. // Set panel HTML content
  891. panelElement.innerHTML = settingsPanelHTML;
  892.  
  893. // Cache DOM elements within the panel
  894. if (!cachePanelElements(panelElement)) {
  895. throw new Error("Failed to cache panel elements after creation.");
  896. }
  897.  
  898. // Populate UI with current settings (Settings.load should have run already)
  899. populateControlsUI();
  900.  
  901. // Add event listeners to the UI controls
  902. addPanelEventListeners();
  903.  
  904. log('Settings panel initialized successfully.');
  905.  
  906. } catch (err) {
  907. error("Error during settings panel initialization:", err);
  908. // Display error message within the panel itself
  909. panelElement.innerHTML = `<p style="color: red; border: 1px solid red; padding: 10px;">
  910. Error initializing ${SCRIPT_ID} settings panel. Please check the browser console (F12) for details.
  911. <br>Error: ${err.message || 'Unknown error'}
  912. </p>`;
  913. }
  914. }
  915.  
  916. /** `onActivate` callback for SettingsTabManager. Called every time the tab is clicked. */
  917. function onSettingsTabActivate(panelElement, tabElement) {
  918. log(`${SCRIPT_ID} settings tab activated.`);
  919. // Ensure UI reflects the latest settings (in case they were changed programmatically - unlikely)
  920. populateControlsUI();
  921. // Clear any previous status messages
  922. setStatusMessage('', 'info', 0); // Clear immediately
  923. }
  924.  
  925. // --- Main Initialization ---
  926.  
  927. /** Sets up the script: Loads settings, registers with STM (with timeout), starts observer, processes initial content. */
  928. async function initialize() {
  929. log(`Initializing ${SCRIPT_ID} v${SCRIPT_VERSION}...`);
  930.  
  931. // 1. Load settings first
  932. await Settings.load();
  933.  
  934. // 2. Register settings panel with SettingsTabManager (with waiting logic and timeout)
  935. let stmAttempts = 0;
  936. const MAX_STM_ATTEMPTS = 20; // e.g., 20 attempts
  937. const STM_RETRY_DELAY_MS = 250; // Retry every 250ms
  938. const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total wait
  939.  
  940. function attemptStmRegistration() {
  941. stmAttempts++;
  942. debugLog(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`);
  943.  
  944. // *** Check unsafeWindow directly ***
  945. if (typeof unsafeWindow !== 'undefined' // Ensure unsafeWindow exists
  946. && typeof unsafeWindow.SettingsTabManager !== 'undefined'
  947. && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined')
  948. {
  949. log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...');
  950. // Found it, call the async registration function, but don't wait for it here.
  951. // Let the rest of the script initialization continue.
  952. registerWithStm().catch(err => {
  953. error("Async registration with STM failed after finding it:", err);
  954. // Even if registration fails *after* finding STM, we proceed without the panel.
  955. });
  956. // STM found (or at least its .ready property), stop polling.
  957. return; // Exit the polling function
  958. }
  959.  
  960. // STM not found/ready yet, check if we should give up
  961. if (stmAttempts >= MAX_STM_ATTEMPTS) {
  962. warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`);
  963. // Give up polling, DO NOT call setTimeout again.
  964. return; // Exit the polling function
  965. }
  966.  
  967. // STM not found, limit not reached, schedule next attempt
  968. if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') {
  969. debugLog('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...');
  970. } else {
  971. debugLog('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...');
  972. }
  973. setTimeout(attemptStmRegistration, STM_RETRY_DELAY_MS); // Retry after a delay
  974. }
  975.  
  976. async function registerWithStm() {
  977. // This function now only runs if STM.ready was detected
  978. try {
  979. // *** Access via unsafeWindow ***
  980. if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') {
  981. // Should not happen if called correctly, but check defensively
  982. error('SettingsTabManager.ready disappeared before registration could complete.');
  983. return; // Cannot register
  984. }
  985. const stm = await unsafeWindow.SettingsTabManager.ready;
  986. // *** End Access via unsafeWindow ***
  987.  
  988. // Now register the tab using the resolved stm object
  989. const registrationSuccess = stm.registerTab({
  990. scriptId: SCRIPT_ID,
  991. tabTitle: 'Spoilers',
  992. order: 30,
  993. onInit: initializeSettingsPanel,
  994. onActivate: onSettingsTabActivate
  995. });
  996. if (registrationSuccess) {
  997. log('Successfully registered settings tab with STM.');
  998. } else {
  999. warn('STM registration returned false (tab might already exist or other registration issue).');
  1000. }
  1001. } catch (err) {
  1002. // Catch errors during the await SettingsTabManager.ready or stm.registerTab
  1003. error('Failed to register settings tab via SettingsTabManager:', err);
  1004. // No need to retry here, just log the failure.
  1005. }
  1006. }
  1007.  
  1008. // Start the check/wait process *asynchronously*.
  1009. // We don't await this; the rest of the script continues immediately.
  1010. attemptStmRegistration();
  1011.  
  1012. // 3. Set up MutationObserver (Runs regardless of STM status)
  1013. const observerOptions = {
  1014. childList: true,
  1015. subtree: true
  1016. };
  1017. const contentObserver = new MutationObserver((mutations) => {
  1018. const linksToProcess = new Set();
  1019. mutations.forEach((mutation) => {
  1020. if (mutation.addedNodes && mutation.addedNodes.length > 0) {
  1021. mutation.addedNodes.forEach((node) => {
  1022. if (node.nodeType === Node.ELEMENT_NODE) {
  1023. if (node.matches(SELECTORS.IMG_LINK) && node.querySelector(SELECTORS.SPOILER_IMG)) {
  1024. linksToProcess.add(node);
  1025. } else {
  1026. node.querySelectorAll(`${SELECTORS.IMG_LINK} ${SELECTORS.SPOILER_IMG}`)
  1027. .forEach(spoiler => {
  1028. const link = spoiler.closest(SELECTORS.IMG_LINK);
  1029. if (link) linksToProcess.add(link);
  1030. });
  1031. }
  1032. }
  1033. });
  1034. }
  1035. });
  1036. if (linksToProcess.size > 0) {
  1037. debugLog(`MutationObserver found ${linksToProcess.size} new potential links.`);
  1038. linksToProcess.forEach(link => processImgLink(link));
  1039. }
  1040. });
  1041. contentObserver.observe(document.body, observerOptions);
  1042. log('Mutation observer started.');
  1043.  
  1044. // 4. Process initial content (Runs regardless of STM status)
  1045. log('Performing initial content scan...');
  1046. processContainer(document.body);
  1047.  
  1048. log('Script initialization logic finished (STM check running in background).');
  1049. }
  1050.  
  1051. // --- Run Initialization ---
  1052. // Use .catch here for errors during the initial synchronous part of initialize()
  1053. // or the Settings.load() promise. Errors within async STM polling/registration
  1054. // are handled by their respective try/catch blocks.
  1055. initialize().catch(err => {
  1056. error("Critical error during script initialization startup:", err);
  1057. });
  1058.  
  1059. })();