Next Image/Previous Image with Alignment and Large Image Support

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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