Feat: Reader Pan + Zoom

This commit is contained in:
Youwes09
2026-04-10 19:30:51 -05:00
parent 1a08d2415f
commit 15079f7755
+97 -4
View File
@@ -160,6 +160,16 @@
let sliderDragging = $state(false); let sliderDragging = $state(false);
let sliderHover = $state(false); let sliderHover = $state(false);
let inspectScale = $state(1);
let inspectPanX = $state(0);
let inspectPanY = $state(0);
let inspectDragging = false;
let inspectDragMoved = false;
let inspectDragStartX = 0;
let inspectDragStartY = 0;
let inspectPanStartX = 0;
let inspectPanStartY = 0;
const rtl = $derived(store.settings.readingDirection === "rtl"); const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
const style = $derived((store.settings.pageStyle ?? "single") as PageStyle); const style = $derived((store.settings.pageStyle ?? "single") as PageStyle);
@@ -337,7 +347,9 @@
} }
}); });
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; }); $effect(() => { if (style !== "longstrip") { void store.pageNumber; inspectScale = 1; inspectPanX = 0; inspectPanY = 0; } });
$effect(() => { $effect(() => {
const chId = visibleChapterId; const chId = visibleChapterId;
@@ -770,10 +782,49 @@
} }
}); });
const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8;
function getInspectImageEl(): HTMLElement | null {
if (!containerEl) return null;
return (
containerEl.querySelector<HTMLElement>(".inspect-wrap .double-wrap") ??
containerEl.querySelector<HTMLElement>(".inspect-wrap img")
);
}
function clampInspectPan(scale: number, px: number, py: number): [number, number] {
const img = getInspectImageEl();
if (!img) return [px, py];
const maxX = Math.max(0, (img.offsetWidth * (scale - 1)) / 2);
const maxY = Math.max(0, (img.offsetHeight * (scale - 1)) / 2);
return [Math.max(-maxX, Math.min(maxX, px)), Math.max(-maxY, Math.min(maxY, py))];
}
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!e.ctrlKey) return; if (e.ctrlKey) {
e.preventDefault();
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
return;
}
if (style === "longstrip") return;
e.preventDefault(); e.preventDefault();
adjustZoom(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP); const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, inspectScale + delta));
if (next === inspectScale) return;
if (next === 1) { inspectScale = 1; inspectPanX = 0; inspectPanY = 0; return; }
const img = getInspectImageEl();
const anchor = img ?? containerEl;
const rect = anchor?.getBoundingClientRect();
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
const ratio = next / inspectScale;
const rawPanX = cx + (inspectPanX - cx) * ratio;
const rawPanY = cy + (inspectPanY - cy) * ratio;
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
inspectScale = next;
inspectPanX = clampedX;
inspectPanY = clampedY;
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -814,10 +865,36 @@
function handleTap(e: MouseEvent) { function handleTap(e: MouseEvent) {
if (style === "longstrip") return; if (style === "longstrip") return;
if (inspectDragMoved) { inspectDragMoved = false; return; }
const x = e.clientX / window.innerWidth; const x = e.clientX / window.innerWidth;
if (x > 0.6) goNext(); else if (x < 0.4) goPrev(); if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
} }
function onInspectMouseDown(e: MouseEvent) {
if (style === "longstrip" || inspectScale <= 1) return;
inspectDragging = true;
inspectDragMoved = false;
inspectDragStartX = e.clientX;
inspectDragStartY = e.clientY;
inspectPanStartX = inspectPanX;
inspectPanStartY = inspectPanY;
e.preventDefault();
}
function onInspectMouseMove(e: MouseEvent) {
if (!inspectDragging) return;
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(inspectScale, rawX, rawY);
inspectPanX = cx;
inspectPanY = cy;
}
function onInspectMouseUp() {
inspectDragging = false;
}
async function runDl(fn: () => Promise<unknown>) { async function runDl(fn: () => Promise<unknown>) {
dlBusy = true; dlBusy = true;
try { await fn(); } catch (e: any) { console.error(e); } try { await fn(); } catch (e: any) { console.error(e); }
@@ -828,6 +905,8 @@
showUi(); showUi();
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("wheel", onWheel, { passive: false });
window.addEventListener("mousemove", onInspectMouseMove);
window.addEventListener("mouseup", onInspectMouseUp);
containerEl?.focus({ preventScroll: true }); containerEl?.focus({ preventScroll: true });
let roTimer: ReturnType<typeof setTimeout> | null = null; let roTimer: ReturnType<typeof setTimeout> | null = null;
@@ -844,6 +923,8 @@
if (roTimer) clearTimeout(roTimer); if (roTimer) clearTimeout(roTimer);
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("wheel", onWheel); window.removeEventListener("wheel", onWheel);
window.removeEventListener("mousemove", onInspectMouseMove);
window.removeEventListener("mouseup", onInspectMouseUp);
cleanupScroll(); cleanupScroll();
ro.disconnect(); ro.disconnect();
}; };
@@ -1020,11 +1101,13 @@
bind:this={containerEl} bind:this={containerEl}
class="viewer" class="viewer"
class:strip={style === "longstrip"} class:strip={style === "longstrip"}
class:inspect-active={inspectScale > 1}
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""} style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation" role="presentation"
tabindex="-1" tabindex="-1"
onclick={handleTap} onclick={handleTap}
onwheel={(e) => { if (e.ctrlKey) e.preventDefault(); }} onmousedown={onInspectMouseDown}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }} onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
> >
@@ -1048,13 +1131,16 @@
<div style="height:1px;flex-shrink:0"></div> <div style="height:1px;flex-shrink:0"></div>
{:else if style === "fade" && pageReady} {:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" /> <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
{/await} {/await}
</div>
{:else if style === "double" && pageReady} {:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
{#if pageGroups.length} {#if pageGroups.length}
<div class="double-wrap"> <div class="double-wrap">
{#each currentGroup as pg, i} {#each currentGroup as pg, i}
@@ -1068,13 +1154,16 @@
{:else} {:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div> <div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if} {/if}
</div>
{:else if pageReady} {:else if pageReady}
<div class="inspect-wrap" style="transform:scale({inspectScale}) translate({inspectPanX / inspectScale}px,{inspectPanY / inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src} {:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" /> <img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await} {/await}
</div>
{/if} {/if}
</div> </div>
@@ -1239,6 +1328,10 @@
.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; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .viewer:focus { outline: none; }
.viewer.inspect-active { cursor: grab; overflow: hidden; }
.viewer.inspect-active:active { cursor: grabbing; }
.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; } .img { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }