Kohinoor2 / Mobile Text Reflow (Hybrid Mode)

// ==UserScript==
// @name         Mobile Text Reflow (Hybrid Mode)
// @description  Reflows text on zoom using a hybrid touch/viewport approach. Handles dynamic content and scroll preservation.
// @version      3.0.0
// @author       kohinoor2
// @license MIT
// @match        *://*/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // =================================================================
  // CONFIGURATION & CONSTANTS
  // =================================================================
  const CLASS_NAME = 'reflow-processed';
  const CSS_VAR_NAME = '--reflow-max-width';

  // Selectors for elements that should receive the reflow class.
  // We prioritize block-level text containers.
  const TEXT_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, pre, blockquote, article, section, dd, dt, .comment, .post';

  // CSS Injection: Uses a variable for width.
  // Transition ensures smooth resize, but strict max-width ensures fit.
  const STYLES = `
        .${CLASS_NAME} {
            box-sizing: border-box !important;
            max-width: var(${CSS_VAR_NAME}, 100vw) !important;
            overflow-wrap: break-word !important;
            word-wrap: break-word !important;
            white-space: normal !important;
        }
    `;

  // =================================================================
  // STATE MACHINE
  // =================================================================
  const State = {
    IDLE: 'IDLE',
    PINCHING: 'PINCHING',
    ZOOMED: 'ZOOMED'
  };

  let currentState = State.IDLE;
  let resizeFrameId = null;
  let baselineScale = 1.0;

  // Target Preservation State
  let zoomFocusTarget = null;
  let targetRelativeY = 0; // The target's position relative to the viewport (0.0 to 1.0)

  // Fast lookups
  const processedElements = new WeakSet();

  // =================================================================
  // CORE LOGIC: INITIALIZATION & CSS
  // =================================================================
  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = STYLES;
    document.head.appendChild(style);
  }

  // =================================================================
  // CORE LOGIC: DOM PROCESSING (Incremental)
  // =================================================================

  /**
   * Checks if a node is a valid text container and applies the class.
   * Checks for direct text nodes to avoid squashing layout containers.
   */
  function processNode(node) {
    if (node.nodeType !== 1) return; // Element nodes only
    if (processedElements.has(node)) return;

    // 1. Check if it matches our selector list
    if (!node.matches(TEXT_SELECTOR) && node.tagName !== 'DIV') return;

    // 2. Check if it has direct text content (heuristic)
    // This prevents us from applying max-width to a flex container wrapper
    let hasDirectText = false;
    for (let child of node.childNodes) {
      if (child.nodeType === 3 && child.textContent.trim().length > 1) {
        hasDirectText = true;
        break;
      }
    }

    if (hasDirectText) {
      node.classList.add(CLASS_NAME);
      processedElements.add(node);
    }

    // 3. If it's a container, check its children immediately (for initial load)
    if (node.children.length > 0) {
      Array.from(node.children).forEach(processNode);
    }
  }

  function initDomScanner() {
    // 1. Initial Scan
    document.querySelectorAll('body *').forEach(processNode);

    // 2. MutationObserver for Dynamic Content (Infinite Scroll, Comments)
    const observer = new MutationObserver((mutations) => {
      // Batch processing slightly to avoid UI blocking on massive DOM updates
      requestAnimationFrame(() => {
        mutations.forEach(mutation => {
          if (mutation.type === 'childList') {
            mutation.addedNodes.forEach(processNode);
          }
        });
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  }

  // =================================================================
  // CORE LOGIC: TARGET ACQUISITION
  // =================================================================

  /**
   * Finds the block-level element closest to a specific point (x, y)
   */
  function findTargetElement(x, y) {
    // Use elementsFromPoint to drill down
    const elements = document.elementsFromPoint(x, y);

    // Find the first element in the stack that we have processed (has our class)
    // or is a likely text block.
    for (let el of elements) {
      if (processedElements.has(el)) return el;
      if (['P', 'H1', 'H2', 'H3', 'LI'].includes(el.tagName)) return el;
    }
    return elements[0] || document.body;
  }

  /**
   * Captures the element the user is interested in and its relative position.
   */
  function captureZoomTarget(x, y) {
    zoomFocusTarget = findTargetElement(x, y);

    if (zoomFocusTarget) {
      const rect = zoomFocusTarget.getBoundingClientRect();
      // Calculate where the element is relative to the viewport height (0% to 100%)
      targetRelativeY = (rect.top + (rect.height / 2)) / window.innerHeight;
    }
  }

  // =================================================================
  // EVENT HANDLERS
  // =================================================================

  function handleTouchStart(e) {
    if (e.touches.length === 2) {
      currentState = State.PINCHING;

      // Calculate midpoint of the pinch
      const touch1 = e.touches[0];
      const touch2 = e.touches[1];
      const midX = (touch1.clientX + touch2.clientX) / 2;
      const midY = (touch1.clientY + touch2.clientY) / 2;

      captureZoomTarget(midX, midY);
    }
  }

  function handleTouchEnd(e) {
    if (e.touches.length < 2 && currentState === State.PINCHING) {
      // Transition out of pinching.
      // Note: We don't change to IDLE yet; we wait for the visualViewport event
      // to confirm if we ended up zoomed in or snapped back.
    }
  }

  function handleViewportResize() {
    if (resizeFrameId) cancelAnimationFrame(resizeFrameId);

    resizeFrameId = requestAnimationFrame(() => {
      const vv = window.visualViewport;
      if (!vv) return;

      const scale = vv.scale;
      const width = vv.width;

      // Threshold for floating point errors
      if (scale > baselineScale + 0.05) {
        // --- STATE: ZOOMED IN ---
        currentState = State.ZOOMED;

        // 1. Fallback Target: If we didn't pinch (double tap), use center of screen
        if (!zoomFocusTarget) {
          captureZoomTarget(width / 2, window.innerHeight / 2);
        }

        // 2. Apply Reflow Logic
        // Set the CSS variable to fit the NEW visual viewport width
        // We subtract a small buffer (4%) for padding/margins
        const newWidth = Math.floor(width * 0.96);
        document.documentElement.style.setProperty(CSS_VAR_NAME, `${newWidth}px`);

        // 3. Scroll Preservation
        // Now that the width has changed, the page is longer.
        // We must scroll the target back to its relative position.
        if (zoomFocusTarget) {
          // We need to wait for the layout to repaint with the new width
          requestAnimationFrame(() => {
            zoomFocusTarget.scrollIntoView({
              block: 'center',
              inline: 'nearest'
            });

            // Optional: Refined offset calculation could go here,
            // but scrollIntoView('center') is usually robust enough
            // and less jumpy than manual window.scrollTo logic.
          });
        }

      }
      else {
        // --- STATE: RESET / IDLE ---
        if (currentState !== State.IDLE) {
          // Reset CSS variable to unlock width
          document.documentElement.style.removeProperty(CSS_VAR_NAME);
          zoomFocusTarget = null;
          currentState = State.IDLE;
        }
      }
    });
  }

  // =================================================================
  // BOOTSTRAP
  // =================================================================
  function init() {
    if (!window.visualViewport) {
      console.warn('Text Reflow: VisualViewport API not supported.');
      return;
    }

    injectStyles();

    // Wait for DOM to be ready for initial scan
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', initDomScanner);
    }
    else {
      initDomScanner();
    }

    // Listeners
    window.addEventListener('touchstart', handleTouchStart, {
      passive: true
    });
    window.addEventListener('touchend', handleTouchEnd, {
      passive: true
    });
    window.visualViewport.addEventListener('resize', handleViewportResize);

    // Handle scroll just to update target logic if needed (optional optimization)
    // window.visualViewport.addEventListener('scroll', ...);
  }

  init();

})();