// ==UserScript== // @name 8chan Spoiler Thumbnail Enhancer // @namespace nipah-scripts-8chan // @version 2.5.0 // @description Pre-sizes spoiler images, shows thumbnail (original on hover, or blurred/unblurred on hover), with dynamic settings updates via SettingsTabManager. // @author nipah, Gemini // @license MIT // @match https://8chan.moe/* // @match https://8chan.se/* // @grant GM.setValue // @grant GM.getValue // @grant GM_addStyle // @run-at document-idle // @downloadURL https://update.greasyfork.icu/scripts/533302/8chan%20Spoiler%20Thumbnail%20Enhancer.user.js // @updateURL https://update.greasyfork.icu/scripts/533302/8chan%20Spoiler%20Thumbnail%20Enhancer.meta.js // ==/UserScript== (async function() { 'use strict'; // --- Configuration --- const SCRIPT_ID = 'SpoilerEnh'; // Unique ID for settings, attributes, classes const SCRIPT_VERSION = '2.2.0'; const DEBUG_MODE = false; // Set to true for more verbose logging // --- Constants --- const DEFAULT_SETTINGS = Object.freeze({ thumbnailMode: 'spoiler', // 'spoiler' or 'blurred' blurAmount: 5, // Pixels for blur effect disableHoverWhenBlurred: false, // Prevent unblurring on hover in blurred mode }); const GM_SETTINGS_KEY = `${SCRIPT_ID}_Settings`; // --- Data Attributes --- // Tracks the overall processing state of an image link const ATTR_PROCESSED_STATE = `data-${SCRIPT_ID.toLowerCase()}-processed`; // Tracks the state of fetching spoiler dimensions from its thumbnail const ATTR_DIMENSION_STATE = `data-${SCRIPT_ID.toLowerCase()}-dims-state`; // Stores the calculated thumbnail URL directly on the link element const ATTR_THUMBNAIL_URL = `data-${SCRIPT_ID.toLowerCase()}-thumb-url`; // Tracks if event listeners have been attached to avoid duplicates const ATTR_LISTENERS_ATTACHED = `data-${SCRIPT_ID.toLowerCase()}-listeners`; // --- CSS Classes --- const CLASS_REVEAL_THUMBNAIL = `${SCRIPT_ID}-revealThumbnail`; // Temporary thumbnail shown on hover (spoiler mode) or blurred preview const CLASS_BLUR_WRAPPER = `${SCRIPT_ID}-blurWrapper`; // Wrapper for the blurred thumbnail to handle sizing and overflow // --- Selectors --- const SELECTORS = Object.freeze({ // Matches standard 8chan spoiler images and common custom spoiler names SPOILER_IMG: `img[src="/spoiler.png"], img[src$="/custom.spoiler"]`, // The anchor tag wrapping the spoiler image IMG_LINK: 'a.imgLink', // Selector for the dynamically created blur wrapper div BLUR_WRAPPER: `.${CLASS_BLUR_WRAPPER}`, // Selector for the thumbnail image (used in both modes, potentially temporarily) REVEAL_THUMBNAIL: `img.${CLASS_REVEAL_THUMBNAIL}`, // More specific selector using tag + class }); // --- Global State --- let scriptSettings = { ...DEFAULT_SETTINGS }; // --- Utility Functions --- const log = (...args) => console.log(`[${SCRIPT_ID}]`, ...args); const debugLog = (...args) => DEBUG_MODE && console.log(`[${SCRIPT_ID} Debug]`, ...args); const warn = (...args) => console.warn(`[${SCRIPT_ID}]`, ...args); const error = (...args) => console.error(`[${SCRIPT_ID}]`, ...args); /** * Extracts the image hash from a full image URL. * @param {string | null} imageUrl The full URL of the image. * @returns {string | null} The extracted hash or null if parsing fails. */ function getHashFromImageUrl(imageUrl) { if (!imageUrl) return null; try { // Prefer URL parsing for robustness const url = new URL(imageUrl); const filename = url.pathname.split('/').pop(); if (!filename) return null; // Hash is typically the part before the first dot const hash = filename.split('.')[0]; return hash || null; } catch (e) { // Fallback for potentially invalid URLs or non-standard paths warn("Could not parse image URL with URL API, falling back:", imageUrl, e); const parts = imageUrl.split('/'); const filename = parts.pop(); if (!filename) return null; const hash = filename.split('.')[0]; return hash || null; } } /** * Constructs the thumbnail URL based on the full image URL and hash. * Assumes 8chan's '/path/to/image/HASH.ext' and '/path/to/image/t_HASH' structure. * @param {string | null} fullImageUrl The full URL of the image. * @param {string | null} hash The image hash. * @returns {string | null} The constructed thumbnail URL or null. */ function getThumbnailUrl(fullImageUrl, hash) { if (!fullImageUrl || !hash) return null; try { // Prefer URL parsing const url = new URL(fullImageUrl); const pathParts = url.pathname.split('/'); pathParts.pop(); // Remove filename const basePath = pathParts.join('/') + '/'; // Construct new URL relative to the origin return new URL(basePath + 't_' + hash, url.origin).toString(); } catch (e) { // Fallback for potentially invalid URLs warn("Could not construct thumbnail URL with URL API, falling back:", fullImageUrl, hash, e); const parts = fullImageUrl.split('/'); parts.pop(); // Remove filename const basePath = parts.join('/') + '/'; // Basic string concatenation fallback (might lack origin if relative) return basePath + 't_' + hash; } } /** * Validates raw settings data against defaults, ensuring correct types and ranges. * @param {object} settingsToValidate - The raw settings object (e.g., from GM.getValue). * @returns {object} A validated settings object. */ function validateSettings(settingsToValidate) { const validated = {}; const source = { ...DEFAULT_SETTINGS, ...settingsToValidate }; // Merge with defaults first validated.thumbnailMode = (source.thumbnailMode === 'spoiler' || source.thumbnailMode === 'blurred') ? source.thumbnailMode : DEFAULT_SETTINGS.thumbnailMode; validated.blurAmount = (typeof source.blurAmount === 'number' && source.blurAmount >= 0 && source.blurAmount <= 50) // Increased max blur slightly ? source.blurAmount : DEFAULT_SETTINGS.blurAmount; validated.disableHoverWhenBlurred = (typeof source.disableHoverWhenBlurred === 'boolean') ? source.disableHoverWhenBlurred : DEFAULT_SETTINGS.disableHoverWhenBlurred; return validated; } // --- Settings Module --- // Manages loading, saving, and accessing script settings. const Settings = { /** Loads settings from storage, validates them, and updates the global state. */ async load() { try { const storedSettings = await GM.getValue(GM_SETTINGS_KEY, {}); scriptSettings = validateSettings(storedSettings); log('Settings loaded:', scriptSettings); } catch (e) { warn('Failed to load settings, using defaults.', e); scriptSettings = { ...DEFAULT_SETTINGS }; // Reset to defaults on error } }, /** Saves the current global settings state to storage after validation. */ async save() { try { // Always validate before saving const settingsToSave = validateSettings(scriptSettings); await GM.setValue(GM_SETTINGS_KEY, settingsToSave); log('Settings saved.'); } catch (e) { error('Failed to save settings.', e); // Consider notifying the user here if appropriate throw e; // Re-throw for the caller (e.g., save button handler) } }, // --- Getters for accessing current settings --- getThumbnailMode: () => scriptSettings.thumbnailMode, getBlurAmount: () => scriptSettings.blurAmount, getDisableHoverWhenBlurred: () => scriptSettings.disableHoverWhenBlurred, // --- Setters for updating global settings state (used by UI before saving) --- setThumbnailMode: (mode) => { scriptSettings.thumbnailMode = mode; }, setBlurAmount: (amount) => { scriptSettings.blurAmount = amount; }, setDisableHoverWhenBlurred: (isDisabled) => { scriptSettings.disableHoverWhenBlurred = isDisabled; }, }; // --- Image Style Manipulation --- /** * Applies the current blur setting to an element. * @param {HTMLElement} element - The element to blur. */ function applyBlur(element) { const blurAmount = Settings.getBlurAmount(); element.style.filter = `blur(${blurAmount}px)`; element.style.willChange = 'filter'; // Hint for performance debugLog('Applied blur:', blurAmount, element); } /** * Removes blur from an element. * @param {HTMLElement} element - The element to unblur. */ function removeBlur(element) { element.style.filter = 'none'; element.style.willChange = 'auto'; debugLog('Removed blur:', element); } // --- Image Structure Management --- /** * Fetches thumbnail dimensions and applies them to the spoiler image. * Avoids layout shifts by pre-sizing the spoiler placeholder. * @param {HTMLImageElement} spoilerImg - The original spoiler image element. * @param {string} thumbnailUrl - The URL of the corresponding thumbnail. */ function setSpoilerDimensionsFromThumbnail(spoilerImg, thumbnailUrl) { // Use a more descriptive attribute name if possible, but keep current for compatibility const currentState = spoilerImg.getAttribute(ATTR_DIMENSION_STATE); if (!spoilerImg || currentState === 'success' || currentState === 'pending') { debugLog('Skipping dimension setting (already done or pending):', spoilerImg); return; // Avoid redundant work or race conditions } if (!thumbnailUrl) { spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-no-thumb-url'); warn('Cannot set dimensions: no thumbnail URL provided for spoiler:', spoilerImg.closest('a')?.href); return; } spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'pending'); debugLog('Attempting to set dimensions from thumbnail:', thumbnailUrl); const tempImg = new Image(); const cleanup = () => { tempImg.removeEventListener('load', loadHandler); tempImg.removeEventListener('error', errorHandler); }; const loadHandler = () => { if (tempImg.naturalWidth > 0 && tempImg.naturalHeight > 0) { spoilerImg.width = tempImg.naturalWidth; // Set explicit dimensions spoilerImg.height = tempImg.naturalHeight; spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'success'); log('Spoiler dimensions set from thumbnail:', spoilerImg.width, 'x', spoilerImg.height); } else { warn(`Thumbnail loaded with zero dimensions: ${thumbnailUrl}`); spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-zero-dim'); } cleanup(); }; const errorHandler = (errEvent) => { warn(`Failed to load thumbnail for dimension setting: ${thumbnailUrl}`, errEvent); spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-load-error'); cleanup(); }; tempImg.addEventListener('load', loadHandler); tempImg.addEventListener('error', errorHandler); try { // Set src to start loading tempImg.src = thumbnailUrl; } catch (e) { error("Error assigning src for dimension check:", thumbnailUrl, e); spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-src-assign'); cleanup(); // Ensure cleanup even if src assignment fails } } /** * Creates or updates the necessary DOM structure for the 'blurred' mode. * Hides the original spoiler and shows a blurred thumbnail. * @param {HTMLAnchorElement} imgLink - The parent anchor element. * @param {HTMLImageElement} spoilerImg - The original spoiler image. * @param {string} thumbnailUrl - The thumbnail URL. */ function ensureBlurredStructure(imgLink, spoilerImg, thumbnailUrl) { let blurWrapper = imgLink.querySelector(SELECTORS.BLUR_WRAPPER); let revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); // --- Structure Check and Cleanup --- // If elements exist but aren't nested correctly, remove them to rebuild if (revealThumbnail && (!blurWrapper || !blurWrapper.contains(revealThumbnail))) { debugLog('Incorrect blurred structure found, removing orphan thumbnail.'); revealThumbnail.remove(); revealThumbnail = null; // Reset variable } if (blurWrapper && !revealThumbnail) { // Wrapper exists but no image inside? Rebuild. debugLog('Incorrect blurred structure found, removing empty wrapper.'); blurWrapper.remove(); blurWrapper = null; // Reset variable } // --- Create or Update Structure --- if (!blurWrapper) { debugLog('Creating blur wrapper and thumbnail for:', imgLink.href); blurWrapper = document.createElement('div'); blurWrapper.className = CLASS_BLUR_WRAPPER; blurWrapper.style.overflow = 'hidden'; blurWrapper.style.display = 'inline-block'; // Match image display blurWrapper.style.lineHeight = '0'; // Prevent extra space below image blurWrapper.style.visibility = 'hidden'; // Hide until loaded and sized revealThumbnail = document.createElement('img'); revealThumbnail.className = CLASS_REVEAL_THUMBNAIL; revealThumbnail.style.display = 'block'; // Ensure it fills wrapper correctly const cleanup = () => { revealThumbnail.removeEventListener('load', loadHandler); revealThumbnail.removeEventListener('error', errorHandler); }; const loadHandler = () => { if (revealThumbnail.naturalWidth > 0 && revealThumbnail.naturalHeight > 0) { const w = revealThumbnail.naturalWidth; const h = revealThumbnail.naturalHeight; // Set size on wrapper and image blurWrapper.style.width = `${w}px`; blurWrapper.style.height = `${h}px`; revealThumbnail.width = w; revealThumbnail.height = h; applyBlur(revealThumbnail); // Apply blur *after* loading and sizing blurWrapper.style.visibility = 'visible'; // Show it now spoilerImg.style.display = 'none'; // Hide original spoiler imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-blurred'); debugLog('Blurred thumbnail structure created successfully.'); } else { warn('Blurred thumbnail loaded with zero dimensions:', thumbnailUrl); blurWrapper.remove(); // Clean up failed elements spoilerImg.style.display = ''; // Show spoiler again imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-blurred-zero-dims'); } cleanup(); }; const errorHandler = () => { warn(`Failed to load blurred thumbnail: ${thumbnailUrl}`); blurWrapper.remove(); // Clean up failed elements spoilerImg.style.display = ''; // Show spoiler again imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-blurred-thumb-load'); cleanup(); }; revealThumbnail.addEventListener('load', loadHandler); revealThumbnail.addEventListener('error', errorHandler); blurWrapper.appendChild(revealThumbnail); // Insert the wrapper before the original spoiler image imgLink.insertBefore(blurWrapper, spoilerImg); try { revealThumbnail.src = thumbnailUrl; } catch (e) { error("Error assigning src to blurred thumbnail:", thumbnailUrl, e); errorHandler(); // Trigger error handling manually } } else { // Structure exists, just ensure blur is correct and elements are visible debugLog('Blurred structure already exists, ensuring blur and visibility.'); if (revealThumbnail) applyBlur(revealThumbnail); // Re-apply current blur amount spoilerImg.style.display = 'none'; blurWrapper.style.display = 'inline-block'; // Ensure state attribute reflects current mode imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-blurred'); } } /** * Ensures the 'spoiler' mode structure is active. * Removes any blurred elements and ensures the original spoiler image is visible. * Also triggers dimension setting if needed. * @param {HTMLAnchorElement} imgLink - The parent anchor element. * @param {HTMLImageElement} spoilerImg - The original spoiler image. * @param {string} thumbnailUrl - The thumbnail URL (needed for dimension setting). */ function ensureSpoilerStructure(imgLink, spoilerImg, thumbnailUrl) { const blurWrapper = imgLink.querySelector(SELECTORS.BLUR_WRAPPER); if (blurWrapper) { debugLog('Removing blurred structure for:', imgLink.href); blurWrapper.remove(); // Removes wrapper and its contents (revealThumbnail) } // Ensure the original spoiler image is visible spoilerImg.style.display = ''; // Reset to default display // Ensure dimensions are set (might switch before initial dimension setting completed) // This function has internal checks to prevent redundant work. setSpoilerDimensionsFromThumbnail(spoilerImg, thumbnailUrl); imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-spoiler'); debugLog('Ensured spoiler structure for:', imgLink.href); } /** * Dynamically updates the visual appearance of a single image link * based on the current script settings (mode, blur amount). * This is called during initial processing and when settings change. * @param {HTMLAnchorElement} imgLink - The image link element to update. */ function updateImageAppearance(imgLink) { if (!imgLink || !imgLink.matches(SELECTORS.IMG_LINK)) return; const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG); if (!spoilerImg) { // This link doesn't have a spoiler, state should reflect this if (!imgLink.hasAttribute(ATTR_PROCESSED_STATE)) { imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler'); } return; } const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL); if (!thumbnailUrl) { // This is unexpected if processing reached this point, but handle defensively warn("Cannot update appearance, missing thumbnail URL attribute on:", imgLink.href); // Mark as failed if not already processed otherwise if (!imgLink.hasAttribute(ATTR_PROCESSED_STATE) || imgLink.getAttribute(ATTR_PROCESSED_STATE) === 'processing') { imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-missing-thumb-attr'); } return; } const currentMode = Settings.getThumbnailMode(); debugLog(`Updating appearance for ${imgLink.href} to mode: ${currentMode}`); if (currentMode === 'blurred') { ensureBlurredStructure(imgLink, spoilerImg, thumbnailUrl); } else { // mode === 'spoiler' ensureSpoilerStructure(imgLink, spoilerImg, thumbnailUrl); } // If switching TO blurred mode OR blur amount changed while blurred, ensure blur is applied. // The ensureBlurredStructure function already calls applyBlur, so this check might be slightly redundant, // but it catches cases where the user is hovering WHILE changing settings. const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); if (currentMode === 'blurred' && revealThumbnail) { // Re-apply blur in case it was removed by a hover event that hasn't triggered mouseleave yet applyBlur(revealThumbnail); } } // --- Event Handlers --- /** Checks if the image link's container is in an expanded state. */ function isImageExpanded(imgLink) { // Find the closest ancestor figure element const figure = imgLink.closest('figure.uploadCell'); // Check if the figure exists and has the 'expandedCell' class const isExpanded = figure && figure.classList.contains('expandedCell'); if (isExpanded) { debugLog(`Image container for ${imgLink.href} is expanded.`); } return isExpanded; } /** Handles mouse entering the image link area. */ function handleLinkMouseEnter(event) { const imgLink = event.currentTarget; // *** ADD THIS CHECK *** // If the image is already expanded by 8chan's logic, do nothing. if (isImageExpanded(imgLink)) { return; } // *** END CHECK *** const mode = Settings.getThumbnailMode(); const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL); const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG); // Essential elements must exist if (!thumbnailUrl || !spoilerImg) return; debugLog('Mouse Enter (Non-Expanded):', imgLink.href, 'Mode:', mode); if (mode === 'spoiler') { // Show original thumbnail temporarily if (imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL)) return; // Avoid duplicates const revealThumbnail = document.createElement('img'); revealThumbnail.src = thumbnailUrl; revealThumbnail.className = CLASS_REVEAL_THUMBNAIL; revealThumbnail.style.display = 'block'; if (spoilerImg.width > 0 && spoilerImg.getAttribute(ATTR_DIMENSION_STATE) === 'success') { revealThumbnail.width = spoilerImg.width; revealThumbnail.height = spoilerImg.height; debugLog('Applying spoiler dims to hover thumb:', spoilerImg.width, spoilerImg.height); } else if (spoilerImg.offsetWidth > 0) { revealThumbnail.style.width = `${spoilerImg.offsetWidth}px`; revealThumbnail.style.height = `${spoilerImg.offsetHeight}px`; debugLog('Applying spoiler offset dims to hover thumb:', spoilerImg.offsetWidth, spoilerImg.offsetHeight); } imgLink.insertBefore(revealThumbnail, spoilerImg); // *** IMPORTANT: Set display to none *** spoilerImg.style.display = 'none'; } else if (mode === 'blurred') { if (Settings.getDisableHoverWhenBlurred()) return; const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); if (revealThumbnail) { removeBlur(revealThumbnail); } } } /** Handles mouse leaving the image link area. */ function handleLinkMouseLeave(event) { const imgLink = event.currentTarget; // *** ADD THIS CHECK *** // If the image is already expanded by 8chan's logic, do nothing. // The expansion logic handles visibility, we should not interfere. if (isImageExpanded(imgLink)) { return; } // *** END CHECK *** const mode = Settings.getThumbnailMode(); debugLog('Mouse Leave (Non-Expanded):', imgLink.href, 'Mode:', mode); if (mode === 'spoiler') { // Find the temporary hover thumbnail const revealThumbnail = imgLink.querySelector(`img.${CLASS_REVEAL_THUMBNAIL}`); // Only perform cleanup if the hover thumbnail exists (meaning mouseenter completed) if (revealThumbnail) { revealThumbnail.remove(); const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG); if (spoilerImg) { // Restore spoiler visibility *only if* it's currently hidden by our script if (spoilerImg.style.display === 'none') { debugLog('Restoring spoilerImg visibility after hover (non-expanded).'); spoilerImg.style.display = ''; // Reset display } else { debugLog('SpoilerImg display was not "none" during non-expanded mouseleave cleanup.'); } } } // If revealThumbnail wasn't found (e.g., rapid mouse out before enter completed fully), // we don't need to do anything, as the spoiler should still be visible. } else if (mode === 'blurred') { // Re-apply blur const blurredThumbnail = imgLink.querySelector(SELECTORS.BLUR_WRAPPER + ' .' + CLASS_REVEAL_THUMBNAIL); if (blurredThumbnail) { applyBlur(blurredThumbnail); } } } // --- Content Processing & Observation --- /** * Processes a single image link element if it hasn't been processed yet. * Fetches metadata, attaches listeners, and sets initial appearance. * @param {HTMLAnchorElement} imgLink - The image link element. */ function processImgLink(imgLink) { // Check if already processed or currently processing if (!imgLink || imgLink.hasAttribute(ATTR_PROCESSED_STATE)) { // Allow re-running updateImageAppearance even if processed if (imgLink?.getAttribute(ATTR_PROCESSED_STATE)?.startsWith('processed-')) { debugLog('Link already processed, potentially re-applying appearance:', imgLink.href); updateImageAppearance(imgLink); // Ensure appearance matches current settings } return; } const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG); if (!spoilerImg) { // Mark as skipped only if it wasn't processed before imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler'); return; } // Mark as processing to prevent duplicate runs from observer/initial scan imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processing'); debugLog('Processing link:', imgLink.href); // --- Metadata Acquisition (Done only once) --- const fullImageUrl = imgLink.href; const hash = getHashFromImageUrl(fullImageUrl); if (!hash) { warn('Failed to get hash for:', fullImageUrl); imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-hash'); return; } const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash); if (!thumbnailUrl) { warn('Failed to get thumbnail URL for:', fullImageUrl, hash); imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-thumb-url'); return; } // Store the thumbnail URL on the element for easy access later imgLink.setAttribute(ATTR_THUMBNAIL_URL, thumbnailUrl); debugLog(`Stored thumb URL: ${thumbnailUrl}`); // --- Attach Event Listeners (Done only once) --- if (!imgLink.hasAttribute(ATTR_LISTENERS_ATTACHED)) { imgLink.addEventListener('mouseenter', handleLinkMouseEnter); imgLink.addEventListener('mouseleave', handleLinkMouseLeave); imgLink.setAttribute(ATTR_LISTENERS_ATTACHED, 'true'); debugLog('Attached event listeners.'); } // --- Set Initial Appearance based on current settings --- // This function also sets the final 'processed-*' state attribute updateImageAppearance(imgLink); // Dimension setting is triggered within updateImageAppearance -> ensureSpoilerStructure if needed } /** * Scans a container element for unprocessed spoiler image links and processes them. * @param {Node} container - The DOM node (usually an Element) to scan within. */ function processContainer(container) { if (!container || typeof container.querySelectorAll !== 'function') return; // Select links that contain a spoiler image and are *not yet processed* // This selector is more specific upfront. const imgLinks = container.querySelectorAll( `${SELECTORS.IMG_LINK}:not([${ATTR_PROCESSED_STATE}]) ${SELECTORS.SPOILER_IMG}` ); if (imgLinks.length > 0) { debugLog(`Found ${imgLinks.length} potential new spoilers in container:`, container.nodeName); // Get the parent link element for each found spoiler image imgLinks.forEach(spoiler => { const link = spoiler.closest(SELECTORS.IMG_LINK); if (link) { processImgLink(link); } else { warn("Found spoiler image without parent imgLink:", spoiler); } }); } // Additionally, check links that might have failed processing previously and could be retried // (Example: maybe a network error prevented thumb loading before) - This might be too aggressive. // For now, stick to processing only newly added/unprocessed links. } // --- Settings Panel UI (STM Integration) --- // Cache for panel DOM elements to avoid repeated queries let panelElementsCache = {}; // Unique IDs for elements within the settings panel const PANEL_IDS = Object.freeze({ MODE_SPOILER: `${SCRIPT_ID}-mode-spoiler`, MODE_BLURRED: `${SCRIPT_ID}-mode-blurred`, BLUR_OPTIONS: `${SCRIPT_ID}-blur-options`, BLUR_AMOUNT_LABEL: `${SCRIPT_ID}-blur-amount-label`, BLUR_SLIDER: `${SCRIPT_ID}-blur-amount`, BLUR_VALUE: `${SCRIPT_ID}-blur-value`, DISABLE_HOVER_CHECKBOX: `${SCRIPT_ID}-disable-hover`, DISABLE_HOVER_LABEL: `${SCRIPT_ID}-disable-hover-label`, SAVE_BUTTON: `${SCRIPT_ID}-save-settings`, SAVE_STATUS: `${SCRIPT_ID}-save-status`, }); // CSS for the settings panel (scoped via STM panel ID) function getSettingsPanelCSS(stmPanelId) { return ` #${stmPanelId} > div { margin-bottom: 12px; } #${stmPanelId} label { display: inline-block; margin-right: 10px; vertical-align: middle; cursor: pointer; } #${stmPanelId} input[type="radio"], #${stmPanelId} input[type="checkbox"] { vertical-align: middle; margin-right: 3px; cursor: pointer; } #${stmPanelId} input[type="range"] { vertical-align: middle; width: 180px; margin-left: 5px; cursor: pointer; } #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} { /* Use class selector for options div */ margin-left: 20px; padding-left: 15px; border-left: 1px solid #ccc; margin-top: 8px; transition: opacity 0.3s ease, filter 0.3s ease; } #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS}.disabled { opacity: 0.5; filter: grayscale(50%); pointer-events: none; } #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} > div { margin-bottom: 8px; } #${stmPanelId} #${PANEL_IDS.BLUR_VALUE} { display: inline-block; min-width: 25px; text-align: right; margin-left: 5px; font-family: monospace; font-weight: bold; } #${stmPanelId} button { margin-top: 15px; padding: 5px 10px; cursor: pointer; } #${stmPanelId} #${PANEL_IDS.SAVE_STATUS} { margin-left: 10px; font-size: 0.9em; font-style: italic; } #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.success { color: green; } #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.error { color: red; } #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.info { color: #555; } `; } // HTML structure for the settings panel const settingsPanelHTML = `
Error initializing ${SCRIPT_ID} settings panel. Please check the browser console (F12) for details.
Error: ${err.message || 'Unknown error'}