Greasy Fork

Greasy Fork is available in English.

通用网页图片灯箱(WebImageBox)

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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