Greasy Fork

8chan Unspoiler on Mouse Hover

Pre-sizes spoiler images to thumbnail dimensions and shows thumbnail on hover on 8chan.

目前为 2025-04-21 提交的版本。查看 最新版本

// ==UserScript==
// @name         8chan Unspoiler on Mouse Hover
// @namespace    sneed
// @version      0.4
// @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*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 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) {
        const spoilerImg = imgLink.querySelector('img[src="/spoiler.png"]');

        // 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 (Mostly same as before) ---

    function handleLinkMouseEnter() {
        const imgLink = this; // The .imgLink element
        const spoilerImg = imgLink.querySelector('img[src="/spoiler.png"]');
        const existingHoverThumbnail = imgLink.querySelector('img.hoverThumbnail');

        // Only proceed if there's a visible spoiler image 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
        const spoilerImg = imgLink.querySelector('img[src="/spoiler.png"]');
        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 the spoiler, and check if it's visible.
        const otherImages = imgLink.querySelectorAll('img:not([src="/spoiler.png"])');
        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 the spoiler image
        // 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.src.endsWith('/spoiler.png') && 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;
        }

        const spoilerImg = imgLink.querySelector('img[src="/spoiler.png"]');

         // Only process if this link contains a spoiler image
        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)
                         if (node.matches('.imgLink')) {
                             processImgLink(node); // Process just this specific link
                         } else {
                            // If the added node is a container (like a post, thread, or board content)
                            // that might contain imgLinks with spoilers
                            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);

})();