mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Touch Gestures (Pinch Zoom) for Reader (#29)
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user