// ==UserScript== // @name 8chan Spoiler Thumbnail Enhancer // @namespace nipah-scripts-8chan // @version 2.4.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 none // ==/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 --- /** Handles mouse entering the image link area. */ function handleLinkMouseEnter(event) { const imgLink = event.currentTarget; // `this` can be unreliable depending on context 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:', imgLink.href, 'Mode:', mode); if (mode === 'spoiler') { // Show original thumbnail temporarily // Avoid creating if one already exists (e.g., rapid hover) if (imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL)) return; const revealThumbnail = document.createElement('img'); revealThumbnail.src = thumbnailUrl; revealThumbnail.className = CLASS_REVEAL_THUMBNAIL; // Use class for identification revealThumbnail.style.display = 'block'; // Match spoiler image display style // Use dimensions from the pre-sized spoiler image if available and successful 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 { // Fallback: Use spoiler's current offset dimensions if available 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); } // else: let the browser determine size based on loaded image } imgLink.insertBefore(revealThumbnail, spoilerImg); spoilerImg.style.display = 'none'; // Hide original spoiler } else if (mode === 'blurred') { // Unblur the existing thumbnail if hover is enabled if (Settings.getDisableHoverWhenBlurred()) return; const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); // Should be inside blur wrapper if (revealThumbnail) { removeBlur(revealThumbnail); } } } /** Handles mouse leaving the image link area. */ function handleLinkMouseLeave(event) { const imgLink = event.currentTarget; const mode = Settings.getThumbnailMode(); debugLog('Mouse Leave:', imgLink.href, 'Mode:', mode); if (mode === 'spoiler') { // Remove the temporary hover thumbnail const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); if (revealThumbnail) { revealThumbnail.remove(); } // Ensure original spoiler is visible again const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG); if (spoilerImg) { spoilerImg.style.display = ''; // Reset display } } else if (mode === 'blurred') { // Re-apply blur (no need to check disableHoverWhenBlurred, if disabled, blur was never removed) const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL); if (revealThumbnail) { applyBlur(revealThumbnail); // Uses current blur amount setting } } } // --- 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 = `
Thumbnail Mode:

px

`; /** Caches references to panel elements for quick access. */ function cachePanelElements(panelElement) { panelElementsCache = { // Store references in the scoped cache panel: panelElement, modeSpoilerRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_SPOILER}`), modeBlurredRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_BLURRED}`), blurOptionsDiv: panelElement.querySelector(`#${PANEL_IDS.BLUR_OPTIONS}`), // Query by ID is fine here blurSlider: panelElement.querySelector(`#${PANEL_IDS.BLUR_SLIDER}`), blurValueSpan: panelElement.querySelector(`#${PANEL_IDS.BLUR_VALUE}`), disableHoverCheckbox: panelElement.querySelector(`#${PANEL_IDS.DISABLE_HOVER_CHECKBOX}`), saveButton: panelElement.querySelector(`#${PANEL_IDS.SAVE_BUTTON}`), saveStatusSpan: panelElement.querySelector(`#${PANEL_IDS.SAVE_STATUS}`), }; // Basic check for essential elements if (!panelElementsCache.modeSpoilerRadio || !panelElementsCache.saveButton || !panelElementsCache.blurOptionsDiv) { error("Failed to cache essential panel elements. UI may not function correctly."); return false; } debugLog("Panel elements cached."); return true; } /** Updates the enabled/disabled state and appearance of blur options based on mode selection. */ function updateBlurOptionsStateUI() { const elements = panelElementsCache; // Use cached elements if (!elements.blurOptionsDiv) return; const isBlurredMode = elements.modeBlurredRadio?.checked; const isDisabled = !isBlurredMode; // Toggle visual state class elements.blurOptionsDiv.classList.toggle('disabled', isDisabled); // Toggle disabled attribute for form elements if (elements.blurSlider) elements.blurSlider.disabled = isDisabled; if (elements.disableHoverCheckbox) elements.disableHoverCheckbox.disabled = isDisabled; debugLog("Blur options UI state updated. Disabled:", isDisabled); } /** Populates the settings controls with current values from the Settings module. */ function populateControlsUI() { const elements = panelElementsCache; if (!elements.panel) { warn("Cannot populate controls, panel elements not cached/ready."); return; } try { const mode = Settings.getThumbnailMode(); if (elements.modeSpoilerRadio) elements.modeSpoilerRadio.checked = (mode === 'spoiler'); if (elements.modeBlurredRadio) elements.modeBlurredRadio.checked = (mode === 'blurred'); const blurAmount = Settings.getBlurAmount(); if (elements.blurSlider) elements.blurSlider.value = blurAmount; if (elements.blurValueSpan) elements.blurValueSpan.textContent = blurAmount; if (elements.disableHoverCheckbox) { elements.disableHoverCheckbox.checked = Settings.getDisableHoverWhenBlurred(); } updateBlurOptionsStateUI(); // Ensure blur options state is correct on population debugLog("Settings panel UI populated with current settings."); } catch (err) { error("Error populating settings controls:", err); } } /** Sets the status message in the settings panel. */ function setStatusMessage(message, type = 'info', duration = 3000) { const statusSpan = panelElementsCache.saveStatusSpan; if (!statusSpan) return; statusSpan.textContent = message; statusSpan.className = type; // Add class for styling (success, error, info) // Clear message after duration (if duration > 0) if (duration > 0) { setTimeout(() => { if (statusSpan.textContent === message) { // Avoid clearing newer messages statusSpan.textContent = ''; statusSpan.className = ''; } }, duration); } } /** Handles the click on the 'Save Settings' button in the panel. */ async function handleSaveClickUI() { const elements = panelElementsCache; if (!elements.saveButton || !elements.modeSpoilerRadio) return; setStatusMessage('Saving...', 'info', 0); // Indicate saving (no timeout) try { // --- 1. Read new values from UI --- const newMode = elements.modeSpoilerRadio.checked ? 'spoiler' : 'blurred'; const newBlurAmount = parseInt(elements.blurSlider.value, 10); const newDisableHover = elements.disableHoverCheckbox.checked; // Client-side validation (redundant with Settings.validate, but good UX) if (isNaN(newBlurAmount) || newBlurAmount < 1 || newBlurAmount > 50) { throw new Error(`Invalid blur amount: ${newBlurAmount}. Must be between 1 and 50.`); } // --- 2. Update settings in the Settings module --- // This updates the global `scriptSettings` object Settings.setThumbnailMode(newMode); Settings.setBlurAmount(newBlurAmount); Settings.setDisableHoverWhenBlurred(newDisableHover); // --- 3. Save persistently --- await Settings.save(); // This also validates internally // --- 4. Apply changes dynamically to existing elements --- setStatusMessage('Applying changes...', 'info', 0); log(`Applying settings dynamically: Mode=${newMode}, Blur=${newBlurAmount}, DisableHover=${newDisableHover}`); // Select all links that have been successfully processed previously const processedLinks = document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}^="processed-"]`); log(`Found ${processedLinks.length} elements to update dynamically.`); processedLinks.forEach(link => { try { // This function handles switching between modes or updating blur amount updateImageAppearance(link); } catch (updateErr) { // Log error for specific link but continue with others error(`Error updating appearance for ${link.href}:`, updateErr); } }); // --- 5. Final status update --- setStatusMessage('Saved & Applied!', 'success', 3000); log('Settings saved and changes applied dynamically.'); } catch (err) { error('Failed to save or apply settings:', err); setStatusMessage(`Error: ${err.message || 'Could not save/apply.'}`, 'error', 5000); } } /** Attaches event listeners to the controls *within* the settings panel. */ function addPanelEventListeners() { const elements = panelElementsCache; if (!elements.panel) { error("Cannot add panel listeners, panel elements not cached."); return; } // Debounce function to prevent rapid firing during slider drag let debounceTimer; const debounce = (func, delay = 50) => { return (...args) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { func.apply(this, args); }, delay); }; }; // Save Button elements.saveButton?.addEventListener('click', handleSaveClickUI); // Mode Radio Buttons (update blur options enable/disable state) const modeChangeHandler = () => updateBlurOptionsStateUI(); elements.modeSpoilerRadio?.addEventListener('change', modeChangeHandler); elements.modeBlurredRadio?.addEventListener('change', modeChangeHandler); // Blur Slider Input (update value display in real-time) elements.blurSlider?.addEventListener('input', (event) => { if (elements.blurValueSpan) { elements.blurValueSpan.textContent = event.target.value; } // Optional: Apply blur change dynamically while dragging (might be slow) // const applyLiveBlur = debounce(() => { // if (elements.modeBlurredRadio?.checked) { // Settings.setBlurAmount(parseInt(event.target.value, 10)); // document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}="processed-blurred"] ${SELECTORS.REVEAL_THUMBNAIL}`) // .forEach(thumb => applyBlur(thumb)); // } // }); // applyLiveBlur(); }); log("Settings panel event listeners added."); } // --- STM Integration Callbacks --- /** `onInit` callback for SettingsTabManager. Called once when the panel is first created. */ function initializeSettingsPanel(panelElement, tabElement) { log(`STM initializing panel: #${panelElement.id}`); try { // Inject CSS scoped to this panel GM_addStyle(getSettingsPanelCSS(panelElement.id)); // Set panel HTML content panelElement.innerHTML = settingsPanelHTML; // Cache DOM elements within the panel if (!cachePanelElements(panelElement)) { throw new Error("Failed to cache panel elements after creation."); } // Populate UI with current settings (Settings.load should have run already) populateControlsUI(); // Add event listeners to the UI controls addPanelEventListeners(); log('Settings panel initialized successfully.'); } catch (err) { error("Error during settings panel initialization:", err); // Display error message within the panel itself panelElement.innerHTML = `

Error initializing ${SCRIPT_ID} settings panel. Please check the browser console (F12) for details.
Error: ${err.message || 'Unknown error'}

`; } } /** `onActivate` callback for SettingsTabManager. Called every time the tab is clicked. */ function onSettingsTabActivate(panelElement, tabElement) { log(`${SCRIPT_ID} settings tab activated.`); // Ensure UI reflects the latest settings (in case they were changed programmatically - unlikely) populateControlsUI(); // Clear any previous status messages setStatusMessage('', 'info', 0); // Clear immediately } // --- Main Initialization --- /** Sets up the script: Loads settings, registers with STM (with timeout), starts observer, processes initial content. */ async function initialize() { log(`Initializing ${SCRIPT_ID} v${SCRIPT_VERSION}...`); // 1. Load settings first await Settings.load(); // 2. Register settings panel with SettingsTabManager (with waiting logic and timeout) let stmAttempts = 0; const MAX_STM_ATTEMPTS = 20; // e.g., 20 attempts const STM_RETRY_DELAY_MS = 250; // Retry every 250ms const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total wait function attemptStmRegistration() { stmAttempts++; debugLog(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`); // *** Check unsafeWindow directly *** if (typeof unsafeWindow !== 'undefined' // Ensure unsafeWindow exists && typeof unsafeWindow.SettingsTabManager !== 'undefined' && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined') { log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...'); // Found it, call the async registration function, but don't wait for it here. // Let the rest of the script initialization continue. registerWithStm().catch(err => { error("Async registration with STM failed after finding it:", err); // Even if registration fails *after* finding STM, we proceed without the panel. }); // STM found (or at least its .ready property), stop polling. return; // Exit the polling function } // STM not found/ready yet, check if we should give up if (stmAttempts >= MAX_STM_ATTEMPTS) { warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`); // Give up polling, DO NOT call setTimeout again. return; // Exit the polling function } // STM not found, limit not reached, schedule next attempt if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') { debugLog('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...'); } else { debugLog('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...'); } setTimeout(attemptStmRegistration, STM_RETRY_DELAY_MS); // Retry after a delay } async function registerWithStm() { // This function now only runs if STM.ready was detected try { // *** Access via unsafeWindow *** if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') { // Should not happen if called correctly, but check defensively error('SettingsTabManager.ready disappeared before registration could complete.'); return; // Cannot register } const stm = await unsafeWindow.SettingsTabManager.ready; // *** End Access via unsafeWindow *** // Now register the tab using the resolved stm object const registrationSuccess = stm.registerTab({ scriptId: SCRIPT_ID, tabTitle: 'Spoilers', order: 30, onInit: initializeSettingsPanel, onActivate: onSettingsTabActivate }); if (registrationSuccess) { log('Successfully registered settings tab with STM.'); } else { warn('STM registration returned false (tab might already exist or other registration issue).'); } } catch (err) { // Catch errors during the await SettingsTabManager.ready or stm.registerTab error('Failed to register settings tab via SettingsTabManager:', err); // No need to retry here, just log the failure. } } // Start the check/wait process *asynchronously*. // We don't await this; the rest of the script continues immediately. attemptStmRegistration(); // 3. Set up MutationObserver (Runs regardless of STM status) const observerOptions = { childList: true, subtree: true }; const contentObserver = new MutationObserver((mutations) => { const linksToProcess = new Set(); mutations.forEach((mutation) => { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches(SELECTORS.IMG_LINK) && node.querySelector(SELECTORS.SPOILER_IMG)) { linksToProcess.add(node); } else { node.querySelectorAll(`${SELECTORS.IMG_LINK} ${SELECTORS.SPOILER_IMG}`) .forEach(spoiler => { const link = spoiler.closest(SELECTORS.IMG_LINK); if (link) linksToProcess.add(link); }); } } }); } }); if (linksToProcess.size > 0) { debugLog(`MutationObserver found ${linksToProcess.size} new potential links.`); linksToProcess.forEach(link => processImgLink(link)); } }); contentObserver.observe(document.body, observerOptions); log('Mutation observer started.'); // 4. Process initial content (Runs regardless of STM status) log('Performing initial content scan...'); processContainer(document.body); log('Script initialization logic finished (STM check running in background).'); } // --- Run Initialization --- // Use .catch here for errors during the initial synchronous part of initialize() // or the Settings.load() promise. Errors within async STM polling/registration // are handled by their respective try/catch blocks. initialize().catch(err => { error("Critical error during script initialization startup:", err); }); })();