在新标签页打开链接(可取消 + 聚焦新页)

强制所有链接和 SPA 路由在新标签页打开并立即聚焦,新页面获得焦点,当前页保持不动。支持按域名禁用。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         在新标签页打开链接(可取消 + 聚焦新页)
// @namespace    http://tampermonkey.net/
// @version      0.1.0
// @description  强制所有链接和 SPA 路由在新标签页打开并立即聚焦,新页面获得焦点,当前页保持不动。支持按域名禁用。
// @author       AvailableForTheWorld + Grok
// @match        *://*/*
// @icon         https://www.svgrepo.com/show/207466/blank-page-list.svg
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @grant        GM_addStyle
// ==/UserScript==

;(function () {
  'use strict'

  // 如果是由本脚本打开的新标签页,直接退出,避免干扰
  if (sessionStorage.getItem('openedByScript') === 'true') {
    sessionStorage.removeItem('openedByScript')
    return
  }

  const currentDomain = window.location.hostname
  const getDisabledDomains = () => GM_getValue('disabledDomains', {})
  const setDisabledDomains = (obj) => GM_setValue('disabledDomains', obj)

  // Check initial state
  const isDisabled = !!getDisabledDomains()[currentDomain]

  // === UI Management Panel ===
  function openSettingsPanel() {
    // Remove existing panel if any
    const existing = document.getElementById('link-jump-blank-settings')
    if (existing) existing.remove()

    const host = document.createElement('div')
    host.id = 'link-jump-blank-settings'
    document.body.appendChild(host)

    const shadow = host.attachShadow({ mode: 'open' })

    const style = `
      :host {
        all: initial;
        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
        --primary: #6366f1;
        --primary-hover: #4f46e5;
        --danger: #ef4444;
        --bg: #ffffff;
        --bg-sub: #f9fafb;
        --text: #111827;
        --text-sub: #6b7280;
        --border: #e5e7eb;
        --radius: 16px;
        --shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
      }

      @media (prefers-color-scheme: dark) {
        :host {
          --bg: #1f2937;
          --bg-sub: #111827;
          --text: #f9fafb;
          --text-sub: #9ca3af;
          --border: #374151;
        }
      }

      .overlay {
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.4);
        backdrop-filter: blur(4px);
        z-index: 2147483647;
        display: flex;
        align-items: center;
        justify-content: center;
        opacity: 0;
        animation: fadeIn 0.2s forwards;
      }

      .panel {
        background: var(--bg);
        color: var(--text);
        width: 480px;
        max-width: 90vw;
        border-radius: var(--radius);
        box-shadow: var(--shadow);
        border: 1px solid var(--border);
        display: flex;
        flex-direction: column;
        overflow: hidden;
        transform: scale(0.95);
        opacity: 0;
        animation: popIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
      }

      @keyframes fadeIn { to { opacity: 1; } }
      @keyframes popIn { to { opacity: 1; transform: scale(1); } }

      .header {
        padding: 20px 24px;
        border-bottom: 1px solid var(--border);
        display: flex;
        justify-content: space-between;
        align-items: center;
      }

      .title-group {
        display: flex;
        align-items: center;
        gap: 12px;
      }

      .icon {
        width: 32px;
        height: 32px;
        background: var(--primary);
        border-radius: 8px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: white;
      }

      .title {
        font-size: 18px;
        font-weight: 600;
        margin: 0;
      }

      .subtitle {
        font-size: 13px;
        color: var(--text-sub);
        margin-top: 2px;
      }

      .close-btn {
        background: transparent;
        border: none;
        color: var(--text-sub);
        cursor: pointer;
        padding: 8px;
        border-radius: 8px;
        transition: all 0.2s;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .close-btn:hover {
        background: var(--bg-sub);
        color: var(--text);
      }

      .content {
        padding: 24px;
        overflow-y: auto;
        max-height: 65vh;
      }

      .hero-card {
        background: var(--bg-sub);
        border-radius: 12px;
        padding: 20px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        border: 1px solid var(--border);
        margin-bottom: 24px;
      }

      .domain-info {
        display: flex;
        flex-direction: column;
        gap: 4px;
      }

      .current-domain {
        font-size: 16px;
        font-weight: 600;
      }

      .status-text {
        font-size: 13px;
        color: var(--text-sub);
        display: flex;
        align-items: center;
        gap: 6px;
      }

      .status-dot {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: var(--text-sub);
      }
      .status-dot.active { background: #10b981; }
      .status-dot.disabled { background: var(--danger); }

      /* Toggle Switch */
      .switch {
        position: relative;
        display: inline-block;
        width: 48px;
        height: 28px;
      }
      .switch input { opacity: 0; width: 0; height: 0; }
      .slider {
        position: absolute;
        cursor: pointer;
        top: 0; left: 0; right: 0; bottom: 0;
        background-color: #e5e7eb;
        transition: .3s;
        border-radius: 34px;
      }
      .slider:before {
        position: absolute;
        content: "";
        height: 20px;
        width: 20px;
        left: 4px;
        bottom: 4px;
        background-color: white;
        transition: .3s;
        border-radius: 50%;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      }
      input:checked + .slider { background-color: var(--primary); }
      input:checked + .slider:before { transform: translateX(20px); }

      /* Dark mode switch adjustment */
      @media (prefers-color-scheme: dark) {
        .slider { background-color: #4b5563; }
      }

      .section-label {
        font-size: 12px;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        color: var(--text-sub);
        margin-bottom: 12px;
        display: flex;
        justify-content: space-between;
      }

      .add-group {
        display: flex;
        gap: 8px;
        margin-bottom: 16px;
      }

      .input {
        flex: 1;
        background: var(--bg);
        border: 1px solid var(--border);
        padding: 10px 12px;
        border-radius: 8px;
        font-size: 14px;
        color: var(--text);
        transition: border-color 0.2s;
      }
      .input:focus {
        outline: none;
        border-color: var(--primary);
      }

      .btn {
        padding: 0 16px;
        border-radius: 8px;
        font-size: 14px;
        font-weight: 500;
        cursor: pointer;
        border: none;
        transition: background 0.2s;
      }
      .btn-primary {
        background: var(--primary);
        color: white;
      }
      .btn-primary:hover { background: var(--primary-hover); }

      .list-container {
        display: flex;
        flex-direction: column;
        gap: 8px;
      }

      .list-item {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 12px;
        background: var(--bg);
        border: 1px solid var(--border);
        border-radius: 8px;
        font-size: 14px;
        transition: transform 0.2s;
      }
      .list-item:hover {
        border-color: var(--text-sub);
      }

      .edit-input {
        flex: 1;
        background: transparent;
        border: 1px solid var(--primary);
        border-radius: 4px;
        padding: 4px 8px;
        font-size: 14px;
        color: var(--text);
        outline: none;
        margin-right: 8px;
      }

      .domain-text {
        flex: 1;
        cursor: text;
        padding: 4px 0;
      }

      .delete-btn {
        background: transparent;
        border: none;
        color: var(--text-sub);
        cursor: pointer;
        padding: 6px;
        border-radius: 6px;
        display: flex;
        align-items: center;
      }
      .delete-btn:hover {
        background: #fee2e2;
        color: var(--danger);
      }

      .empty-state {
        text-align: center;
        padding: 32px 0;
        color: var(--text-sub);
        font-size: 14px;
        border: 2px dashed var(--border);
        border-radius: 12px;
      }

      .footer {
        padding: 16px 24px;
        background: var(--bg-sub);
        border-top: 1px solid var(--border);
        font-size: 12px;
        color: var(--text-sub);
        text-align: center;
      }

      .toast {
        position: absolute;
        bottom: 24px;
        left: 50%;
        transform: translateX(-50%) translateY(20px);
        background: #10b981;
        color: white;
        padding: 8px 16px;
        border-radius: 20px;
        font-size: 13px;
        font-weight: 500;
        opacity: 0;
        transition: all 0.3s;
        pointer-events: none;
        box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
      }
      .toast.show {
        transform: translateX(-50%) translateY(0);
        opacity: 1;
      }
    `

    const disabledList = getDisabledDomains()
    const isCurrentDisabled = !!disabledList[currentDomain]

    shadow.innerHTML = `
      <style>${style}</style>
      <div class="overlay">
        <div class="panel">
          <div class="header">
            <div class="title-group">
              <div class="icon">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
              </div>
              <div>
                <h2 class="title">New Tab Manager</h2>
                <div class="subtitle">Control where links open</div>
              </div>
            </div>
            <button class="close-btn">
              <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
            </button>
          </div>

          <div class="content">
            <div class="section-label">Current Website</div>
            <div class="hero-card">
              <div class="domain-info">
                <div class="current-domain">${currentDomain}</div>
                <div class="status-text">
                  <div class="status-dot ${
                    isCurrentDisabled ? 'disabled' : 'active'
                  }"></div>
                  ${
                    isCurrentDisabled
                      ? 'Open in same tab (Disabled)'
                      : 'Open in new tab (Active)'
                  }
                </div>
              </div>
              <label class="switch">
                <input type="checkbox" id="toggle-current" ${
                  !isCurrentDisabled ? 'checked' : ''
                }>
                <span class="slider"></span>
              </label>
            </div>

            <div class="section-label">
              <span>Disabled Domains</span>
              <span id="count-badge">0</span>
            </div>

            <div class="add-group">
              <input type="text" class="input" id="add-input" placeholder="example.com">
              <button class="btn btn-primary" id="add-btn">Add</button>
            </div>

            <div class="list-container" id="domain-list">
              <!-- List Items -->
            </div>
          </div>

          <div class="footer">
            Changes to the current domain require a page reload.
          </div>

          <div class="toast" id="toast">Settings Saved</div>
        </div>
      </div>
    `

    // Logic
    const overlay = shadow.querySelector('.overlay')
    const closeBtn = shadow.querySelector('.close-btn')
    const toggleCurrent = shadow.querySelector('#toggle-current')
    const listContainer = shadow.querySelector('#domain-list')
    const addInput = shadow.querySelector('#add-input')
    const addBtn = shadow.querySelector('#add-btn')
    const countBadge = shadow.querySelector('#count-badge')
    const toast = shadow.querySelector('#toast')
    const statusText = shadow.querySelector('.status-text')
    const statusDot = shadow.querySelector('.status-dot')

    function close() {
      host.remove()
    }

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

    function showToast(msg) {
      toast.textContent = msg
      toast.classList.add('show')
      setTimeout(() => toast.classList.remove('show'), 2000)
    }

    function updateCurrentStatusUI(enabled) {
      if (enabled) {
        statusDot.className = 'status-dot active'
        statusText.innerHTML =
          '<div class="status-dot active"></div> Open in new tab (Active)'
      } else {
        statusDot.className = 'status-dot disabled'
        statusText.innerHTML =
          '<div class="status-dot disabled"></div> Open in same tab (Disabled)'
      }
    }

    // Toggle Current
    toggleCurrent.addEventListener('change', (e) => {
      const enabled = e.target.checked
      const domains = getDisabledDomains()

      if (enabled) {
        delete domains[currentDomain]
      } else {
        domains[currentDomain] = true
      }

      setDisabledDomains(domains)
      updateCurrentStatusUI(enabled)
      renderList()
      location.reload()
    })

    // Add Domain
    function handleAdd() {
      const val = addInput.value.trim()
      if (!val) return

      const domains = getDisabledDomains()
      domains[val] = true
      setDisabledDomains(domains)

      addInput.value = ''
      renderList()
      showToast(`Added ${val}`)

      if (val === currentDomain) {
        toggleCurrent.checked = false
        updateCurrentStatusUI(false)
      }
    }
    addBtn.addEventListener('click', handleAdd)
    addInput.addEventListener('keyup', (e) => {
      if (e.key === 'Enter') handleAdd()
    })

    // Render List
    function renderList() {
      const domains = getDisabledDomains()
      const keys = Object.keys(domains).sort()

      countBadge.textContent = keys.length

      if (keys.length === 0) {
        listContainer.innerHTML =
          '<div class="empty-state">No disabled domains</div>'
        return
      }

      listContainer.innerHTML = keys
        .map(
          (domain) => `
        <div class="list-item">
          <span class="domain-text" title="Click to edit">${domain}</span>
          <button class="delete-btn" data-domain="${domain}" title="Remove rule">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
          </button>
        </div>
      `
        )
        .join('')

      shadow.querySelectorAll('.delete-btn').forEach((btn) => {
        btn.addEventListener('click', () => {
          const d = btn.dataset.domain
          const current = getDisabledDomains()
          delete current[d]
          setDisabledDomains(current)
          renderList()
          showToast('Rule removed')

          if (d === currentDomain) {
            toggleCurrent.checked = true
            updateCurrentStatusUI(true)
          }
        })
      })

      shadow.querySelectorAll('.domain-text').forEach((span) => {
        span.addEventListener('click', () => {
          const originalDomain = span.textContent
          const input = document.createElement('input')
          input.type = 'text'
          input.value = originalDomain
          input.className = 'edit-input'

          const save = () => {
            const newDomain = input.value.trim()
            if (newDomain && newDomain !== originalDomain) {
              const current = getDisabledDomains()
              delete current[originalDomain]
              current[newDomain] = true
              setDisabledDomains(current)

              if (
                originalDomain === currentDomain ||
                newDomain === currentDomain
              ) {
                const isCurDisabled = !!current[currentDomain]
                toggleCurrent.checked = !isCurDisabled
                updateCurrentStatusUI(!isCurDisabled)
              }
              showToast('Updated')
            }
            renderList()
          }

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

          span.replaceWith(input)
          input.focus()
        })
      })
    }

    renderList()
  }

  // === Register Menu ===
  GM_registerMenuCommand('⚙️ Configure New Tab Rules', openSettingsPanel)

  // If current domain is disabled, we still register the menu command so user can re-enable it.
  // But we stop the auto-open logic.
  if (isDisabled) {
    console.log(`【New Tab Script】Disabled on ${currentDomain}`)
    return
  }

  // === 1. Global Click Interception ===
  // 使用捕获阶段拦截所有点击,防止原页面跳转(通过 stopPropagation)
  // ... (Logic continues below)

  let lastOpenTime = 0
  let lastOpenUrl = ''
  let lastTriggerTime = 0

  function safeOpenInTab(url, options = {}) {
    const now = Date.now()
    if (now - lastOpenTime < 2000 && url === lastOpenUrl) {
      console.log('【新标签页脚本】拦截重复打开:', url)
      return
    }
    lastOpenTime = now
    lastOpenUrl = url

    // 默认立即聚焦新标签页
    const finalOptions = { active: true, ...options }
    GM_openInTab(url, finalOptions)
  }

  window.addEventListener(
    'click',
    (e) => {
      // 1. 如果按下了修饰键(Ctrl/Meta/Shift/Alt),由浏览器默认处理(通常是后台打开或新窗口)
      if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return
      // 2. 仅处理鼠标左键
      if (e.button !== 0) return

      let target = e.target
      while (target && target.tagName !== 'A') {
        target = target.parentNode
      }

      if (!target || !target.href) return

      // 忽略非 HTTP 协议链接 (javascript:, tel:, mailto: 等)
      if (!target.href.startsWith('http')) return

      // 检查是否为本页锚点跳转
      try {
        const urlObj = new URL(target.href)
        if (
          urlObj.origin === location.origin &&
          urlObj.pathname === location.pathname &&
          urlObj.search === location.search
        ) {
          return // 仅哈希变化或相同页面,允许默认行为
        }
      } catch (err) {
        return
      }

      // === 拦截逻辑 ===
      // 阻止默认行为(防止原页面跳转)和冒泡(防止网站 SPA 路由接管)
      e.preventDefault()
      e.stopPropagation()
      e.stopImmediatePropagation()

      const url = target.href

      // 记录触发时间,通知 pushState/replaceState 忽略
      lastTriggerTime = Date.now()
      sessionStorage.setItem('openedByScript', 'true')

      console.log('【新标签页脚本】拦截点击 → 新标签页打开:', url)
      safeOpenInTab(url, { active: true })
    },
    true // Capture phase
  )

  // === 2. 辅助功能:给链接添加 _blank 样式(视觉提示)===
  // 虽然点击被拦截了,但保留这个为了让用户 hover 时看到 cursor 变化或浏览器提示
  function setTargetBlank(node) {
    if (node.tagName === 'A' && (!node.target || node.target === '_self')) {
      node.target = '_blank'
      node.rel = 'noopener noreferrer'
    }
  }

  function processLinks() {
    document.querySelectorAll('a').forEach(setTargetBlank)
  }

  const observer = new MutationObserver((mutations) => {
    mutations.forEach((m) => {
      m.addedNodes.forEach((node) => {
        if (node.nodeType !== 1) return
        setTargetBlank(node)
        if (node.querySelectorAll)
          node.querySelectorAll('a').forEach(setTargetBlank)
      })
    })
  })
  observer.observe(document.body, { childList: true, subtree: true })
  processLinks()

  // === 3. 拦截 SPA 路由(pushState / hashchange)===
  const origPushState = history.pushState
  const origReplaceState = history.replaceState

  history.pushState = function (state, title, url) {
    if (!url || typeof url !== 'string')
      return origPushState.apply(this, arguments)

    let fullUrl = url
    try {
      fullUrl = new URL(url, location.href).href
    } catch {}

    if (fullUrl === location.href) return origPushState.apply(this, arguments)

    // 如果最近刚点击过链接(被 click 拦截处理了),则忽略此次 pushState
    if (Date.now() - lastTriggerTime < 2000) return

    lastTriggerTime = Date.now()
    sessionStorage.setItem('openedByScript', 'true')
    setTimeout(() => safeOpenInTab(fullUrl, { active: true }), 50)

    console.log('【新标签页脚本】拦截 pushState → 新标签页打开:', fullUrl)
    // 不调用 origPushState
  }

  history.replaceState = function (state, title, url) {
    lastTriggerTime = Date.now()
    lastOpenTime = Date.now()
    if (typeof url === 'string') lastOpenUrl = url
    return origReplaceState.apply(this, arguments)
  }

  window.addEventListener('hashchange', (e) => {
    if (Date.now() - lastTriggerTime < 2000) return
    lastTriggerTime = Date.now()
    sessionStorage.setItem('openedByScript', 'true')
    setTimeout(() => safeOpenInTab(e.newURL, { active: true }), 50)
    console.log('【新标签页脚本】hashchange → 新标签页打开:', e.newURL)
  })

  // === 4. 拦截 window.open ===
  const origOpen = window.open
  window.open = function (url, name, features) {
    if (typeof url === 'string' && url) {
      let fullUrl = url
      try {
        fullUrl = new URL(url, location.href).href
      } catch {}
      if (fullUrl !== location.href) {
        sessionStorage.setItem('openedByScript', 'true')
        safeOpenInTab(fullUrl, { active: true })
        return null
      }
    }
    return origOpen.apply(this, arguments)
  }

  console.log(
    '【新标签页强制脚本】已激活(聚焦新页模式) - 当前域名:',
    currentDomain
  )
})()