// ==UserScript== // @name Force new tabs on chatgpt.com/gpts/mine // @namespace daniel.utils // @version 1.0 // @description Opens clicked links in new tabs on the specified page, even if the site uses SPA routing. // @match https://chatgpt.com/gpts/mine* // @run-at document-start // @grant none // ==/UserScript== (function () { 'use strict'; // --- Config: tweak if you want --- const SKIP_SCHEMES = ['mailto:', 'tel:', 'javascript:', 'blob:', 'data:']; // leave these alone const RESPECT_USER_MODIFIERS = true; // don’t interfere with Ctrl/Meta/Shift or middle-click const RESPECT_DOWNLOAD_ATTR = true; // don’t override const SKIP_HASH_ONLY = true; // don’t force #anchor links into new tabs // Utility: decide if an should be forced into a new tab function shouldForceNewTab(a) { const rawHref = (a.getAttribute('href') || '').trim(); if (!rawHref) return false; if (SKIP_SCHEMES.some(s => rawHref.startsWith(s))) return false; if (RESPECT_DOWNLOAD_ATTR && a.hasAttribute('download')) return false; if (SKIP_HASH_ONLY && rawHref.startsWith('#')) return false; return true; } // Keep anchors stamped with target/_blank + rel, including future ones function stampAnchor(a) { if (!shouldForceNewTab(a)) return; // Hint to the browser: open in new tab a.setAttribute('target', '_blank'); // Security best practice for new tabs const rel = (a.getAttribute('rel') || '').split(/\s+/); if (!rel.includes('noopener')) rel.push('noopener'); if (!rel.includes('noreferrer')) rel.push('noreferrer'); a.setAttribute('rel', rel.filter(Boolean).join(' ')); } function stampAllAnchors(root = document) { root.querySelectorAll('a[href]').forEach(stampAnchor); } // MutationObserver to catch dynamically-added links const mo = new MutationObserver(muts => { for (const m of muts) { if (m.type === 'childList') { m.addedNodes.forEach(node => { if (node.nodeType !== 1) return; if (node.matches?.('a[href]')) stampAnchor(node); if (node.querySelectorAll) stampAllAnchors(node); }); } else if (m.type === 'attributes' && m.target.matches?.('a[href]')) { stampAnchor(m.target); } } }); // Start observing ASAP (document-start) mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['href', 'rel', 'target'] }); // Initial pass (in case DOM is already there) if (document.readyState !== 'loading') { stampAllAnchors(); } else { document.addEventListener('DOMContentLoaded', () => stampAllAnchors(), { once: true }); } // Click-capture fallback: // Some SPAs (Next.js/React Router) call preventDefault() and do client-side navigation. // This handler forces a new tab anyway for simple left-clicks without modifiers. document.addEventListener('click', function (e) { // Respect user modifiers / middle-click, if enabled if (RESPECT_USER_MODIFIERS) { const modified = e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; if (modified) return; } else { // even if disabled, always let middle/right clicks pass if (e.button !== 0) return; } const a = e.target.closest?.('a[href]'); if (!a) return; if (!shouldForceNewTab(a)) return; const href = a.href; // absolute URL if (!href) return; // Block the page’s router from hijacking the click e.preventDefault(); e.stopImmediatePropagation(); // Open tab; relying on the user gesture keeps it out of popup-blocker territory window.open(href, '_blank', 'noopener,noreferrer'); }, true); // capture phase so we run before app handlers })();