Greasy Fork

8chan Lightweight Extended Suite

Minimalist extender for 8chan with in-line replies, spoiler revealing and media preview for videos & images

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

// ==UserScript==
// @name         8chan Lightweight Extended Suite
// @namespace    https://greasyfork.org/en/scripts/533173
// @version      2.2.9
// @description  Minimalist extender for 8chan with in-line replies, spoiler revealing and media preview for videos & images
// @author       impregnator
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @match        https://8chan.cc/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Function to process images and replace spoiler placeholders with thumbnails
    function processImages(images, isCatalog = false) {
        images.forEach(img => {
            // Check if the image is a spoiler placeholder (custom or default)
            if (img.src.includes('custom.spoiler') || img.src.includes('spoiler.png')) {
                let fullFileUrl;
                if (isCatalog) {
                    // Catalog: Get the href from the parent <a class="linkThumb">
                    const link = img.closest('a.linkThumb');
                    if (link) {
                        // Construct the thumbnail URL based on the thread URL
                        fullFileUrl = link.href;
                        const threadMatch = fullFileUrl.match(/\/([a-z0-9]+)\/res\/([0-9]+)\.html$/i);
                        if (threadMatch && threadMatch[1] && threadMatch[2]) {
                            const board = threadMatch[1];
                            const threadId = threadMatch[2];
                            // Fetch the thread page to find the actual image URL
                            fetchThreadImage(board, threadId).then(thumbnailUrl => {
                                if (thumbnailUrl) {
                                    img.src = thumbnailUrl;
                                }
                            });
                        }
                    }
                } else {
                    // Thread: Get the parent <a> element containing the full-sized file URL
                    const link = img.closest('a.imgLink');
                    if (link) {
                        // Extract the full-sized file URL
                        fullFileUrl = link.href;
                        // Extract the file hash (everything after /.media/ up to the extension)
                        const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
                        if (fileHash && fileHash[1]) {
                            // Construct the thumbnail URL using the current domain
                            const thumbnailUrl = `${window.location.origin}/.media/t_${fileHash[1]}`;
                            // Replace the spoiler image with the thumbnail
                            img.src = thumbnailUrl;
                        }
                    }
                }
            }
        });
    }

    // Function to fetch the thread page and extract the thumbnail URL
    async function fetchThreadImage(board, threadId) {
        try {
            const response = await fetch(`https://${window.location.host}/${board}/res/${threadId}.html`);
            const text = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(text, 'text/html');
            // Find the first image in the thread's OP post
            const imgLink = doc.querySelector('.uploadCell a.imgLink');
            if (imgLink) {
                const fullFileUrl = imgLink.href;
                const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
                if (fileHash && fileHash[1]) {
                    return `${window.location.origin}/.media/t_${fileHash[1]}`;
                }
            }
            return null;
        } catch (error) {
            console.error('Error fetching thread image:', error);
            return null;
        }
    }

    // Process existing images on page load
    const isCatalogPage = window.location.pathname.includes('catalog.html');
    if (isCatalogPage) {
        const initialCatalogImages = document.querySelectorAll('.catalogCell a.linkThumb img');
        processImages(initialCatalogImages, true);
    } else {
        const initialThreadImages = document.querySelectorAll('.uploadCell img');
        processImages(initialThreadImages, false);
    }

    // Set up MutationObserver to handle dynamically added posts
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.addedNodes.length) {
                // Check each added node for new images
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (isCatalogPage) {
                            const newCatalogImages = node.querySelectorAll('.catalogCell a.linkThumb img');
                            processImages(newCatalogImages, true);
                        } else {
                            const newThreadImages = node.querySelectorAll('.uploadCell img');
                            processImages(newThreadImages, false);
                        }
                    }
                });
            }
        });
    });

    // Observe changes to the document body, including child nodes and subtrees
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();

//Opening all posts from the catalog in a new tag section

// Add click event listener to catalog thumbnail images
document.addEventListener('click', function(e) {
    // Check if the clicked element is an image inside a catalog cell
    if (e.target.tagName === 'IMG' && e.target.closest('.catalogCell')) {
        // Find the parent link with class 'linkThumb'
        const link = e.target.closest('.linkThumb');
        if (link) {
            // Prevent default link behavior
            e.preventDefault();
            // Open the thread in a new tab
            window.open(link.href, '_blank');
        }
    }
});

//Automatically redirect to catalog section

// Redirect to catalog if on a board's main page, excluding overboard pages
(function() {
    const currentPath = window.location.pathname;
    // Check if the path matches a board's main page (e.g., /v/, /a/) but not overboard pages
    if (currentPath.match(/^\/[a-zA-Z0-9]+\/$/) && !currentPath.match(/^\/(sfw|overboard)\/$/)) {
        // Redirect to the catalog page
        window.location.replace(currentPath + 'catalog.html');
    }
})();

// Text spoiler revealer

(function() {
    // Function to reveal spoilers
    function revealSpoilers() {
        const spoilers = document.querySelectorAll('span.spoiler');
        spoilers.forEach(spoiler => {
            // Override default spoiler styles to make text visible
            spoiler.style.background = 'none';
            spoiler.style.color = 'inherit';
            spoiler.style.textShadow = 'none';
        });
    }

    // Run initially for existing spoilers
    revealSpoilers();

    // Set up MutationObserver to watch for new spoilers
    const observer = new MutationObserver((mutations) => {
        mutations.forEach(mutation => {
            if (mutation.addedNodes.length > 0) {
                // Check if new nodes contain spoilers
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        const newSpoilers = node.querySelectorAll('span.spoiler');
                        newSpoilers.forEach(spoiler => {
                            spoiler.style.background = 'none';
                            spoiler.style.color = 'inherit';
                            spoiler.style.textShadow = 'none';
                        });
                    }
                });
            }
        });
    });

    // Observe the document body for changes (new posts)
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();

//Hash navigation
// Add # links to backlinks and quote links for scrolling
(function() {
    // Function to add # link to backlinks and quote links
    function addHashLinks(container = document) {
        const links = container.querySelectorAll('.panelBacklinks a, .altBacklinks a, .divMessage .quoteLink');
        links.forEach(link => {
            // Skip if # link already exists or processed
            if (link.nextSibling && link.nextSibling.classList && link.nextSibling.classList.contains('hash-link-container')) return;
            if (link.dataset.hashProcessed) return;
            // Create # link as a span to avoid <a> processing
            const hashLink = document.createElement('span');
            hashLink.textContent = ' #';
            hashLink.style.cursor = 'pointer';
            hashLink.style.color = '#0000EE'; // Match link color
            hashLink.title = 'Scroll to post';
            hashLink.className = 'hash-link';
            hashLink.dataset.hashListener = 'true'; // Mark as processed
            // Wrap # link in a span to isolate it
            const container = document.createElement('span');
            container.className = 'hash-link-container';
            container.appendChild(hashLink);
            link.insertAdjacentElement('afterend', container);
            link.dataset.hashProcessed = 'true'; // Mark as processed
        });
    }

    // Event delegation for hash link clicks to mimic .linkSelf behavior
    document.addEventListener('click', function(e) {
        if (e.target.classList.contains('hash-link')) {
            e.preventDefault();
            e.stopPropagation();
            const link = e.target.closest('.hash-link-container').previousElementSibling;
            const postId = link.textContent.replace('>>', '');
            if (document.getElementById(postId)) {
                window.location.hash = `#${postId}`;
                console.log(`Navigated to post #${postId}`);
            } else {
                console.log(`Post ${postId} not found`);
            }
        }
    }, true);

    // Process existing backlinks and quote links on page load
    addHashLinks();
    console.log('Hash links applied on page load');

    // Patch inline reply logic to apply hash links to new inline content
    if (window.tooltips) {
        // Patch loadTooltip to apply hash links after content is loaded
        const originalLoadTooltip = tooltips.loadTooltip;
        tooltips.loadTooltip = function(element, quoteUrl, sourceId, isInline) {
            originalLoadTooltip.apply(this, arguments);
            if (isInline) {
                // Wait for content to be fully loaded
                setTimeout(() => {
                    addHashLinks(element);
                    console.log('Hash links applied to loaded tooltip content:', quoteUrl);
                }, 0);
            }
        };

        // Patch addLoadedTooltip to ensure hash links are applied
        const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
        tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
            originalAddLoadedTooltip.apply(this, arguments);
            if (isInline) {
                addHashLinks(htmlContents);
                console.log('Hash links applied to inline tooltip content:', quoteUrl);
            }
        };

        // Patch addInlineClick to apply hash links after appending
        const originalAddInlineClick = tooltips.addInlineClick;
        tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
            if (!quote.href || quote.classList.contains('hash-link') || quote.closest('.hash-link-container') || quote.href.includes('#q')) {
                console.log('Skipped invalid or hash link:', quote.href || quote.textContent);
                return;
            }
            // Clone quote to remove existing listeners
            const newQuote = quote.cloneNode(true);
            quote.parentNode.replaceChild(newQuote, quote);
            quote = newQuote;

            // Reapply hover events
            tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
            console.log('Hover events reapplied for:', quoteTarget.quoteUrl);

            // Add click handler
            quote.addEventListener('click', function(e) {
                console.log('linkQuote clicked:', quoteTarget.quoteUrl);
                if (!tooltips.inlineReplies) {
                    console.log('inlineReplies disabled');
                    return;
                }
                e.preventDefault();
                e.stopPropagation();

                // Find or create replyPreview
                let replyPreview = innerPost.querySelector('.replyPreview');
                if (!replyPreview) {
                    replyPreview = document.createElement('div');
                    replyPreview.className = 'replyPreview';
                    innerPost.appendChild(replyPreview);
                }

                // Check for duplicates or loading
                if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
                    tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
                    console.log('Duplicate or loading:', quoteTarget.quoteUrl);
                    return;
                }

                // Create and load inline post
                const placeHolder = document.createElement('div');
                placeHolder.style.whiteSpace = 'normal';
                placeHolder.className = 'inlineQuote';
                tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);

                // Verify post loaded
                if (!placeHolder.querySelector('.linkSelf')) {
                    console.log('Failed to load post:', quoteTarget.quoteUrl);
                    return;
                }

                // Add close button
                const close = document.createElement('a');
                close.innerText = 'X';
                close.className = 'closeInline';
                close.onclick = () => placeHolder.remove();
                placeHolder.querySelector('.postInfo').prepend(close);

                // Process quotes in the new inline post
                Array.from(placeHolder.querySelectorAll('.linkQuote'))
                    .forEach(a => tooltips.processQuote(a, false, true));

                if (tooltips.bottomBacklinks) {
                    const alts = placeHolder.querySelector('.altBacklinks');
                    if (alts && alts.firstChild) {
                        Array.from(alts.firstChild.children)
                            .forEach(a => tooltips.processQuote(a, true));
                    }
                }

                // Append to replyPreview and apply hash links
                replyPreview.appendChild(placeHolder);
                addHashLinks(placeHolder);
                console.log('Inline post appended and hash links applied:', quoteTarget.quoteUrl);

                tooltips.removeIfExists();
            }, true);
        };

        // Patch processQuote to skip hash links
        const originalProcessQuote = tooltips.processQuote;
        tooltips.processQuote = function(quote, isBacklink) {
            if (!quote.href || quote.classList.contains('hash-link') || quote.closest('.hash-link-container') || quote.href.includes('#q')) {
                console.log('Skipped invalid or hash link in processQuote:', quote.href || quote.textContent);
                return;
            }
            originalProcessQuote.apply(this, arguments);
        };
    }

    // Set up MutationObserver to handle dynamically added or updated backlinks and quote links
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.addedNodes.length) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        // Check for new backlink or quote link <a> elements
                        const newLinks = node.matches('.panelBacklinks a, .altBacklinks a, .divMessage .quoteLink') ? [node] : node.querySelectorAll('.panelBacklinks a, .altBacklinks a, .divMessage .quoteLink');
                        newLinks.forEach(link => {
                            addHashLinks(link.parentElement);
                            console.log('Hash links applied to new link:', link.textContent);
                        });
                    }
                });
            }
        });
    });

    // Observe changes to the posts container
    const postsContainer = document.querySelector('.divPosts') || document.body;
    observer.observe(postsContainer, {
        childList: true,
        subtree: true
    });
})();
//--Hash navigation

//Inline reply chains

(function() {
    'use strict';

    console.log('Userscript is running');

    // Add CSS for visual nesting
    const style = document.createElement('style');
    style.innerHTML = `
        .inlineQuote .replyPreview {
            margin-left: 20px;
            border-left: 1px solid #ccc;
            padding-left: 10px;
        }
        .closeInline {
            color: #ff0000;
            cursor: pointer;
            margin-left: 5px;
            font-weight: bold;
        }
    `;
    document.head.appendChild(style);

    // Wait for tooltips to initialize
    window.addEventListener('load', function() {
        if (!window.tooltips) {
            console.error('tooltips module not found');
            return;
        }
        console.log('tooltips module found');

        // Ensure Inline Replies is enabled
        if (!tooltips.inlineReplies) {
            console.log('Enabling Inline Replies');
            localStorage.setItem('inlineReplies', 'true');
            tooltips.inlineReplies = true;

            // Check and update the checkbox, retrying if not yet loaded
            const enableCheckbox = () => {
                const inlineCheckbox = document.getElementById('settings-SW5saW5lIFJlcGxpZX');
                if (inlineCheckbox) {
                    inlineCheckbox.checked = true;
                    console.log('Inline Replies checkbox checked');
                    return true;
                }
                console.warn('Inline Replies checkbox not found, retrying...');
                return false;
            };

            // Try immediately
            if (!enableCheckbox()) {
                // Retry every 500ms up to 5 seconds
                let attempts = 0;
                const maxAttempts = 10;
                const interval = setInterval(() => {
                    if (enableCheckbox() || attempts >= maxAttempts) {
                        clearInterval(interval);
                        if (attempts >= maxAttempts) {
                            console.error('Failed to find Inline Replies checkbox after retries');
                        }
                    }
                    attempts++;
                }, 500);
            }
        } else {
            console.log('Inline Replies already enabled');
        }

        // Override addLoadedTooltip to ensure replyPreview exists
        const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
        tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
            console.log('addLoadedTooltip called for:', quoteUrl);
            originalAddLoadedTooltip.apply(this, arguments);
            if (isInline) {
                let replyPreview = htmlContents.querySelector('.replyPreview');
                if (!replyPreview) {
                    replyPreview = document.createElement('div');
                    replyPreview.className = 'replyPreview';
                    htmlContents.appendChild(replyPreview);
                }
            }
        };

        // Override addInlineClick for nested replies, excluding post number links
        tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
            // Skip post number links (href starts with #q)
            if (quote.href.includes('#q')) {
                console.log('Skipping post number link:', quote.href);
                return;
            }

            // Remove existing listeners by cloning
            const newQuote = quote.cloneNode(true);
            quote.parentNode.replaceChild(newQuote, quote);
            quote = newQuote;

            // Reapply hover events to preserve preview functionality
            tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
            console.log('Hover events reapplied for:', quoteTarget.quoteUrl);

            // Add click handler
            quote.addEventListener('click', function(e) {
                console.log('linkQuote clicked:', quoteTarget.quoteUrl);
                if (!tooltips.inlineReplies) {
                    console.log('inlineReplies disabled');
                    return;
                }
                e.preventDefault();
                e.stopPropagation(); // Prevent site handlers

                // Find or create replyPreview
                let replyPreview = innerPost.querySelector('.replyPreview');
                if (!replyPreview) {
                    replyPreview = document.createElement('div');
                    replyPreview.className = 'replyPreview';
                    innerPost.appendChild(replyPreview);
                }

                // Check for duplicates or loading
                if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
                    tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
                    console.log('Duplicate or loading:', quoteTarget.quoteUrl);
                    return;
                }

                // Create and load inline post
                const placeHolder = document.createElement('div');
                placeHolder.style.whiteSpace = 'normal';
                placeHolder.className = 'inlineQuote';
                tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);

                // Verify post loaded
                if (!placeHolder.querySelector('.linkSelf')) {
                    console.log('Failed to load post:', quoteTarget.quoteUrl);
                    return;
                }

                // Add close button
                const close = document.createElement('a');
                close.innerText = 'X';
                close.className = 'closeInline';
                close.onclick = () => placeHolder.remove();
                placeHolder.querySelector('.postInfo').prepend(close);

                // Process quotes in the new inline post
                Array.from(placeHolder.querySelectorAll('.linkQuote'))
                    .forEach(a => tooltips.processQuote(a, false, true));

                if (tooltips.bottomBacklinks) {
                    const alts = placeHolder.querySelector('.altBacklinks');
                    if (alts && alts.firstChild) {
                        Array.from(alts.firstChild.children)
                            .forEach(a => tooltips.processQuote(a, true));
                    }
                }

                // Append to replyPreview
                replyPreview.appendChild(placeHolder);
                console.log('Inline post appended:', quoteTarget.quoteUrl);

                tooltips.removeIfExists();
            }, true); // Use capture phase
        };

        // Reprocess all existing linkQuote and backlink elements, excluding post numbers
        console.log('Reprocessing linkQuote elements');
        const quotes = document.querySelectorAll('.linkQuote, .panelBacklinks a');
        quotes.forEach(quote => {
            const innerPost = quote.closest('.innerPost, .innerOP');
            if (!innerPost) {
                console.log('No innerPost found for quote:', quote.href);
                return;
            }

            // Skip post number links
            if (quote.href.includes('#q')) {
                console.log('Skipping post number link:', quote.href);
                return;
            }

            const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
                               quote.parentElement.classList.contains('altBacklinks');
            const quoteTarget = api.parsePostLink(quote.href);
            const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;

            tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
        });

        // Observe for dynamically added posts
        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType !== 1) return;
                    const newQuotes = node.querySelectorAll('.linkQuote, .panelBacklinks a');
                    newQuotes.forEach(quote => {
                        if (quote.dataset.processed || quote.href.includes('#q')) {
                            if (quote.href.includes('#q')) {
                                console.log('Skipping post number link:', quote.href);
                            }
                            return;
                        }
                        quote.dataset.processed = 'true';
                        const innerPost = quote.closest('.innerPost, .innerOP');
                        if (!innerPost) return;

                        const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
                                           quote.parentElement.classList.contains('altBacklinks');
                        const quoteTarget = api.parsePostLink(quote.href);
                        const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;

                        tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
                    });
                });
            });
        });
        observer.observe(document.querySelector('.divPosts') || document.body, {
            childList: true,
            subtree: true
        });
        console.log('MutationObserver set up');
    });
})();

//--Inline replies

//Auto TOS accept
(function() {
    'use strict';

    // Check if on the disclaimer page
    if (window.location.pathname === '/.static/pages/disclaimer.html') {
        // Redirect to confirmed page
        window.location.replace('https://8chan.se/.static/pages/confirmed.html');
        console.log('Automatically redirected from disclaimer to confirmed page');
    }
})();
//--Auto TOS accept

//Media Auto-Preview
// Auto-preview images and videos on hover for un-expanded thumbnails, disabling native hover
(function() {
    'use strict';

    // Disable native hover preview
    localStorage.setItem('hoveringImage', 'false'); // Disable "Image Preview on Hover" setting
    if (window.thumbs && typeof window.thumbs.removeHoveringExpand === 'function') {
        window.thumbs.removeHoveringExpand(); // Remove native hover listeners
    }
    // Override addHoveringExpand to prevent re-enabling
    if (window.thumbs) {
        window.thumbs.addHoveringExpand = function() {
            // Do nothing to prevent native hover preview
            console.log('Native hover preview (addHoveringExpand) blocked by userscript');
        };
    }

    // Supported file extensions for images and videos
    const supportedExtensions = {
        image: ['.gif', '.webp', '.png', '.jfif', '.pjpeg', '.jpeg', '.pjp', '.jpg', '.bmp', '.dib', '.svgz', '.svg'],
        video: ['.webm', '.m4v', '.mp4', '.ogm', '.ogv', '.avi', '.asx', '.mpg', '.mpeg']
    };

    // Create preview container
    const previewContainer = document.createElement('div');
    previewContainer.style.position = 'fixed';
    previewContainer.style.zIndex = '1000';
    previewContainer.style.pointerEvents = 'none'; // Allow clicks to pass through
    previewContainer.style.display = 'none';
    document.body.appendChild(previewContainer);

    // Function to check if URL is a supported image or video
    function isSupportedMedia(url) {
        const ext = (url.match(/\.[a-z0-9]+$/i) || [''])[0].toLowerCase();
        return supportedExtensions.image.includes(ext) || supportedExtensions.video.includes(ext);
    }

    // Function to check if URL is a video
    function isVideo(url) {
        const ext = (url.match(/\.[a-z0-9]+$/i) || [''])[0].toLowerCase();
        return supportedExtensions.video.includes(ext);
    }

    // Function to check if link is in un-expanded state
    function isUnexpanded(link) {
        const thumbnail = link.querySelector('img:not(.imgExpanded)');
        const expanded = link.querySelector('img.imgExpanded');
        return thumbnail && window.getComputedStyle(thumbnail).display !== 'none' &&
               (!expanded || window.getComputedStyle(expanded).display === 'none');
    }

    // Function to calculate preview dimensions
    function getPreviewDimensions(naturalWidth, naturalHeight) {
        // Detect zoom level
        const zoomLevel = window.devicePixelRatio || 1; // Fallback to 1 if undefined
        // Content area (excludes scrollbar) for max size
        const maxWidth = document.documentElement.clientWidth;
        const maxHeight = document.documentElement.clientHeight;
        // Screen resolution for small media check
        const screenWidth = window.screen.width || 1920; // Fallback to 1920
        const screenHeight = window.screen.height || 1080; // Fallback to 1080

        // If media fits within screen resolution, use full native size
        if (naturalWidth <= screenWidth && naturalHeight <= screenHeight) {
            let width = naturalWidth;
            let height = naturalHeight;

            // If native size exceeds content area, scale down
            const scaleByWidth = maxWidth / width;
            const scaleByHeight = maxHeight / height;
            const scale = Math.min(scaleByWidth, scaleByHeight, 1);
            width = Math.round(width * scale);
            height = Math.round(height * scale);

            return { width, height };
        }

        // Otherwise, adjust for zoom and scale to fit content area
        let width = naturalWidth / zoomLevel;
        let height = naturalHeight / zoomLevel;

        const scaleByWidth = maxWidth / width;
        const scaleByHeight = maxHeight / height;
        const scale = Math.min(scaleByWidth, scaleByHeight, 1);
        width = Math.round(width * scale);
        height = Math.round(height * scale);

        return { width, height };
    }

    // Function to position preview near cursor
    function positionPreview(event) {
        const mouseX = event.clientX;
        const mouseY = event.clientY;
        const previewWidth = previewContainer.offsetWidth;
        const previewHeight = previewContainer.offsetHeight;

        // Skip if dimensions are not yet available
        if (previewWidth === 0 || previewHeight === 0) {
            return;
        }

        // Use content area for positioning (excludes scrollbar)
        const maxWidth = document.documentElement.clientWidth;
        const maxHeight = document.documentElement.clientHeight;

        // Calculate centered position
        const centerX = (maxWidth - previewWidth) / 2;
        const centerY = (maxHeight - previewHeight) / 2;

        // Allow cursor to influence position with a bounded offset
        const maxOffset = 100; // Maximum pixels to shift from center
        const cursorOffsetX = Math.max(-maxOffset, Math.min(maxOffset, mouseX - maxWidth / 2));
        const cursorOffsetY = Math.max(-maxOffset, Math.min(maxOffset, mouseY - maxHeight / 2));

        // Calculate initial position with cursor influence
        let left = centerX + cursorOffsetX;
        let top = centerY + cursorOffsetY;

        // Ensure preview stays fully within content area
        left = Math.max(0, Math.min(left, maxWidth - previewWidth));
        top = Math.max(0, Math.min(top, maxHeight - previewHeight));

        previewContainer.style.left = `${left}px`;
        previewContainer.style.top = `${top}px`;
    }

    // Function to show preview
    function showPreview(link, event) {
        if (!isUnexpanded(link)) return; // Skip if expanded
        const url = link.href;
        if (!isSupportedMedia(url)) return;

        // Clear existing preview
        previewContainer.innerHTML = '';

        if (isVideo(url)) {
            // Create video element
            const video = document.createElement('video');
            video.src = url;
            video.autoplay = true;
            video.muted = false; // Play with audio
            video.loop = true;
            video.style.maxWidth = '100%';
            video.style.maxHeight = '100%';

            // Set dimensions and position when metadata is loaded
            video.onloadedmetadata = () => {
                const { width, height } = getPreviewDimensions(video.videoWidth, video.videoHeight);
                video.width = width;
                video.height = height;
                previewContainer.style.width = `${width}px`;
                previewContainer.style.height = `${height}px`;
                previewContainer.style.display = 'block'; // Show after dimensions are set
                positionPreview(event);
            };

            previewContainer.appendChild(video);
        } else {
            // Create image element
            const img = document.createElement('img');
            img.src = url;
            img.style.maxWidth = '100%';
            img.style.maxHeight = '100%';

            // Set dimensions and position when image is loaded
            img.onload = () => {
                const { width, height } = getPreviewDimensions(img.naturalWidth, img.naturalHeight);
                img.width = width;
                img.height = height;
                previewContainer.style.width = `${width}px`;
                previewContainer.style.height = `${height}px`;
                previewContainer.style.display = 'block'; // Show after dimensions are set
                positionPreview(event);
            };

            previewContainer.appendChild(img);
        }
    }

    // Function to hide preview
    function hidePreview() {
        previewContainer.style.display = 'none';
        // Stop video playback
        const video = previewContainer.querySelector('video');
        if (video) {
            video.pause();
            video.currentTime = 0;
        }
        previewContainer.innerHTML = '';
    }

    // Function to apply hover events to links
    function applyHoverEvents(container = document) {
        const links = container.querySelectorAll('.uploadCell a.imgLink');
        links.forEach(link => {
            // Skip if already processed
            if (link.dataset.previewProcessed) return;
            link.dataset.previewProcessed = 'true';

            link.addEventListener('mouseenter', (e) => {
                showPreview(link, e);
            });

            link.addEventListener('mousemove', (e) => {
                if (previewContainer.style.display === 'block') {
                    positionPreview(e);
                }
            });

            link.addEventListener('mouseleave', () => {
                hidePreview();
            });

            // Hide preview on click if expanded
            link.addEventListener('click', () => {
                if (!isUnexpanded(link)) {
                    hidePreview();
                }
            });
        });
    }

    // Apply hover events to existing links on page load
    applyHoverEvents();
    console.log('Media preview events applied on page load');

    // Patch inline reply logic to apply hover events to new inline content
    if (window.tooltips) {
        // Patch loadTooltip to apply hover events after content is loaded
        const originalLoadTooltip = tooltips.loadTooltip;
        tooltips.loadTooltip = function(element, quoteUrl, sourceId, isInline) {
            originalLoadTooltip.apply(this, arguments);
            if (isInline) {
                setTimeout(() => {
                    applyHoverEvents(element);
                    console.log('Media preview events applied to loaded tooltip content:', quoteUrl);
                }, 0);
            }
        };

        // Patch addLoadedTooltip to ensure hover events are applied
        const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
        tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
            originalAddLoadedTooltip.apply(this, arguments);
            if (isInline) {
                applyHoverEvents(htmlContents);
                console.log('Media preview events applied to inline tooltip content:', quoteUrl);
            }
        };
    }

    // Set up MutationObserver to handle dynamically added posts
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.addedNodes.length) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        // Handle new posts and inline replies
                        const newLinks = node.matches('.uploadCell a.imgLink') ? [node] : node.querySelectorAll('.uploadCell a.imgLink');
                        newLinks.forEach(link => {
                            applyHoverEvents(link.parentElement);
                            console.log('Media preview events applied to new link:', link.href);
                        });
                    }
                });
            }
        });
    });

    // Observe changes to the posts container
    const postsContainer = document.querySelector('.divPosts') || document.body;
    observer.observe(postsContainer, {
        childList: true,
        subtree: true
    });
})();
//--Media Auto-Preview