Onche Topic Preview

Prévisualise le premier message d'un topic au survol sur Onche.org

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Onche Topic Preview
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Prévisualise le premier message d'un topic au survol sur Onche.org
// @author       Bkz1
// @match        https://onche.org/*
// @license MIT
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // Cache pour stocker les éléments de messages pré-chargés (URL normalisée -> Element cloné)
    const previewCache = new Map();

    // Variables de contrôle du survol
    let hoverTimeout = null;
    let activeLink = null;
    let mouseX = 0;
    let mouseY = 0;

    // Création et injection du style CSS personnalisé
    const style = document.createElement('style');
    style.textContent = `
        #onche-preview-tooltip {
            position: fixed;
            z-index: 999999;
            max-width: 600px;
            max-height: 400px;
            background: rgba(30, 39, 58, 0.96);
            border: 1px solid rgba(255, 255, 255, 0.12);
            border-radius: 12px;
            box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.05);
            backdrop-filter: blur(16px);
            -webkit-backdrop-filter: blur(16px);
            overflow-y: auto;
            padding: 16px;
            pointer-events: none;
            opacity: 0;
            visibility: hidden;
            transform: translateY(12px) scale(0.98);
            transition: opacity 0.2s cubic-bezier(0.25, 0.8, 0.25, 1),
                        transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1),
                        visibility 0.2s;
            font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            box-sizing: border-box;
        }

        #onche-preview-tooltip.visible {
            opacity: 1;
            visibility: visible;
            transform: translateY(0) scale(1);
        }

        /* Réinitialisation et harmonisation de la boîte de message native */
        #onche-preview-tooltip .message {
            border: none !important;
            background: transparent !important;
            padding: 0 !important;
            margin: 0 !important;
            box-shadow: none !important;
            display: flex !important;
            flex-direction: column !important;
        }

        #onche-preview-tooltip .message-top {
            border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
            padding-bottom: 10px !important;
            margin-bottom: 10px !important;
            display: flex !important;
            align-items: center !important;
            gap: 12px !important;
        }

        #onche-preview-tooltip .message-infos {
            display: flex !important;
            flex-direction: column !important;
            gap: 2px !important;
        }

        #onche-preview-tooltip .message-content {
            font-size: 14.5px !important;
            line-height: 1.6 !important;
            color: #e2e8f0 !important;
            word-break: break-word !important;
        }

        #onche-preview-tooltip .avatar {
            width: 44px !important;
            height: 44px !important;
            border-radius: 50% !important;
            object-fit: cover !important;
        }

        #onche-preview-tooltip .message-username {
            font-size: 14.5px !important;
            font-weight: 600 !important;
            color: #3b82f6 !important;
            text-decoration: none !important;
        }

        #onche-preview-tooltip .message-bottom {
            margin-top: 12px !important;
            padding-top: 8px !important;
            border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
            display: flex !important;
            justify-content: flex-end !important;
        }

        #onche-preview-tooltip .message-date {
            font-size: 12px !important;
            color: #64748b !important;
        }

        #onche-preview-tooltip .message-content img,
        #onche-preview-tooltip .message-content video {
            max-width: 100% !important;
            height: auto !important;
            border-radius: 6px !important;
            margin-top: 6px !important;
        }

        #onche-preview-tooltip .message-badges img {
            width: 18px !important;
            height: 18px !important;
            max-width: 18px !important;
            max-height: 18px !important;
            margin: 0 2px !important;
            display: inline-block !important;
            vertical-align: middle !important;
        }

        /* Personnalisation de la barre de défilement de l'aperçu */
        #onche-preview-tooltip::-webkit-scrollbar {
            width: 6px;
            height: 6px;
        }
        #onche-preview-tooltip::-webkit-scrollbar-track {
            background: transparent;
        }
        #onche-preview-tooltip::-webkit-scrollbar-thumb {
            background: rgba(255, 255, 255, 0.15);
            border-radius: 4px;
        }
        #onche-preview-tooltip::-webkit-scrollbar-thumb:hover {
            background: rgba(255, 255, 255, 0.3);
        }

        /* Spinner de chargement */
        .onche-preview-loader {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 24px;
            color: #94a3b8;
            font-size: 13.5px;
            font-weight: 500;
        }

        .onche-preview-spinner {
            width: 28px;
            height: 28px;
            border: 3px solid rgba(255, 255, 255, 0.08);
            border-top-color: #3b82f6;
            border-radius: 50%;
            animation: onche-spin 0.8s linear infinite;
            margin-bottom: 12px;
        }

        @keyframes onche-spin {
            to { transform: rotate(360deg); }
        }

        /* Affichage des erreurs */
        .onche-preview-error {
            display: flex;
            align-items: center;
            gap: 10px;
            color: #ef4444;
            font-size: 14px;
            padding: 12px;
            border-radius: 8px;
            background: rgba(239, 68, 68, 0.1);
            border: 1px solid rgba(239, 68, 68, 0.2);
        }
    `;
    document.head.appendChild(style);

    // Création et injection du conteneur du tooltip de prévisualisation
    const tooltip = document.createElement('div');
    tooltip.id = 'onche-preview-tooltip';
    document.body.appendChild(tooltip);

    // Normalise l'URL d'un topic pour cibler la page 1 et sans ancre/paramètre
    function getPage1Url(href) {
        try {
            const url = new URL(href, window.location.origin);
            url.hash = '';
            url.search = '';

            const pathname = url.pathname;
            // Vérifie si c'est un lien vers un topic
            if (!pathname.startsWith('/topic/')) return null;

            const segments = pathname.split('/').filter(Boolean);

            // Format typique : ["topic", "1196595", "slug", "page"]
            if (segments.length >= 3) {
                const lastSegment = segments[segments.length - 1];
                if (/^\d+$/.test(lastSegment)) {
                    // Supprime le segment du numéro de page
                    segments.pop();
                }
                url.pathname = '/' + segments.join('/');
            }
            return url.href;
        } catch (e) {
            return null;
        }
    }

    // Convertit les URLs relatives d'un élément cloné en URLs absolues
    function resolveRelativeUrls(element, baseUrl) {
        const attributes = ['src', 'href'];
        attributes.forEach(attr => {
            element.querySelectorAll(`[${attr}]`).forEach(el => {
                const val = el.getAttribute(attr);
                if (val) {
                    try {
                        el.setAttribute(attr, new URL(val, baseUrl).href);
                    } catch (e) {}
                }
            });
        });
    }

    // Cache et affiche le tooltip
    function hideTooltip() {
        tooltip.classList.remove('visible');
    }

    function showTooltipLoading() {
        tooltip.innerHTML = `
            <div class="onche-preview-loader">
                <div class="onche-preview-spinner"></div>
                <span>Chargement de l'aperçu...</span>
            </div>
        `;
        tooltip.classList.add('visible');
    }

    function showTooltipContent(contentNode) {
        tooltip.innerHTML = '';
        tooltip.appendChild(contentNode.cloneNode(true));
        tooltip.classList.add('visible');
    }

    function showTooltipError(message) {
        tooltip.innerHTML = `
            <div class="onche-preview-error">
                <span style="font-size: 18px; line-height: 1;">⚠️</span>
                <span>${message}</span>
            </div>
        `;
        tooltip.classList.add('visible');
    }

    // Met à jour la position du tooltip en évitant qu'il sorte de l'écran
    function updateTooltipPosition(x, y) {
        const offset = 18;
        let targetX = x + offset;
        let targetY = y + offset;

        const tooltipWidth = tooltip.offsetWidth;
        const tooltipHeight = tooltip.offsetHeight;

        const screenWidth = window.innerWidth;
        const screenHeight = window.innerHeight;

        // Ajustement horizontal si débordement à droite
        if (targetX + tooltipWidth > screenWidth) {
            targetX = x - tooltipWidth - offset;
        }
        if (targetX < 10) {
            targetX = 10;
        }

        // Ajustement vertical si débordement en bas
        if (targetY + tooltipHeight > screenHeight) {
            targetY = y - tooltipHeight - offset;
        }
        if (targetY < 10) {
            targetY = 10;
        }

        tooltip.style.left = `${targetX}px`;
        tooltip.style.top = `${targetY}px`;
    }

    // Déclenche le chargement et l'affichage de l'aperçu d'un topic
    function showPreviewForLink(link) {
        const page1Url = getPage1Url(link.href);
        if (!page1Url) return;

        showTooltipLoading();
        updateTooltipPosition(mouseX, mouseY);

        // Si l'aperçu est déjà en cache
        if (previewCache.has(page1Url)) {
            showTooltipContent(previewCache.get(page1Url));
            return;
        }

        // Chargement asynchrone de la première page du topic
        fetch(page1Url)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`Erreur réseau (${response.status})`);
                }
                return response.text();
            })
            .then(html => {
                // S'assurer que le curseur est toujours sur ce lien
                if (activeLink !== link) return;

                const parser = new DOMParser();
                const doc = parser.parseFromString(html, 'text/html');
                const firstMessage = doc.querySelector('.message[data-is-first]') || doc.querySelector('.message');

                if (!firstMessage) {
                    throw new Error("Impossible de lire le contenu de ce sujet.");
                }

                // Cloner et nettoyer l'élément du premier message
                const previewEl = firstMessage.cloneNode(true);

                // Résoudre les liens et médias relatifs
                resolveRelativeUrls(previewEl, page1Url);

                // Retirer les éléments indésirables (signature, publicités, boutons)
                const signature = previewEl.querySelector('.signature');
                if (signature) signature.remove();

                const actions = previewEl.querySelector('.message-actions, .right');
                if (actions) actions.remove();

                const ads = previewEl.querySelector('.topic-ad');
                if (ads) ads.remove();

                // Stocker dans le cache local
                previewCache.set(page1Url, previewEl);

                showTooltipContent(previewEl);
            })
            .catch(err => {
                if (activeLink !== link) return;
                showTooltipError(err.message);
            });
    }

    // Gestion du cycle de survol avec temporisation
    function clearHover() {
        if (hoverTimeout) {
            clearTimeout(hoverTimeout);
            hoverTimeout = null;
        }
        activeLink = null;
        hideTooltip();
    }

    // Écouteur global pour suivre la souris
    document.addEventListener('mousemove', (e) => {
        mouseX = e.clientX;
        mouseY = e.clientY;
        if (tooltip.classList.contains('visible')) {
            updateTooltipPosition(mouseX, mouseY);
        }
    });

    // Délégation d'événements pour le survol des titres de topics
    document.addEventListener('mouseover', (e) => {
        const link = e.target.closest('a.topic-subject.link');
        if (!link) return;

        if (activeLink !== link) {
            clearHover();
            activeLink = link;
            hoverTimeout = setTimeout(() => {
                showPreviewForLink(link);
            }, 350); // Délai d'attente de 350ms
        }
    });

    document.addEventListener('mouseout', (e) => {
        if (!activeLink) return;

        // Vérifie si le pointeur quitte vraiment l'élément survolé
        const related = e.relatedTarget;
        if (!related || !activeLink.contains(related)) {
            clearHover();
        }
    });

})();