Last active
September 4, 2025 22:25
-
-
Save pscheid92/c4cbd273c5a45d8fbf172641570fc7d2 to your computer and use it in GitHub Desktop.
ZEIT ePaper – Natural Pinch & Pan (Tampermonkey Script)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name ZEIT ePaper – Natural Pinch & Pan | |
| // @namespace github.com/YOURNAME | |
| // @description Fixes unintuitive gestures: pinch zooms via toolbar buttons, two-finger scroll pans the page. | |
| // @version 4.0.0 | |
| // @match https://epaper.zeit.de/webreader-v3/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| /** ----------------------------- | |
| * Configuration | |
| * ------------------------------*/ | |
| const CONFIG = { | |
| minVisibleSize: 100, // Minimum size for element to be considered visible | |
| dragEndDelay: 50, // Delay before ending drag (ms) | |
| panSmoothFactor: 1.2, // Smoothing factor for pan movement | |
| retryInterval: 100, // Initial setup retry interval (ms) | |
| maxRetries: 50, // Maximum setup retries | |
| zoomClicksPerGesture: 2, // How many button clicks per large gesture | |
| zoomGestureThreshold: 50, // deltaY threshold for multi-click zoom | |
| minTimeBetweenClicks: 10, // Minimum ms between button clicks | |
| }; | |
| /** ----------------------------- | |
| * State Management | |
| * ------------------------------*/ | |
| class GestureState { | |
| constructor() { | |
| this.target = null; | |
| this.dragging = false; | |
| this.cursorX = 0; | |
| this.cursorY = 0; | |
| this.dragTimer = null; | |
| this.observer = null; | |
| this.initialized = false; | |
| this.lastClickTime = 0; | |
| this.zoomQueue = []; | |
| this.processingQueue = false; | |
| } | |
| reset() { | |
| this.endDrag(); | |
| this.target = null; | |
| this.zoomQueue = []; | |
| } | |
| endDrag() { | |
| if (this.dragTimer) { | |
| clearTimeout(this.dragTimer); | |
| this.dragTimer = null; | |
| } | |
| this.dragging = false; | |
| } | |
| destroy() { | |
| this.reset(); | |
| if (this.observer) { | |
| this.observer.disconnect(); | |
| this.observer = null; | |
| } | |
| } | |
| } | |
| const state = new GestureState(); | |
| /** ----------------------------- | |
| * Helper Functions | |
| * ------------------------------*/ | |
| function isVisible(el) { | |
| if (!el) return false; | |
| const rect = el.getBoundingClientRect(); | |
| return ( | |
| rect.width >= CONFIG.minVisibleSize && | |
| rect.height >= CONFIG.minVisibleSize && | |
| rect.top < window.innerHeight && | |
| rect.bottom > 0 && | |
| el.offsetParent !== null | |
| ); | |
| } | |
| function findTarget() { | |
| // Priority order for finding the pan target | |
| const selectors = [ | |
| "img.pageImage:not([style*='display: none'])", | |
| ".page-box img", | |
| "[ng-pinch-zoom] img", | |
| ".pageContainer img" | |
| ]; | |
| for (const selector of selectors) { | |
| const elements = document.querySelectorAll(selector); | |
| for (const img of elements) { | |
| if (isVisible(img)) { | |
| // Find the appropriate container for panning | |
| const container = | |
| img.closest(".page-box") || | |
| img.closest("[ng-pinch-zoom]") || | |
| img.closest(".pageContainer") || | |
| img.parentElement; | |
| if (container) { | |
| return container; | |
| } | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function ensureTarget() { | |
| if (!state.target || !isVisible(state.target)) { | |
| state.target = findTarget(); | |
| if (state.target && !state.initialized) { | |
| state.initialized = true; | |
| console.log("ZEIT ePaper gesture handler: Target acquired"); | |
| } | |
| } | |
| return state.target; | |
| } | |
| function getZoomButtons() { | |
| // Cache button references if possible | |
| if (!getZoomButtons.cache || !document.body.contains(getZoomButtons.cache.plus)) { | |
| const plus = document.querySelector( | |
| "buttons-only-zoom-control .wr-simple-plus, " + | |
| ".zoom-control .plus-button, " + | |
| "[aria-label*='zoom in' i], " + | |
| ".wr-icon-zoom-in" | |
| ); | |
| const minus = document.querySelector( | |
| "buttons-only-zoom-control .wr-simple-minus, " + | |
| ".zoom-control .minus-button, " + | |
| "[aria-label*='zoom out' i], " + | |
| ".wr-icon-zoom-out" | |
| ); | |
| getZoomButtons.cache = { plus, minus }; | |
| } | |
| return getZoomButtons.cache; | |
| } | |
| /** ----------------------------- | |
| * Event Dispatching | |
| * ------------------------------*/ | |
| function createPointerEvent(type, x, y) { | |
| const buttons = (type === "pointerdown" || type === "pointermove") ? 1 : 0; | |
| return new PointerEvent(type, { | |
| bubbles: true, | |
| cancelable: true, | |
| composed: true, | |
| clientX: x, | |
| clientY: y, | |
| screenX: x, | |
| screenY: y, | |
| buttons, | |
| button: 0, | |
| pointerId: 1, | |
| pointerType: "mouse", | |
| view: window | |
| }); | |
| } | |
| function dispatch(type, x, y) { | |
| if (!state.target) return false; | |
| try { | |
| // Dispatch both pointer and mouse events for compatibility | |
| state.target.dispatchEvent(createPointerEvent(type, x, y)); | |
| state.target.dispatchEvent(new MouseEvent( | |
| type.replace("pointer", "mouse"), | |
| createPointerEvent(type, x, y) | |
| )); | |
| return true; | |
| } catch (e) { | |
| console.warn("ZEIT ePaper gesture handler: Event dispatch failed", e); | |
| return false; | |
| } | |
| } | |
| /** ----------------------------- | |
| * Drag/Pan Implementation | |
| * ------------------------------*/ | |
| function beginDrag(x, y) { | |
| if (state.dragging) return; | |
| state.dragging = true; | |
| state.cursorX = x; | |
| state.cursorY = y; | |
| dispatch("pointerdown", x, y); | |
| } | |
| function moveDrag(dx, dy) { | |
| if (!state.dragging) return; | |
| // Apply smoothing | |
| const smoothDx = dx * CONFIG.panSmoothFactor; | |
| const smoothDy = dy * CONFIG.panSmoothFactor; | |
| state.cursorX += smoothDx; | |
| state.cursorY += smoothDy; | |
| dispatch("pointermove", state.cursorX, state.cursorY); | |
| } | |
| function endDragSoon() { | |
| clearTimeout(state.dragTimer); | |
| state.dragTimer = setTimeout(() => { | |
| if (state.dragging) { | |
| dispatch("pointerup", state.cursorX, state.cursorY); | |
| state.dragging = false; | |
| } | |
| }, CONFIG.dragEndDelay); | |
| } | |
| /** ----------------------------- | |
| * Zoom Implementation | |
| * ------------------------------*/ | |
| async function processZoomQueue() { | |
| if (state.processingQueue || state.zoomQueue.length === 0) return; | |
| state.processingQueue = true; | |
| const buttons = getZoomButtons(); | |
| while (state.zoomQueue.length > 0) { | |
| const direction = state.zoomQueue.shift(); | |
| const button = direction === 'in' ? buttons.plus : buttons.minus; | |
| if (button) { | |
| button.click(); | |
| // Small delay between clicks to let the viewer process them | |
| await new Promise(resolve => setTimeout(resolve, CONFIG.minTimeBetweenClicks)); | |
| } | |
| } | |
| state.processingQueue = false; | |
| } | |
| function handleZoom(deltaY) { | |
| const buttons = getZoomButtons(); | |
| if (!buttons.plus || !buttons.minus) { | |
| console.warn("ZEIT ePaper gesture handler: Zoom buttons not found"); | |
| return; | |
| } | |
| // Determine zoom intensity based on gesture magnitude | |
| const absDelta = Math.abs(deltaY); | |
| let clicks = 1; | |
| // For large gestures, queue multiple clicks | |
| if (absDelta > CONFIG.zoomGestureThreshold) { | |
| clicks = Math.min(CONFIG.zoomClicksPerGesture, Math.floor(absDelta / CONFIG.zoomGestureThreshold)); | |
| } | |
| // Queue the clicks | |
| const direction = deltaY < 0 ? 'in' : 'out'; | |
| for (let i = 0; i < clicks; i++) { | |
| state.zoomQueue.push(direction); | |
| } | |
| // Process the queue | |
| processZoomQueue(); | |
| } | |
| /** ----------------------------- | |
| * Event Handlers | |
| * ------------------------------*/ | |
| function handleWheel(e) { | |
| // Only handle events on our target | |
| if (!ensureTarget()) return; | |
| const rect = state.target.getBoundingClientRect(); | |
| const isInside = | |
| e.clientX >= rect.left && | |
| e.clientX <= rect.right && | |
| e.clientY >= rect.top && | |
| e.clientY <= rect.bottom; | |
| if (!isInside) return; | |
| // Prevent default behavior and stop propagation | |
| e.preventDefault(); | |
| e.stopImmediatePropagation(); | |
| if (e.ctrlKey || e.metaKey) { | |
| // Pinch-to-zoom (Ctrl+wheel or Cmd+wheel) | |
| handleZoom(e.deltaY); | |
| } else { | |
| // Two-finger scroll for panning | |
| const multiplier = e.deltaMode === 0 ? 1 : (e.deltaMode === 1 ? 16 : 100); | |
| const dx = -e.deltaX * multiplier; | |
| const dy = -e.deltaY * multiplier; | |
| if (!state.dragging) { | |
| beginDrag(e.clientX, e.clientY); | |
| } | |
| moveDrag(dx, dy); | |
| endDragSoon(); | |
| } | |
| } | |
| function handleGestureChange(e) { | |
| // Safari native pinch gesture | |
| e.preventDefault(); | |
| const buttons = getZoomButtons(); | |
| if (!buttons.plus || !buttons.minus) return; | |
| // Convert scale to deltaY-like value | |
| const scaleDelta = (1 - e.scale) * 100; | |
| // Use the same zoom handler for consistency | |
| handleZoom(scaleDelta); | |
| e.stopImmediatePropagation(); | |
| } | |
| /** ----------------------------- | |
| * Initialization | |
| * ------------------------------*/ | |
| function setupEventListeners() { | |
| const opts = { capture: true, passive: false }; | |
| // Main wheel handler for both zoom and pan | |
| window.addEventListener("wheel", handleWheel, opts); | |
| // Safari gesture support | |
| if ("GestureEvent" in window) { | |
| window.addEventListener("gesturestart", e => e.preventDefault(), opts); | |
| window.addEventListener("gesturechange", handleGestureChange, opts); | |
| window.addEventListener("gestureend", e => e.preventDefault(), opts); | |
| } | |
| // Cleanup on page unload | |
| window.addEventListener("beforeunload", () => state.destroy()); | |
| } | |
| function setupObserver() { | |
| // Only observe specific containers for performance | |
| const observerTarget = document.querySelector( | |
| "#webreader-main, .webreader-container, [ng-app], body" | |
| ); | |
| if (!observerTarget) return; | |
| state.observer = new MutationObserver(() => { | |
| // Debounce target checking | |
| if (!state.observer.debounceTimer) { | |
| state.observer.debounceTimer = setTimeout(() => { | |
| ensureTarget(); | |
| // Clear button cache when DOM changes | |
| getZoomButtons.cache = null; | |
| state.observer.debounceTimer = null; | |
| }, 100); | |
| } | |
| }); | |
| state.observer.observe(observerTarget, { | |
| childList: true, | |
| subtree: true, | |
| // Don't observe all attributes for performance | |
| attributeFilter: ["class", "style", "hidden"] | |
| }); | |
| } | |
| function initialize() { | |
| let retries = 0; | |
| const tryInit = () => { | |
| if (ensureTarget()) { | |
| setupEventListeners(); | |
| setupObserver(); | |
| console.log("ZEIT ePaper gesture handler: Initialized successfully"); | |
| } else if (retries++ < CONFIG.maxRetries) { | |
| setTimeout(tryInit, CONFIG.retryInterval); | |
| } else { | |
| console.warn("ZEIT ePaper gesture handler: Could not find target after maximum retries"); | |
| // Set up listeners anyway in case the page loads later | |
| setupEventListeners(); | |
| setupObserver(); | |
| } | |
| }; | |
| // Start initialization | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", tryInit); | |
| } else { | |
| tryInit(); | |
| } | |
| } | |
| // Start the script | |
| initialize(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment