Next Image/Previous Image with Alignment and Large Image Support

Scroll down/up by aligning images with proper large image section handling

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Next Image/Previous Image with Alignment and Large Image Support
// @author       cdr-x
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Scroll down/up by aligning images with proper large image section handling
// @include      *
// @grant        unsafeWindow
// ==/UserScript==

(function() {
  const KEY_W = "w";
  const KEY_S = "s";
  const SCROLL_PERCENTAGE = 0.85; // Scroll 85% of viewport to ensure overlap

  let positions = [];
  let positionsCache = [];
  let lastCacheTime = 0;
  const CACHE_DURATION = 100; // Cache positions for 100ms to avoid recalculating too often
  let lastScrollPosition = 0;
  let stuckCount = 0;

  // Create toast notification system
  function createToast(message, duration = 3000) {
    // Remove any existing toast
    const existingToast = document.getElementById('scroll-debug-toast');
    if (existingToast) {
      existingToast.remove();
    }

    const toast = document.createElement('div');
    toast.id = 'scroll-debug-toast';
    toast.style.cssText = `
      position: fixed;
      top: 20px;
      right: 20px;
      background: rgba(0, 0, 0, 0.9);
      color: #fff;
      padding: 12px 16px;
      border-radius: 6px;
      font-family: monospace;
      font-size: 12px;
      z-index: 999999;
      max-width: 400px;
      line-height: 1.4;
      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
      white-space: pre-wrap;
    `;
    toast.textContent = message;
    document.body.appendChild(toast);

    setTimeout(() => {
      if (toast.parentNode) {
        toast.remove();
      }
    }, duration);
  }

  function getAbsoluteTop(element) {
    // Get absolute position from top of document, handling iframes correctly
    let top = 0;
    let currentElement = element;

    // Walk up the element tree to calculate offset
    while (currentElement) {
      top += currentElement.offsetTop || 0;

      // If we hit the body/html of an iframe document, we need to add the iframe's position
      if (currentElement.offsetParent === null && currentElement.ownerDocument !== document) {
        // We're at the top of an iframe document, find the iframe element in parent
        const frames = window.document.querySelectorAll('iframe');
        for (const frame of frames) {
          try {
            if (frame.contentDocument === currentElement.ownerDocument ||
                frame.contentWindow.document === currentElement.ownerDocument) {
              // Found the iframe, now add its position and continue from there
              currentElement = frame;
              break;
            }
          } catch (e) {
            // Cross-origin iframe, skip
          }
        }
        // If we didn't find the iframe, break to avoid infinite loop
        if (currentElement === element || currentElement.nodeName !== 'IFRAME') {
          break;
        }
      } else {
        currentElement = currentElement.offsetParent;
      }
    }

    return Math.round(top);
  }

  function getAllImages() {
    // Get all images from main document and all iframes
    let allImages = [];

    // Get images from main document
    allImages.push(...document.querySelectorAll('img'));

    // Get images from all iframes
    const iframes = document.querySelectorAll('iframe');
    for (const iframe of iframes) {
      try {
        // Try to access iframe content (will fail for cross-origin)
        const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
        if (iframeDoc) {
          allImages.push(...iframeDoc.querySelectorAll('img'));
        }
      } catch (e) {
        // Cross-origin iframe, skip it
        console.debug('Cannot access iframe:', e);
      }
    }

    return allImages;
  }

  function updateImagePositions(forceUpdate = false) {
    const now = Date.now();

    // Use cache if available and recent, unless forced
    if (!forceUpdate && positionsCache.length > 0 && now - lastCacheTime < CACHE_DURATION) {
      positions = positionsCache;
      return;
    }

    positions = [];
    // Get all images including those in iframes
    const allImages = getAllImages();

    for (let idx = 0; idx < allImages.length; idx++) {
      const img = allImages[idx];

      // Skip tiny images
      if (img.width * img.height < 80 * 80) continue;

      // Skip images that haven't loaded yet or are hidden
      if (!img.complete || img.naturalHeight === 0) continue;

      const computedStyle = img.ownerDocument.defaultView.getComputedStyle(img);
      if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden') continue;

      const ytop = getAbsoluteTop(img);
      const ybottom = ytop + img.height;

      // Sanity check - skip images with invalid positions
      if (ytop < 0 || isNaN(ytop) || isNaN(ybottom)) continue;

      positions.push([idx, ytop, ybottom, img]); // Store img reference too
    }

    positions.sort((a, b) => a[1] - b[1]);

    // Update cache
    positionsCache = positions;
    lastCacheTime = now;
  }

  function findNonTruncatedPosition(initialPosition, viewportHeight) {
    let currentPosition = initialPosition;
    let found = false;
    let iterations = 0;
    const maxIterations = 100; // Prevent infinite loops

    while (!found && iterations < maxIterations) {
      found = true;
      iterations++;

      for (let i = 0; i < positions.length; i++) {
        const [, imgTop, imgBottom] = positions[i];
        // If position would land in middle of a small image, snap to its top
        if (imgTop < currentPosition && imgBottom > currentPosition && imgBottom - imgTop < viewportHeight * 0.8) {
          currentPosition = imgTop;
          found = false;
          break;
        }
      }
    }
    return currentPosition;
  }

  function scrollToNextImage(currentScroll, viewportHeight) {
    const currentBottomEdge = currentScroll + viewportHeight;
    const scrollIncrement = viewportHeight * SCROLL_PERCENTAGE;

    // First check: Is there an image that's truncated at the bottom of the viewport?
    // (partially visible, with its top above the bottom edge but below current scroll)
    // This should take priority for alignment UNLESS it's a large image
    for (let i = 0; i < positions.length; i++) {
      const [, imgTop, imgBottom] = positions[i];
      const imgHeight = imgBottom - imgTop;
      const isLargeImage = imgHeight > viewportHeight;

      // Check if image is truncated at bottom (top is visible but bottom is cut off)
      const isTruncatedAtBottom = imgTop >= currentScroll && imgTop < currentBottomEdge && imgBottom > currentBottomEdge;

      if (isTruncatedAtBottom && !isLargeImage) {
        // Small/medium image truncated at bottom - align to its top
        // But only if we'd actually move (not already aligned)
        if (imgTop > currentScroll + 10) {
          return {
            position: findNonTruncatedPosition(imgTop, viewportHeight),
            reason: `S: Truncated img[${i}] at bottom\n→ Align to top @ ${imgTop}px\nCurrent: ${currentScroll}px\nImgHeight: ${imgHeight}px (not large)`
          };
        }
      }
    }

    // Second check: Are we viewing a large image?
    let currentLargeImageIndex = -1;
    for (let i = 0; i < positions.length; i++) {
      const [, imgTop, imgBottom] = positions[i];
      const imgHeight = imgBottom - imgTop;

      // Check if this is a large image (taller than viewport)
      if (imgHeight > viewportHeight) {
        const viewingTop = currentScroll;
        const viewingBottom = currentBottomEdge;

        // Are we currently viewing any part of this large image?
        const isViewingThisImage = imgBottom > viewingTop && imgTop < viewingBottom;

        if (isViewingThisImage) {
          currentLargeImageIndex = i;
          break;
        }
      }
    }

    // If we found a large image we're viewing, handle scrolling within it
    if (currentLargeImageIndex >= 0) {
      const [, imgTop, imgBottom] = positions[currentLargeImageIndex];

      // Calculate how much of the image is below our current view
      const remainingImageBelow = imgBottom - currentBottomEdge;

      // If there's significant image content below (more than 10% of viewport), scroll within the image
      if (remainingImageBelow > viewportHeight * 0.1) {
        const nextPosition = currentScroll + scrollIncrement;
        // Make sure we don't scroll past the bottom of the image
        const maxScroll = imgBottom - viewportHeight;
        return {
          position: Math.min(nextPosition, maxScroll),
          reason: `S: Inside large img[${currentLargeImageIndex}]\n→ Scroll within image\nCurrent: ${currentScroll}px → ${Math.min(nextPosition, maxScroll)}px\nRemaining below: ${remainingImageBelow}px`
        };
      } else {
        // We're near the bottom of this large image, move to next image
        for (let j = currentLargeImageIndex + 1; j < positions.length; j++) {
          const nextImgTop = positions[j][1];
          // Find next image that starts at or after this one's bottom
          if (nextImgTop >= imgBottom - 50) {
            return {
              position: findNonTruncatedPosition(nextImgTop, viewportHeight),
              reason: `S: At bottom of large img[${currentLargeImageIndex}]\n→ Jump to next img[${j}] @ ${nextImgTop}px\nCurrent: ${currentScroll}px`
            };
          }
        }
        // No next image found, just scroll down
        const newPos = Math.min(currentScroll + scrollIncrement, document.documentElement.scrollHeight - viewportHeight);
        return {
          position: newPos,
          reason: `S: At bottom of large img[${currentLargeImageIndex}]\n→ No next img, scroll down\nCurrent: ${currentScroll}px → ${newPos}px`
        };
      }
    }

    // Normal navigation: find next image below current view
    for (let i = 0; i < positions.length; i++) {
      const [, imgTop, imgBottom] = positions[i];

      // Find next image that starts at or below current bottom edge
      if (imgTop >= currentBottomEdge - 10) {
        return {
          position: findNonTruncatedPosition(imgTop, viewportHeight),
          reason: `S: Normal nav\n→ Jump to next img[${i}] @ ${imgTop}px\nCurrent: ${currentScroll}px`
        };
      }
    }

    // No images found, scroll by viewport
    const newPos = Math.min(currentScroll + scrollIncrement, document.documentElement.scrollHeight - viewportHeight);
    return {
      position: newPos,
      reason: `S: No images below\n→ Scroll down by viewport\nCurrent: ${currentScroll}px → ${newPos}px`
    };
  }

  function scrollToPreviousImage(currentScroll, viewportHeight) {
    const currentBottomEdge = currentScroll + viewportHeight;
    const scrollIncrement = viewportHeight * SCROLL_PERCENTAGE;

    // First, find if we're currently viewing any large image
    let currentLargeImageIndex = -1;
    for (let i = 0; i < positions.length; i++) {
      const [, imgTop, imgBottom] = positions[i];
      const imgHeight = imgBottom - imgTop;

      // Check if this is a large image (taller than viewport)
      if (imgHeight > viewportHeight) {
        const viewingTop = currentScroll;
        const viewingBottom = currentBottomEdge;

        // Are we currently viewing any part of this large image?
        const isViewingThisImage = imgBottom > viewingTop && imgTop < viewingBottom;

        if (isViewingThisImage) {
          currentLargeImageIndex = i;
          break; // Found it, stop searching
        }
      }
    }

    // If we found a large image we're viewing, handle scrolling within it
    if (currentLargeImageIndex >= 0) {
      const [, imgTop, imgBottom] = positions[currentLargeImageIndex];
      const remainingImageAbove = currentScroll - imgTop;

      // Check if we're already at or very close to the top of this image (within 50px)
      if (remainingImageAbove <= 50) {
        // Already at top of this large image, go to previous image
        // IMPORTANT: Only look for images whose TOP is significantly ABOVE current scroll
        for (let j = currentLargeImageIndex - 1; j >= 0; j--) {
          const prevImgTop = positions[j][1];
          const prevImgBottom = positions[j][2];

          // Skip if this image's top is at or below where we are
          if (prevImgTop >= currentScroll - 50) continue;

          // Find previous image that ends before this one starts
          if (prevImgBottom < imgTop - 10) {
            return {
              position: findNonTruncatedPosition(prevImgTop, viewportHeight),
              reason: `W: At top of large img[${currentLargeImageIndex}]\n→ Jump to prev img[${j}] @ ${prevImgTop}px\nCurrent: ${currentScroll}px, ImgTop: ${imgTop}px\nPrevImgTop: ${prevImgTop}px (${currentScroll - prevImgTop}px above)`
            };
          }
        }
        // No previous image, scroll up by viewport or to top
        const newPos = Math.max(0, currentScroll - scrollIncrement);
        return {
          position: newPos,
          reason: `W: At top of large img[${currentLargeImageIndex}]\n→ No prev img found, scroll up by viewport\nCurrent: ${currentScroll}px → ${newPos}px`
        };
      }

      // We're somewhere in the middle of the large image, scroll up within it
      const prevPosition = currentScroll - scrollIncrement;
      const newPos = Math.max(prevPosition, imgTop);
      return {
        position: newPos,
        reason: `W: Inside large img[${currentLargeImageIndex}]\n→ Scroll within image\nCurrent: ${currentScroll}px → ${newPos}px\nRemaining above: ${remainingImageAbove}px\nImgTop: ${imgTop}px, ImgBottom: ${imgBottom}px`
      };
    }

    // Normal navigation - find previous image ABOVE current position
    // Search backwards to find the closest image above us
    for (let i = positions.length - 1; i >= 0; i--) {
      const [, imgTop, imgBottom] = positions[i];

      // Skip images at or below current scroll position (with 50px tolerance)
      if (imgTop >= currentScroll - 50) continue;

      // Found the first image above us - use it
      return {
        position: findNonTruncatedPosition(imgTop, viewportHeight),
        reason: `W: Normal nav\n→ Jump to prev img[${i}] @ ${imgTop}px\nCurrent: ${currentScroll}px (${currentScroll - imgTop}px below)`
      };
    }

    // No images found above, scroll up by viewport
    const newPos = Math.max(0, currentScroll - scrollIncrement);
    return {
      position: newPos,
      reason: `W: No images above\n→ Scroll up by viewport\nCurrent: ${currentScroll}px → ${newPos}px`
    };
  }

  function scrollShiftUp(currentScroll, viewportHeight) {
    // Shift+W should scroll up by one viewport and align to nearest image
    // It should ONLY look at images that are above or near the NEW scroll position
    const targetScrollTop = Math.max(currentScroll - viewportHeight, 0);

    // Find images that would be visible or near the top after scrolling up
    let bestImageTop = targetScrollTop; // Default to just scrolling up one viewport

    // Look through images in reverse order (bottom to top)
    for (let i = positions.length - 1; i >= 0; i--) {
      const [, imgTop, imgBottom] = positions[i];

      // Only consider images that are above our current position
      // This prevents jumping back to earlier pages
      if (imgTop >= currentScroll) continue;

      // Find images near where we want to scroll to
      // We want the image closest to targetScrollTop that's above current position
      if (imgTop <= targetScrollTop + viewportHeight * 0.3 && imgTop >= targetScrollTop - viewportHeight * 0.1) {
        bestImageTop = imgTop;
        break; // Found the best match
      }

      // If image is just above target area, use it
      if (imgTop < targetScrollTop && imgTop >= targetScrollTop - 100) {
        bestImageTop = imgTop;
        break;
      }
    }

    return findNonTruncatedPosition(bestImageTop, viewportHeight);
  }

  // Set up MutationObserver to detect dynamic content changes
  const observer = new MutationObserver((mutations) => {
    let shouldUpdate = false;

    for (const mutation of mutations) {
      // Check if images or iframes were added
      if (mutation.addedNodes.length > 0) {
        for (const node of mutation.addedNodes) {
          if (node.nodeName === 'IMG' ||
              node.nodeName === 'IFRAME' ||
              node.nodeName === 'HTML' ||
              node.nodeName === 'BODY' ||
              (node.querySelectorAll && node.querySelectorAll('img').length > 0)) {
            shouldUpdate = true;
            break;
          }
        }
      }

      // Check if attributes changed (like src, width, height)
      if (mutation.type === 'attributes' &&
          (mutation.target.nodeName === 'IMG' || mutation.target.nodeName === 'IFRAME')) {
        shouldUpdate = true;
      }

      if (shouldUpdate) break;
    }

    if (shouldUpdate) {
      // Invalidate cache when DOM changes
      lastCacheTime = 0;
    }
  });

  // Start observing the document for changes
  observer.observe(document.documentElement, { // Watch from documentElement to catch nested html tags
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['src', 'width', 'height', 'style', 'class']
  });

  // Also listen for image load events in main document and iframes
  document.addEventListener('load', (event) => {
    if (event.target.nodeName === 'IMG' || event.target.nodeName === 'IFRAME') {
      lastCacheTime = 0; // Invalidate cache

      // If iframe loaded, observe it too
      if (event.target.nodeName === 'IFRAME') {
        try {
          const iframeDoc = event.target.contentDocument || event.target.contentWindow.document;
          if (iframeDoc) {
            iframeDoc.addEventListener('load', (e) => {
              if (e.target.nodeName === 'IMG') {
                lastCacheTime = 0;
              }
            }, true);
          }
        } catch (e) {
          // Cross-origin iframe
        }
      }
    }
  }, true);

  document.addEventListener("keydown", function(event) {
    if (event.ctrlKey || event.altKey) return;

    const tagName = event.target.tagName;
    const contentEditable = event.target.getAttribute("contenteditable");
    if ((tagName && tagName.match(/input|select|textarea/i)) || contentEditable === "true") {
      return;
    }

    // Update image positions (will use cache if recent)
    updateImagePositions();

    const currentScroll = Math.max(document.documentElement.scrollTop, document.body.scrollTop);
    const viewportHeight = window.innerHeight;

    let result = null;

    const key = event.key;
    const lowerKey = key.toLowerCase();

    if (lowerKey === KEY_S && !event.shiftKey) {
      result = scrollToNextImage(currentScroll, viewportHeight);
    } else if (lowerKey === KEY_W && !event.shiftKey) {
      result = scrollToPreviousImage(currentScroll, viewportHeight);
    } else if (lowerKey === KEY_W && event.shiftKey) {
      result = {
        position: scrollShiftUp(currentScroll, viewportHeight),
        reason: `Shift+W: Scroll up one viewport\nCurrent: ${currentScroll}px`
      };
    } else {
      return;
    }

    const newScrollPosition = result.position;
    const scrollDelta = Math.abs(newScrollPosition - currentScroll);

    // Check if scroll is stuck (position didn't change or changed very little)
    if (scrollDelta < 5) {
      stuckCount++;
      createToast(`⚠️ STUCK (${stuckCount}x)\n${result.reason}\n\nScroll delta: ${scrollDelta.toFixed(1)}px\nPositions found: ${positions.length}`, 5000);
    } else {
      stuckCount = 0; // Reset stuck counter on successful scroll
      // Show normal navigation toast (optional, comment out if too noisy)
      // createToast(result.reason, 2000);
    }

    if (newScrollPosition !== currentScroll) {
      event.preventDefault();
      window.scrollTo({
        top: newScrollPosition,
        behavior: 'instant'
      });

      lastScrollPosition = newScrollPosition;

      // After scrolling, wait a moment for any lazy-loaded images to appear
      setTimeout(() => {
        lastCacheTime = 0; // Invalidate cache after scroll
      }, 150);
    }
  });
})();