Hover Preview Pro — macOS Style

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

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