Velmi dlouho jsem na svém android telefonu používal Operu Mobile. Důvod byl, že jako jediný prohlížeč podporovala text reflow při změně zoomu ( typicky situace, kdy nějaká mobile-unfriendly stránka má moc široký odstavce a malý text a člověk je nucenej po zoomu scrolovat doprava a doleva. Např. některý klasický diskuzní fóra.) Tohle opera vždycky řešila úplně perfektně.
Na PC ale používám Firefox a už mě nebavilo, jak to nefunguje dohromady z hlediska nějakýho sdílení záložek a historie atd. Takže jsem začal používat Fennec [1], který má slušnou podporu rozšíření a používal jsem tam rozšíření Text reflow on zoom (text wrap) [2]. Fungovalo to skoro stejně dobře jako ta funkcionalita v Opeře, ale mělo to pár much - některý textový prvky to newrapuje, vůbec to nesahá na dynamicky načtený nový prvky (typicky načtení dalších komentářů v diskuzi na redditu atd.) a nepodporovalo to double-tap-to-zoom (jenom pinch-to-zoom).
Naštěstí je ten plugin open source a MIT licensed a funguje i jako user script. Tak jsem s pomocí Gemini odstranil mouchy, co mě trápily a teď ho používám skrz Violentmonkey [3] a jsem extrémně spokojenej.
[1]
https://f-droid.org/packages/org.mozilla.fennec_fdroid/[2]
https://addons.mozilla.org/en-US/android/addon/text-reflow-on-zoom-mobile/[3]
https://addons.mozilla.org/cs/firefox/addon/violentmonkey/Skript zde (permanentně zapnutý):
// ==UserScript==
// @name Text reflow on zoom for mobile (text wrap) - Mixed Content Fix
// @name:ru Text reflow on zoom for mobile (text wrap) - Mixed Content Fix
// @description Fits all text to the screen width after a pinch or double-tap zoom gesture (fixes mixed text/quote blocks)
// @description:ru Подгонка текста под ширину экрана после жеста увеличения на телефоне
// @version 1.0.12
// @author emvaized
// @license MIT
// @homepageURL https://github.com/emvaized/text-reflow-on-zoom-mobile/
// @namespace text_reflow_on_pinch_zoom
// @match *://*/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// UPDATED SELECTOR: Uses text()[normalize-space()] to find ANY valid text node, not just the first one.
const xpathSelector = `
//p |
//li |
//h1 | //h2 | //h3 | //h4 | //h5 | //h6 |
//pre |
//a[text()[normalize-space()]] |
//div[b or em or i] |
//div[text()[normalize-space()]] |
//div[span[text()[normalize-space()]]]`;
let isCssInjected = false;
let isPinching = false;
let zoomTarget, targetDyOffsetRatio;
let resizeDebounceTimer;
let mutationDebounceTimer;
// Track viewport width to ignore height-only changes (keyboard)
let lastViewportWidth = window.visualViewport ? window.visualViewport.width : window.innerWidth;
// Track all text elements queried by the selector
const allTextElements = new Set();
/**
* Reflows text to fit screen.
* @param {boolean} shouldRestoreScroll - If true, scrolls back to the zoom target. False for passive updates (infinite scroll).
*/
function reflowText(shouldRestoreScroll = false) {
if (!isCssInjected) {
const styleContent = `.text-reflow-userscript { word-wrap: break-word !important; overflow-wrap:break-word !important; max-width:var(--text-reflow-max-width) !important; }
.text-reflow-scroll-padding {scroll-margin-left: 1vw !important;}`;
const styleElement = document.createElement('style');
styleElement.textContent = styleContent;
document.head.appendChild(styleElement);
isCssInjected = true;
}
// Calculate new width based on the visual viewport (zoomed area)
const maxAllowedWidth = Math.round(window.visualViewport.width * 0.96);
document.documentElement.style.setProperty('--text-reflow-max-width', `${maxAllowedWidth}px`);
// Update our width tracker
lastViewportWidth = window.visualViewport.width;
// Select elements likely to contain text
const xpathResult = document.evaluate(xpathSelector, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
// Refresh text element set
allTextElements.clear();
for (let i = 0, n = xpathResult.snapshotLength, el; i < n; i++) {
el = xpathResult.snapshotItem(i);
if (!el.offsetParent) continue;
// Double check text content to be safe
if (!el.textContent.trim()) continue;
// Process only top-level text elements
let isTopLevel = true;
let parent = el.parentElement;
while (parent) {
if (elementIsTextElement(parent)) {
isTopLevel = false;
break;
}
parent = parent.parentElement;
}
if (isTopLevel) {
el.classList.add('text-reflow-userscript');
allTextElements.add(el);
}
}
/// Scroll initial target element into view (ONLY if this is a zoom-triggered reflow)
if (shouldRestoreScroll && zoomTarget && targetDyOffsetRatio) {
try {
// Scroll to element vertically, according to new page layout
const targetOffset = targetDyOffsetRatio * window.innerHeight;
const rect = zoomTarget.getBoundingClientRect();
const targetTop = rect.top + window.pageYOffset;
const scrollToPosition = targetTop - targetOffset;
window.scrollTo({
top: scrollToPosition,
behavior: 'instant'
});
// Scroll element into view horizontally if it's text
if (zoomTarget.nodeName !== 'IMG' && zoomTarget.nodeName !== 'VIDEO' && zoomTarget.nodeName !== 'IFRAME'){
zoomTarget.classList.add('text-reflow-scroll-padding');
zoomTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
zoomTarget.classList.remove('text-reflow-scroll-padding');
}
} catch (e) {
// Fail silently if target is detached from DOM
}
zoomTarget = null;
targetDyOffsetRatio = null;
}
}
function elementIsTextElement(element) {
return allTextElements.has(element);
}
// --- Interaction Handlers ---
function handleTouchStart(event) {
if (!event.touches) return;
// Logic for tracking the target element
let midpointX, midpointY;
if (event.touches.length >= 2) {
isPinching = true;
const touch1 = event.touches[0];
const touch2 = event.touches[1];
midpointX = (touch1.clientX + touch2.clientX) / 2;
midpointY = (touch1.clientY + touch2.clientY) / 2;
} else {
midpointX = event.touches[0].clientX;
midpointY = event.touches[0].clientY;
}
let possibleZoomTarget;
const elementsFromPoint = document.elementsFromPoint(midpointX, midpointY);
for (let i = 0, n = elementsFromPoint.length, element; i < n; i++) {
element = elementsFromPoint[i];
if (elementIsTextElement(element)) {
possibleZoomTarget = element;
break;
}
}
if (!possibleZoomTarget) possibleZoomTarget = elementsFromPoint[0];
if (possibleZoomTarget) {
zoomTarget = possibleZoomTarget;
const targetRect = zoomTarget.getBoundingClientRect();
targetDyOffsetRatio = targetRect.top / window.innerHeight;
}
}
function handleTouchEnd(event) {
if (isPinching && (event.touches && event.touches.length === 0)) {
isPinching = false;
reflowText(true);
}
}
function handleViewportResize() {
if (isPinching) return;
const currentWidth = window.visualViewport.width;
if (Math.abs(currentWidth - lastViewportWidth) < 1) {
return;
}
clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(() => {
reflowText(true);
}, 400);
}
// --- Dynamic Content Handling ---
function initObserver() {
if (!document.body) return;
const observer = new MutationObserver((mutations) => {
if (isPinching) return;
let hasAddedNodes = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
hasAddedNodes = true;
break;
}
}
if (hasAddedNodes) {
clearTimeout(mutationDebounceTimer);
mutationDebounceTimer = setTimeout(() => {
reflowText(false);
}, 500);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// --- Listeners ---
window.addEventListener('touchstart', handleTouchStart, { passive: true });
window.addEventListener('touchend', handleTouchEnd);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
}
if (document.body) {
initObserver();
} else {
window.addEventListener('DOMContentLoaded', initObserver);
}
})();a tady je ještě novější verze, u které lze podržením tří prstů na displeji text-wrap globálně vypnout a zapnout podle potřeby (když třeba zlobí na některých stránkách).
// ==UserScript==
// @name Text reflow on zoom for mobile (text wrap) - Sync Fix
// @name:ru Text reflow on zoom for mobile (text wrap) - Sync Fix
// @description Fits all text to the screen width after a pinch or double-tap zoom gesture (Global 3-finger toggle)
// @description:ru Подгонка текста под ширину экрана после жеста увеличения на телефоне (Глобальный Вкл/Выкл 3 пальцами)
// @version 1.0.20
// @author emvaized
// @license MIT
// @homepageURL https://github.com/emvaized/text-reflow-on-zoom-mobile/
// @namespace text_reflow_on_pinch_zoom
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const ENABLED_CLASS = 'userscript-mobile-text-reflow-enabled';
// v1.0.12 Selector
const xpathSelector = `
//p |
//a[normalize-space(text())] |
//h1 | //h2 | //h3 | //h4 | //h5 | //h6 |
//li |
//pre |
//div[b or em or i] |
//div[text()[normalize-space()]] |
//div[span[text()[normalize-space()]]]`;
let isReflowEnabled = GM_getValue('reflowEnabled', true);
let isCssInjected = false;
let isPinching = false;
let zoomTarget, targetDyOffsetRatio;
let resizeDebounceTimer;
let mutationDebounceTimer;
let longPressTimer;
const LONG_PRESS_DURATION = 800;
const MOVE_THRESHOLD = 15;
let startTouches = [];
let lastViewportWidth = window.visualViewport ? window.visualViewport.width : window.innerWidth;
const allTextElements = new Set();
let notificationEl = null;
// Apply initial state
updateHtmlClass();
// 1. Listen for background changes (if browser allows)
if (typeof GM_addValueChangeListener !== 'undefined') {
GM_addValueChangeListener('reflowEnabled', (name, oldVal, newVal, remote) => {
if (isReflowEnabled !== newVal) {
isReflowEnabled = newVal;
updateHtmlClass();
if (isReflowEnabled) reflowText(false);
}
});
}
// 2. FORCE SYNC on tab switch (Fixes frozen background tabs)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === 'visible') {
const globalState = GM_getValue('reflowEnabled', true);
if (isReflowEnabled !== globalState) {
isReflowEnabled = globalState;
updateHtmlClass();
if (isReflowEnabled) reflowText(false);
}
}
});
function updateHtmlClass() {
if (isReflowEnabled) {
document.documentElement.classList.add(ENABLED_CLASS);
} else {
document.documentElement.classList.remove(ENABLED_CLASS);
document.documentElement.style.setProperty('--text-reflow-max-width', 'unset');
}
}
function showNotification(text) {
if (!notificationEl) {
notificationEl = document.createElement('div');
Object.assign(notificationEl.style, {
position: 'fixed',
top: '50%',
left: '50%',
backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: 'white',
padding: '16px 24px',
borderRadius: '12px',
zIndex: '2147483647',
fontFamily: 'sans-serif',
fontSize: '18px',
fontWeight: 'bold',
pointerEvents: 'none',
opacity: '0',
transition: 'opacity 0.2s ease',
textAlign: 'center',
boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
userSelect: 'none'
});
document.body.appendChild(notificationEl);
}
const scale = window.visualViewport ? (1 / window.visualViewport.scale) : 1;
notificationEl.style.transform = `translate(-50%, -50%) scale(${scale})`;
notificationEl.textContent = text;
notificationEl.style.opacity = '1';
setTimeout(() => {
notificationEl.style.opacity = '0';
}, 2000);
}
function reflowText(shouldRestoreScroll = false) {
if (!isReflowEnabled) return;
if (!isCssInjected) {
const styleContent = `html.${ENABLED_CLASS} .text-reflow-userscript { word-wrap: break-word !important; overflow-wrap:break-word !important; max-width:var(--text-reflow-max-width) !important; }
.text-reflow-scroll-padding {scroll-margin-left: 1vw !important;}`;
const styleElement = document.createElement('style');
styleElement.textContent = styleContent;
document.head.appendChild(styleElement);
isCssInjected = true;
}
const maxAllowedWidth = Math.round(window.visualViewport.width * 0.96);
document.documentElement.style.setProperty('--text-reflow-max-width', `${maxAllowedWidth}px`);
lastViewportWidth = window.visualViewport.width;
const xpathResult = document.evaluate(xpathSelector, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
allTextElements.clear();
for (let i = 0, n = xpathResult.snapshotLength, el; i < n; i++) {
el = xpathResult.snapshotItem(i);
if (!el.offsetParent) continue;
if (!el.textContent.trim()) continue;
let isTopLevel = true;
let parent = el.parentElement;
while (parent) {
if (elementIsTextElement(parent)) {
isTopLevel = false;
break;
}
parent = parent.parentElement;
}
if (isTopLevel) {
el.classList.add('text-reflow-userscript');
allTextElements.add(el);
}
}
if (shouldRestoreScroll && zoomTarget && targetDyOffsetRatio) {
try {
const targetOffset = targetDyOffsetRatio * window.innerHeight;
const rect = zoomTarget.getBoundingClientRect();
const targetTop = rect.top + window.pageYOffset;
const scrollToPosition = targetTop - targetOffset;
window.scrollTo({
top: scrollToPosition,
behavior: 'instant'
});
if (zoomTarget.nodeName !== 'IMG' && zoomTarget.nodeName !== 'VIDEO' && zoomTarget.nodeName !== 'IFRAME'){
zoomTarget.classList.add('text-reflow-scroll-padding');
zoomTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
zoomTarget.classList.remove('text-reflow-scroll-padding');
}
} catch (e) {
// Fail silently
}
zoomTarget = null;
targetDyOffsetRatio = null;
}
}
function elementIsTextElement(element) {
return allTextElements.has(element);
}
// --- Interaction Handlers ---
function handleTouchStart(event) {
if (!event.touches) return;
// Toggle Gesture (3 Fingers)
if (event.touches.length === 3) {
startTouches = [
{x: event.touches[0].clientX, y: event.touches[0].clientY},
{x: event.touches[1].clientX, y: event.touches[1].clientY},
{x: event.touches[2].clientX, y: event.touches[2].clientY}
];
clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
isReflowEnabled = !isReflowEnabled;
GM_setValue('reflowEnabled', isReflowEnabled);
updateHtmlClass();
showNotification(isReflowEnabled ? "Reflow: ON" : "Reflow: OFF");
if (isReflowEnabled) reflowText(true);
longPressTimer = null;
}, LONG_PRESS_DURATION);
return;
}
let midpointX, midpointY;
if (event.touches.length >= 2) {
isPinching = true;
const touch1 = event.touches[0];
const touch2 = event.touches[1];
midpointX = (touch1.clientX + touch2.clientX) / 2;
midpointY = (touch1.clientY + touch2.clientY) / 2;
} else {
midpointX = event.touches[0].clientX;
midpointY = event.touches[0].clientY;
}
if (isReflowEnabled) {
let possibleZoomTarget;
const elementsFromPoint = document.elementsFromPoint(midpointX, midpointY);
for (let i = 0, n = elementsFromPoint.length, element; i < n; i++) {
element = elementsFromPoint[i];
if (elementIsTextElement(element)) {
possibleZoomTarget = element;
break;
}
}
if (!possibleZoomTarget) possibleZoomTarget = elementsFromPoint[0];
if (possibleZoomTarget) {
zoomTarget = possibleZoomTarget;
const targetRect = zoomTarget.getBoundingClientRect();
targetDyOffsetRatio = targetRect.top / window.innerHeight;
}
}
}
function handleTouchMove(event) {
if (longPressTimer && event.touches.length === 3 && startTouches.length === 3) {
for (let i = 0; i < 3; i++) {
const dx = event.touches[i].clientX - startTouches[i].x;
const dy = event.touches[i].clientY - startTouches[i].y;
if (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
return;
}
}
return;
}
if (longPressTimer && event.touches.length !== 3) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd(event) {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
if (isPinching && (event.touches && event.touches.length === 0)) {
isPinching = false;
if (isReflowEnabled) reflowText(true);
}
}
function handleViewportResize() {
if (isPinching || !isReflowEnabled) return;
const currentWidth = window.visualViewport.width;
if (Math.abs(currentWidth - lastViewportWidth) < 1) {
return;
}
clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(() => {
reflowText(true);
}, 400);
}
function initObserver() {
if (!document.body) return;
const observer = new MutationObserver((mutations) => {
if (isPinching || !isReflowEnabled) return;
let hasAddedNodes = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
hasAddedNodes = true;
break;
}
}
if (hasAddedNodes) {
clearTimeout(mutationDebounceTimer);
mutationDebounceTimer = setTimeout(() => {
reflowText(false);
}, 500);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
window.addEventListener('touchstart', handleTouchStart, { passive: true });
window.addEventListener('touchmove', handleTouchMove, { passive: true });
window.addEventListener('touchend', handleTouchEnd);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
}
if (document.body) {
initObserver();
} else {
window.addEventListener('DOMContentLoaded', initObserver);
}
})();Třeba se to bude někomu hodit. Obecně jsem u těhle user scriptů s tím kóděním přes gemini a chatGpt udělal dobrou zkušenost. Programovat umím, ale na tyhle věci mi nezbývá čas.