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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
    });

})();