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