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:
1 parent
8f8fecfb96
commit
580eed8992
4 files changed
+211
-7
No files matched your search
@@ -40,6 +40,31 @@ a rebuild is not bit-identical.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-22 — Ctrl and the wheel over a PDF
|
||||
|
||||
**Images** — web `sha256:783ccf92…`, server `sha256:2141f145…`
|
||||
|
||||
No backend source changed this round; the server digest moved only because
|
||||
`build.sh` builds both and a rebuild is not bit-identical.
|
||||
|
||||
**Added**
|
||||
- **Ctrl (or ⌘) and the wheel zoom the document**, as in every other viewer, and
|
||||
a trackpad pinch with it — it arrives as the same event. The listener is
|
||||
registered by hand rather than through `onWheel`: React attaches wheel
|
||||
listeners passively, a passive listener may not call `preventDefault`, and
|
||||
without that the *browser* zooms the whole application instead, toolbar and
|
||||
all. **The point under the pointer stays under the pointer**: you are pointing
|
||||
at a word and asking for that to grow, not to be sent to the top of the page.
|
||||
|
||||
**Fixed**
|
||||
- **Zooming with the buttons drifted.** A zoom changes how tall the document is,
|
||||
so the scroll position means something else afterwards, and nothing was holding
|
||||
anything still — three presses of + and you were elsewhere in the file. They
|
||||
hold the page being read now, which is what "show me this page larger" asks
|
||||
for.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-22 — A bigger face, and the theme where the settings are
|
||||
|
||||
**Images** — web `sha256:fe35b0e7…`, server `sha256:a557fb4d…`
|
||||
|
||||
+78
@@ -270,6 +270,84 @@ try {
|
||||
await p.getByTitle("Zoom out").click();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
/* ── Ctrl and the wheel ──
|
||||
* The claim is not only "the number changes". It is that the browser is kept
|
||||
* out of it — an unprevented Ctrl+wheel zooms the whole application, toolbar
|
||||
* and all — and that the spot under the pointer is the spot that stays put,
|
||||
* which is the whole difference between a viewer that feels steady and one
|
||||
* that jumps to the top of the page at every step. */
|
||||
const zoomNow = () => p.locator("[data-pdf-zoom]").evaluate((el) => Number(el.dataset.pdfZoom));
|
||||
const pageRect = () =>
|
||||
p.locator('[data-pdf-page="1"]').evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, height: r.height };
|
||||
});
|
||||
|
||||
const firstPage = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||||
// A point well inside the page, and low enough that holding it still means
|
||||
// scrolling rather than doing nothing.
|
||||
const at = { x: firstPage.x + firstPage.width * 0.5, y: firstPage.y + firstPage.height * 0.6 };
|
||||
const beforeRect = await pageRect();
|
||||
const fraction = (at.y - beforeRect.top) / beforeRect.height;
|
||||
const zoomBefore = await zoomNow();
|
||||
|
||||
const wheeled = await p.evaluate(
|
||||
([x, y]) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
let prevented = null;
|
||||
const spy = (e) => (prevented = e.defaultPrevented);
|
||||
// Added after the viewer's own listener, so it runs after it and can see
|
||||
// whether the default was called off.
|
||||
document.addEventListener("wheel", spy, { passive: true });
|
||||
el.dispatchEvent(
|
||||
new WheelEvent("wheel", { deltaY: -120, ctrlKey: true, bubbles: true, cancelable: true, clientX: x, clientY: y })
|
||||
);
|
||||
document.removeEventListener("wheel", spy);
|
||||
return prevented;
|
||||
},
|
||||
[at.x, at.y]
|
||||
);
|
||||
await p.waitForTimeout(2000);
|
||||
|
||||
check((await zoomNow()) > zoomBefore, "Ctrl and the wheel zoom in", `${zoomBefore} → ${await zoomNow()}`);
|
||||
check(wheeled === true, "and the browser is stopped from zooming itself instead", `defaultPrevented ${wheeled}`);
|
||||
|
||||
const afterRect = await pageRect();
|
||||
const held = afterRect.top + fraction * afterRect.height;
|
||||
check(
|
||||
Math.abs(held - at.y) < 12,
|
||||
"and the spot under the pointer stays under the pointer",
|
||||
`${Math.round(at.y)} → ${Math.round(held)}`
|
||||
);
|
||||
|
||||
await p.evaluate(
|
||||
([x, y]) => {
|
||||
document
|
||||
.elementFromPoint(x, y)
|
||||
.dispatchEvent(
|
||||
new WheelEvent("wheel", { deltaY: 120, ctrlKey: true, bubbles: true, cancelable: true, clientX: x, clientY: y })
|
||||
);
|
||||
},
|
||||
[at.x, at.y]
|
||||
);
|
||||
await p.waitForTimeout(1800);
|
||||
check((await zoomNow()) === zoomBefore, "and the other way out again", `${await zoomNow()}`);
|
||||
|
||||
/* The buttons hold something else still: the page being read. "Show me this
|
||||
page larger" should not answer by moving to another one. */
|
||||
await p.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded();
|
||||
await p.waitForTimeout(1200);
|
||||
const readingBefore = await p.locator("[data-pdf-position]").innerText();
|
||||
await p.getByTitle("Zoom in").click();
|
||||
await p.waitForTimeout(2200);
|
||||
check(
|
||||
(await p.locator("[data-pdf-position]").innerText()) === readingBefore,
|
||||
"zooming from the buttons keeps the page being read",
|
||||
`${readingBefore} → ${await p.locator("[data-pdf-position]").innerText()}`
|
||||
);
|
||||
await p.getByTitle("Zoom out").click();
|
||||
await p.waitForTimeout(1500);
|
||||
|
||||
/* ── hiding it ── */
|
||||
await p.locator("[data-pdf-hide-ink]").click();
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -618,7 +618,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
|
||||
| `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 96 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
@@ -880,6 +880,21 @@ page is expensive to draw and cheap to hold, so scrolling back up repaints
|
||||
nothing. Canvases are sized in device pixels and scaled down by CSS, which is
|
||||
what keeps text sharp on a high-density screen.
|
||||
|
||||
**Zooming holds something still.** A zoom changes how tall the document is, so
|
||||
the scroll position means something different afterwards — left alone, every step
|
||||
drifts and three presses of + leave you somewhere else in the file. What should
|
||||
stay put depends on how the zoom was asked for, and the two answers are
|
||||
different: from the buttons it is **the page you are reading**, because the
|
||||
request is "show me this page larger"; from **Ctrl + the wheel** it is the point
|
||||
under the pointer, because you are pointing at a word and asking for that to
|
||||
grow. The wheel listener is registered by hand rather than through `onWheel`:
|
||||
React attaches wheel listeners passively, a passive listener may not call
|
||||
`preventDefault`, and without that call the *browser* zooms the whole application
|
||||
— toolbar, sidebar and all. A trackpad pinch arrives as the same event and needs
|
||||
nothing further. The suite checks all three: the step, that the default really
|
||||
was prevented, and that a point picked on the page is still under the pointer
|
||||
afterwards.
|
||||
|
||||
**pdf.js is not in the main bundle.** It is larger than the rest of the
|
||||
application put together, so it sits behind a dynamic import: a session that
|
||||
never opens a PDF never downloads it. The worker is bundled too, not fetched
|
||||
|
||||
Reference in new issue
Block a user