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