X Long Image Enlarge Tool

Add a button on the left top conner of a image,for convenience of displaying long images on X

// ==UserScript==
// @name         X Long Image Enlarge Tool
// @name:zh-CN   推特长图放大
// @namespace    https://github.com/yanzhili/xImageEnlarge
// @version      2024-07-19
// @description  Add a button on the left top conner of a image,for convenience of displaying long images on X
// @description:zh-CN 在图片右上角显示一个放大按钮,方便显示推特中的长图
// @author       James.Yan
// @match        https://x.com/*
// @match        https://twitter.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant        none
// @license      MIT
// ==/UserScript==

;(function () {
  'use strict'

  window.dev = false
  const MatchPic = 'pbs.twimg.com/media'
  const MarkTag = 'zx-btn-added'
  const ImgClass = 'zx-img-class'
  const ImgBtnClass = 'zx-btn-class'
  const aHrefMap = new Map()
  const imgMap = new Map()

  function main() {
    initCss()
    initFullscreenDiv()
    observeHtml((imgElement) => {
      imgElement.classList.add(ImgClass)
      const largeImg = imgToLarge(imgElement.src)
      distribute(imgElement, largeImg)
      addButton(imgElement.parentElement, largeImg)
    })
  }

  function distribute(element, src) {
    if (!element) return
    if (element.parentElement && element.parentElement.tagName == 'A') {
      const aHref = element.parentElement.getAttribute('href')
      if (aHref.indexOf('/photo/') > -1) {
        const aHrefArr = aHref.split('/photo/')
        if (aHrefArr.length < 1) return
        log(aHrefArr[0], 'keyHref')
        const keyHref = aHrefArr[0]
        const index = Number(aHrefArr[1])
        if (index <= 0) return
        if (!imgMap.has(src)) {
          imgMap.set(src, keyHref)
        }
        if (!aHrefMap.has(keyHref)) {
          let imgArr = []
          imgArr[index - 1] = src
          aHrefMap.set(keyHref, imgArr)
        } else {
          const imgArr = aHrefMap.get(keyHref)
          if (imgArr.indexOf(src) < 0) {
            imgArr[index - 1] = src
            aHrefMap.set(keyHref, imgArr)
          }
        }
      }
    } else {
      distribute(element.parentElement, src)
    }
  }

  function isLongImage(imgSrc) {
    if (
      imgSrc.indexOf('name=4096x4096') > -1 ||
      imgSrc.indexOf('name=large') > -1
    ) {
      return true
    }
    return false
  }

  function addButton(parentElement, imgSrc) {
    if (parentElement.id.indexOf('zx-') > -1) {
      return
    }

    if (parentElement.getAttribute(MarkTag)) {
      log(imgSrc, 'Btn-Added')
      return
    }

    let button = document.createElement('div')
    button.id = genRandomID('btn')
    button.className = ImgBtnClass
    button.style =
      'width:50px;height:22px;line-height:22px;text-align: center;font-size:12px;font-famliy:ui-monospace;cursor: pointer;color: white;'
    button.style.backgroundColor = '#000'
    button.style.opacity = '0.7'
    button.innerText = 'ZOOM'
    button.onclick = (e) => {
      e.preventDefault()
      e.stopPropagation()
      displayFullScreenImg(imgSrc)
    }
    parentElement.setAttribute(MarkTag, 'added')
    parentElement.appendChild(button)
  }

  function isMediaImg(img) {
    return img.indexOf(MatchPic) > -1
  }

  function observeHtml(imgAddedCallback) {
    const targetNode = document.body
    const config = {
      attributes: true,
      childList: true,
      subtree: true,
      attributeFilter: ['src'],
    }
    const callback = function (mutationsList, observer) {
      for (let mutation of mutationsList) {
        if (
          mutation.attributeName == 'src' &&
          isMediaImg(mutation.target.src)
        ) {
          imgAddedCallback(mutation.target)
        }
      }
    }
    const observer = new MutationObserver(callback)
    observer.observe(targetNode, config)
    // observer.disconnect()
  }

  function imgToLarge(imgSrc) {
    if (!imgSrc) return
    let nameValue = ''
    if (imgSrc.indexOf('name=') > -1) {
      let arr1 = imgSrc.split('?')
      if ((arr1.length = 2)) {
        let str1 = arr1[1]
        let arr2 = str1.split('&')
        if (arr2) {
          arr2.map((i) => {
            let arr3 = i.split('=')
            if (arr3.length == 2 && arr3[0] == 'name' && arr3[1] != 'large') {
              let tempNameValue = arr3[1]
              if (tempNameValue.indexOf('x') > -1) {
                let arr4 = tempNameValue.split('x')
                //大图不替换
                if (Number(arr4[0]) < 1024) {
                  nameValue = arr3[1]
                }
              } else {
                nameValue = arr3[1]
              }
            }
          })
        }
      }
    }
    if (nameValue) {
      imgSrc = imgSrc.replace(`name=${nameValue}`, 'name=large')
    }
    log(imgSrc, 'Large-Imgage-Src')
    return imgSrc
  }

  window.onload = function () {
    main()
  }

  const ImgFullWidthCss = 'width:100%;height:auto;margin:auto;cursor:zoom-in'
  const ImgFullHeightCss = 'width:auto;height:100%;margin:auto;cursor:zoom-in'
  const ImgAutoCss = 'width:auto;height:auto;margin:auto;cursor:zoom-in'
  const FSDivId = 'zx-fullsceen-div-id'
  const FSImgId = 'zx-fullsceen-img-id'
  const ImgDisplayType = 'zx-display-type'
  const NextArrowId = 'zx-next-arrow-id'
  const PreviousArrowId = 'zx-previous-arrow-id'

  function initFullscreenDiv() {
    let fsDiv = document.createElement('div')
    fsDiv.id = FSDivId
    fsDiv.style =
      'text-align: center;width:100%;height:100%; position: fixed;top: 0px;bottom: 0px;overflow-y:auto;display:none'
    fsDiv.style.backgroundColor = 'black'
    fsDiv.onclick = () => {
      dismissImg()
    }
    let imgElmt = document.createElement('img')
    imgElmt.id = FSImgId
    imgElmt.onclick = (e) => {
      e.preventDefault()
      e.stopPropagation()
      if (imgElmt.getAttribute(ImgDisplayType) == 'image-auto') {
        imgElmt.setAttribute(ImgDisplayType, 'image-fw')
        imgElmt.style = ImgFullWidthCss
      } else if (imgElmt.getAttribute(ImgDisplayType) == 'image-fw') {
        imgElmt.setAttribute(ImgDisplayType, 'image-fh')
        imgElmt.style = ImgFullHeightCss
      } else if (imgElmt.getAttribute(ImgDisplayType) == 'image-fh') {
        imgElmt.setAttribute(ImgDisplayType, 'image-auto')
        imgElmt.style = ImgAutoCss
      }
    }

    fsDiv.appendChild(imgElmt)

    fsDiv.appendChild(genCloseButton())
    fsDiv.appendChild(genArrowButton(true))
    fsDiv.appendChild(genArrowButton(false))
    document.body.appendChild(fsDiv)
  }

  function genCloseButton() {
    let closeImgDiv = document.createElement('div')
    closeImgDiv.style =
      'top: 0;right: 0px;margin-top: 10px;margin-right: 10px;width: 40px;height: 40px;position: fixed;'

    closeImgDiv.style.backgroundColor = '#000'
    closeImgDiv.style.opacity = '0.8'
    closeImgDiv.style.backgroundImage =
      'url(data:image/svg+xml;base64,PHN2ZyB0PSIxNzE5NTU1MTg1MTM4IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjE0MDAzIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiPjxwYXRoIGQ9Ik01MTAuODA5NiA0MjAuMzAwOGwzMzUuMjk2LTMzNS4yOTYgOTAuNTA4OCA5MC41MDg4LTMzNS4yOTYgMzM1LjI5NiAzMzUuMjk2IDMzNS4yOTYtOTAuNTA4OCA5MC41MDg4LTMzNS4yOTYtMzM1LjI5Ni0zMzUuMjk2IDMzNS4yOTYtOTAuNTA4OC05MC41MDg4IDMzNS4yOTYtMzM1LjI5Ni0zMzUuMjk2LTMzNS4yOTYgOTAuNTA4OC05MC41MDg4eiIgZmlsbD0iI2ZmZmZmZiIgcC1pZD0iMTQwMDQiPjwvcGF0aD48L3N2Zz4=)'
    closeImgDiv.style.backgroundRepeat = 'no-repeat'
    closeImgDiv.style.backgroundSize = 'cover'
    closeImgDiv.style.cursor = 'pointer'
    closeImgDiv.onclick = () => {
      dismissImg()
    }
    return closeImgDiv
  }

  function genArrowButton(showNext) {
    let arrowDiv = document.createElement('div')
    if (showNext) {
      arrowDiv.id = NextArrowId
    } else {
      arrowDiv.id = PreviousArrowId
    }
    if (showNext) {
      arrowDiv.style = 'right: 0px; margin-right: 10px;'
    } else {
      arrowDiv.style = 'left: 0px; margin-left: 10px;'
    }
    arrowDiv.style.display = 'none'
    arrowDiv.style.top = '50%'
    arrowDiv.style.width = '45px'
    arrowDiv.style.height = '45px'
    arrowDiv.style.position = 'fixed'
    arrowDiv.style.borderRadius = '50%'
    arrowDiv.style.backgroundColor = '#80808091'
    arrowDiv.style.backgroundImage =
      'url(data:image/svg+xml;base64,PHN2ZyB0PSIxNzIwNDMwNjM5Mzg3IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjgxMzgiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiI+PHBhdGggZD0iTTU0NCA2OTAuNzczMzMzbDExNi4wNTMzMzMtMTE2LjA1MzMzM2EzMiAzMiAwIDEgMSA0NS4yMjY2NjcgNDUuMjI2NjY3bC0xNzAuNjY2NjY3IDE3MC42NjY2NjZhMzIgMzIgMCAwIDEtNDUuMjI2NjY2IDBsLTE3MC42NjY2NjctMTcwLjY2NjY2NmEzMiAzMiAwIDEgMSA0NS4yMjY2NjctNDUuMjI2NjY3bDExNi4wNTMzMzMgMTE2LjA1MzMzM1YyNzcuMzMzMzMzYTMyIDMyIDAgMCAxIDY0IDB2NDEzLjQ0eiIgZmlsbD0iI2ZmZmZmZiIgcC1pZD0iODEzOSI+PC9wYXRoPjwvc3ZnPg==)'
    arrowDiv.style.backgroundRepeat = 'no-repeat'
    arrowDiv.style.backgroundSize = 'cover'
    arrowDiv.style.cursor = 'pointer'
    if (showNext) {
      arrowDiv.style.transform = 'rotate(-90deg)'
    } else {
      arrowDiv.style.transform = 'rotate(90deg)'
    }
    arrowDiv.onclick = (e) => {
      e.preventDefault()
      e.stopPropagation()
      if (showNext) {
        showNextImg(true)
      } else {
        showNextImg(false)
      }
    }
    return arrowDiv
  }

  function showNextImg(showNext) {
    let imgElmt = document.getElementById(FSImgId)
    const imgSrc = imgElmt.getAttribute('src')
    const keyHref = imgMap.get(imgSrc)
    log(keyHref, imgSrc)
    if (!keyHref) return
    const imgArr = aHrefMap.get(keyHref)
    log(imgArr, keyHref)
    if (!imgArr) return
    const imgIndex = imgArr.indexOf(imgSrc)
    const len = imgArr.length

    let fsDiv = document.getElementById(FSDivId)
    const windowRatio = fsDiv.clientWidth / fsDiv.clientHeight
    let imgUrl
    if (showNext) {
      if (imgIndex + 1 + 1 <= len) {
        imgUrl = imgArr[imgIndex + 1]
      }
    } else {
      if (imgIndex > 0) {
        imgUrl = imgArr[imgIndex - 1]
      }
    }
    if (!imgUrl) return
    checkHasNextAndHasPrevious(imgUrl)
    displayImg(imgUrl, windowRatio)
  }

  function initCss() {
    var css = `
    .${ImgBtnClass}
      {display:none}
    .${ImgClass}:hover + .${ImgBtnClass}, .${ImgBtnClass}:hover
      { display: inline-block }
    `
    var style = document.createElement('style')

    if (style.styleSheet) {
      style.styleSheet.cssText = css
    } else {
      style.appendChild(document.createTextNode(css))
    }

    document.getElementsByTagName('head')[0].appendChild(style)
  }

  function displayFullScreenImg(imgSrc) {
    let fsDiv = document.getElementById(FSDivId)
    fsDiv.style.overflowY = 'overflow-y:auto'
    fsDiv.style.display = 'flex'
    fsDiv.style.justifyContent = 'center'
    fsDiv.style.alignItems = 'center'
    const windowRatio = fsDiv.clientWidth / fsDiv.clientHeight
    displayImg(imgSrc, windowRatio)
    checkHasNextAndHasPrevious(imgSrc)
  }

  function checkHasNextAndHasPrevious(imgSrc) {
    clearEmptyImg(imgSrc)
    const nextBtn = document.getElementById(NextArrowId)
    const previousBtn = document.getElementById(PreviousArrowId)
    nextBtn.style.display = 'none'
    previousBtn.style.display = 'none'
    if (hasNext(imgSrc)) {
      nextBtn.style.display = 'block'
    }
    if (hasPrevious(imgSrc)) {
      if (previousBtn) {
        previousBtn.style.display = 'block'
      }
    }
  }

  function clearEmptyImg(imgSrc) {
    const keyHref = imgMap.get(imgSrc)
    if (!keyHref) return
    const imgArr = aHrefMap.get(keyHref)
    if (imgArr) {
      const newImgArr = imgArr.filter((i) => {
        return !!i
      })
      aHrefMap.set(keyHref, newImgArr)
    }
  }

  function hasNext(imgSrc) {
    const keyHref = imgMap.get(imgSrc)
    if (!keyHref) return false
    const imgArr = aHrefMap.get(keyHref)
    if (!imgArr) return false
    const imgIndex = imgArr.indexOf(imgSrc)
    const len = imgArr.length
    if (len == 1) {
      return false
    }
    return imgIndex + 1 < len
  }

  function hasPrevious(imgSrc) {
    const keyHref = imgMap.get(imgSrc)
    if (!keyHref) return false
    const imgArr = aHrefMap.get(keyHref)
    if (!imgArr) return false
    const imgIndex = imgArr.indexOf(imgSrc)
    const len = imgArr.length
    if (len == 1) {
      return false
    }

    return imgIndex + 1 > 1 && imgIndex + 1 <= len
  }

  function displayImg(imgSrc, windowRatio) {
    let imgElmt = document.getElementById(FSImgId)
    if (!imgElmt) return
    imgElmt.setAttribute('src', '')
    const img = new Image()
    img.onload = function () {
      const imgRatio = this.width / this.height
      if (imgRatio > windowRatio) {
        imgElmt.style = ImgFullWidthCss
        imgElmt.setAttribute(ImgDisplayType, 'image-fw')
      } else {
        imgElmt.style = ImgFullHeightCss
        imgElmt.setAttribute(ImgDisplayType, 'image-fh')
      }
      imgElmt.setAttribute('src', imgSrc)
    }
    img.src = imgSrc
  }

  function dismissImg() {
    let fsDiv = document.getElementById(FSDivId)
    fsDiv.style.display = 'none'
    let imgElmt = document.getElementById(FSImgId)
    if (!imgElmt) return
    imgElmt.removeAttribute('src')
  }

  function genRandomID(tag) {
    return `zx-${tag}-${Math.random().toString(36).slice(-8)}`
  }

  /**
   * 将标准时间格式化
   * @param {Date} time 标准时间
   * @param {String} format 格式
   * @return {String}
   */
  function moment(time) {
    // 获取年⽉⽇时分秒
    let y = time.getFullYear()
    let m = (time.getMonth() + 1).toString().padStart(2, `0`)
    let d = time.getDate().toString().padStart(2, `0`)
    let h = time.getHours().toString().padStart(2, `0`)
    let min = time.getMinutes().toString().padStart(2, `0`)
    let s = time.getSeconds().toString().padStart(2, `0`)
    return `${y}-${m}-${d} ${h}:${min}:${s}`
  }

  function log(msg, tag) {
    if (!window.dev) {
      return false
    }
    if (tag) {
      console.log(`${moment(new Date())}  ${tag}`, msg)
    } else {
      console.log(`${moment(new Date())}`, msg)
    }
  }
})()