// ==UserScript== // @name 8chan Unspoiler Thumbnails on Mouse Hover // @namespace sneed // @version 0.5.1 // @description Pre-sizes spoiler images to thumbnail dimensions and shows thumbnail on hover on 8chan. // @author Gemini 2.5 // @license MIT // @match https://8chan.moe/*/res/*.html* // @match https://8chan.se/*/res/*.html* // @downloadURL none // ==/UserScript== (function() { 'use strict'; // Define the selector for both types of spoiler images // This matches the old /spoiler.png OR any image whose src ends with /custom.spoiler const spoilerImgSelector = 'img[src="/spoiler.png"], img[src$="/custom.spoiler"]'; // Function to extract the hash from a FoolFuuka image URL // Expects URL like /path/to/media/HASH.EXT function getHashFromImageUrl(imageUrl) { if (!imageUrl) return null; const parts = imageUrl.split('/'); const filename = parts.pop(); // Get HASH.EXT if (!filename) return null; const hash = filename.split('.')[0]; // Get HASH (assuming no dots in hash) return hash || null; } // Function to construct the thumbnail URL // Assumes thumbnail is in the same directory as the full image with 't_' prefix and no extension function getThumbnailUrl(fullImageUrl, hash) { if (!fullImageUrl || !hash) return null; const parts = fullImageUrl.split('/'); parts.pop(); // Remove the filename (HASH.EXT) const basePath = parts.join('/') + '/'; // Rejoin path parts and add trailing slash return basePath + 't_' + hash; // Use the t_HASH format } // --- Dimension Setting Logic --- // Function to load the thumbnail invisibly and set the spoiler image's dimensions function setSpoilerDimensionsFromThumbnail(imgLink) { // Find the specific spoiler image within this link using the updated selector const spoilerImg = imgLink.querySelector(spoilerImgSelector); // Only proceed if we have a spoiler image and its dimensions haven't already been set by this script // We use a data attribute on the spoiler image itself to track if dimensions were attempted. if (!spoilerImg || spoilerImg.dataset.dimensionsSet) { return; } const fullImageUrl = imgLink.href; const hash = getHashFromImageUrl(fullImageUrl); if (!hash) return; const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash); if (!thumbnailUrl) { // Mark as dimensions attempted, but failed due to URL issues spoilerImg.dataset.dimensionsSet = 'failed_url'; return; } // Create a temporary image element to load the thumbnail and get its dimensions const tempImg = new Image(); // Use new Image() which is efficient for this tempImg.style.display = 'none'; // Hide it tempImg.style.position = 'absolute'; // Position it off-screen or just hidden tempImg.style.left = '-9999px'; tempImg.style.top = '-9999px'; // Mark the spoiler image now as dimensions are being attempted. spoilerImg.dataset.dimensionsSet = 'attempting'; tempImg.addEventListener('load', function() { // Set the dimensions of the spoiler image if loaded successfully if (this.naturalWidth > 0 && this.naturalHeight > 0) { spoilerImg.width = this.naturalWidth; spoilerImg.height = this.naturalHeight; // Mark as dimensions successfully set spoilerImg.dataset.dimensionsSet = 'success'; } else { // Mark as failed if dimensions were unexpectedly zero spoilerImg.dataset.dimensionsSet = 'failed_zero_dim'; console.warn(`[SpoilerThumbnailHover] Thumbnail loaded but reported zero dimensions: ${thumbnailUrl}`); } // Clean up the temporary image regardless of success/failure after load if (this.parentNode) { this.parentNode.removeChild(this); } }); tempImg.addEventListener('error', function() { console.warn(`[SpoilerThumbnailHover] Failed to load thumbnail: ${thumbnailUrl}`); // Mark as failed on error spoilerImg.dataset.dimensionsSet = 'failed_error'; // Clean up the temporary image if (this.parentNode) { this.parentNode.removeChild(this); } // Don't set dimensions if load failed. The spoiler keeps its default size. }); // Append the temporary image to the body to start loading // This must happen *after* setting up event listeners. document.body.appendChild(tempImg); // Start loading the image by setting the src tempImg.src = thumbnailUrl; } // --- Hover Event Handlers --- function handleLinkMouseEnter() { const imgLink = this; // The .imgLink element // Find the specific spoiler image within this link using the updated selector const spoilerImg = imgLink.querySelector(spoilerImgSelector); const existingHoverThumbnail = imgLink.querySelector('img.hoverThumbnail'); // Only proceed if there's a visible spoiler image (of either type) and no hover thumbnail already exists if (!spoilerImg || spoilerImg.style.display === 'none' || existingHoverThumbnail) { return; } // Ensure dimensions were at least attempted before proceeding with hover effect // if (spoilerImg.dataset.dimensionsSet === 'attempting') { // // Thumbnail loading is still in progress, maybe wait or do nothing? // // Doing nothing for now is simplest. The spoiler stays default size until load finishes. // return; // } // Removed the check above because we want the hover to work even if dimensions failed or are pending. // The thumbnail creation below will just use the *current* size of the spoiler img. const fullImageUrl = imgLink.href; // Use href of the imgLink for the full image URL const hash = getHashFromImageUrl(fullImageUrl); if (!hash) return; const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash); if (!thumbnailUrl) return; // Create the thumbnail image element const hoverThumbnail = document.createElement('img'); hoverThumbnail.src = thumbnailUrl; hoverThumbnail.classList.add('hoverThumbnail'); // Add a class to identify our element // Set thumbnail dimensions to match the current spoiler image size. // The spoiler image should now have the correct size set by setSpoilerDimensionsFromThumbnail. // If dimension setting failed or hasn't completed, it will use the default spoiler size. if (spoilerImg.width > 0 && spoilerImg.height > 0) { hoverThumbnail.width = spoilerImg.width; hoverThumbnail.height = spoilerImg.height; } // Note: If the thumbnail loads *after* the mouse enters but before mouse leaves, // the dimensions might be updated on the spoiler, but the hover thumbnail already created // won't update dynamically. This is an acceptable minor edge case. // Insert the thumbnail right before the spoiler image imgLink.insertBefore(hoverThumbnail, spoilerImg); // Hide the original spoiler image spoilerImg.style.display = 'none'; } function handleLinkMouseLeave() { const imgLink = this; // The .imgLink element // Find the specific spoiler image within this link using the updated selector const spoilerImg = imgLink.querySelector(spoilerImgSelector); const hoverThumbnail = imgLink.querySelector('img.hoverThumbnail'); // If our hover thumbnail exists, remove it if (hoverThumbnail) { hoverThumbnail.remove(); } // Check if the board's full image expansion is visible // Selects any img within the link that is NOT a spoiler image type, and check if it's visible. const otherImages = imgLink.querySelectorAll(`img:not(${spoilerImgSelector})`); let isOtherImageVisible = false; for(const img of otherImages) { // Check if the image is not hidden by display: none or visibility: hidden etc. // offsetParent is null if the element or its parent is display: none // Checking style.display is more direct for FoolFuuka's toggle if (img.style.display !== 'none') { isOtherImageVisible = true; break; } } // Show the original spoiler image again IF // 1. It exists and is still one of the spoiler image types // 2. It's currently hidden (style.display === 'none') - implies our script or board script hid it // 3. The board's expanded image is NOT currently visible. // 4. Add a check to ensure the spoiler image is still in the DOM hierarchy of the link. if (spoilerImg && imgLink.contains(spoilerImg) && spoilerImg.matches(spoilerImgSelector) && spoilerImg.style.display === 'none' && !isOtherImageVisible) { spoilerImg.style.display = ''; // Reset to default display } } // Function to process an individual imgLink element function processImgLink(imgLink) { // Prevent processing multiple times if (imgLink.dataset.spoilerHoverProcessed) { return; } // Find the specific spoiler image within this link using the updated selector const spoilerImg = imgLink.querySelector(spoilerImgSelector); // Only process if this link contains a spoiler image (of either type) if (!spoilerImg) { return; } imgLink.dataset.spoilerHoverProcessed = 'true'; // Mark element as processed // 1. Attempt to set spoiler dimensions based on thumbnail as soon as possible // This happens asynchronously via the temp image loader. setSpoilerDimensionsFromThumbnail(imgLink); // 2. Attach the hover listeners for showing the thumbnail on hover // These listeners rely on the spoiler image potentially having updated dimensions // by the time the mouse enters. imgLink.addEventListener('mouseenter', handleLinkMouseEnter); imgLink.addEventListener('mouseleave', handleLinkMouseLeave); // Optional: Handle clicks on the link to ensure the hover thumbnail is removed // immediately if the user clicks to expand the image. // However, the handleLinkMouseLeave check for isOtherImageVisible should handle this // when the mouse leaves after clicking/expanding. Let's stick to just mouse events for now. } // Function to find all imgLink elements within a container and process them function processContainer(container) { // Select imgLink elements const imgLinks = container.querySelectorAll('.imgLink'); imgLinks.forEach(processImgLink); // Process each found imgLink } // Use a MutationObserver to handle new nodes being added to the DOM (e.g., infinite scroll) const observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(function(node) { // nodeType 1 is Element if (node.nodeType === Node.ELEMENT_NODE) { // If the added node is an imgLink (potentially with a spoiler) // Or if it's a container that might contain imgLinks (like posts, board content) if (node.matches('.imgLink')) { processImgLink(node); // Process just this specific link } else { // Select all imgLink elements within the added node's subtree processContainer(node); } } }); } }); }); // Configuration for the observer: // - childList: true means observe direct children being added/removed // - subtree: true means observe changes in the entire subtree observer.observe(document.body, { childList: true, subtree: true }); // Process imgLink elements that are already present in the DOM when the script runs processContainer(document); })();