Topic Preview Floating Window

Open topics in a floating window (modal) instead of navigating to a new page. Click outside to close. Auto-mark as read. Supports linux.do, github.com issues, and google search result links. Includes floating search widget.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Topic Preview Floating Window
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  Open topics in a floating window (modal) instead of navigating to a new page. Click outside to close. Auto-mark as read. Supports linux.do, github.com issues, and google search result links. Includes floating search widget.
// @author       Trae AI
// @match        https://linux.do/*
// @match        https://github.com/*
// @match        https://www.google.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Constants
    const STORAGE_KEY = 'tp_read_topics';

    // Selectors for different sites
    const TARGET_SELECTORS = [
        'a.raw-topic-link',
        'a[data-hovercard-type="issue"]',
        'a[data-hovercard-type="pull_request"]',
        'a[data-testid="issue-pr-title-link"]',
        '#search a[jsname="UWckNb"]',
        '#search a[href^="/url?"]'
    ].join(',');

    // Helper to get visited list
    function getVisitedSet() {
        try {
            const stored = localStorage.getItem(STORAGE_KEY);
            return new Set(stored ? JSON.parse(stored) : []);
        } catch (e) {
            return new Set();
        }
    }

    // Helper to add to visited list
    function addToVisited(idOrUrl) {
        const visited = getVisitedSet();
        visited.add(idOrUrl);
        let items = [...visited];
        if (items.length > 500) {
            items = items.slice(-500);
        }
        localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
    }

    function isGoogleDomain(hostname) {
        return hostname === 'google.com' || hostname === 'www.google.com' || hostname.endsWith('.google.com');
    }

    function isRestrictedFrameHost(hostname) {
        return hostname === 'linux.do' || hostname.endsWith('.linux.do') || hostname === 'github.com';
    }

    function getPreviewUrl(link) {
        if (!link) return null;
        const rawHref = link.getAttribute('href') || link.href;
        if (!rawHref) return null;

        if (window.location.hostname !== 'www.google.com') {
            return link.href;
        }

        let parsedUrl;
        try {
            parsedUrl = new URL(rawHref, window.location.href);
        } catch (e) {
            return null;
        }

        let targetUrl = parsedUrl;
        if (parsedUrl.pathname === '/url') {
            const q = parsedUrl.searchParams.get('q') || parsedUrl.searchParams.get('url');
            if (!q) return null;
            try {
                targetUrl = new URL(q, window.location.href);
            } catch (e) {
                return null;
            }
        }

        if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
            return null;
        }
        if (isGoogleDomain(targetUrl.hostname)) {
            return null;
        }
        return targetUrl.href;
    }

    function isGoogleSearchPage() {
        return window.location.hostname === 'www.google.com';
    }

    // Add styles
    const style = document.createElement('style');
    style.innerHTML = `
        .tp-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background: rgba(0, 0, 0, 0.6);
            z-index: 10000;
            display: flex;
            justify-content: center;
            align-items: center;
            opacity: 0;
            transition: opacity 0.3s ease;
            backdrop-filter: blur(3px);
        }
        .tp-overlay.visible {
            opacity: 1;
        }
        .tp-modal {
            width: 60%;
            height: 90%;
            background: var(--tp-bg, #fff);
            border-radius: 12px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            overflow: hidden;
            position: relative;
            transform: scale(0.95);
            transition: transform 0.3s ease;
            display: flex;
            flex-direction: column;
        }
        .tp-overlay.visible .tp-modal {
            transform: scale(1);
        }
        .tp-iframe, .tp-content {
            width: 100%;
            border: none;
            background: var(--tp-bg, #fff);
            flex: 1;
            min-height: 0;
        }
        .tp-iframe {
            display: block;
        }
        .tp-content {
            overflow-y: auto;
        }
        .tp-close {
            position: absolute;
            top: 10px;
            left: 15px;
            right: auto;
            font-size: 24px;
            color: #555;
            cursor: pointer;
            z-index: 10001;
            background: rgba(255,255,255,0.8);
            border-radius: 50%;
            width: 32px;
            height: 32px;
            text-align: center;
            line-height: 32px;
            display: none;
        }
        .tp-modal:hover .tp-close {
            display: block;
        }
        a.tp-visited, a.tp-visited span {
            color: #999 !important;
            opacity: 0.8;
        }
        @media (prefers-color-scheme: dark) {
            a.tp-visited, a.tp-visited span {
                color: #666 !important;
            }
        }

        /* Search Widget Styles */
        .tp-search-widget {
            position: fixed;
            bottom: 30px;
            right: 30px;
            z-index: 9999;
            display: flex;
            align-items: center;
            background: rgba(255, 255, 255, 0.85);
            border-radius: 28px; /* Capsule shape */
            box-shadow:
                0 4px 6px -1px rgba(0, 0, 0, 0.1),
                0 2px 4px -1px rgba(0, 0, 0, 0.06),
                0 10px 15px -3px rgba(0, 0, 0, 0.1);
            padding: 0;
            transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            border: 1px solid rgba(255, 255, 255, 0.5);
            height: 56px;
            width: 56px;
            overflow: hidden;
        }
        .tp-search-widget:hover, .tp-search-widget.active {
            width: 320px;
            box-shadow:
                0 20px 25px -5px rgba(0, 0, 0, 0.1),
                0 10px 10px -5px rgba(0, 0, 0, 0.04);
            background: rgba(255, 255, 255, 0.95);
        }
        .tp-search-icon {
            width: 56px;
            height: 56px;
            min-width: 56px; /* Prevent shrinking */
            display: flex;
            justify-content: center;
            align-items: center;
            cursor: pointer;
            color: #555;
            transition: color 0.3s;
        }
        .tp-search-icon svg {
            width: 24px;
            height: 24px;
            stroke-width: 2.5;
        }
        .tp-search-widget:hover .tp-search-icon, .tp-search-widget.active .tp-search-icon {
            color: #2563eb; /* Blue highlight */
        }
        .tp-search-input {
            width: 100%;
            height: 100%;
            padding: 0 20px 0 0;
            border: none;
            outline: none;
            background: transparent;
            font-size: 16px;
            color: #1f2937;
            font-family: inherit;
            opacity: 0;
            transform: translateX(10px);
            transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
            pointer-events: none; /* Prevent interaction when closed */
        }
        .tp-search-widget:hover .tp-search-input, .tp-search-widget.active .tp-search-input {
            opacity: 1;
            transform: translateX(0);
            pointer-events: auto;
        }

        /* Dark mode */
        @media (prefers-color-scheme: dark) {
            .tp-search-widget {
                background: rgba(30, 30, 30, 0.85);
                border-color: rgba(255, 255, 255, 0.1);
                box-shadow:
                    0 4px 6px -1px rgba(0, 0, 0, 0.3),
                    0 2px 4px -1px rgba(0, 0, 0, 0.15);
            }
            .tp-search-widget:hover, .tp-search-widget.active {
                background: rgba(40, 40, 40, 0.95);
                box-shadow:
                    0 20px 25px -5px rgba(0, 0, 0, 0.4),
                    0 10px 10px -5px rgba(0, 0, 0, 0.2);
            }
            .tp-search-icon {
                color: #9ca3af;
            }
            .tp-search-widget:hover .tp-search-icon, .tp-search-widget.active .tp-search-icon {
                color: #60a5fa; /* Lighter blue */
            }
            .tp-search-input {
                color: #f3f4f6;
            }
            .tp-search-input::placeholder {
                color: #6b7280;
            }
        }
    `;
    document.head.appendChild(style);

    // Create Search Widget
    function createSearchWidget() {
        const widget = document.createElement('div');
        widget.className = 'tp-search-widget';

        const icon = document.createElement('div');
        icon.className = 'tp-search-icon';
        // SVG Icon
        icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
        </svg>`;
        icon.title = 'Search';

        const input = document.createElement('input');
        input.className = 'tp-search-input';
        input.type = 'text';
        input.placeholder = `Search ${window.location.hostname}...`;

        widget.appendChild(icon);
        widget.appendChild(input);
        document.body.appendChild(widget);

        // Search logic
        const performSearch = () => {
            const query = input.value.trim();
            if (query) {
                const site = window.location.hostname;
                const searchQuery = `site:${site} ${query}`;
                const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`;
                window.open(searchUrl, '_blank');
            }
        };

        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                performSearch();
            }
        });

        icon.addEventListener('click', () => {
            if (widget.classList.contains('active')) {
                performSearch();
            } else {
                input.focus();
            }
        });

        input.addEventListener('focus', () => {
            widget.classList.add('active');
        });

        input.addEventListener('blur', () => {
            if (!input.value) {
                widget.classList.remove('active');
            }
        });
    }

    // Initialize Search Widget
    createSearchWidget();

    async function openModal(url) {
        // Create elements
        const overlay = document.createElement('div');
        overlay.className = 'tp-overlay';
        let targetUrl = null;
        try {
            targetUrl = new URL(url, window.location.href);
        } catch (e) {
            window.open(url, '_blank');
            return;
        }
        const isGithubHost = targetUrl.hostname === 'github.com';
        const isSameOrigin = targetUrl.origin === window.location.origin;
        const shouldUseRestrictedFallback = !isSameOrigin && isRestrictedFrameHost(targetUrl.hostname);

        const modal = document.createElement('div');
        modal.className = 'tp-modal';

        const detectBgColor = () => {
            const bodyBg = getComputedStyle(document.body).backgroundColor;
            const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
            const parseRgb = (str) => {
                const m = str.match(/\d+/g);
                return m ? m.map(Number) : null;
            };
            const isTransparent = (str) => str === 'transparent' || str === 'rgba(0, 0, 0, 0)';
            const bg = !isTransparent(bodyBg) ? bodyBg : (!isTransparent(htmlBg) ? htmlBg : null);
            if (bg) {
                const rgb = parseRgb(bg);
                if (rgb && rgb.length >= 3) {
                    // ITU-R BT.601 luma: dark < 128, light >= 128
                    const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
                    return lum < 128 ? bg : null;
                }
            }
            return null;
        };
        const detectedBg = detectBgColor();
        if (detectedBg) {
            overlay.style.setProperty('--tp-bg', detectedBg);
        }

        const closeBtn = document.createElement('div');
        closeBtn.className = 'tp-close';
        closeBtn.innerHTML = '&times;';
        closeBtn.title = 'Close';

        // Assemble basic structure
        modal.appendChild(closeBtn);
        overlay.appendChild(modal);

        document.body.appendChild(overlay);

        // Animation
        requestAnimationFrame(() => {
            overlay.classList.add('visible');
        });

        // Event Listeners
        let isClosing = false;
        let escListener = null;
        let wheelListener = null;
        let contentDiv = null;
        let iframe = null;
        let autoCloseTimer = null;

        const close = () => {
            if (isClosing) return;
            isClosing = true;
            if (autoCloseTimer) {
                clearTimeout(autoCloseTimer);
            }
            if (escListener) {
                document.removeEventListener('keydown', escListener);
            }
            if (wheelListener) {
                document.removeEventListener('wheel', wheelListener, true);
            }
            overlay.classList.remove('visible');
            setTimeout(() => {
                if (overlay.parentNode) {
                    overlay.parentNode.removeChild(overlay);
                }
            }, 300);
        };

        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) close();
        });
        closeBtn.addEventListener('click', close);

        escListener = (e) => {
            if (e.key === 'Escape') {
                close();
            }
        };
        document.addEventListener('keydown', escListener);

        wheelListener = (e) => {
            if (!overlay.isConnected || e.ctrlKey) return;
            e.preventDefault();
            e.stopPropagation();

            if (isGithubHost && contentDiv) {
                contentDiv.scrollBy({
                    top: e.deltaY,
                    left: e.deltaX,
                    behavior: 'auto'
                });
                return;
            }

            if (!isGithubHost && iframe) {
                try {
                    const win = iframe.contentWindow;
                    if (win) {
                        win.scrollBy({
                            top: e.deltaY,
                            left: e.deltaX,
                            behavior: 'auto'
                        });
                    }
                } catch (err) {
                    // Ignore cross-origin errors
                }
            }
        };
        document.addEventListener('wheel', wheelListener, {
            capture: true,
            passive: false
        });

        // Content Loading Logic
        if (shouldUseRestrictedFallback) {
            contentDiv = document.createElement('div');
            contentDiv.className = 'tp-content';
            const openedWindow = window.open(targetUrl.href, '_blank', 'noopener,noreferrer');
            if (openedWindow) {
                contentDiv.innerHTML = `<div style="padding:40px;text-align:center;line-height:1.8;">
                    <p style="font-size:18px;margin-bottom:10px;">已在新标签页打开目标内容</p>
                    <p style="font-size:14px;color:#666;">${targetUrl.hostname} 限制跨站嵌入,当前小窗口将自动关闭。</p>
                </div>`;
                autoCloseTimer = setTimeout(() => {
                    close();
                }, 800);
            } else {
                contentDiv.innerHTML = `<div style="padding:40px;text-align:center;line-height:1.8;">
                    <p style="font-size:18px;margin-bottom:10px;">当前站点限制跨站小窗预览</p>
                    <p style="font-size:14px;color:#666;margin-bottom:18px;">浏览器拦截了自动打开,请手动点击下方按钮。</p>
                    <a href="${targetUrl.href}" target="_blank" style="display:inline-block;padding:8px 16px;background:#0969da;color:#fff;border-radius:8px;text-decoration:none;">在新标签页打开</a>
                </div>`;
            }
            modal.appendChild(contentDiv);
        } else if (isGithubHost) {
            // GitHub: Use fetch to bypass X-Frame-Options
            contentDiv = document.createElement('div');
            contentDiv.className = 'tp-content';
            contentDiv.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100%;font-size:16px;color:#888;">Loading preview...</div>';
            modal.appendChild(contentDiv);

            try {
                const response = await fetch(url);
                if (!response.ok) throw new Error('Network response was not ok');
                const html = await response.text();
                const parser = new DOMParser();
                const doc = parser.parseFromString(html, 'text/html');

                // Try to find the relevant content container
                // .application-main is the main wrapper for GitHub pages
                const mainContent = doc.querySelector('.application-main') || doc.querySelector('main');

                if (mainContent) {
                    contentDiv.innerHTML = '';
                    contentDiv.appendChild(mainContent);

                    // Add padding to make it look better
                    if (contentDiv.firstElementChild) {
                        contentDiv.firstElementChild.style.marginTop = '0';
                        contentDiv.firstElementChild.style.paddingTop = '20px';
                    }
                } else {
                    contentDiv.innerHTML = '<div style="padding:40px;text-align:center;">Could not extract content from this page.</div>';
                }
            } catch (err) {
                console.error(err);
                contentDiv.innerHTML = `<div style="padding:40px;text-align:center;">
                    <p style="font-size:16px;margin-bottom:12px;">无法加载预览</p>
                    <p style="font-size:14px;color:#666;margin-bottom:20px;">该页面可能需要登录或暂时无法访问</p>
                    <a href="${url}" target="_blank" style="display:inline-block;padding:8px 16px;background:#0969da;color:#fff;border-radius:8px;text-decoration:none;">在新标签页打开</a>
                </div>`;
            }
        } else {
            // Default (Linux.do / Discourse): Use Iframe
            iframe = document.createElement('iframe');
            iframe.className = 'tp-iframe';

            iframe.onload = () => {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow.document;
                    if (doc) {
                        const base = doc.createElement('base');
                        base.target = '_blank';
                        doc.head.prepend(base);
                        doc.addEventListener('click', (e) => {
                            const anchor = e.target.closest('a');
                            if (anchor && anchor.href) anchor.target = '_blank';
                        }, true);
                    }
                } catch (e) {
                    // Ignore cross-origin errors
                }
            };

            iframe.src = targetUrl.href;
            modal.appendChild(iframe);
        }
    }

    // Function to update styles based on visited history
    function updateVisitedStyles() {
        const visited = getVisitedSet();
        const links = document.querySelectorAll(TARGET_SELECTORS);

        links.forEach(link => {
            if (link.classList.contains('tp-visited')) return;
            const id = link.getAttribute('data-topic-id');
            const url = getPreviewUrl(link);
            if ((id && visited.has(id)) || (url && visited.has(url))) {
                link.classList.add('tp-visited');
            }
        });
    }

    // Main logic: Intercept clicks
    document.addEventListener('click', (e) => {
        const link = e.target.closest(TARGET_SELECTORS);
        const previewUrl = getPreviewUrl(link);

        if (link && previewUrl) {
            if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
                e.preventDefault();
                e.stopPropagation();

                const id = link.getAttribute('data-topic-id');
                if (id) addToVisited(id);
                else addToVisited(previewUrl);

                link.classList.add('tp-visited');
                if (isGoogleSearchPage()) {
                    e.stopImmediatePropagation();
                    const openedWindow = window.open(previewUrl, '_blank', 'noopener,noreferrer');
                    if (!openedWindow) {
                        const tempLink = document.createElement('a');
                        tempLink.href = previewUrl;
                        tempLink.target = '_blank';
                        tempLink.rel = 'noopener noreferrer';
                        tempLink.style.display = 'none';
                        document.body.appendChild(tempLink);
                        tempLink.click();
                        tempLink.remove();
                    }
                    return;
                }
                openModal(previewUrl);
            }
        }
    }, true);

    updateVisitedStyles();

    const observer = new MutationObserver((mutations) => {
        let shouldUpdate = false;
        for (const mutation of mutations) {
            if (mutation.addedNodes.length > 0) {
                shouldUpdate = true;
                break;
            }
        }
        if (shouldUpdate) {
            updateVisitedStyles();
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    window.addEventListener('beforeunload', () => {
        observer.disconnect();
    });

})();