Hover Preview Pro — macOS Style

鼠标悬停 + Ctrl 打开悬浮预览;macOS风格UI,流畅动画

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Hover Preview Pro — macOS Style
// @namespace    hover-preview-macos
// @version      2.0.0
// @description  鼠标悬停 + Ctrl 打开悬浮预览;macOS风格UI,流畅动画
// @match        *://*/*
// @run-at       document-idle
// @allFrames    true
// @grant        GM_addStyle
// ==/UserScript==

(() => {
    'use strict';

    const MODIFIER = 'Control';     // Control / Shift / Alt
    const OPEN_DELAY = 60;          // 触发延时
    const PANEL_W = 75, PANEL_H = 75;

    const css = `
      /* 遮罩层 */
      .hp-mask {
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0);
        z-index: 2147483646;
        display: none;
        backdrop-filter: blur(0px);
        -webkit-backdrop-filter: blur(0px);
        transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
      }

      .hp-mask.visible {
        background: rgba(0, 0, 0, .4);
        backdrop-filter: blur(8px);
        -webkit-backdrop-filter: blur(8px);
      }

      /* 主面板 - macOS风格 */
      .hp-panel {
        position: absolute;
        left: 50%;
        top: 50%;
        width: ${PANEL_W}vw;
        height: ${PANEL_H}vh;
        min-width: 600px;
        min-height: 400px;
        transform: translate(-50%, -50%) scale(0.85);
        background: linear-gradient(145deg, rgba(255,255,255,0.96) 0%, rgba(251,251,253,0.94) 100%);
        border-radius: 12px;
        box-shadow:
          0 22px 70px rgba(0, 0, 0, 0.25),
          0 10px 30px rgba(0, 0, 0, 0.12),
          0 0 0 0.5px rgba(0, 0, 0, 0.08),
          inset 0 0 0 0.5px rgba(255, 255, 255, 0.8);
        display: grid;
        grid-template-rows: 38px 1fr;
        overflow: hidden;
        opacity: 0;
        transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
      }

      .hp-panel.visible {
        transform: translate(-50%, -50%) scale(1);
        opacity: 1;
      }

      /* 标题栏 - 仿 macOS */
      .hp-header {
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 0 12px;
        background: linear-gradient(180deg, rgba(255,255,255,0.65) 0%, rgba(255,255,255,0.45) 100%);
        border-bottom: 0.5px solid rgba(0, 0, 0, 0.1);
        cursor: move;
        user-select: none;
        -webkit-app-region: drag;
      }

      /* 窗口控制按钮组 */
      .hp-controls {
        display: flex;
        gap: 8px;
        align-items: center;
      }

      /* macOS 风格圆形按钮 */
      .hp-dot {
        width: 12px;
        height: 12px;
        border-radius: 50%;
        border: 0.5px solid rgba(0, 0, 0, 0.12);
        cursor: pointer;
        transition: all 0.2s ease;
        -webkit-app-region: no-drag;
      }

      .hp-dot-close {
        background: linear-gradient(180deg, #FF5F57 0%, #FF3B30 100%);
      }

      .hp-dot-minimize {
        background: linear-gradient(180deg, #FFBD2E 0%, #FFA500 100%);
      }

      .hp-dot-maximize {
        background: linear-gradient(180deg, #28CA42 0%, #00C800 100%);
      }

      .hp-controls:hover .hp-dot-close::after {
        content: "×";
        color: rgba(0, 0, 0, 0.5);
        font-size: 10px;
        font-weight: 600;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-top: -1px;
      }

      .hp-controls:hover .hp-dot-minimize::after {
        content: "−";
        color: rgba(0, 0, 0, 0.5);
        font-size: 10px;
        font-weight: 600;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-top: -2px;
      }

      .hp-dot:hover {
        filter: brightness(0.9);
      }

      .hp-dot:active {
        filter: brightness(0.8);
      }

      /* 标题文字 */
      .hp-title {
        flex: 1;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font: 500 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
        color: #3C3C43;
        text-align: center;
        margin-right: 60px;
      }

      /* 新标签打开按钮 */
      .hp-btn {
        appearance: none;
        border: 0;
        background: linear-gradient(180deg, rgba(0,122,255,0.9) 0%, rgba(0,100,220,0.9) 100%);
        color: #fff;
        padding: 5px 12px;
        border-radius: 6px;
        cursor: pointer;
        font: 500 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        transition: all 0.2s ease;
        box-shadow: 0 1px 3px rgba(0, 122, 255, 0.3);
        -webkit-app-region: no-drag;
      }

      .hp-btn:hover {
        background: linear-gradient(180deg, rgba(0,122,255,1) 0%, rgba(0,100,220,1) 100%);
        transform: translateY(-1px);
        box-shadow: 0 2px 6px rgba(0, 122, 255, 0.4);
      }

      .hp-btn:active {
        transform: translateY(0);
        box-shadow: 0 1px 2px rgba(0, 122, 255, 0.3);
      }

      /* 内容区域 */
      .hp-body {
        position: relative;
        background: #FFFFFF;
        overflow: hidden;
      }

      /* iframe 和图片 */
      .hp-iframe, .hp-img {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        border: 0;
        object-fit: contain;
        background: #FFFFFF;
      }

      /* 加载和错误提示 */
      .hp-tip {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        padding: 16px 24px;
        border-radius: 12px;
        background: linear-gradient(145deg, rgba(255,255,255,0.98) 0%, rgba(245,245,247,0.95) 100%);
        color: #1C1C1E;
        font: 400 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        text-align: center;
        box-shadow:
          0 10px 40px rgba(0, 0, 0, 0.12),
          0 2px 10px rgba(0, 0, 0, 0.06);
        animation: tipFadeIn 0.3s ease-out;
      }

      @keyframes tipFadeIn {
        from {
          opacity: 0;
          transform: translate(-50%, -50%) scale(0.9);
        }
        to {
          opacity: 1;
          transform: translate(-50%, -50%) scale(1);
        }
      }

      /* 加载动画 */
      .hp-loading {
        display: inline-block;
        width: 24px;
        height: 24px;
        margin: 0 auto 12px;
        border: 3px solid rgba(0, 122, 255, 0.2);
        border-top-color: #007AFF;
        border-radius: 50%;
        animation: spin 0.8s linear infinite;
      }

      @keyframes spin {
        to { transform: rotate(360deg); }
      }

      /* 深色模式支持 */
      @media (prefers-color-scheme: dark) {
        .hp-panel {
          background: linear-gradient(145deg, rgba(40,40,42,0.96) 0%, rgba(35,35,37,0.94) 100%);
          box-shadow:
            0 22px 70px rgba(0, 0, 0, 0.5),
            0 10px 30px rgba(0, 0, 0, 0.3),
            inset 0 0 0 0.5px rgba(255, 255, 255, 0.1);
        }

        .hp-header {
          background: linear-gradient(180deg, rgba(60,60,62,0.65) 0%, rgba(50,50,52,0.45) 100%);
          border-bottom-color: rgba(255, 255, 255, 0.1);
        }

        .hp-title {
          color: #F2F2F7;
        }

        .hp-body {
          background: #1C1C1E;
        }

        .hp-iframe, .hp-img {
          background: #1C1C1E;
        }

        .hp-tip {
          background: linear-gradient(145deg, rgba(45,45,47,0.98) 0%, rgba(35,35,37,0.95) 100%);
          color: #F2F2F7;
        }
      }

      /* 过渡动画优化 */
      .hp-panel.closing {
        transform: translate(-50%, -50%) scale(0.9);
        opacity: 0;
        transition: all 0.2s cubic-bezier(0.55, 0.055, 0.675, 0.19);
      }
    `;

    (typeof GM_addStyle === 'function')
      ? GM_addStyle(css)
      : document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));

    // UI
    const mask = div('hp-mask');
    const panel = div('hp-panel');
    const header = div('hp-header');

    // macOS 风格控制按钮
    const controls = div('hp-controls');
    const closeBtn = div('hp-dot hp-dot-close');
    const minimizeBtn = div('hp-dot hp-dot-minimize');
    const maximizeBtn = div('hp-dot hp-dot-maximize');
    controls.append(closeBtn, minimizeBtn, maximizeBtn);

    const title = div('hp-title');
    const openBtn = btn('新标签打开');
    const body = div('hp-body');

    header.append(controls, title, openBtn);
    panel.append(header, body);
    mask.append(panel);
    document.documentElement.appendChild(mask);

    // 事件绑定
    closeBtn.addEventListener('click', hide);
    minimizeBtn.addEventListener('click', hide); // 暂时也是关闭
    maximizeBtn.addEventListener('click', () => {
      // 切换全屏
      if (panel.style.width === '95vw') {
        panel.style.width = `${PANEL_W}vw`;
        panel.style.height = `${PANEL_H}vh`;
      } else {
        panel.style.width = '95vw';
        panel.style.height = '95vh';
      }
    });

    mask.addEventListener('click', e => { if (e.target === mask) hide(); });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') hide(); }, true);
    makeDraggable(panel, header);

    // 状态
    let lastX = 0, lastY = 0;
    let timer = null;

    // 捕获阶段
    document.addEventListener('mousemove', e => {
      lastX = e.clientX; lastY = e.clientY;
    }, { passive:true, capture:true });

    document.addEventListener('keydown', e => {
      if (!matchMod(e, MODIFIER)) return;
      clearTimeout(timer);
      timer = setTimeout(() => {
        const el = document.elementFromPoint(lastX, lastY);
        const url = resolveUrl(el);
        if (url) open(url);
      }, OPEN_DELAY);
    }, true);

    document.addEventListener('keyup', e => {
      if (e.key === MODIFIER || (MODIFIER === 'Control' && e.key === 'Control')) {
        clearTimeout(timer);
      }
    }, true);

    // ====== 核心逻辑:URL 解析 ======
    function resolveUrl(start) {
      if (!start) return null;

      // 1) 当前元素自己或其后代里是否有 <a>
      const selfA = (start.matches?.('a[href]') ? start : start.querySelector?.('a[href]'));
      if (isGoodA(selfA)) return abs(selfA.getAttribute('href'));

      // 2) 向上查找包含当前元素的 <a> 标签
      let cur = start;
      while (cur && cur !== document.documentElement) {
        if (cur.matches?.('a[href]') && isGoodA(cur)) {
          return abs(cur.getAttribute('href'));
        }
        cur = cur.parentElement;
      }

      // 3) 在同一个父节点里(兄弟)找 <a>
      const p = start.parentElement;
      if (p) {
        const sibA = p.querySelector('a[href]');
        if (isGoodA(sibA)) return abs(sibA.getAttribute('href'));
        const altA = p.querySelector('a[href*=".htm"], a[href*="/profile/"], a[class*="images"][href]');
        if (isGoodA(altA)) return abs(altA.getAttribute('href'));
      }

      // 4) 再往上 2~3 层
      cur = start;
      for (let i=0; i<3 && cur && cur !== document.documentElement; i++) {
        cur = cur.parentElement;
        if (!cur) break;
        const a1 = cur.querySelector('a[class*="images"][href]') || cur.querySelector('a[href*="/profile/"]') || cur.querySelector('a[href]');
        if (isGoodA(a1)) return abs(a1.getAttribute('href'));

        const img = cur.querySelector('img[srcset], img[src]');
        if (img) {
          const u = bestSrcFromSrcset(img) || img.getAttribute('src');
          if (isHttp(u)) return abs(u);
        }
      }

      // 5) 背景图兜底
      const bg = getComputedStyle(start).backgroundImage;
      const m = /url\(["']?([^"')]+)["']?\)/.exec(bg || '');
      if (m && isHttp(m[1])) return abs(m[1]);

      return null;
    }

    // ====== 打开预览 ======
    function open(url) {
      body.innerHTML = '';
      title.textContent = decodeURI(url.split('/').pop() || url);
      openBtn.onclick = () => window.open(url, '_blank', 'noopener,noreferrer');

      // 显示动画
      mask.style.display = 'block';
      requestAnimationFrame(() => {
        mask.classList.add('visible');
        panel.classList.add('visible');
      });

      if (isImage(url)) {
        const img = new Image();
        img.className = 'hp-img';
        img.referrerPolicy = 'no-referrer';
        img.onerror = () => showBlocked('图片加载失败');
        img.src = url;
        body.appendChild(img);
        return;
      }

      const ifr = document.createElement('iframe');
      ifr.className = 'hp-iframe';
      ifr.setAttribute('sandbox','allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock');

      const loading = div('hp-tip');
      loading.innerHTML = '<div class="hp-loading"></div>正在加载预览…';
      body.appendChild(loading);

      let loaded = false;
      ifr.addEventListener('load', () => {
        loaded = true;
        loading.remove();
      });

      setTimeout(() => {
        if (!loaded) showBlocked('此站点禁止在 iframe 中预览');
      }, 2000);

      ifr.src = url;
      body.appendChild(ifr);
    }

    // ====== 工具函数 ======
    function isGoodA(a){
      return a && a.getAttribute && a.getAttribute('href') && !a.getAttribute('href').startsWith('javascript:');
    }

    function bestSrcFromSrcset(img){
      const ss = img.getAttribute('srcset');
      if (!ss) return null;
      const items = ss.split(',').map(s=>s.trim()).map(s=>{
        const m=s.match(/(\S+)\s+(\d+\.?\d*)(w|x)/i);
        return m ? {url:m[1], val:parseFloat(m[2])} : {url:s.split(/\s+/)[0], val:1};
      });
      items.sort((a,b)=>b.val-a.val);
      return items[0]?.url || null;
    }

    function isHttp(u){ return /^https?:\/\//i.test(u); }
    function isImage(u){ return /\.(png|jpe?g|gif|webp|svg|bmp|avif)(\?|#|$)/i.test(u); }
    function abs(u){ return new URL(u, location.href).href; }
    function matchMod(e, name){
      return (name==='Control'&&e.ctrlKey)||(name==='Shift'&&e.shiftKey)||(name==='Alt'&&e.altKey);
    }

    function showBlocked(msg){
      body.querySelectorAll('.hp-tip').forEach(n=>n.remove());
      const d=div('hp-tip');
      d.innerHTML = `<strong>${msg}</strong><br><br>点击 "新标签打开" 在新窗口查看`;
      body.appendChild(d);
    }

    function show(){
      mask.style.display = 'block';
      requestAnimationFrame(() => {
        mask.classList.add('visible');
        panel.classList.add('visible');
      });
    }

    function hide(){
      panel.classList.add('closing');
      panel.classList.remove('visible');
      mask.classList.remove('visible');
      setTimeout(() => {
        mask.style.display = 'none';
        panel.classList.remove('closing');
        body.innerHTML = '';
      }, 200);
    }

    function div(cls){
      const d=document.createElement('div');
      if (cls) d.className = cls;
      return d;
    }

    function btn(t,cls='hp-btn'){
      const b=document.createElement('button');
      b.className = cls;
      b.textContent = t;
      return b;
    }

    function makeDraggable(container, handle){
      let sx=0, sy=0, ox=0, oy=0, dragging=false;
      handle.addEventListener('mousedown', (e)=>{
        // 排除控制按钮
        if (e.target.classList.contains('hp-dot') || e.target.classList.contains('hp-btn')) return;
        if (e.button!==0) return;
        dragging=true;
        const r=container.getBoundingClientRect();
        ox=r.left; oy=r.top; sx=e.clientX; sy=e.clientY;
        document.addEventListener('mousemove', move, true);
        document.addEventListener('mouseup', up, true);
        e.preventDefault();
      }, true);

      function move(e){
        if(!dragging) return;
        const dx=e.clientX-sx, dy=e.clientY-sy;
        container.style.left = `${ox + dx + container.offsetWidth/2}px`;
        container.style.top  = `${oy + dy + container.offsetHeight/2}px`;
        container.style.transform = 'translate(-50%, -50%)';
      }

      function up(){
        dragging=false;
        document.removeEventListener('mousemove', move, true);
        document.removeEventListener('mouseup', up, true);
      }
    }
  })();