Skip to content

Instantly share code, notes, and snippets.

@pscheid92
Last active September 4, 2025 22:25
Show Gist options
  • Save pscheid92/c4cbd273c5a45d8fbf172641570fc7d2 to your computer and use it in GitHub Desktop.
Save pscheid92/c4cbd273c5a45d8fbf172641570fc7d2 to your computer and use it in GitHub Desktop.
ZEIT ePaper – Natural Pinch & Pan (Tampermonkey Script)
// ==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