Pornolab Image Previewer

Preview post images on Pornolab tracker and forum pages by hovering over topic links.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name              Pornolab Image Previewer
// @name:ru           Pornolab Предпросмотр Изображений
// @namespace         http://tampermonkey.net/
// @version           1.0.1
// @description       Preview post images on Pornolab tracker and forum pages by hovering over topic links.
// @description:ru    Предпросмотр изображений из постов Pornolab при наведении на ссылки тем в трекере и разделах форума.
// @author            DrkDev1l
// @match             https://pornolab.net/forum/tracker.php*
// @match             https://pornolab.net/forum/viewforum.php*
// @grant             GM_xmlhttpRequest
// @connect           pornolab.net
// @connect           *
// @icon              https://www.google.com/s2/favicons?domain=pornolab.net&sz=64
// @license           MIT
// ==/UserScript==

(function () {
    'use strict';

    const EXCLUDE_LIST = [
        'thumb_clickview.png',
        'no_image.jpg'
    ];

    const SEARCH_RESULTS_SELECTOR = '#search-results';
    const LINK_SELECTOR = 'a[href*="viewtopic.php?t="]';
    const RESULT_LINK_SELECTOR = 'a.tLink[href*="viewtopic.php?t="], a[href*="viewtopic.php?t="]';
    const HITBOX_CLASS = 'pl-preview-hitbox';
    const HITBOX_SELECTOR = `.${HITBOX_CLASS}`;

    const cache = new Map();
    const queue = [];
    const pendingUrls = new Set();

    let activeRequests = 0;
    const MAX_CONCURRENT = 2;

    let activeHitbox = null;
    let activeLink = null;
    let activeUrl = null;

    let lastMouseX = 0;
    let lastMouseY = 0;

    let renderToken = 0;
    let posRaf = 0;

    const style = document.createElement('style');

    style.textContent = `
        ${SEARCH_RESULTS_SELECTOR} .${HITBOX_CLASS} {
            display: block;
            padding: 2px 0;
        }
    `;

    document.head.appendChild(style);

    const preview = document.createElement('div');

    Object.assign(preview.style, {
        position: 'fixed',
        display: 'none',
        zIndex: '100000',
        backgroundColor: '#1c1c1c',
        border: '1px solid #ff9000',
        boxShadow: '0 0 15px rgba(0,0,0,0.8)',
        padding: '2px',
        borderRadius: '4px',
        pointerEvents: 'none',
        color: '#ccc',
        fontSize: '11px',
        maxWidth: '550px',
        maxHeight: '95vh',
        boxSizing: 'border-box',
        overflow: 'hidden'
    });

    document.body.appendChild(preview);

    const getSearchResultsRoot = () => {
        return document.querySelector(SEARCH_RESULTS_SELECTOR);
    };

    const isInsideSearchResults = (element) => {
        const root = getSearchResultsRoot();

        return Boolean(root && element && root.contains(element));
    };

    const isBadImageUrl = (url) => {
        const lower = String(url || '').toLowerCase();

        return EXCLUDE_LIST.some(bad => lower.includes(bad.toLowerCase()));
    };

    const normalizeImageUrl = (url) => {
        let result = String(url || '')
            .trim()
            .replace(/&/g, '&')
            .replace(/^http:/, 'https:');

        if (result.startsWith('//')) {
            result = `https:${result}`;
        }

        if (!result.startsWith('http')) {
            result = new URL(result, 'https://pornolab.net/forum/').href;
        }

        return result;
    };

    const isActivePreview = (url) => {
        return activeUrl === url &&
            activeHitbox &&
            activeHitbox.isConnected &&
            isInsideSearchResults(activeHitbox) &&
            activeLink &&
            activeLink.isConnected;
    };

    const updatePos = (x, y) => {
        if (preview.style.display === 'none') return;

        const offset = 20;
        const margin = 10;

        const rect = preview.getBoundingClientRect();

        const width = Math.min(
            rect.width || preview.offsetWidth || 0,
            window.innerWidth - margin * 2
        );

        const height = Math.min(
            rect.height || preview.offsetHeight || 0,
            window.innerHeight - margin * 2
        );

        let posX = x + offset;
        let posY = y + offset;

        if (posX + width + margin > window.innerWidth) {
            posX = x - width - offset;
        }

        if (posY + height + margin > window.innerHeight) {
            posY = y - height - offset;
        }

        posX = Math.max(margin, Math.min(posX, window.innerWidth - width - margin));
        posY = Math.max(margin, Math.min(posY, window.innerHeight - height - margin));

        preview.style.left = `${Math.round(posX)}px`;
        preview.style.top = `${Math.round(posY)}px`;
    };

    const scheduleUpdatePos = () => {
        if (!activeUrl || preview.style.display === 'none') return;

        cancelAnimationFrame(posRaf);

        posRaf = requestAnimationFrame(() => {
            updatePos(lastMouseX, lastMouseY);
        });
    };

    const showPreview = () => {
        preview.style.display = 'flex';
        scheduleUpdatePos();
    };

    const hidePreview = () => {
        activeHitbox = null;
        activeLink = null;
        activeUrl = null;

        renderToken++;

        cancelAnimationFrame(posRaf);

        preview.style.display = 'none';
        preview.innerHTML = '';
    };

    const showPreviewMessage = (text) => {
        preview.innerHTML = `<div style="padding:5px; white-space:nowrap">${text}</div>`;
        showPreview();
    };

    const fetchImage = (url, priority = false) => {
        if (cache.has(url)) return;

        if (pendingUrls.has(url)) {
            if (priority) {
                const idx = queue.indexOf(url);

                if (idx > -1) {
                    queue.splice(idx, 1);
                    queue.unshift(url);
                }
            }

            return;
        }

        pendingUrls.add(url);

        if (priority) {
            queue.unshift(url);
        } else {
            queue.push(url);
        }

        processQueue();
    };

    const processQueue = () => {
        if (activeRequests >= MAX_CONCURRENT || queue.length === 0) return;

        const url = queue.shift();

        activeRequests++;

        GM_xmlhttpRequest({
            method: 'GET',
            url,

            onload: function (res) {
                try {
                    const html = res.responseText || '';
                    const bodyStart = html.indexOf('class="post_body"');

                    const content = bodyStart !== -1
                        ? html.substring(bodyStart, bodyStart + 18000)
                        : html;

                    const regex = /<(?:img|var)[^>]+(?:src|title|data-src)="([^"]+)"[^>]+class="[^"]*postImg[^"]*"|<(?:img|var)[^>]+class="[^"]*postImg[^"]*"[^>]+(?:src|title|data-src)="([^"]+)"/gi;

                    const matches = [];
                    let match;

                    while ((match = regex.exec(content)) !== null) {
                        const imageUrl = normalizeImageUrl(match[1] || match[2]);

                        if (!isBadImageUrl(imageUrl) && !matches.includes(imageUrl)) {
                            matches.push(imageUrl);
                        }
                    }

                    matches.sort((a, b) => {
                        const aIsJpg = /\.(jpe?g)(?:[?#].*)?$/i.test(a);
                        const bIsJpg = /\.(jpe?g)(?:[?#].*)?$/i.test(b);

                        return Number(bIsJpg) - Number(aIsJpg);
                    });

                    cache.set(url, matches.length > 0 ? matches : null);
                } catch (err) {
                    console.error('[PL Previewer] Parse failed:', err);
                    cache.set(url, null);
                } finally {
                    pendingUrls.delete(url);
                    activeRequests--;

                    if (isActivePreview(url)) {
                        renderPreview(url);
                    }

                    processQueue();
                }
            },

            onerror: () => {
                cache.set(url, null);
                pendingUrls.delete(url);
                activeRequests--;

                if (isActivePreview(url)) {
                    renderPreview(url);
                }

                processQueue();
            }
        });
    };

    const renderPreview = (url) => {
        if (!isActivePreview(url)) return;

        const imgList = cache.get(url);
        const token = ++renderToken;

        if (Array.isArray(imgList) && imgList.length > 0) {
            showPreviewMessage('Загрузка изображения...');

            const img = document.createElement('img');

            Object.assign(img.style, {
                maxWidth: '100%',
                maxHeight: 'calc(95vh - 10px)',
                display: 'block',
                objectFit: 'contain'
            });

            img.decoding = 'async';

            let attempt = 0;

            const tryNext = () => {
                if (token !== renderToken || !isActivePreview(url)) return;

                if (attempt >= imgList.length) {
                    showPreviewMessage('Нет доступных скриншотов');
                    return;
                }

                img.src = imgList[attempt];
                attempt++;
            };

            img.onerror = () => {
                tryNext();
            };

            img.onload = () => {
                if (token !== renderToken || !isActivePreview(url)) return;

                const finalSrc = (img.currentSrc || img.src || '').toLowerCase();

                if (isBadImageUrl(finalSrc) || !img.naturalWidth || !img.naturalHeight) {
                    tryNext();
                    return;
                }

                preview.innerHTML = '';
                preview.appendChild(img);

                showPreview();

                requestAnimationFrame(() => {
                    updatePos(lastMouseX, lastMouseY);
                });
            };

            tryNext();
            return;
        }

        if (imgList === null) {
            showPreviewMessage('Картинок нет');
            return;
        }

        showPreviewMessage('Загрузка...');
    };

    const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (!entry.isIntersecting) return;

            const root = getSearchResultsRoot();

            if (!root || !root.contains(entry.target)) {
                observer.unobserve(entry.target);
                return;
            }

            const link = entry.target.querySelector(RESULT_LINK_SELECTOR);

            if (link) {
                fetchImage(link.href);
            }

            observer.unobserve(entry.target);
        });
    }, {
        rootMargin: '400px'
    });

    document.addEventListener('pointerover', (e) => {
        const hitbox = e.target.closest(HITBOX_SELECTOR);

        if (!hitbox) return;
        if (!isInsideSearchResults(hitbox)) return;
        if (activeHitbox === hitbox) return;

        const link = hitbox.querySelector(LINK_SELECTOR);

        if (!link) return;

        activeHitbox = hitbox;
        activeLink = link;
        activeUrl = link.href;

        lastMouseX = e.clientX;
        lastMouseY = e.clientY;

        if (!cache.has(activeUrl)) {
            fetchImage(activeUrl, true);
        }

        renderPreview(activeUrl);
    });

    document.addEventListener('pointermove', (e) => {
        if (!activeUrl) return;

        lastMouseX = e.clientX;
        lastMouseY = e.clientY;

        scheduleUpdatePos();
    });

    document.addEventListener('pointerout', (e) => {
        const hitbox = e.target.closest(HITBOX_SELECTOR);

        if (!hitbox) return;
        if (!isInsideSearchResults(hitbox)) return;

        const nextTarget = e.relatedTarget;

        if (nextTarget && hitbox.contains(nextTarget)) {
            return;
        }

        if (hitbox === activeHitbox) {
            hidePreview();
        }
    });

    window.addEventListener('resize', () => {
        scheduleUpdatePos();
    });

    window.addEventListener('scroll', () => {
        if (!activeHitbox) return;

        if (!activeHitbox.matches(':hover') || !isInsideSearchResults(activeHitbox)) {
            hidePreview();
        }
    }, {
        passive: true
    });

    const markHitboxes = () => {
        const root = getSearchResultsRoot();

        if (!root) {
            hidePreview();
            return;
        }

        const links = root.querySelectorAll(RESULT_LINK_SELECTOR);

        links.forEach(link => {
            const hitbox = link.closest('div') || link.parentElement;

            if (hitbox && root.contains(hitbox)) {
                hitbox.classList.add(HITBOX_CLASS);
            }
        });
    };

    const runInit = () => {
        const root = getSearchResultsRoot();

        if (!root) {
            hidePreview();
            return;
        }

        markHitboxes();

        const rows = root.querySelectorAll('#tor-tbl tbody tr, tbody tr, tr');

        rows.forEach(row => {
            observer.observe(row);
        });
    };

    if (document.readyState === 'complete') {
        runInit();
    } else {
        window.addEventListener('load', runInit);
    }
})();