Ctrl and the wheel over a PDF

The same gesture Aether has, whose comment already claimed InkNote had it.
Registered by hand rather than through onWheel, because React attaches wheel
listeners passively and a passive listener may not call preventDefault —
without which the browser zooms the whole application instead of the
document.

What stays still depends on how the zoom was asked for, and the two answers
differ: the wheel holds the point under the pointer, the buttons hold the
page being read. The buttons held nothing at all before, so three presses of
+ left you somewhere else in the file; that came out of building this and is
fixed with it.

The checks are the step, that the default really was prevented, and that a
point measured on the page is still under the pointer afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-25 15:00:54 +02:00
1 parent 8f8fecfb96
commit 580eed8992
4 files changed
+211 -7

No files matched your search

+92 -6
View File
@@ -500,12 +500,93 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
}
};
/* ── zooming, and what stays still while it happens ──
*
* A zoom changes how tall the document is, and the scroll position means
* something different afterwards. Left alone, every step drifts: press + three
* times and you are somewhere else in the document.
*
* What should stay put depends on how the zoom was asked for. From the
* buttons, it is the page you are reading — the request is "show me this page
* larger". From the wheel, it is the point under the pointer: you are pointing
* at a word and asking for *that* to grow, and having the page jump to its top
* instead is exactly what makes a viewer feel unruly.
*/
const keepAnchor = useRef(null);
const keepPage = useRef(null);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
const anchor = keepAnchor.current;
if (anchor) {
keepAnchor.current = null;
// The document grew by this much, so the point that was under the pointer
// is now that much further from the top.
const ratio = pageWidth / anchor.from;
el.scrollTop = Math.max(0, anchor.py * ratio - anchor.cy);
el.scrollLeft = Math.max(0, anchor.px * ratio - anchor.cx);
return;
}
const page = keepPage.current;
if (page === null) return;
keepPage.current = null;
const target = innerRef.current?.querySelector(`[data-pdf-page="${page}"]`);
// Not `goToPage`: that one glides, which is right for "take me to page 7"
// and wrong for holding still — the glide is a jump with a delay on it.
if (target) el.scrollTop = Math.max(0, target.offsetTop - 8);
}, [pageWidth]);
/** The next step up or down, or nothing at the end of the ladder. */
const nextZoom = (from, dir) =>
dir > 0 ? ZOOM_STEPS.find((s) => s > from + 0.001) : [...ZOOM_STEPS].reverse().find((s) => s < from - 0.001);
const stepZoom = (dir) => {
const i = ZOOM_STEPS.indexOf(zoom);
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, (i < 0 ? 2 : i) + dir))];
if (next) setZoom(next);
const next = nextZoom(zoom, dir);
if (!next) return;
keepPage.current = pageNo;
setZoom(next);
};
/**
* Ctrl (or ⌘) and the wheel, as in every other viewer.
*
* Registered by hand rather than with `onWheel`: React attaches wheel
* listeners passively, and a passive listener may not call `preventDefault`.
* Without that call the *browser* zooms the whole page instead — toolbar,
* sidebar and all — which is not what the gesture means over a document.
*
* A trackpad pinch arrives here as the same event, so it works too without
* anything further.
*/
useEffect(() => {
const el = scrollRef.current;
if (!el || state.status !== "ready") return;
const onWheel = (e) => {
if (!e.ctrlKey && !e.metaKey) return;
if (!e.deltaY) return; // a sideways wheel is not a zoom
e.preventDefault();
const next = nextZoom(zoom, e.deltaY < 0 ? 1 : -1);
if (!next) return;
const r = el.getBoundingClientRect();
const cx = e.clientX - r.left;
const cy = e.clientY - r.top;
keepAnchor.current = {
px: el.scrollLeft + cx,
py: el.scrollTop + cy,
cx,
cy,
// Measured in page width rather than in the zoom factor: the fitted
// width is what the pages are actually drawn from, and it changes on
// its own when the window is resized or the margin column appears.
from: pageWidth,
};
setZoom(next);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [zoom, pageWidth, state.status]);
const barButton = {
display: "flex",
alignItems: "center",
@@ -724,11 +805,16 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
{notesToggle}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => stepZoom(-1)} title="Zoom out" style={barButton}>
{/* The gesture is named in the tooltip, because a viewer that answers
Ctrl + wheel and never says so is only helping the people who
already tried it. */}
<button onClick={() => stepZoom(-1)} title="Zoom out (Ctrl + wheel)" style={barButton}>
−
</button>
<span style={{ minWidth: 42, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<button onClick={() => stepZoom(1)} title="Zoom in" style={barButton}>
<span data-pdf-zoom={zoom} style={{ minWidth: 42, textAlign: "center" }}>
{Math.round(zoom * 100)}%
</span>
<button onClick={() => stepZoom(1)} title="Zoom in (Ctrl + wheel)" style={barButton}>
+
</button>