Pull Down to Refresh

Pull-down-to-refresh with adaptive overlay, spinner, and real-time drag physics

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pull Down to Refresh
// @namespace    UHVsbCBEb3duIHRvIFJlZnJlc2g
// @version      1.4
// @description  Pull-down-to-refresh with adaptive overlay, spinner, and real-time drag physics
// @author       smed79
// @license      GPLv3
// @icon         https://i25.servimg.com/u/f25/11/94/21/24/pd2r10.png
// @match        *://*/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const MIN_DY = 180; // Distance needed to trigger refresh
  const RESISTANCE = 0.4; // How much the spinner resists being pulled down
  const KEY = encodeURIComponent('Pull down to refresh');

  const EXCLUDED_DOMAINS = [];

  if (window[KEY]) return;
  window[KEY] = true;

  let startX = 0;
  let startY = 0;
  let isAtTop = false;
  let isPulling = false;
  let centerEl = null;

  function isExcludedDomain(hostname) {
    if (!hostname) return false;
    for (const pat of EXCLUDED_DOMAINS) {
      try {
        const re = new RegExp('^' + pat.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$', 'i');
        if (re.test(hostname)) return true;
      } catch (err) {}
    }
    return false;
  }

  if (isExcludedDomain(location.hostname)) return;

  // Build UI
  const overlay = document.createElement('div');
  overlay.className = 'pdr-overlay';
  overlay.setAttribute('aria-hidden', 'true');
  overlay.style.display = 'none';
  overlay.innerHTML = `
    <div class="pdr-center" id="pdr-center">
      <div class="pdr-loading-circle" role="status" aria-label="Loading"></div>
    </div>
  `;

  const style = document.createElement('style');
  style.textContent = `
    .pdr-overlay {
      position: fixed;
      inset: 0;
      z-index: 2147483646;
      display: flex;
      align-items: flex-start;
      justify-content: center;
      pointer-events: none;
      -webkit-backdrop-filter: blur(2px);
      backdrop-filter: blur(2px);
      transition: opacity 200ms ease;
      opacity: 0;
    }
    .pdr-overlay.pdr-visible {
      opacity: 1;
    }
    .pdr-center {
      position: absolute;
      top: -60px; /* Hide above screen initially */
      left: 50%;
      margin-left: -21px; /* Half of width */
      display: flex;
      align-items: center;
      pointer-events: none;
      will-change: transform;
      transition: transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
    }
    /* When actively dragging, remove transition for instant 1:1 finger tracking */
    .pdr-dragging .pdr-center {
      transition: none; 
    }
    .pdr-loading-circle {
      box-sizing: border-box;
      border-radius: 50%;
      width: 42px;
      height: 42px;
      border: 5px solid transparent;
      animation: pdr-spin 800ms linear infinite;
    }
    @keyframes pdr-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

    @media (prefers-color-scheme: light) {
      .pdr-overlay { background: rgba(255,255,255,0.4); }
      .pdr-loading-circle { border-top-color: rgba(0,0,0,0.8); border-right-color: rgba(0,0,0,0.2); border-bottom-color: rgba(0,0,0,0.2); border-left-color: rgba(0,0,0,0.2); }
    }
    @media (prefers-color-scheme: dark) {
      .pdr-overlay { background: rgba(0,0,0,0.4); }
      .pdr-loading-circle { border-top-color: rgba(255,255,255,0.9); border-right-color: rgba(255,255,255,0.2); border-bottom-color: rgba(255,255,255,0.2); border-left-color: rgba(255,255,255,0.2); }
    }
  `;

  function attachUI() {
    if (!document.head || !document.body) return;
    if (!document.head.contains(style)) document.head.appendChild(style);
    if (!document.body.contains(overlay)) {
        document.body.appendChild(overlay);
        centerEl = document.getElementById('pdr-center');
    }
  }

  attachUI();
  document.addEventListener('DOMContentLoaded', attachUI);

  // Optimized Scroll Checker (Fixes Layout Thrashing)
  function isElementScrollable(el) {
    if (!el || el === document.documentElement || el === document.body) return false;
    
    // FAST PATH: If it physically cannot scroll, skip getComputedStyle immediately
    if (el.scrollHeight <= el.clientHeight + 1) return false;
    
    try {
      const overflowY = window.getComputedStyle(el).overflowY;
      return (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay');
    } catch (err) {
      return false;
    }
  }

  function isInScrollableOrInteractiveArea(target) {
    let el = target;
    while (el && el !== document.documentElement) {
      if (el.matches && el.matches('input, textarea, select, button, [contenteditable="true"]')) return true;
      if (isElementScrollable(el)) return true;
      el = el.parentElement;
    }
    return false;
  }

  function getScrollTop() {
    return Math.max(
      window.scrollY || 0,
      document.documentElement.scrollTop || 0,
      document.body.scrollTop || 0
    );
  }

  document.addEventListener('touchstart', (e) => {
    if (e.touches.length !== 1) return;
    attachUI();

    if (getScrollTop() > 5 || isInScrollableOrInteractiveArea(e.target)) {
      isAtTop = false;
      return;
    }

    isAtTop = true;
    startX = e.touches[0].screenX;
    startY = e.touches[0].screenY;
  }, { passive: true });

  // Add Real-time visual pull effect
  document.addEventListener('touchmove', (e) => {
    if (!isAtTop) return;
    
    const touch = e.touches[0];
    const dY = touch.screenY - startY;
    const dX = Math.abs(touch.screenX - startX);

    // If pulling down and mostly vertically
    if (dY > 0 && dX < dY * 0.6) {
      if (!isPulling) {
        isPulling = true;
        overlay.style.display = 'flex';
        overlay.classList.add('pdr-dragging');
      }
      
      // Calculate resistance physics
      const pullDistance = Math.min(dY * RESISTANCE, MIN_DY + 40);
      
      // Move spinner down with finger
      if (centerEl) centerEl.style.transform = `translate3d(0, ${pullDistance}px, 0)`;
      overlay.style.opacity = Math.min(pullDistance / MIN_DY, 1).toString();
    }
  }, { passive: true });

  document.addEventListener('touchend', (e) => {
    if (!isAtTop || !isPulling) {
      isAtTop = false;
      isPulling = false;
      return;
    }

    isAtTop = false;
    isPulling = false;
    overlay.classList.remove('pdr-dragging');

    const touch = e.changedTouches[0];
    const dY = touch.screenY - startY;

    if (dY > MIN_DY) {
      // Trigger Reload
      overlay.classList.add('pdr-visible');
      if (centerEl) centerEl.style.transform = `translate3d(0, 120px, 0)`; // lock spinner in view
      
      setTimeout(() => {
        try { location.reload(); } 
        catch (err) { overlay.style.display = 'none'; }
      }, 300);
      
    } else {
      // Abort and snap back
      if (centerEl) centerEl.style.transform = 'translate3d(0, 0, 0)';
      overlay.classList.remove('pdr-visible');
      setTimeout(() => { overlay.style.display = 'none'; }, 200); // Wait for transition
    }
  }, { passive: true });

})();