- // ==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
- // ==/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 = `
- <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 (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);
- });
-
- })();