Greasy Fork

Greasy Fork is available in English.

8chan Unspoiler Thumbnails on Mouse Hover

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();