Greasy Fork

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.

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         8chan Spoiler Thumbnail Enhancer
// @namespace    sneed
// @version      2.2.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/*/res/*.html*
// @match        https://8chan.se/*/res/*.html*
// @require      https://update.greasyfork.icu/scripts/533630/1575650/Settings%20Tab%20Manager%20%28STM%29.js
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_addStyle
// @run-at       document-idle
// ==/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 = `
        <div>
            <strong>Thumbnail Mode:</strong><br>
            <input type="radio" id="${PANEL_IDS.MODE_SPOILER}" name="${SCRIPT_ID}-mode" value="spoiler">
            <label for="${PANEL_IDS.MODE_SPOILER}">Show Original Thumbnail on Hover</label><br>
            <input type="radio" id="${PANEL_IDS.MODE_BLURRED}" name="${SCRIPT_ID}-mode" value="blurred">
            <label for="${PANEL_IDS.MODE_BLURRED}">Show Blurred Thumbnail</label>
        </div>
        <div class="${PANEL_IDS.BLUR_OPTIONS}" id="${PANEL_IDS.BLUR_OPTIONS}"> <!-- Use class and ID -->
            <div>
                <label for="${PANEL_IDS.BLUR_SLIDER}" id="${PANEL_IDS.BLUR_AMOUNT_LABEL}">Blur Amount:</label>
                <input type="range" id="${PANEL_IDS.BLUR_SLIDER}" min="1" max="50" step="1"> <!-- Max 50 -->
                <span id="${PANEL_IDS.BLUR_VALUE}"></span>px
            </div>
            <div>
                <input type="checkbox" id="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}">
                <label for="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}" id="${PANEL_IDS.DISABLE_HOVER_LABEL}">Disable Unblur on Hover</label>
            </div>
        </div>
        <hr>
        <div>
            <button id="${PANEL_IDS.SAVE_BUTTON}">Save & Apply Settings</button>
            <span id="${PANEL_IDS.SAVE_STATUS}"></span>
        </div>`;

    /** 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 = `<p style="color: red; border: 1px solid red; padding: 10px;">
                 Error initializing ${SCRIPT_ID} settings panel. Please check the browser console (F12) for details.
                 <br>Error: ${err.message || 'Unknown error'}
             </p>`;
        }
    }

    /** `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, 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
        try {
            if (typeof window.SettingsTabManager === 'undefined') {
                throw new Error("Required library 'SettingsTabManager' not found. Make sure it's installed and enabled.");
            }
            // Wait for STM to be ready (it might be loaded asynchronously)
            const stm = await window.SettingsTabManager.ready;
            const registrationSuccess = stm.registerTab({
                 scriptId: SCRIPT_ID,
                 tabTitle: 'Spoilers', // Keep concise
                 order: 30, // Position relative to other STM tabs
                 onInit: initializeSettingsPanel,
                 onActivate: onSettingsTabActivate
            });
            if (registrationSuccess) log('Successfully registered settings tab with STM.');
            else warn('STM registration reported failure (tab might already exist?).');
        } catch (err) {
            // Log the error but continue script execution if possible (core functionality might still work)
            error('Failed to initialize settings UI via SettingsTabManager:', err);
            // Optionally alert the user if the UI is critical
            // alert(`${SCRIPT_ID}: Failed to initialize settings panel. Core functionality may work, but settings cannot be changed via the UI.`);
        }

        // 3. Set up MutationObserver to watch for dynamically loaded content
        const observerOptions = {
            childList: true, // Watch for added/removed nodes
            subtree: true    // Watch descendants as well
        };
        const contentObserver = new MutationObserver((mutations) => {
            // Use Set to avoid processing the same link multiple times if multiple mutations affect it
            const linksToProcess = new Set();

            mutations.forEach((mutation) => {
                if (mutation.addedNodes && mutation.addedNodes.length > 0) {
                    mutation.addedNodes.forEach((node) => {
                        // Only process element nodes
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            // Check if the added node itself is a target link containing a spoiler
                            if (node.matches(SELECTORS.IMG_LINK) && node.querySelector(SELECTORS.SPOILER_IMG)) {
                                linksToProcess.add(node);
                            }
                            // Check if the added node contains target links with spoilers
                            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));
            }
        });

        // Start observing the body for changes
        contentObserver.observe(document.body, observerOptions);
        log('Mutation observer started.');

        // 4. Process any spoiler images already present on the page at script start
        log('Performing initial content scan...');
        processContainer(document.body);

        log('Script initialized successfully.');
    }

    // --- Run Initialization ---
    initialize().catch(err => {
        error("Critical error during script initialization:", err);
        // Notify user of fatal error if desired
        // alert(`${SCRIPT_ID}: A critical error occurred during initialization. The script may not function. Check the console.`);
    });

})();