Greasy Fork is available in English.

8chan Spoiler Thumbnail Enhancer

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

  1. // ==UserScript==
  2. // @name 8chan Spoiler Thumbnail Enhancer
  3. // @namespace nipah-scripts-8chan
  4. // @version 2.5.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. /** Checks if the image link's container is in an expanded state. */
  455. function isImageExpanded(imgLink) {
  456. // Find the closest ancestor figure element
  457. const figure = imgLink.closest('figure.uploadCell');
  458. // Check if the figure exists and has the 'expandedCell' class
  459. const isExpanded = figure && figure.classList.contains('expandedCell');
  460. if (isExpanded) {
  461. debugLog(`Image container for ${imgLink.href} is expanded.`);
  462. }
  463. return isExpanded;
  464. }
  465.  
  466.  
  467. /** Handles mouse entering the image link area. */
  468. function handleLinkMouseEnter(event) {
  469. const imgLink = event.currentTarget;
  470.  
  471. // *** ADD THIS CHECK ***
  472. // If the image is already expanded by 8chan's logic, do nothing.
  473. if (isImageExpanded(imgLink)) {
  474. return;
  475. }
  476. // *** END CHECK ***
  477.  
  478. const mode = Settings.getThumbnailMode();
  479. const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL);
  480. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  481.  
  482. // Essential elements must exist
  483. if (!thumbnailUrl || !spoilerImg) return;
  484.  
  485. debugLog('Mouse Enter (Non-Expanded):', imgLink.href, 'Mode:', mode);
  486.  
  487. if (mode === 'spoiler') {
  488. // Show original thumbnail temporarily
  489. if (imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL)) return; // Avoid duplicates
  490.  
  491. const revealThumbnail = document.createElement('img');
  492. revealThumbnail.src = thumbnailUrl;
  493. revealThumbnail.className = CLASS_REVEAL_THUMBNAIL;
  494. revealThumbnail.style.display = 'block';
  495.  
  496. if (spoilerImg.width > 0 && spoilerImg.getAttribute(ATTR_DIMENSION_STATE) === 'success') {
  497. revealThumbnail.width = spoilerImg.width;
  498. revealThumbnail.height = spoilerImg.height;
  499. debugLog('Applying spoiler dims to hover thumb:', spoilerImg.width, spoilerImg.height);
  500. } else if (spoilerImg.offsetWidth > 0) {
  501. revealThumbnail.style.width = `${spoilerImg.offsetWidth}px`;
  502. revealThumbnail.style.height = `${spoilerImg.offsetHeight}px`;
  503. debugLog('Applying spoiler offset dims to hover thumb:', spoilerImg.offsetWidth, spoilerImg.offsetHeight);
  504. }
  505.  
  506. imgLink.insertBefore(revealThumbnail, spoilerImg);
  507. // *** IMPORTANT: Set display to none ***
  508. spoilerImg.style.display = 'none';
  509.  
  510. } else if (mode === 'blurred') {
  511. if (Settings.getDisableHoverWhenBlurred()) return;
  512. const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
  513. if (revealThumbnail) {
  514. removeBlur(revealThumbnail);
  515. }
  516. }
  517. }
  518.  
  519. /** Handles mouse leaving the image link area. */
  520. function handleLinkMouseLeave(event) {
  521. const imgLink = event.currentTarget;
  522.  
  523. // *** ADD THIS CHECK ***
  524. // If the image is already expanded by 8chan's logic, do nothing.
  525. // The expansion logic handles visibility, we should not interfere.
  526. if (isImageExpanded(imgLink)) {
  527. return;
  528. }
  529. // *** END CHECK ***
  530.  
  531.  
  532. const mode = Settings.getThumbnailMode();
  533. debugLog('Mouse Leave (Non-Expanded):', imgLink.href, 'Mode:', mode);
  534.  
  535. if (mode === 'spoiler') {
  536. // Find the temporary hover thumbnail
  537. const revealThumbnail = imgLink.querySelector(`img.${CLASS_REVEAL_THUMBNAIL}`);
  538.  
  539. // Only perform cleanup if the hover thumbnail exists (meaning mouseenter completed)
  540. if (revealThumbnail) {
  541. revealThumbnail.remove();
  542.  
  543. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  544. if (spoilerImg) {
  545. // Restore spoiler visibility *only if* it's currently hidden by our script
  546. if (spoilerImg.style.display === 'none') {
  547. debugLog('Restoring spoilerImg visibility after hover (non-expanded).');
  548. spoilerImg.style.display = ''; // Reset display
  549. } else {
  550. debugLog('SpoilerImg display was not "none" during non-expanded mouseleave cleanup.');
  551. }
  552. }
  553. }
  554. // If revealThumbnail wasn't found (e.g., rapid mouse out before enter completed fully),
  555. // we don't need to do anything, as the spoiler should still be visible.
  556.  
  557. } else if (mode === 'blurred') {
  558. // Re-apply blur
  559. const blurredThumbnail = imgLink.querySelector(SELECTORS.BLUR_WRAPPER + ' .' + CLASS_REVEAL_THUMBNAIL);
  560. if (blurredThumbnail) {
  561. applyBlur(blurredThumbnail);
  562. }
  563. }
  564. }
  565.  
  566. // --- Content Processing & Observation ---
  567.  
  568. /**
  569. * Processes a single image link element if it hasn't been processed yet.
  570. * Fetches metadata, attaches listeners, and sets initial appearance.
  571. * @param {HTMLAnchorElement} imgLink - The image link element.
  572. */
  573. function processImgLink(imgLink) {
  574. // Check if already processed or currently processing
  575. if (!imgLink || imgLink.hasAttribute(ATTR_PROCESSED_STATE)) {
  576. // Allow re-running updateImageAppearance even if processed
  577. if (imgLink?.getAttribute(ATTR_PROCESSED_STATE)?.startsWith('processed-')) {
  578. debugLog('Link already processed, potentially re-applying appearance:', imgLink.href);
  579. updateImageAppearance(imgLink); // Ensure appearance matches current settings
  580. }
  581. return;
  582. }
  583.  
  584. const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
  585. if (!spoilerImg) {
  586. // Mark as skipped only if it wasn't processed before
  587. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler');
  588. return;
  589. }
  590.  
  591. // Mark as processing to prevent duplicate runs from observer/initial scan
  592. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processing');
  593. debugLog('Processing link:', imgLink.href);
  594.  
  595. // --- Metadata Acquisition (Done only once) ---
  596. const fullImageUrl = imgLink.href;
  597. const hash = getHashFromImageUrl(fullImageUrl);
  598. if (!hash) {
  599. warn('Failed to get hash for:', fullImageUrl);
  600. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-hash');
  601. return;
  602. }
  603.  
  604. const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash);
  605. if (!thumbnailUrl) {
  606. warn('Failed to get thumbnail URL for:', fullImageUrl, hash);
  607. imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-thumb-url');
  608. return;
  609. }
  610.  
  611. // Store the thumbnail URL on the element for easy access later
  612. imgLink.setAttribute(ATTR_THUMBNAIL_URL, thumbnailUrl);
  613. debugLog(`Stored thumb URL: ${thumbnailUrl}`);
  614.  
  615. // --- Attach Event Listeners (Done only once) ---
  616. if (!imgLink.hasAttribute(ATTR_LISTENERS_ATTACHED)) {
  617. imgLink.addEventListener('mouseenter', handleLinkMouseEnter);
  618. imgLink.addEventListener('mouseleave', handleLinkMouseLeave);
  619. imgLink.setAttribute(ATTR_LISTENERS_ATTACHED, 'true');
  620. debugLog('Attached event listeners.');
  621. }
  622.  
  623. // --- Set Initial Appearance based on current settings ---
  624. // This function also sets the final 'processed-*' state attribute
  625. updateImageAppearance(imgLink);
  626.  
  627. // Dimension setting is triggered within updateImageAppearance -> ensureSpoilerStructure if needed
  628. }
  629.  
  630. /**
  631. * Scans a container element for unprocessed spoiler image links and processes them.
  632. * @param {Node} container - The DOM node (usually an Element) to scan within.
  633. */
  634. function processContainer(container) {
  635. if (!container || typeof container.querySelectorAll !== 'function') return;
  636.  
  637. // Select links that contain a spoiler image and are *not yet processed*
  638. // This selector is more specific upfront.
  639. const imgLinks = container.querySelectorAll(
  640. `${SELECTORS.IMG_LINK}:not([${ATTR_PROCESSED_STATE}]) ${SELECTORS.SPOILER_IMG}`
  641. );
  642.  
  643. if (imgLinks.length > 0) {
  644. debugLog(`Found ${imgLinks.length} potential new spoilers in container:`, container.nodeName);
  645. // Get the parent link element for each found spoiler image
  646. imgLinks.forEach(spoiler => {
  647. const link = spoiler.closest(SELECTORS.IMG_LINK);
  648. if (link) {
  649. processImgLink(link);
  650. } else {
  651. warn("Found spoiler image without parent imgLink:", spoiler);
  652. }
  653. });
  654. }
  655. // Additionally, check links that might have failed processing previously and could be retried
  656. // (Example: maybe a network error prevented thumb loading before) - This might be too aggressive.
  657. // For now, stick to processing only newly added/unprocessed links.
  658. }
  659.  
  660. // --- Settings Panel UI (STM Integration) ---
  661.  
  662. // Cache for panel DOM elements to avoid repeated queries
  663. let panelElementsCache = {};
  664.  
  665. // Unique IDs for elements within the settings panel
  666. const PANEL_IDS = Object.freeze({
  667. MODE_SPOILER: `${SCRIPT_ID}-mode-spoiler`,
  668. MODE_BLURRED: `${SCRIPT_ID}-mode-blurred`,
  669. BLUR_OPTIONS: `${SCRIPT_ID}-blur-options`,
  670. BLUR_AMOUNT_LABEL: `${SCRIPT_ID}-blur-amount-label`,
  671. BLUR_SLIDER: `${SCRIPT_ID}-blur-amount`,
  672. BLUR_VALUE: `${SCRIPT_ID}-blur-value`,
  673. DISABLE_HOVER_CHECKBOX: `${SCRIPT_ID}-disable-hover`,
  674. DISABLE_HOVER_LABEL: `${SCRIPT_ID}-disable-hover-label`,
  675. SAVE_BUTTON: `${SCRIPT_ID}-save-settings`,
  676. SAVE_STATUS: `${SCRIPT_ID}-save-status`,
  677. });
  678.  
  679. // CSS for the settings panel (scoped via STM panel ID)
  680. function getSettingsPanelCSS(stmPanelId) {
  681. return `
  682. #${stmPanelId} > div { margin-bottom: 12px; }
  683. #${stmPanelId} label { display: inline-block; margin-right: 10px; vertical-align: middle; cursor: pointer; }
  684. #${stmPanelId} input[type="radio"], #${stmPanelId} input[type="checkbox"] { vertical-align: middle; margin-right: 3px; cursor: pointer; }
  685. #${stmPanelId} input[type="range"] { vertical-align: middle; width: 180px; margin-left: 5px; cursor: pointer; }
  686. #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} { /* Use class selector for options div */
  687. margin-left: 20px; padding-left: 15px; border-left: 1px solid #ccc;
  688. margin-top: 8px; transition: opacity 0.3s ease, filter 0.3s ease;
  689. }
  690. #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS}.disabled { opacity: 0.5; filter: grayscale(50%); pointer-events: none; }
  691. #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} > div { margin-bottom: 8px; }
  692. #${stmPanelId} #${PANEL_IDS.BLUR_VALUE} { display: inline-block; min-width: 25px; text-align: right; margin-left: 5px; font-family: monospace; font-weight: bold; }
  693. #${stmPanelId} button { margin-top: 15px; padding: 5px 10px; cursor: pointer; }
  694. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS} { margin-left: 10px; font-size: 0.9em; font-style: italic; }
  695. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.success { color: green; }
  696. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.error { color: red; }
  697. #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.info { color: #555; }
  698. `;
  699. }
  700.  
  701. // HTML structure for the settings panel
  702. const settingsPanelHTML = `
  703. <div>
  704. <strong>Thumbnail Mode:</strong><br>
  705. <input type="radio" id="${PANEL_IDS.MODE_SPOILER}" name="${SCRIPT_ID}-mode" value="spoiler">
  706. <label for="${PANEL_IDS.MODE_SPOILER}">Show Original Thumbnail on Hover</label><br>
  707. <input type="radio" id="${PANEL_IDS.MODE_BLURRED}" name="${SCRIPT_ID}-mode" value="blurred">
  708. <label for="${PANEL_IDS.MODE_BLURRED}">Show Blurred Thumbnail</label>
  709. </div>
  710. <div class="${PANEL_IDS.BLUR_OPTIONS}" id="${PANEL_IDS.BLUR_OPTIONS}"> <!-- Use class and ID -->
  711. <div>
  712. <label for="${PANEL_IDS.BLUR_SLIDER}" id="${PANEL_IDS.BLUR_AMOUNT_LABEL}">Blur Amount:</label>
  713. <input type="range" id="${PANEL_IDS.BLUR_SLIDER}" min="1" max="50" step="1"> <!-- Max 50 -->
  714. <span id="${PANEL_IDS.BLUR_VALUE}"></span>px
  715. </div>
  716. <div>
  717. <input type="checkbox" id="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}">
  718. <label for="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}" id="${PANEL_IDS.DISABLE_HOVER_LABEL}">Disable Unblur on Hover</label>
  719. </div>
  720. </div>
  721. <hr>
  722. <div>
  723. <button id="${PANEL_IDS.SAVE_BUTTON}">Save & Apply Settings</button>
  724. <span id="${PANEL_IDS.SAVE_STATUS}"></span>
  725. </div>`;
  726.  
  727. /** Caches references to panel elements for quick access. */
  728. function cachePanelElements(panelElement) {
  729. panelElementsCache = { // Store references in the scoped cache
  730. panel: panelElement,
  731. modeSpoilerRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_SPOILER}`),
  732. modeBlurredRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_BLURRED}`),
  733. blurOptionsDiv: panelElement.querySelector(`#${PANEL_IDS.BLUR_OPTIONS}`), // Query by ID is fine here
  734. blurSlider: panelElement.querySelector(`#${PANEL_IDS.BLUR_SLIDER}`),
  735. blurValueSpan: panelElement.querySelector(`#${PANEL_IDS.BLUR_VALUE}`),
  736. disableHoverCheckbox: panelElement.querySelector(`#${PANEL_IDS.DISABLE_HOVER_CHECKBOX}`),
  737. saveButton: panelElement.querySelector(`#${PANEL_IDS.SAVE_BUTTON}`),
  738. saveStatusSpan: panelElement.querySelector(`#${PANEL_IDS.SAVE_STATUS}`),
  739. };
  740. // Basic check for essential elements
  741. if (!panelElementsCache.modeSpoilerRadio || !panelElementsCache.saveButton || !panelElementsCache.blurOptionsDiv) {
  742. error("Failed to cache essential panel elements. UI may not function correctly.");
  743. return false;
  744. }
  745. debugLog("Panel elements cached.");
  746. return true;
  747. }
  748.  
  749. /** Updates the enabled/disabled state and appearance of blur options based on mode selection. */
  750. function updateBlurOptionsStateUI() {
  751. const elements = panelElementsCache; // Use cached elements
  752. if (!elements.blurOptionsDiv) return;
  753.  
  754. const isBlurredMode = elements.modeBlurredRadio?.checked;
  755. const isDisabled = !isBlurredMode;
  756.  
  757. // Toggle visual state class
  758. elements.blurOptionsDiv.classList.toggle('disabled', isDisabled);
  759.  
  760. // Toggle disabled attribute for form elements
  761. if (elements.blurSlider) elements.blurSlider.disabled = isDisabled;
  762. if (elements.disableHoverCheckbox) elements.disableHoverCheckbox.disabled = isDisabled;
  763.  
  764. debugLog("Blur options UI state updated. Disabled:", isDisabled);
  765. }
  766.  
  767. /** Populates the settings controls with current values from the Settings module. */
  768. function populateControlsUI() {
  769. const elements = panelElementsCache;
  770. if (!elements.panel) {
  771. warn("Cannot populate controls, panel elements not cached/ready.");
  772. return;
  773. }
  774.  
  775. try {
  776. const mode = Settings.getThumbnailMode();
  777. if (elements.modeSpoilerRadio) elements.modeSpoilerRadio.checked = (mode === 'spoiler');
  778. if (elements.modeBlurredRadio) elements.modeBlurredRadio.checked = (mode === 'blurred');
  779.  
  780. const blurAmount = Settings.getBlurAmount();
  781. if (elements.blurSlider) elements.blurSlider.value = blurAmount;
  782. if (elements.blurValueSpan) elements.blurValueSpan.textContent = blurAmount;
  783.  
  784. if (elements.disableHoverCheckbox) {
  785. elements.disableHoverCheckbox.checked = Settings.getDisableHoverWhenBlurred();
  786. }
  787.  
  788. updateBlurOptionsStateUI(); // Ensure blur options state is correct on population
  789. debugLog("Settings panel UI populated with current settings.");
  790.  
  791. } catch (err) {
  792. error("Error populating settings controls:", err);
  793. }
  794. }
  795.  
  796. /** Sets the status message in the settings panel. */
  797. function setStatusMessage(message, type = 'info', duration = 3000) {
  798. const statusSpan = panelElementsCache.saveStatusSpan;
  799. if (!statusSpan) return;
  800.  
  801. statusSpan.textContent = message;
  802. statusSpan.className = type; // Add class for styling (success, error, info)
  803.  
  804. // Clear message after duration (if duration > 0)
  805. if (duration > 0) {
  806. setTimeout(() => {
  807. if (statusSpan.textContent === message) { // Avoid clearing newer messages
  808. statusSpan.textContent = '';
  809. statusSpan.className = '';
  810. }
  811. }, duration);
  812. }
  813. }
  814.  
  815. /** Handles the click on the 'Save Settings' button in the panel. */
  816. async function handleSaveClickUI() {
  817. const elements = panelElementsCache;
  818. if (!elements.saveButton || !elements.modeSpoilerRadio) return;
  819.  
  820. setStatusMessage('Saving...', 'info', 0); // Indicate saving (no timeout)
  821.  
  822. try {
  823. // --- 1. Read new values from UI ---
  824. const newMode = elements.modeSpoilerRadio.checked ? 'spoiler' : 'blurred';
  825. const newBlurAmount = parseInt(elements.blurSlider.value, 10);
  826. const newDisableHover = elements.disableHoverCheckbox.checked;
  827.  
  828. // Client-side validation (redundant with Settings.validate, but good UX)
  829. if (isNaN(newBlurAmount) || newBlurAmount < 1 || newBlurAmount > 50) {
  830. throw new Error(`Invalid blur amount: ${newBlurAmount}. Must be between 1 and 50.`);
  831. }
  832.  
  833. // --- 2. Update settings in the Settings module ---
  834. // This updates the global `scriptSettings` object
  835. Settings.setThumbnailMode(newMode);
  836. Settings.setBlurAmount(newBlurAmount);
  837. Settings.setDisableHoverWhenBlurred(newDisableHover);
  838.  
  839. // --- 3. Save persistently ---
  840. await Settings.save(); // This also validates internally
  841.  
  842. // --- 4. Apply changes dynamically to existing elements ---
  843. setStatusMessage('Applying changes...', 'info', 0);
  844. log(`Applying settings dynamically: Mode=${newMode}, Blur=${newBlurAmount}, DisableHover=${newDisableHover}`);
  845.  
  846. // Select all links that have been successfully processed previously
  847. const processedLinks = document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}^="processed-"]`);
  848. log(`Found ${processedLinks.length} elements to update dynamically.`);
  849.  
  850. processedLinks.forEach(link => {
  851. try {
  852. // This function handles switching between modes or updating blur amount
  853. updateImageAppearance(link);
  854. } catch (updateErr) {
  855. // Log error for specific link but continue with others
  856. error(`Error updating appearance for ${link.href}:`, updateErr);
  857. }
  858. });
  859.  
  860. // --- 5. Final status update ---
  861. setStatusMessage('Saved & Applied!', 'success', 3000);
  862. log('Settings saved and changes applied dynamically.');
  863.  
  864. } catch (err) {
  865. error('Failed to save or apply settings:', err);
  866. setStatusMessage(`Error: ${err.message || 'Could not save/apply.'}`, 'error', 5000);
  867. }
  868. }
  869.  
  870. /** Attaches event listeners to the controls *within* the settings panel. */
  871. function addPanelEventListeners() {
  872. const elements = panelElementsCache;
  873. if (!elements.panel) {
  874. error("Cannot add panel listeners, panel elements not cached.");
  875. return;
  876. }
  877.  
  878. // Debounce function to prevent rapid firing during slider drag
  879. let debounceTimer;
  880. const debounce = (func, delay = 50) => {
  881. return (...args) => {
  882. clearTimeout(debounceTimer);
  883. debounceTimer = setTimeout(() => { func.apply(this, args); }, delay);
  884. };
  885. };
  886.  
  887. // Save Button
  888. elements.saveButton?.addEventListener('click', handleSaveClickUI);
  889.  
  890. // Mode Radio Buttons (update blur options enable/disable state)
  891. const modeChangeHandler = () => updateBlurOptionsStateUI();
  892. elements.modeSpoilerRadio?.addEventListener('change', modeChangeHandler);
  893. elements.modeBlurredRadio?.addEventListener('change', modeChangeHandler);
  894.  
  895. // Blur Slider Input (update value display in real-time)
  896. elements.blurSlider?.addEventListener('input', (event) => {
  897. if (elements.blurValueSpan) {
  898. elements.blurValueSpan.textContent = event.target.value;
  899. }
  900. // Optional: Apply blur change dynamically while dragging (might be slow)
  901. // const applyLiveBlur = debounce(() => {
  902. // if (elements.modeBlurredRadio?.checked) {
  903. // Settings.setBlurAmount(parseInt(event.target.value, 10));
  904. // document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}="processed-blurred"] ${SELECTORS.REVEAL_THUMBNAIL}`)
  905. // .forEach(thumb => applyBlur(thumb));
  906. // }
  907. // });
  908. // applyLiveBlur();
  909. });
  910.  
  911. log("Settings panel event listeners added.");
  912. }
  913.  
  914. // --- STM Integration Callbacks ---
  915.  
  916. /** `onInit` callback for SettingsTabManager. Called once when the panel is first created. */
  917. function initializeSettingsPanel(panelElement, tabElement) {
  918. log(`STM initializing panel: #${panelElement.id}`);
  919. try {
  920. // Inject CSS scoped to this panel
  921. GM_addStyle(getSettingsPanelCSS(panelElement.id));
  922.  
  923. // Set panel HTML content
  924. panelElement.innerHTML = settingsPanelHTML;
  925.  
  926. // Cache DOM elements within the panel
  927. if (!cachePanelElements(panelElement)) {
  928. throw new Error("Failed to cache panel elements after creation.");
  929. }
  930.  
  931. // Populate UI with current settings (Settings.load should have run already)
  932. populateControlsUI();
  933.  
  934. // Add event listeners to the UI controls
  935. addPanelEventListeners();
  936.  
  937. log('Settings panel initialized successfully.');
  938.  
  939. } catch (err) {
  940. error("Error during settings panel initialization:", err);
  941. // Display error message within the panel itself
  942. panelElement.innerHTML = `<p style="color: red; border: 1px solid red; padding: 10px;">
  943. Error initializing ${SCRIPT_ID} settings panel. Please check the browser console (F12) for details.
  944. <br>Error: ${err.message || 'Unknown error'}
  945. </p>`;
  946. }
  947. }
  948.  
  949. /** `onActivate` callback for SettingsTabManager. Called every time the tab is clicked. */
  950. function onSettingsTabActivate(panelElement, tabElement) {
  951. log(`${SCRIPT_ID} settings tab activated.`);
  952. // Ensure UI reflects the latest settings (in case they were changed programmatically - unlikely)
  953. populateControlsUI();
  954. // Clear any previous status messages
  955. setStatusMessage('', 'info', 0); // Clear immediately
  956. }
  957.  
  958. // --- Main Initialization ---
  959.  
  960. /** Sets up the script: Loads settings, registers with STM (with timeout), starts observer, processes initial content. */
  961. async function initialize() {
  962. log(`Initializing ${SCRIPT_ID} v${SCRIPT_VERSION}...`);
  963.  
  964. // 1. Load settings first
  965. await Settings.load();
  966.  
  967. // 2. Register settings panel with SettingsTabManager (with waiting logic and timeout)
  968. let stmAttempts = 0;
  969. const MAX_STM_ATTEMPTS = 20; // e.g., 20 attempts
  970. const STM_RETRY_DELAY_MS = 250; // Retry every 250ms
  971. const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total wait
  972.  
  973. function attemptStmRegistration() {
  974. stmAttempts++;
  975. debugLog(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`);
  976.  
  977. // *** Check unsafeWindow directly ***
  978. if (typeof unsafeWindow !== 'undefined' // Ensure unsafeWindow exists
  979. && typeof unsafeWindow.SettingsTabManager !== 'undefined'
  980. && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined')
  981. {
  982. log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...');
  983. // Found it, call the async registration function, but don't wait for it here.
  984. // Let the rest of the script initialization continue.
  985. registerWithStm().catch(err => {
  986. error("Async registration with STM failed after finding it:", err);
  987. // Even if registration fails *after* finding STM, we proceed without the panel.
  988. });
  989. // STM found (or at least its .ready property), stop polling.
  990. return; // Exit the polling function
  991. }
  992.  
  993. // STM not found/ready yet, check if we should give up
  994. if (stmAttempts >= MAX_STM_ATTEMPTS) {
  995. warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`);
  996. // Give up polling, DO NOT call setTimeout again.
  997. return; // Exit the polling function
  998. }
  999.  
  1000. // STM not found, limit not reached, schedule next attempt
  1001. if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') {
  1002. debugLog('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...');
  1003. } else {
  1004. debugLog('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...');
  1005. }
  1006. setTimeout(attemptStmRegistration, STM_RETRY_DELAY_MS); // Retry after a delay
  1007. }
  1008.  
  1009. async function registerWithStm() {
  1010. // This function now only runs if STM.ready was detected
  1011. try {
  1012. // *** Access via unsafeWindow ***
  1013. if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') {
  1014. // Should not happen if called correctly, but check defensively
  1015. error('SettingsTabManager.ready disappeared before registration could complete.');
  1016. return; // Cannot register
  1017. }
  1018. const stm = await unsafeWindow.SettingsTabManager.ready;
  1019. // *** End Access via unsafeWindow ***
  1020.  
  1021. // Now register the tab using the resolved stm object
  1022. const registrationSuccess = stm.registerTab({
  1023. scriptId: SCRIPT_ID,
  1024. tabTitle: 'Spoilers',
  1025. order: 30,
  1026. onInit: initializeSettingsPanel,
  1027. onActivate: onSettingsTabActivate
  1028. });
  1029. if (registrationSuccess) {
  1030. log('Successfully registered settings tab with STM.');
  1031. } else {
  1032. warn('STM registration returned false (tab might already exist or other registration issue).');
  1033. }
  1034. } catch (err) {
  1035. // Catch errors during the await SettingsTabManager.ready or stm.registerTab
  1036. error('Failed to register settings tab via SettingsTabManager:', err);
  1037. // No need to retry here, just log the failure.
  1038. }
  1039. }
  1040.  
  1041. // Start the check/wait process *asynchronously*.
  1042. // We don't await this; the rest of the script continues immediately.
  1043. attemptStmRegistration();
  1044.  
  1045. // 3. Set up MutationObserver (Runs regardless of STM status)
  1046. const observerOptions = {
  1047. childList: true,
  1048. subtree: true
  1049. };
  1050. const contentObserver = new MutationObserver((mutations) => {
  1051. const linksToProcess = new Set();
  1052. mutations.forEach((mutation) => {
  1053. if (mutation.addedNodes && mutation.addedNodes.length > 0) {
  1054. mutation.addedNodes.forEach((node) => {
  1055. if (node.nodeType === Node.ELEMENT_NODE) {
  1056. if (node.matches(SELECTORS.IMG_LINK) && node.querySelector(SELECTORS.SPOILER_IMG)) {
  1057. linksToProcess.add(node);
  1058. } else {
  1059. node.querySelectorAll(`${SELECTORS.IMG_LINK} ${SELECTORS.SPOILER_IMG}`)
  1060. .forEach(spoiler => {
  1061. const link = spoiler.closest(SELECTORS.IMG_LINK);
  1062. if (link) linksToProcess.add(link);
  1063. });
  1064. }
  1065. }
  1066. });
  1067. }
  1068. });
  1069. if (linksToProcess.size > 0) {
  1070. debugLog(`MutationObserver found ${linksToProcess.size} new potential links.`);
  1071. linksToProcess.forEach(link => processImgLink(link));
  1072. }
  1073. });
  1074. contentObserver.observe(document.body, observerOptions);
  1075. log('Mutation observer started.');
  1076.  
  1077. // 4. Process initial content (Runs regardless of STM status)
  1078. log('Performing initial content scan...');
  1079. processContainer(document.body);
  1080.  
  1081. log('Script initialization logic finished (STM check running in background).');
  1082. }
  1083.  
  1084. // --- Run Initialization ---
  1085. // Use .catch here for errors during the initial synchronous part of initialize()
  1086. // or the Settings.load() promise. Errors within async STM polling/registration
  1087. // are handled by their respective try/catch blocks.
  1088. initialize().catch(err => {
  1089. error("Critical error during script initialization startup:", err);
  1090. });
  1091.  
  1092. })();