feat: Make tolerance a pixel value

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-26 07:59:09 +00:00
parent 1b75a1dbe2
commit 17eac29a9c
3 changed files with 49 additions and 23 deletions
+1 -1
View File
@@ -4432,7 +4432,7 @@ class App extends React.Component<AppProps, AppState> {
/** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when
* unconstrained). Runs as a queued update, so it sees the preceding change.
* `tolerance` (01) relaxes the bounds for rubberbanding. */
* `tolerance` (screen px) relaxes the bounds for rubberbanding. */
private constrainViewportToScrollConstraints = (tolerance = 0) => {
this.setState((prevState) =>
prevState.scrollConstraints
+16 -11
View File
@@ -90,9 +90,10 @@ const constrainScrollAxis = (
* inside the box, any zoom anchor (which lives within the viewport) is also
* guaranteed to stay inside the box.
*
* `tolerance` (01, a fraction of the viewport) relaxes the bounds for
* rubberbanding: the viewport may pan past the edges / zoom out below the fit
* zoom by that fraction. Pass `0` (default) for a hard clamp.
* `tolerance` is the rubberband overscroll allowance, in screen pixels: the
* viewport may pan past the box edges (and zoom out below the fit zoom) until
* each edge has crossed the box by up to `tolerance` pixels on screen,
* independent of the current zoom. Pass `0` (default) for a hard clamp.
*/
export const constrainScrollState = (
state: Pick<
@@ -107,31 +108,35 @@ export const constrainScrollState = (
return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
}
tolerance = clamp(tolerance, 0, 1);
tolerance = Math.max(tolerance, 0);
const minZoom = getMinZoomForConstraints(scrollConstraints, {
width,
height,
// relax the min zoom so the user can briefly zoom out past the fit zoom,
// letting each viewport edge cross the box by up to `tolerance` screen px
const relaxedMinZoom = getMinZoomForConstraints(scrollConstraints, {
width: width - 2 * tolerance,
height: height - 2 * tolerance,
});
// relax the min zoom so the user can briefly zoom out past the fit zoom
const zoomValue = getNormalizedZoom(
clamp(state.zoom.value, minZoom * (1 - tolerance), MAX_ZOOM),
clamp(state.zoom.value, relaxedMinZoom, MAX_ZOOM),
);
// `tolerance` screen px expressed in scene coords at the current zoom
const overscroll = tolerance / zoomValue;
return {
scrollX: constrainScrollAxis(
state.scrollX,
scrollConstraints.x,
scrollConstraints.width,
width / zoomValue,
(tolerance * width) / zoomValue,
overscroll,
),
scrollY: constrainScrollAxis(
state.scrollY,
scrollConstraints.y,
scrollConstraints.height,
height / zoomValue,
(tolerance * height) / zoomValue,
overscroll,
),
zoom: { value: zoomValue },
};
@@ -221,19 +221,38 @@ describe("setScrollConstraints (integration)", () => {
describe("rubberband tolerance (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("allows scroll overscroll up to `tolerance` fraction of the viewport", () => {
const tolerance = 0.25;
// hard max for scrollX is -box.x = 0; soft adds tolerance * width / zoom
it("allows scroll overscroll up to `tolerance` screen pixels", () => {
const tolerance = 30; // screen px
// hard max for scrollX is -box.x = 0; soft adds tolerance / zoom scene px
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: { ...box, tolerance } }),
tolerance,
);
expect(result.scrollX).toBeCloseTo((tolerance * VIEWPORT.width) / 1); // 50
expect(result.scrollX).toBeCloseTo(tolerance / 1); // 30 (zoom 1)
});
it("relaxes the minimum zoom by the `tolerance` fraction", () => {
const tolerance = 0.25;
const minZoom = getMinZoomForConstraints(box, VIEWPORT); // 0.2
it("keeps the overscroll a fixed screen distance regardless of zoom", () => {
const tolerance = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: { ...box, tolerance },
}),
tolerance,
);
// 30 screen px at zoom 2 -> 15 scene px of overscroll
expect(result.scrollX).toBeCloseTo(tolerance / 2); // 15
});
it("relaxes the minimum zoom by the `tolerance` screen pixels", () => {
const tolerance = 25; // screen px
// relaxed min zoom lets the box shrink within the viewport by `tolerance`
// px on each side: max((200-50)/1000, (100-50)/1000) = 0.15
const expected = getMinZoomForConstraints(box, {
width: VIEWPORT.width - 2 * tolerance,
height: VIEWPORT.height - 2 * tolerance,
});
const result = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.01) },
@@ -241,7 +260,7 @@ describe("rubberband tolerance (pure)", () => {
}),
tolerance,
);
expect(result.zoom.value).toBeCloseTo(minZoom * (1 - tolerance)); // 0.15
expect(result.zoom.value).toBeCloseTo(expected); // 0.15
});
it("hard-clamps when tolerance is 0 (default)", () => {
@@ -305,7 +324,7 @@ describe("rubberband tolerance (integration)", () => {
y: 0,
width: 1000,
height: 1000,
tolerance: 0.25,
tolerance: 25,
});
});
@@ -315,8 +334,10 @@ describe("rubberband tolerance (integration)", () => {
// page-up pans up, pushing scrollY past the hard edge into the overscroll
Keyboard.keyPress(KEYS.PAGE_UP);
// overscrolled past 0, but bounded by tolerance * height = 25
// overscrolled past 0, but bounded by tolerance (25 screen px / zoom 1)
expect(h.state.scrollY).toBeGreaterThan(0);
expect(h.state.scrollY).toBeLessThanOrEqual(0.25 * h.state.height + 0.001);
expect(h.state.scrollY).toBeLessThanOrEqual(
25 / h.state.zoom.value + 0.001,
);
});
});