Feat: Touch Gestures (Pinch Zoom) for Reader (#29)

This commit is contained in:
Youwes09
2026-04-27 13:31:10 -05:00
parent dc174bee4a
commit 84c2a82c2c
8 changed files with 415 additions and 227 deletions
+60 -5
View File
@@ -3,6 +3,8 @@
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import type { StripChapter } from "../lib/scrollHandler";
import { createPinchTracker } from "../lib/pinchZoom";
import type { PinchTracker } from "../lib/pinchZoom";
interface Props {
style: string;
@@ -16,6 +18,9 @@
stripToRender: StripChapter[];
fadingOut: boolean;
tapToToggleBar: boolean;
pinchZoomEnabled: boolean;
onGetZoom: () => number;
onSetZoom: (z: number) => void;
resolveUrl: (url: string, priority?: number) => Promise<string>;
onTap: (e: MouseEvent) => void;
onWheel: (e: WheelEvent) => void;
@@ -26,7 +31,8 @@
const {
style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props();
const INSPECT_ZOOM_STEP = 0.15;
@@ -57,12 +63,28 @@
let inspectPanStartX = 0;
let inspectPanStartY = 0;
// Drag-to-scroll state for longstrip mode
let stripDragging = false;
let stripDragMoved = false;
let stripDragStartY = 0;
let stripScrollStart = 0;
let pinch: PinchTracker | null = null;
$effect(() => {
if (pinchZoomEnabled) {
pinch = createPinchTracker({
getZoom: onGetZoom,
setZoom: onSetZoom,
getInspectScale: () => readerState.inspectScale,
setInspectScale: (s) => { readerState.inspectScale = s; },
resetInspectPan: () => { readerState.inspectPanX = 0; readerState.inspectPanY = 0; },
isLongstrip: () => style === "longstrip",
});
} else {
pinch = null;
}
});
export function onInspectMouseDown(e: MouseEvent) {
if (style === "longstrip") {
stripDragging = true;
@@ -103,13 +125,43 @@
inspectDragging = false;
}
export function onPointerDown(e: PointerEvent) {
pinch?.onPointerDown(e);
}
export function onPointerMove(e: PointerEvent) {
if (pinch?.isPinching()) {
pinch.onPointerMove(e);
return;
}
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
if (inspectDragging) {
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
readerState.inspectPanX = cx;
readerState.inspectPanY = cy;
}
}
export function onPointerUp(e: PointerEvent) {
pinch?.onPointerUp(e);
if (!pinch?.isPinching()) {
stripDragging = false;
inspectDragging = false;
}
}
export function handleWheel(e: WheelEvent) {
if (style === "longstrip") {
// In longstrip, Ctrl+scroll drives reader-level zoom; plain scroll scrolls naturally.
if (e.ctrlKey) { onWheel(e); }
return;
}
// In paged modes, Ctrl+scroll drives inspect-zoom (magnify); plain scroll pages forward/back.
if (!e.ctrlKey) { onWheel(e); return; }
e.preventDefault();
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
@@ -154,6 +206,7 @@
onclick={handleTap}
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
@@ -217,12 +270,14 @@
</div>
<style>
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; }
.viewer.inspect-active { cursor: grab; overflow: hidden; }
.viewer.inspect-active:active { cursor: grabbing; }
:global(.pinch-active) .viewer { touch-action: none; }
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.img { display: block; user-select: none; image-rendering: auto; }
+11 -2
View File
@@ -46,6 +46,7 @@
const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100));
const pinchZoomEnabled = $derived(store.settings.pinchZoom ?? false);
const displayChapter = $derived(
style === "longstrip" && readerState.visibleChapterId
@@ -195,8 +196,6 @@
if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
}
// onWheel is only invoked from PageView for longstrip Ctrl+scroll (reader-level zoom).
// In paged modes, Ctrl+scroll is handled inside PageView as inspect-zoom instead.
function handleWheel(e: WheelEvent) {
if (!e.ctrlKey) return;
e.preventDefault();
@@ -481,6 +480,8 @@
window.addEventListener("keydown", onKey);
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.addEventListener("pointermove", pageViewRef.onPointerMove);
window.addEventListener("pointerup", pageViewRef.onPointerUp);
readerState.isFullscreen = await win.isFullscreen();
const unlistenFs = await win.onResized(async () => {
@@ -502,6 +503,8 @@
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
cleanupScroll();
unlistenFs();
ro.disconnect();
@@ -514,6 +517,7 @@
class:overlay-bars={overlayBars}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:pinch-active={pinchZoomEnabled}
role="presentation"
onmousemove={(e) => {
if (!tapToToggleBar) {
@@ -582,6 +586,9 @@
{currentGroup} {stripToRender}
fadingOut={readerState.fadingOut}
{tapToToggleBar}
{pinchZoomEnabled}
onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
onTap={handleTap}
onWheel={handleWheel}
@@ -627,4 +634,6 @@
.root.bar-left :global(.viewer) { margin-left: 40px; }
.root.bar-right :global(.viewer) { margin-right: 40px; }
.root.pinch-active :global(.viewer) { touch-action: none; }
</style>
@@ -266,6 +266,16 @@
aria-checked={effectiveSettings.optimizeContrast}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Pinch to zoom <span class="toggle-badge">experimental</span></span>
<button
class="toggle"
class:on={store.settings.pinchZoom ?? false}
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
role="switch"
aria-checked={store.settings.pinchZoom ?? false}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Mark read on chapter advance</span>
<button
@@ -534,6 +544,19 @@
color: var(--text-secondary);
}
.toggle-badge {
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 4px;
margin-left: var(--sp-1);
vertical-align: middle;
}
.toggle {
position: relative;
width: 32px;
+78
View File
@@ -0,0 +1,78 @@
import { clampZoom } from "./zoomHelpers";
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
export interface PinchTrackerOptions {
getZoom: () => number;
setZoom: (z: number) => void;
getInspectScale: () => number;
setInspectScale: (s: number) => void;
resetInspectPan: () => void;
isLongstrip: () => boolean;
}
export interface PinchTracker {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
isPinching: () => boolean;
}
const INSPECT_ZOOM_MAX = 8;
export function createPinchTracker(opts: PinchTrackerOptions): PinchTracker {
const pointers = new Map<number, { x: number; y: number }>();
let startDist = 0;
let startZoom = 0;
let startInspect = 0;
let pinching = false;
function dist(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
function onPointerDown(e: PointerEvent) {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
startDist = dist(a, b);
startZoom = opts.getZoom();
startInspect = opts.getInspectScale();
pinching = true;
}
}
function onPointerMove(e: PointerEvent) {
if (!pinching || !pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size < 2) return;
const [a, b] = [...pointers.values()];
const current = dist(a, b);
if (startDist === 0) return;
const ratio = current / startDist;
if (opts.isLongstrip()) {
opts.setZoom(clampZoom(startZoom * ratio));
} else {
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * ratio));
if (next !== opts.getInspectScale()) {
if (next === 1) opts.resetInspectPan();
opts.setInspectScale(next);
}
}
}
function onPointerUp(e: PointerEvent) {
pointers.delete(e.pointerId);
if (pointers.size < 2) {
pinching = false;
startDist = 0;
startZoom = 0;
startInspect = 0;
}
}
function isPinching() { return pinching; }
return { onPointerDown, onPointerMove, onPointerUp, isPinching };
}