NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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();
})();