红薯放大器

Viewer: background click close, avoid closing after drag, pan/zoom, and keyboard left/right to switch images.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name 红薯放大器
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Viewer: background click close, avoid closing after drag, pan/zoom, and keyboard left/right to switch images.
// @match *://*.xiaohongshu.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function(){
  'use strict';
  const LOG = (...a)=>console.log('%cXHS-V2.2','background:#0b5;color:#fff;padding:2px 6px;border-radius:3px;', ...a);
  const ERR = (...a)=>console.error('%cXHS-V2.2','background:#a00;color:#fff;padding:2px 6px;border-radius:3px;', ...a);
  // gallery state (when viewer open)
  let gallery = [];
  let currentIndex = 0;
  let overlayRef = null;
  let imgRef = null;
  let hintRef = null;
  function findClickedImage(target){
    if(!target) return null;
    const img = target.tagName === 'IMG' ? target : (target.closest && target.closest('img'));
    return img || null;
  }
  function makeAbsolute(u){
    try{
      if(!u) return null;
      if(u.startsWith('//')) return location.protocol + u;
      if(u.startsWith('http://') || u.startsWith('https://')) return u;
      return new URL(u, location.href).href;
    }catch(e){ return u; }
  }
  // 找到与当前 img 同一“帖子/容器”下的图片集合(向上找有多张 img 的最近祖先)
  function buildGalleryFromImg(img){
    try{
      if(!img) return [img.src];
      let anc = img.parentElement;
      let found = null;
      while(anc && anc !== document.body){
        const imgs = Array.from(anc.querySelectorAll('img'));
        // 过滤掉极小、像图标之类的图片(可选)
        const realImgs = imgs.filter(i=>{
          const rect = i.getBoundingClientRect();
          return rect.width>30 && rect.height>30;
        });
        if(realImgs.length > 1){
          found = realImgs;
          break;
        }
        anc = anc.parentElement;
      }
      // 如果没找到包含多张的祖先,尝试查找当前视口内的同帖子图片(更宽松)
      if(!found){
        const imgs = Array.from(document.querySelectorAll('img')).filter(i=>{
          const rect = i.getBoundingClientRect();
          return rect.width>30 && rect.height>30;
        });
        // 尝试把与当前 img 相近(在 DOM 距离上较近)的图片聚为一个组:取和当前 img 有同一祖先的前后 8 张
        const idx = imgs.indexOf(img);
        if(idx >= 0){
          const start = Math.max(0, idx-8), end = Math.min(imgs.length, idx+9);
          const slice = imgs.slice(start, end);
          if(slice.length>1) found = slice;
        }
      }
      // 最后兜底:只使用当前图片
      if(!found) found = [img];
      // map to srcs (preserve absolute)
      const srcs = found.map(i => i.currentSrc || i.src || i.getAttribute('data-src') || i.getAttribute('data-original') || '');
      return srcs.map(makeAbsolute);
    }catch(e){
      ERR('buildGalleryFromImg', e);
      return [(img && (img.currentSrc || img.src)) || ''];
    }
  }
  // show image by index (assumes overlay exists) —— 更新现有 img 元素而不重建 overlay
  function showImageAt(index){
    if(!overlayRef || !imgRef) return;
    if(!gallery || gallery.length === 0) return;
    index = (index % gallery.length + gallery.length) % gallery.length;
    currentIndex = index;
    const src = gallery[currentIndex];
    // 设置 src,并在加载后重置 transform(scale/translate)
    imgRef.style.transition = 'transform 0s';
    imgRef.src = src;
    // reset transform params stored on overlayRef
    overlayRef._scale = overlayRef._fitScale || 1;
    overlayRef._tx = 0; overlayRef._ty = 0;
    // apply after image load to compute fit
    imgRef.onload = ()=>{
      const vw = window.innerWidth*0.9, vh = window.innerHeight*0.9;
      const iw = imgRef.naturalWidth || imgRef.width, ih = imgRef.naturalHeight || imgRef.height;
      const fit = Math.min(1, vw/iw, vh/ih) || 1;
      overlayRef._scale = fit;
      overlayRef._tx = 0; overlayRef._ty = 0;
      imgRef.style.transform = `translate(0px, 0px) scale(${overlayRef._scale})`;
      // slight delay remove transition so subsequent drags are immediate
      setTimeout(()=>{ imgRef.style.transition = 'transform 0s'; }, 10);
    };
  }
  function updateHint(){
    if(!hintRef) return;
    hintRef.textContent = '';
  }
  function openViewerForSrcAndGallery(src, srcGallery, startIndex){
    try{
      // 如果已有 overlay,则直接切换(避免重复创建)
      if(overlayRef){
        gallery = srcGallery || gallery;
        currentIndex = typeof startIndex === 'number' ? startIndex : (gallery.indexOf(src) >= 0 ? gallery.indexOf(src) : 0);
        showImageAt(currentIndex);
        return;
      }
      // store old styles to restore on close
      const oldBodyPointer = document.body.style.pointerEvents;
      const oldHtmlOverflow = document.documentElement.style.overflow;
      document.body.style.pointerEvents = 'none';
      document.documentElement.style.overflow = 'hidden';
      const overlay = document.createElement('div');
      overlay.id = 'xhs-viewer-v2-overlay';
      Object.assign(overlay.style, {
        position:'fixed', inset:'0', display:'flex', alignItems:'center', justifyContent:'center',
        background:'rgba(0,0,0,0.95)', zIndex:2147483647, cursor:'grab',
        touchAction:'none', pointerEvents:'auto'
      });
      const wrapper = document.createElement('div');
      Object.assign(wrapper.style, {
        maxWidth:'95vw', maxHeight:'95vh', display:'flex', alignItems:'center', justifyContent:'center',
        overflow:'visible'
      });
      const img = document.createElement('img');
      img.draggable = false;
      Object.assign(img.style, {
        maxWidth:'90vw', maxHeight:'90vh', width:'auto', height:'auto',
        display:'block', transformOrigin:'50% 50%', willChange:'transform', userSelect:'none',
        transition:'transform 0s'
      });
      wrapper.appendChild(img);
      overlay.appendChild(wrapper);
      document.body.appendChild(overlay);
      const hint = document.createElement('div');
      Object.assign(hint.style, {
        position:'fixed', left:'50%', transform:'translateX(-50%)', bottom:'18px',
        color:'#ddd', fontSize:'12px', zIndex:2147483648, userSelect:'none', pointerEvents:'none'
      });
      document.body.appendChild(hint);
      // set refs & gallery state
      overlayRef = overlay; imgRef = img; hintRef = hint;
      gallery = srcGallery || [src];
      currentIndex = typeof startIndex === 'number' ? startIndex : (gallery.indexOf(src) >= 0 ? gallery.indexOf(src) : 0);
      // interaction state
      overlayRef._scale = 1;
      overlayRef._tx = 0; overlayRef._ty = 0;
      overlayRef._fitScale = 1;
      let dragging = false, lastX = 0, lastY = 0;
      let pointers = new Map();
      let moved = false;
      const MOVE_THRESHOLD = 4; // px
      // wheel zoom
      overlay.addEventListener('wheel', e=>{
        e.preventDefault();
        const factor = e.deltaY < 0 ? 1.12 : 0.9;
        const prev = overlayRef._scale;
        overlayRef._scale = Math.max(0.2, Math.min(8, overlayRef._scale * factor));
        const rect = img.getBoundingClientRect();
        const cx = e.clientX - rect.left - rect.width/2;
        const cy = e.clientY - rect.top - rect.height/2;
        overlayRef._tx = overlayRef._tx - cx * (overlayRef._scale/prev - 1);
        overlayRef._ty = overlayRef._ty - cy * (overlayRef._scale/prev - 1);
        img.style.transform = `translate(${overlayRef._tx}px, ${overlayRef._ty}px) scale(${overlayRef._scale})`;
      }, {passive:false});
      // pointer events for drag & pinch
      overlay.addEventListener('pointerdown', e=>{
        overlay.setPointerCapture && overlay.setPointerCapture(e.pointerId);
        pointers.set(e.pointerId, e);
        if(pointers.size === 1 && (e.target === img || img.contains(e.target))){
          dragging = true; moved = false; lastX = e.clientX; lastY = e.clientY; overlay.style.cursor='grabbing';
        } else if(pointers.size === 2){
          const arr = Array.from(pointers.values());
          overlay._pinchStartDist = Math.hypot(arr[0].clientX-arr[1].clientX, arr[0].clientY-arr[1].clientY);
          overlay._pinchStartScale = overlayRef._scale;
        }
      });
      overlay.addEventListener('pointermove', e=>{
        if(!pointers.has(e.pointerId)) return;
        const prevEvt = pointers.get(e.pointerId);
        pointers.set(e.pointerId, e);
        if(pointers.size === 1 && dragging){
          const dx = e.clientX - lastX, dy = e.clientY - lastY;
          if(Math.hypot(dx, dy) > MOVE_THRESHOLD) moved = true;
          overlayRef._tx += dx; overlayRef._ty += dy;
          lastX = e.clientX; lastY = e.clientY;
          img.style.transform = `translate(${overlayRef._tx}px, ${overlayRef._ty}px) scale(${overlayRef._scale})`;
        } else if(pointers.size === 2){
          const arr = Array.from(pointers.values());
          const cur = Math.hypot(arr[0].clientX-arr[1].clientX, arr[0].clientY-arr[1].clientY);
          if(overlay._pinchStartDist){
            const factor = cur / overlay._pinchStartDist;
            overlayRef._scale = Math.max(0.2, Math.min(8, overlay._pinchStartScale * factor));
            img.style.transform = `translate(${overlayRef._tx}px, ${overlayRef._ty}px) scale(${overlayRef._scale})`;
            moved = true;
          }
        }
      });
      overlay.addEventListener('pointerup', e=>{
        try{ overlay.releasePointerCapture && overlay.releasePointerCapture(e.pointerId); }catch(_){}
        pointers.delete(e.pointerId);
        if(pointers.size === 0){ dragging = false; overlay._pinchStartDist = undefined; overlay.style.cursor='grab'; }
      });
      overlay.addEventListener('pointercancel', e=>{ pointers.delete(e.pointerId); });
      overlay.addEventListener('dblclick', e=>{
        if(e.target === img || img.contains(e.target)){
          overlayRef._scale = overlayRef._fitScale || 1; overlayRef._tx = 0; overlayRef._ty = 0;
          img.style.transform = `translate(0px, 0px) scale(${overlayRef._scale})`;
        }
      });
      // click to close only when truly clicking overlay or wrapper and not after drag
      overlay.addEventListener('click', e=>{
        if(e.button !== 0) return;
        if(moved){ moved = false; return; }
        if(e.target === overlay || e.target === wrapper){
          e.preventDefault(); e.stopPropagation();
          closeViewer();
        }
      }, {capture:true});
      // keyboard handlers: left/right to navigate, Escape to close
      function onKeyNav(ev){
        // don't interfere when typing in input/textarea or contenteditable
        const active = document.activeElement;
        if(active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return;
        if(ev.key === 'Escape'){ ev.preventDefault(); closeViewer(); }
        else if(ev.key === 'ArrowLeft' || ev.key === 'Left'){ ev.preventDefault(); // prev
          if(gallery && gallery.length>1) showImageAt(currentIndex-1);
        }
        else if(ev.key === 'ArrowRight' || ev.key === 'Right'){ ev.preventDefault(); // next
          if(gallery && gallery.length>1) showImageAt(currentIndex+1);
        }
      }
      window.addEventListener('keydown', onKeyNav);
      // observe overlay removal to cleanup
      const observer = new MutationObserver(()=>{ if(!document.getElementById('xhs-viewer-v2-overlay')){ document.body.style.pointerEvents = oldBodyPointer || ''; document.documentElement.style.overflow = oldHtmlOverflow || ''; observer.disconnect(); window.removeEventListener('keydown', onKeyNav); overlayRef = null; imgRef = null; hintRef = null; }});
      observer.observe(document.body, {childList:true});
      // load initial image via showImageAt (it sets transform etc)
      // first set img.src to something to start load cycle
      img.src = gallery[currentIndex];
      img.onload = ()=>{
        const vw = window.innerWidth*0.9, vh = window.innerHeight*0.9;
        const iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
        const fit = Math.min(1, vw/iw, vh/ih) || 1;
        overlayRef._fitScale = fit;
        overlayRef._scale = fit;
        overlayRef._tx = 0; overlayRef._ty = 0;
        img.style.transform = `translate(0px, 0px) scale(${overlayRef._scale})`;
      };
      LOG('viewer opened', gallery[currentIndex]);
    }catch(e){ ERR('openViewerForSrcAndGallery', e); }
  }
  function closeViewer(){
    try{
      if(overlayRef) overlayRef.remove();
      if(hintRef) hintRef.remove();
      overlayRef = null; imgRef = null; hintRef = null;
      // restore styles (we store/reset inside open)
      document.body.style.pointerEvents = '';
      document.documentElement.style.overflow = '';
      LOG('viewer closed');
    }catch(e){ ERR('closeViewer', e); }
  }
  // 点击页面:只在可信的左键点击并且目标为 <img> 时打开
  function onDocClick(ev){
    try{
      if(!ev.isTrusted) return;
      if(ev.button !== 0) return;
      const img = findClickedImage(ev.target);
      if(!img) return;
      ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation && ev.stopImmediatePropagation();
      const src = img.currentSrc || img.src || img.getAttribute('data-src') || img.getAttribute('data-original') || '';
      const srcGallery = buildGalleryFromImg(img);
      const startIndex = srcGallery.indexOf(makeAbsolute(src));
      openViewerForSrcAndGallery(makeAbsolute(src), srcGallery, startIndex>=0?startIndex:0);
    }catch(e){ ERR('onDocClick', e); }
  }
  document.removeEventListener('click', onDocClick, true);
  document.addEventListener('click', onDocClick, {capture:true, passive:false});
  LOG('XHS Viewer v2.2 (keyboard nav) loaded');
})();