通用网页图片灯箱(WebImageBox)

通用网页图片灯箱:旋转、缩放、拖拽、切换、单张/批量下载,让你看图下图不再受限

// ==UserScript==
// @name         通用网页图片灯箱(WebImageBox)
// @author       setube
// @namespace    https://github.com/setube/webImageBox
// @version      1.6.4
// @description  通用网页图片灯箱:旋转、缩放、拖拽、切换、单张/批量下载,让你看图下图不再受限
// @match        *://*/*
// @require      https://registry.npmmirror.com/fflate/0.8.2/files/umd/index.js
// @require      https://unpkg.com/[email protected]/dist/index.umd.js
// @resource     iconFontCSS https://at.alicdn.com/t/c/font_5026690_6mvd6y6o6pr.css
// @grant        GM_getResourceText
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      *
// @license      Apache-2.0
// ==/UserScript==

;(function () {
  'use strict'
  // 读取资源
  const css = GM_getResourceText('iconFontCSS')
  // 注入到页面
  GM_addStyle(css)
  // 内联 CSS
  const style = document.createElement('style')

  style.textContent = `
  #myLightboxOverlay {
    position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);
    display:flex;flex-direction:column;justify-content:center;align-items:center;z-index:100;
    overflow:hidden;
  }
  .lb-main-container { position:relative; width:90%; height:70%; overflow:hidden; display:flex; justify-content:center; align-items:center; }
  .lb-main { max-width:100%; max-height:100%; position:absolute; }
  .lb-buttons { position:absolute; top:20px; right:20px; display:flex; gap:8px; z-index:100001; }
  .lb-buttons button { background: rgba(0,0,0,0.5); color:#fff; border:none; padding:6px 10px; cursor:pointer; border-radius:4px; }
  .lb-nav-button, .lb-buttons button {
    background: rgba(0,0,0,0.6); color: #fff; border: none; width: 34px; height: 34px; display: flex;
    justify-content: center; align-items: center; cursor: pointer; border-radius: 50%; transition: background 0.3s, transform 0.2s;
  }
  .lb-nav-button:hover, .lb-buttons button:hover { background: rgba(255,255,255,0.2); }
  button svg { pointer-events: none; }
  .lb-prev { position: fixed; left:20px; top:50%; transform:translateY(-50%); }
  .lb-next { position: fixed; right:20px; top:50%; transform:translateY(-50%); }
  .lb-thumbs { display:flex; gap:5px; margin-top:10px; overflow-x:auto; max-width:90%; }
  .lb-thumbs::-webkit-scrollbar { display: none; }
  .lb-thumbs img { height:60px; cursor:pointer; opacity:0.5; transition:0.3s; flex-shrink:0; }
  .lb-thumbs img.active { opacity:1; border:2px solid #fff; }
  .qmsg { z-index: 100002; }
  @media (max-width: 768px) {
    .lb-nav-button:hover, .lb-buttons button:hover { background: rgba(0,0,0,0.6); }
  }
  `
  document.head.appendChild(style)

  // 创建遮罩和主图片容器
  const overlay = document.createElement('div')
  overlay.id = 'myLightboxOverlay'
  overlay.style.display = 'none'

  const mainContainer = document.createElement('div')
  mainContainer.className = 'lb-main-container'

  const lbImg = document.createElement('img')
  lbImg.className = 'lb-main'
  mainContainer.appendChild(lbImg)
  overlay.appendChild(mainContainer)

  // 控制按钮(右上角)
  const controls = document.createElement('div')
  controls.className = 'lb-buttons'

  const btnConfig = [
    { title: '左旋转', icon: 'undo' },
    { title: '右旋转', icon: 'redo' },
    { title: '放大', icon: 'fullscreen' },
    { title: '缩小', icon: 'fullscreen-exit' },
    { title: '下载', icon: 'download' },
    { title: '下载所有', icon: 'file-zip' },
    { title: 'Github', icon: 'github-fill' },
    { title: '关闭', icon: 'close' }
  ]

  btnConfig.forEach(cfg => {
    const btn = document.createElement('button')
    btn.title = cfg.title
    btn.className = `iconfont icon-${cfg.icon}`
    controls.appendChild(btn)
  })
  overlay.appendChild(controls)

  // 左右切换按钮
  const prevBtn = document.createElement('button')
  prevBtn.className = 'lb-prev lb-nav-button iconfont icon-left'
  prevBtn.title = '上一张'
  prevBtn.setAttribute('aria-label', '上一张')
  const nextBtn = document.createElement('button')
  nextBtn.className = 'lb-next lb-nav-button iconfont icon-right'
  nextBtn.title = '下一张'
  nextBtn.setAttribute('aria-label', '下一张')
  overlay.appendChild(prevBtn)
  overlay.appendChild(nextBtn)

  // 缩略图列表
  const thumbBar = document.createElement('div')
  thumbBar.className = 'lb-thumbs'
  overlay.appendChild(thumbBar)

  document.body.appendChild(overlay)

  // 图片数组
  let imgs = []
  let currentIndex = 0
  let rotation = 0
  let scale = 1
  const imgStates = new Map() // key: 图片 src, value: { rotation, scale }

  // 图片切换动画参数
  let isAnimating = false

  const updateTransform = () => {
    lbImg.style.transform = `rotate(${rotation}deg) scale(${scale})`
  }

  // 缩略图居中
  const updateThumbs = () => {
    thumbBar.innerHTML = ''
    imgs.forEach((img, i) => {
      const thumb = document.createElement('img')
      thumb.src = img.src
      if (i === currentIndex) thumb.classList.add('active')
      thumb.addEventListener('click', () => showImage(i))
      thumbBar.appendChild(thumb)
    })

    // 缩略图滚动条,让当前图片居中
    const activeThumb = thumbBar.querySelector('img.active')
    if (activeThumb) {
      const offset = activeThumb.offsetLeft + activeThumb.offsetWidth / 2 - thumbBar.clientWidth / 2
      thumbBar.scrollTo({ left: offset, behavior: 'smooth' })
    }
  }

  // 打开灯箱
  const showImage = (index, direction = 0) => {
    if (imgs.length === 0 || isAnimating) return
    currentIndex = (index + imgs.length) % imgs.length
    // 获取该图片的状态,如果没有则初始化
    const state = imgStates.get(imgs[currentIndex].src) || { rotation: 0, scale: 1 }
    rotation = state.rotation
    scale = state.scale

    isAnimating = true
    const newSrc = imgs[currentIndex].src
    if (lbImg.src) {
      const tempImg = document.createElement('img')
      tempImg.src = newSrc
      tempImg.style.position = 'absolute'
      tempImg.style.maxWidth = '100%'
      tempImg.style.maxHeight = '100%'
      tempImg.style.left = direction >= 0 ? '100%' : '-100%'
      tempImg.style.top = 'auto'
      mainContainer.appendChild(tempImg)

      setTimeout(() => {
        tempImg.style.left = 'auto'
        lbImg.style.left = direction >= 0 ? '-100%' : '100%'
      }, 50)

      setTimeout(() => {
        lbImg.src = newSrc
        lbImg.style.left = 'auto'
        mainContainer.removeChild(tempImg)
        updateTransform()
        overlay.style.display = 'flex'
        updateThumbs()
        isAnimating = false
      }, 50)
    } else {
      lbImg.src = newSrc
      overlay.style.display = 'flex'
      updateThumbs()
      isAnimating = false
    }
  }

  const isSmallOrAvatar = img => {
    // 跳过灯箱内部的缩略图和主图
    if (img.closest('#myLightboxOverlay')) return
    // 忽略头像、小图、被广告插件屏蔽的图片
    if (
      !img.complete ||
      !img.naturalWidth ||
      !img.naturalHeight ||
      !img.width ||
      !img.height ||
      img.width < 100 ||
      img.height < 100
    )
      return false
    // 图片元素必须在页面中可见
    const rect = img.getBoundingClientRect()
    if (!rect.width || !rect.height) return false
    // CSS 隐藏或无尺寸
    const style = getComputedStyle(img)
    if (style.display === 'none' || style.visibility === 'hidden') return false
    const keywords = [
      'icon',
      'ico',
      'avatar',
      'ava',
      'emoji',
      'biaoqing',
      'logo',
      'btn',
      'button',
      'qrcode',
      'advertisement',
      'ads',
      'promotation'
    ]
    const checkString = str => keywords.some(k => (str || '').toLowerCase().includes(k))
    // 检查 img 本身
    if (checkString(img.src) || checkString(img.className) || checkString(img.id)) return false
    for (let attr of img.attributes) {
      if (checkString(attr.value)) return false
    }
    // 检查父 a 标签
    let parent = img.parentElement
    while (parent) {
      if (checkString(parent.href) || checkString(parent.className) || checkString(parent.id)) return false
      parent = parent.parentElement
    }
    return true
  }

  // 设置图片,过滤重复(按 URL 或文件名)
  const setupImages = () => {
    const pageImgs = Array.from(document.querySelectorAll('img'))
    const uniqueSrc = new Set()
    const uniqueName = new Set()
    imgs = []
    pageImgs.forEach(img => {
      if (!isSmallOrAvatar(img)) return
      const fileName = img.src.split('/').pop()
      if (!uniqueSrc.has(img.src) && !uniqueName.has(fileName)) {
        uniqueSrc.add(img.src)
        uniqueName.add(fileName)
        imgs.push(img)
        // 避免重复绑定
        if (!img.dataset.lb) {
          img.dataset.lb = 'true'
          img.style.cursor = 'zoom-in'
          // 绑定点击事件,打开灯箱
          img.addEventListener('click', e => {
            e.preventDefault()
            e.stopPropagation()
            const index = imgs.indexOf(img)
            openLightbox(index)
          })
        }
      }
    })
  }

  const openLightbox = index => {
    if (imgs.length === 0) return
    currentIndex = index
    rotation = 0
    scale = 1
    // 显示 overlay,初始化透明度和缩放
    overlay.style.display = 'flex'
    overlay.style.opacity = '0'
    lbImg.style.opacity = '0'
    lbImg.src = imgs[currentIndex].src
    // 强制浏览器渲染
    requestAnimationFrame(() => {
      overlay.style.transition = 'opacity 0.35s'
      overlay.style.opacity = '1'
      lbImg.style.transition = 'transform 0.35s, opacity 0.35s'
      lbImg.style.opacity = '1'
      overlay.style.transition = ''
      lbImg.style.transition = ''
    })
    updateThumbs()
    lockBodyScroll()
  }

  const closeLightbox = () => {
    // 淡出动画
    overlay.style.opacity = '0'
    lbImg.style.opacity = '0'
    setTimeout(() => {
      overlay.style.display = 'none'
      // 重置样式,确保下一次打开动画生效
      lbImg.style.transition = ''
      lbImg.style.opacity = '0'
    }, 350)
    unlockBodyScroll()
  }

  const lockBodyScroll = () => {
    // 保存当前滚动位置
    const scrollY = window.scrollY
    // 阻止页面滚动
    document.body.style.overflow = 'hidden'
    document.body.dataset.scrollY = scrollY // 保存 scrollY 方便解锁
  }

  const unlockBodyScroll = () => {
    const scrollY = document.body.dataset.scrollY || 0
    document.body.style.overflow = 'auto'
    window.scrollTo(0, scrollY)
  }

  const observer = new MutationObserver(setupImages)
  observer.observe(document.body, { childList: true, subtree: true })
  setupImages()

  // 控制按钮事件
  const [rotateL, rotateR, zoomIn, zoomOut, download, downloadAll, github, closeBtn] =
    controls.querySelectorAll('button')

  // 左旋转按钮
  rotateL.addEventListener('click', () => {
    rotation -= 90
    imgStates.set(lbImg.src, { rotation, scale })
    updateTransform()
  })

  // 右旋转按钮
  rotateR.addEventListener('click', () => {
    rotation += 90
    imgStates.set(lbImg.src, { rotation, scale })
    updateTransform()
  })

  // 放大按钮
  zoomIn.addEventListener('click', () => {
    scale *= 1.2
    imgStates.set(lbImg.src, { rotation, scale })
    updateTransform()
  })

  // 缩小按钮
  zoomOut.addEventListener('click', () => {
    scale /= 1.2
    imgStates.set(lbImg.src, { rotation, scale })
    updateTransform()
  })

  // 打开 Github
  github.addEventListener('click', () => {
    window.open('https://github.com/setube/webImageBox', '_blank')
  })

  // 关闭按钮
  closeBtn.addEventListener('click', closeLightbox)

  const fetchBlob = url => {
    const Referer = new URL(url).origin
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        headers: {
          Referer
        },
        responseType: 'blob',
        onload: res => resolve(res.response),
        onerror: err => reject(err)
      })
    })
  }

  // 单张下载
  download.addEventListener('click', async () => {
    try {
      let blob, filename
      const src = lbImg.src
      if (src.startsWith('data:')) {
        const [header, data] = src.split(',')
        const mimeMatch = header.match(/:(.*?)(;|$)/)
        const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream'
        if (header.includes('base64')) {
          // base64 解码
          const bstr = atob(data)
          const n = bstr.length
          const u8arr = new Uint8Array(n)
          for (let i = 0; i < n; i++) u8arr[i] = bstr.charCodeAt(i)
          blob = new Blob([u8arr], { type: mime })
        } else {
          // URI 编码解码(如 SVG)
          blob = new Blob([decodeURIComponent(data)], { type: mime })
        }
        // 自动扩展名
        let ext = 'png'
        if (mime === 'image/png') ext = 'png'
        else if (mime === 'image/jpeg') ext = 'jpg'
        else if (mime === 'image/svg+xml') ext = 'svg'
        else if (mime.includes('/')) ext = mime.split('/')[1]
        filename = `image.${ext}`
      } else if (src.startsWith('blob:')) {
        blob = await fetchBlob(src)
        const mime = blob.type || 'image/png'
        let ext = mime.includes('/') ? mime.split('/')[1] : 'png'
        filename = `image.${ext}`
      } else {
        const cleanUrl = src.split('?')[0]
        try {
          blob = await fetchBlob(cleanUrl)
          const mime = blob.type || 'image/png'
          let ext = mime.includes('/') ? mime.split('/')[1] : 'png'
          filename = cleanUrl.split('/').pop() || `image.${ext}`
        } catch (err) {
          Qmsg.error('无法下载 URL:' + cleanUrl)
          console.error('无法下载 URL:', cleanUrl, err)
          return // 直接退出,不触发下载
        }
      }
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = filename
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
      Qmsg.success('图片下载成功!')
    } catch (err) {
      Qmsg.error('下载失败:' + src)
      console.error('下载失败', err)
    }
  })

  // 下载相册
  downloadAll.addEventListener('click', async () => {
    if (!window.fflate) {
      Qmsg.error('fflate未加载,请稍等')
      return
    }

    let dataUrlCount = 1
    const zipFiles = {}
    const total = imgs.length
    let completed = 0

    // 显示加载条
    const loadingMsg = Qmsg.loading(`正在下载图片 0/${total} ...`)

    // 构建下载任务
    const tasks = imgs.map(async img => {
      try {
        const src = img.src
        let uint8arr, filename

        if (src.startsWith('data:')) {
          const [header, data] = src.split(',')
          const mimeMatch = header.match(/:(.*?)(;|$)/)
          const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream'

          if (header.includes('base64')) {
            const bstr = atob(data)
            const n = bstr.length
            uint8arr = new Uint8Array(n)
            for (let i = 0; i < n; i++) uint8arr[i] = bstr.charCodeAt(i)
          } else {
            const decoded = decodeURIComponent(data)
            uint8arr = new Uint8Array(decoded.length)
            for (let i = 0; i < decoded.length; i++) uint8arr[i] = decoded.charCodeAt(i)
          }

          let ext = mime.split('/')[1] || 'bin'
          if (mime === 'image/svg+xml') ext = 'svg'
          filename = `image_${dataUrlCount++}.${ext}`
        } else if (src.startsWith('blob:')) {
          Qmsg.warn('blob URL 图片无法下载,已跳过')
          return
        } else {
          let cleanUrl = src.split('?')[0].replace(/\/([^\/]+):[^\/]+$/, '/$1')
          try {
            const blob = await fetchBlob(cleanUrl)
            const arrayBuffer = await blob.arrayBuffer()
            uint8arr = new Uint8Array(arrayBuffer)

            const mime = blob.type || 'image/png'
            let ext = mime.split('/')[1] || 'png'
            if (mime === 'image/svg+xml') ext = 'svg'
            const baseName = cleanUrl.split('/').pop()
            filename = baseName.includes('.') ? baseName : `image_${dataUrlCount++}.${ext}`
          } catch (err) {
            Qmsg.error('无法下载 URL: ' + cleanUrl)
            console.warn('无法下载 URL:', cleanUrl, err)
            return
          }
        }
        zipFiles[filename] = uint8arr
      } catch (err) {
        console.warn('下载失败:', img.src, err)
      } finally {
        // 更新进度
        completed++
        loadingMsg.setText(`正在下载图片 ${completed}/${total} ...`)
      }
    })
    await Promise.all(tasks)
    loadingMsg.setText(`图片下载完成,正在生成 ZIP...`)
    try {
      const zipped = fflate.zipSync(zipFiles)
      const blob = new Blob([zipped], { type: 'application/zip' })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = 'album.zip'
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
      Qmsg.success('批量下载任务完成!')
    } catch (err) {
      Qmsg.error('生成 ZIP 失败')
      console.error(err)
    } finally {
      loadingMsg.close()
    }
  })

  // 左右切换
  prevBtn.addEventListener('click', () => showImage(currentIndex - 1, -1))
  nextBtn.addEventListener('click', () => showImage(currentIndex + 1, 1))

  // 点击背景或 ESC 关闭
  overlay.addEventListener('click', e => {
    if (e.target === overlay) closeLightbox()
  })

  window.addEventListener('keydown', e => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    if (e.key === 'Escape') closeLightbox()
    else if (e.key === 'ArrowLeft') showImage(currentIndex - 1, -1)
    else if (e.key === 'ArrowRight') showImage(currentIndex + 1, 1)
  })

  // 滚轮缩放
  window.addEventListener(
    'wheel',
    e => {
      // 只有灯箱打开时才缩放
      if (overlay.style.display === 'flex') {
        e.preventDefault() // 阻止页面滚动
        const delta = e.deltaY || e.detail || e.wheelDelta
        if (delta < 0) {
          scale *= 1.1 // 放大
        } else {
          scale /= 1.1 // 缩小
        }
        updateTransform()
      }
    },
    { passive: false }
  )

  // 双击图片放大 / 恢复
  lbImg.addEventListener('dblclick', () => {
    scale = scale === 1 ? 2 : 1
    updateTransform()
  })

  let translateX = 0
  let translateY = 0
  let isDragging = false
  let dragStartX = 0
  let dragStartY = 0
  let spacePressed = false

  // 阻止浏览器默认拖拽图片
  lbImg.addEventListener('dragstart', e => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    e.preventDefault()
  })

  // 监听空格键
  document.addEventListener('keydown', e => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    if (e.code === 'Space') {
      e.preventDefault() // 阻止页面滚动
      spacePressed = true
      lbImg.style.cursor = 'grab'
    }
  })

  // 监听空格键
  document.addEventListener('keyup', e => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    if (e.code === 'Space') {
      spacePressed = false
      lbImg.style.cursor = '' // 恢复默认
    }
  })

  // 鼠标按下
  lbImg.addEventListener('mousedown', e => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    if (!spacePressed) return
    e.preventDefault() // 阻止默认点击/拖拽行为
    isDragging = true
    dragStartX = e.clientX - translateX
    dragStartY = e.clientY - translateY
    lbImg.style.cursor = 'grabbing'
  })

  // 鼠标移动
  document.addEventListener('mousemove', e => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    if (!isDragging) return
    translateX = e.clientX - dragStartX
    translateY = e.clientY - dragStartY
    lbImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotation}deg)`
  })

  // 鼠标松开
  window.addEventListener('mouseup', () => {
    // 灯箱没打开就不处理
    if (overlay.style.display !== 'flex') return
    isDragging = false
    lbImg.style.cursor = spacePressed ? 'grab' : 'default'
  })
})()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址