Mobile Base64 Text Decoder

Base64 decoder with movable toggle button, mobile touch support, toast, Tampermonkey menu, color picker, and gesture support

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Mobile Base64 Text Decoder
// @version      1.6
// @description  Base64 decoder with movable toggle button, mobile touch support, toast, Tampermonkey menu, color picker, and gesture support
// @match        *://*/*
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @noframes
// @namespace https://greasyfork.org/users/1548859
// ==/UserScript==

;(function () {
  'use strict'
  if (window.top !== window.self) return

  const BTN_ID = 'b64-mobile-toggle-btn'
  if (document.getElementById(BTN_ID)) return

  const DEFAULT_PREFS = { autoCopy: false, toastDuration: 1200, colorIndex: 0 }
  const prefs = Object.assign({}, DEFAULT_PREFS, GM_getValue('b64MobileDecoderPrefs', {}))
  const menuIds = {}

  // ==============================
  // 🎨 CSS Styles
  // ==============================
  GM_addStyle(`
.b64-toggle-btn {
  position: fixed;
  width: 40px;
  height: 40px;
  border-radius: 20px;
  background: rgba(0,0,0,0.6);
  color: #fff;
  font-size: 16px;
  line-height: 40px;
  text-align: center;
  cursor: pointer;
  max-height: 300px;
  overflow: auto;
  z-index: 2147483647;
  user-select: none;
  transition: background 0.2s, box-shadow 0.2s;
  pointer-events: auto;
}
.b64-toggle-btn.active {
  background: var(--accent-color, rgba(0,128,255,0.8));
  box-shadow: 0 0 8px 2px var(--accent-color, rgba(0,128,255,0.8));
}
.b64-toast {
  position: fixed;
  padding: 6px 12px;
  background: rgba(0,0,0,0.8);
  color: var(--accent-color, #0f0);
  font-size: 12px;
  border-radius: 4px;
  opacity: 0;
  transition: opacity 0.3s;
  z-index: 2147483647;
}
.b64-toast.show {
  opacity: 1;
}
.b64-float {
  position: absolute;
  padding: 10px;
  background: rgba(0,0,0,0.8);
  color: #fff;
  border-radius: 5px;
  font-size: 14px;
  z-index: 2147483647;
  display: none;
  max-width: 80%;
  word-wrap: break-word;
  box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  user-select: none;
  cursor: pointer;
}
.b64-color-overlay {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(0,0,0,0.7);
  z-index: 2147483647;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  padding: 20px;
}
.b64-color-swatch {
  width: 40px; height: 40px;
  border-radius: 20px;
  margin: 10px;
  border: 2px solid #fff;
  cursor: pointer;
}
.b64-color-done {
  flex-basis: 100%;
  margin-top: 20px;
  padding: 10px 20px;
  background: #fff;
  color: #000;
  border-radius: 6px;
  font-weight: bold;
  text-align: center;
  cursor: pointer;
}
`)

  // ==============================
  // 🧱 UI Elements
  // ==============================
  const toggleBtn = document.createElement('div')
  toggleBtn.id = BTN_ID
  toggleBtn.className = 'b64-toggle-btn'
  toggleBtn.textContent = 'B64'
  document.body.appendChild(toggleBtn)

  const toast = document.createElement('div')
  toast.className = 'b64-toast'
  document.body.appendChild(toast)

  let floatingWin = null
  function getFloating() {
    if (floatingWin) return floatingWin
    const win = document.createElement('div')
    win.className = 'b64-float'
    document.body.appendChild(win)
    win.addEventListener('click', e => {
      e.stopPropagation()
      if (decodedText) {
        copyToClipboard(decodedText)
        showToast('Copied ✓')
      }
    })
    floatingWin = win
    return win
  }

  function showToast(msg) {
    const color = COLORS[prefs.colorIndex %COLORS.length];
    toast.style.color = color;
    toast.textContent = msg
    toast.style.display = 'block'
    const btnRect = toggleBtn.getBoundingClientRect()
    const toastW = toast.offsetWidth
    const left = btnRect.left + (btnRect.width - toastW) / 2
    const top = btnRect.bottom + 8
    toast.style.left = `${left}px`
    toast.style.top = `${top}px`
    toast.classList.add('show')
    setTimeout(() => {
      toast.classList.remove('show')
      toast.style.display = 'none'
    }, prefs.toastDuration)
  }

  // ==============================
  // 🎨 Color Palette & Initial Color
  // ==============================
  const COLORS = [
    'rgba(0,124,62,0.8)', // green
    'rgba(0,128,255,0.8)', // blue
    'rgba(255,64,64,0.8)', // red
    'rgba(255,165,0,0.8)', // orange
    'rgba(128,0,255,0.8)' // purple
  ]

  function applyUIColor() {
    const color = COLORS[prefs.colorIndex % COLORS.length]
    toggleBtn.style.setProperty('--accent-color', color)
  }

  applyUIColor()

  // Double-tap gesture to cycle colors
  let lastTap = 0
  toggleBtn.addEventListener('touchend', e => {
    const now = Date.now()
    if (now - lastTap < 300) {
      prefs.colorIndex = (prefs.colorIndex + 1) % COLORS.length
      GM_setValue('b64MobileDecoderPrefs', prefs)
      applyUIColor()
      showToast('Color changed')
      e.preventDefault()
    }
    lastTap = now
  }, { passive: false })


  // ==============================
  // 🛠️ Tampermonkey Menu
  // ==============================
  function registerMenu() {
    if (typeof GM_registerMenuCommand !== 'function') return

    // Unregister old commands to prevent duplicates
    if (menuIds.toggleBtn) GM_unregisterMenuCommand(menuIds.toggleBtn)
    if (menuIds.autoCopy) GM_unregisterMenuCommand(menuIds.autoCopy)
    if (menuIds.toastDuration) GM_unregisterMenuCommand(menuIds.toastDuration)
    if (menuIds.resetPos) GM_unregisterMenuCommand(menuIds.resetPos)
    if (menuIds.changeColor) GM_unregisterMenuCommand(menuIds.changeColor)

    // Toggle button visibility
    menuIds.toggleBtn = GM_registerMenuCommand(
      toggleBtn.style.display === 'none' ? 'Show B64' : 'Hide B64',
      () => {
        toggleBtn.style.display = toggleBtn.style.display === 'none' ? '' : 'none'
        registerMenu()
      }
    )

    // Auto-copy toggle
    menuIds.autoCopy = GM_registerMenuCommand(
      prefs.autoCopy ? 'Auto-Copy: ON' : 'Auto-Copy: OFF',
      () => {
        prefs.autoCopy = !prefs.autoCopy
        GM_setValue('b64MobileDecoderPrefs', prefs)
        showToast(`Auto-Copy ${prefs.autoCopy ? 'Enabled' : 'Disabled'}`)
        registerMenu()
      }
    )

    // Toast duration
    menuIds.toastDuration = GM_registerMenuCommand(
      'Set Toast Duration (ms)',
      () => {
        const v = parseInt(prompt('Toast duration in ms:', prefs.toastDuration), 10)
        if (!isNaN(v) && v >= 200) {
          prefs.toastDuration = v
          GM_setValue('b64MobileDecoderPrefs', prefs)
          showToast(`Toast: ${v}ms`)
        } else {
          showToast('Invalid duration')
        }
        registerMenu()
      }
    )

    // Reset button position
    menuIds.resetPos = GM_registerMenuCommand(
      'Restore Button Position',
      () => {
        setBtnPosition(null, true)
        showToast('Position reset')
        registerMenu()
      }
    )

    // Change button color
    menuIds.changeColor = GM_registerMenuCommand(
      'Change Button Color',
      () => showColorPickerOverlay()
    )
  }

  // ==============================
  // 🎨 Color Picker Overlay (Menu + Gesture)
  // ==============================
  function showColorPickerOverlay() {
    if (document.querySelector('.b64-color-overlay')) return

    const overlay = document.createElement('div')
    overlay.className = 'b64-color-overlay'

    COLORS.forEach((c, i) => {
      const swatch = document.createElement('div')
      swatch.className = 'b64-color-swatch'
      swatch.style.background = c
      swatch.addEventListener('click', () => {
        prefs.colorIndex = i
        GM_setValue('b64MobileDecoderPrefs', prefs)
        applyUIColor()
        showToast('Color selected')
        overlay.remove()
      })
      overlay.appendChild(swatch)
    })

    const doneBtn = document.createElement('div')
    doneBtn.className = 'b64-color-done'
    doneBtn.textContent = 'Done'
    doneBtn.addEventListener('click', () => overlay.remove())
    overlay.appendChild(doneBtn)

    document.body.appendChild(overlay)
  }

  // Register menu at startup
  registerMenu()


// ==============================
  // 📍 Button Positioning & Dragging
  // ==============================
  let isActive = false
  let dragging = false
  let startX = 0
  let startY = 0
  let origX = 0
  let origY = 0
  const DRAG_THRESHOLD = 8
  const savedPos = GM_getValue('b64MobileBtnPos', {})

  function setBtnPosition(saved, clearPrefs) {
    toggleBtn.style.right = 'auto'
    toggleBtn.style.bottom = 'auto'
    const btnW = toggleBtn.offsetWidth
    const btnH = toggleBtn.offsetHeight
    const dw = document.documentElement.clientWidth
    const dh = document.documentElement.clientHeight

    if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') {
      let x = Math.min(Math.max(0, saved.x), dw - btnW)
      let y = Math.min(Math.max(0, saved.y), dh - btnH)
      toggleBtn.style.left = x + 'px'
      toggleBtn.style.top = y + 'px'
    } else {
      toggleBtn.style.left = (dw - btnW - 20) + 'px'
      toggleBtn.style.top = (dh - btnH - 20) + 'px'
      if (clearPrefs) GM_setValue('b64MobileBtnPos', {})
    }
  }

  setBtnPosition(savedPos, false)

  toggleBtn.addEventListener('touchstart', e => {
    const touch = e.touches[0]
    startX = touch.clientX
    startY = touch.clientY
    const r = toggleBtn.getBoundingClientRect()
    origX = r.left
    origY = r.top
    dragging = !isActive // can drag only if inactive
    e.preventDefault()
  }, { passive: false })

  window.addEventListener('touchmove', e => {
    if (!dragging) return
    const touch = e.touches[0]
    const dx = touch.clientX - startX
    const dy = touch.clientY - startY
    if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return

    const btnW = toggleBtn.offsetWidth
    const btnH = toggleBtn.offsetHeight
    const maxX = document.documentElement.clientWidth - btnW
    const maxY = document.documentElement.clientHeight - btnH
    const newX = Math.min(Math.max(0, origX + dx), maxX)
    const newY = Math.min(Math.max(0, origY + dy), maxY)

    toggleBtn.style.left = newX + 'px'
    toggleBtn.style.top = newY + 'px'
  }, { passive: false })

  window.addEventListener('touchend', e => {
    const touch = e.changedTouches[0]
    const dx = touch.clientX - startX
    const dy = touch.clientY - startY
    dragging = false
    if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) {
      isActive = !isActive
      toggleBtn.classList.toggle('active', isActive)
      showToast(isActive ? 'Decoder ON' : 'Decoder OFF')
    } else {
      GM_setValue('b64MobileBtnPos', {
        x: parseInt(toggleBtn.style.left, 10),
        y: parseInt(toggleBtn.style.top, 10)
      })
    }
  }, { passive: false })

  // ==============================
  // 📋 Clipboard Copying
  // ==============================
  function copyToClipboard(s) {
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(s).catch(() => fallbackCopy(s))
    } else fallbackCopy(s)
  }

  function fallbackCopy(s) {
    const ta = document.createElement('textarea')
    ta.value = s
    ta.style.position = 'fixed'
    ta.style.opacity = '0'
    document.body.appendChild(ta)
    ta.select()
    document.execCommand('copy')
    document.body.removeChild(ta)
  }

  // ==============================
  // 🔍 Base64 Detection & Decoding
  // ==============================
  let decodeTimer = null
  let decodedText = ''

  function isLikelyBase64(s) {
    const c = s.trim().replace(/\s+/g, '').replace(/[-_]/g, m => m === '-' ? '+' : '/')
    if (!/^[A-Za-z0-9+/=]+$/.test(c) || c.length % 4 !== 0) return false
    const p = c.indexOf('=')
    if (p > -1) {
      const cnt = c.length - p
      if (cnt > 2 || p !== c.length - cnt) return false
    }
    return true
  }

  function decodeBase64(s) {
    try {
      const norm = s.trim().replace(/\s+/g, '').replace(/\u200B/g, '').replace(/[-_]/g, m => m === '-' ? '+' : '/')
      const bin = atob(norm)
      const bytes = Uint8Array.from(bin, ch => ch.charCodeAt(0))
      const out = new TextDecoder('utf-8').decode(bytes)
      const bad = [...out].filter(ch => {
        const code = ch.charCodeAt(0)
        return code < 32 || code > 126
      }).length
      return bad / out.length > 0.2 ? null : out
    } catch {
      return null
    }
  }

  // ==============================
  // 🧠 Selection Monitoring
  // ==============================
  document.addEventListener('selectionchange', () => {
    if (!isActive) return
    clearTimeout(decodeTimer)
    const txt = window.getSelection().toString().trim().replace(/\u200B/g, '')
    if (!txt || !isLikelyBase64(txt)) {
      if (floatingWin) floatingWin.style.display = 'none'
      return
    }
    decodeTimer = setTimeout(() => {
      const sel = window.getSelection()
      if (!sel.rangeCount) return
      const out = decodeBase64(txt)
      if (!out) return
      decodedText = out
      const win = getFloating()
      win.textContent = `Decoded: ${out}`
      win.style.display = 'block'
      if (prefs.autoCopy) {
        copyToClipboard(out)
        showToast('Copied ✓')
      }
      const r = sel.getRangeAt(0).getBoundingClientRect()
      let x = r.left + scrollX
      let y = r.bottom + scrollY + 5
      const w = win.offsetWidth
      const h = win.offsetHeight
      x = Math.min(Math.max(scrollX + 10, x), document.documentElement.clientWidth + scrollX - w - 10)
      if (y + h > innerHeight + scrollY - 10) y = r.top + scrollY - h - 5
      win.style.left = x + 'px'
      win.style.top = y + 'px'
    }, 200)
  })

  // ==============================
  // 🚫 Touch Dismiss Floating Window
  // ==============================
  document.addEventListener('touchstart', e => {
    if (floatingWin && !floatingWin.contains(e.target)) {
      floatingWin.style.display = 'none'
    }
  }, { passive: false })

  // ==============================
  // 📱 Orientation-Aware Resize Handler
  // ==============================
  let lastScreenWidth = document.documentElement.clientWidth
  let resizeTimer = null

  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer)
    resizeTimer = setTimeout(() => {
      const btnW = toggleBtn.offsetWidth
      const btnH = toggleBtn.offsetHeight
      const newScreenWidth = document.documentElement.clientWidth
      const newScreenHeight = document.documentElement.clientHeight
      let x = parseInt(toggleBtn.style.left, 10)
      let y = parseInt(toggleBtn.style.top, 10)
      if (isNaN(x) || isNaN(y)) return

      // Clamp to screen bounds to prevent off-screen in landscape
      x = Math.min(Math.max(0, x), newScreenWidth - btnW)
      y = Math.min(Math.max(0, y), newScreenHeight - btnH)

      toggleBtn.style.left = x + 'px'
      toggleBtn.style.top = y + 'px'
      toggleBtn.classList.toggle('active', isActive)
      GM_setValue('b64MobileBtnPos', { x, y })

      lastScreenWidth = newScreenWidth
    }, 100)
  })

  // ==============================
  // 📐 Mobile Viewport Optimization
  // ==============================
  const meta = document.createElement('meta')
  meta.name = 'viewport'
  meta.content = 'width=device-width, initial-scale=1.0'
  document.head.appendChild(meta)

})();