7chan Enhacenements

Constrains expanded media to viewport and scrolls back to post when contracting

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name            7chan Enhacenements
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @version         1.3
// @description     Constrains expanded media to viewport and scrolls back to post when contracting
// @author          Claude 4.5 Sonnet
// @match           https://7chan.org/*
// @grant           none
// @license         MIT-0
// ==/UserScript==

(function() {
    'use strict';

    const state = {
        expandedImages: new Map(), // Maps img element to {postId, originalScroll, wasExpanded}
        processingImages: new WeakSet() // Track images currently being processed
    };

    function getPostIdFromImage(img) {
        const post = img.closest('.post');
        return post ? post.id : null;
    }

    function isImageExpanded(img) {
        // Check if src points to full image (not thumbnail with 's' before extension)
        const thumbPattern = /thumb\/.*s\.(jpg|png|gif|webm)$/;
        return img.src && !thumbPattern.test(img.src);
    }

    function applyViewportConstraints(img) {
        if (!isImageExpanded(img)) return;

        const post = img.closest('.post');
        if (!post) return;

        const postRect = post.getBoundingClientRect();
        const viewportWidth = document.documentElement.clientWidth;
        const viewportHeight = document.documentElement.clientHeight;

        // Calculate available space (accounting for post position and margins)
        const availableWidth = viewportWidth - postRect.left - 35;
        const availableHeight = viewportHeight - 100;

        // Apply constraints
        img.style.maxWidth = Math.max(availableWidth * 0.95, 300) + 'px';
        img.style.maxHeight = Math.max(availableHeight * 0.95, 300) + 'px';
        img.style.width = 'auto';
        img.style.height = 'auto';
        img.style.objectFit = 'contain';
    }

    function removeViewportConstraints(img) {
        // Remove our custom styles when contracting
        img.style.maxWidth = '';
        img.style.maxHeight = '';
        img.style.objectFit = '';
    }

    function handleImageChange(img) {
        // Prevent processing the same image multiple times for batched mutations
        if (state.processingImages.has(img)) return;
        state.processingImages.add(img);

        // Use requestAnimationFrame to batch multiple attribute changes
        requestAnimationFrame(() => {
            state.processingImages.delete(img);

            const postId = getPostIdFromImage(img);
            if (!postId) return;

            const isExpanded = isImageExpanded(img);
            const savedState = state.expandedImages.get(img);
            const wasExpanded = savedState ? savedState.wasExpanded : false;

            if (isExpanded && !wasExpanded) {
                // Image was just expanded
                state.expandedImages.set(img, {
                    postId: postId,
                    originalScroll: window.scrollY,
                    wasExpanded: true
                });
                applyViewportConstraints(img);
            } else if (!isExpanded && wasExpanded) {
                // Image was just contracted
                removeViewportConstraints(img);
                
                if (savedState) {
                    state.expandedImages.delete(img);
                    
                    // Restore scroll position to the post
                    requestAnimationFrame(() => {
                        const postElement = document.getElementById(savedState.postId);
                        if (postElement) {
                            const postTop = postElement.getBoundingClientRect().top + window.scrollY;
                            window.scrollTo({
                                top: postTop - 20,
                                behavior: 'smooth'
                            });
                        }
                    });
                }
            } else if (isExpanded && wasExpanded) {
                // Image is still expanded, update state
                if (savedState) {
                    savedState.wasExpanded = true;
                }
            }
        });
    }

    // Single observer for all images
    function setupObserver() {
        const observer = new MutationObserver((mutations) => {
            const processedImages = new Set();

            for (const mutation of mutations) {
                if (mutation.type === 'attributes') {
                    const img = mutation.target;
                    if ((img.classList.contains('thumb') || 
                         img.classList.contains('multithumb') || 
                         img.classList.contains('multithumbfirst')) &&
                        !processedImages.has(img)) {
                        processedImages.add(img);
                        handleImageChange(img);
                    }
                }
            }
        });

        const config = {
            attributes: true,
            attributeFilter: ['src', 'style', 'width', 'height'],
            subtree: true
        };

        // Observe the entire page with subtree:true for efficiency
        observer.observe(document.body, config);

        return observer;
    }

    // Handle window resize - reapply constraints to expanded images
    let resizeTimeout;
    function handleResize() {
        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(() => {
            state.expandedImages.forEach((value, img) => {
                if (value.wasExpanded && isImageExpanded(img)) {
                    applyViewportConstraints(img);
                }
            });
        }, 100); // Debounce resize events
    }

    // Initialize
    function init() {
        setupObserver();
        window.addEventListener('resize', handleResize);
        
        // Apply constraints to any already-expanded images
        document.querySelectorAll('img.thumb, img.multithumb, img.multithumbfirst').forEach(img => {
            if (isImageExpanded(img)) {
                const postId = getPostIdFromImage(img);
                if (postId) {
                    state.expandedImages.set(img, {
                        postId: postId,
                        originalScroll: window.scrollY,
                        wasExpanded: true
                    });
                    applyViewportConstraints(img);
                }
            }
        });
    }

    // Wait for DOM to be ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();