feat: minZoom and maxZoom

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-26 16:17:18 +00:00
parent d56794024f
commit 2fb12fdd5f
4 changed files with 81 additions and 7 deletions
+4
View File
@@ -4458,6 +4458,10 @@ class App extends React.Component<AppProps, AppState> {
* Constrains pan & zoom to a scene-coordinate box, so the viewport can't be
* scrolled or zoomed out past it. Pass `null` to remove the constraint. When
* the box is smaller than the viewport, zoom is increased best-effort to fit.
*
* Optional `minZoom`/`maxZoom` take precedence over the box: `minZoom` lets
* the viewport zoom out past the fit zoom, while `maxZoom` caps zoom-in below
* the global limit.
*/
setScrollConstraints = (scrollConstraints: ScrollConstraints | null) => {
// apply the constraint and clamp the viewport in a single, synchronously
+18 -7
View File
@@ -94,6 +94,11 @@ const constrainScrollAxis = (
* 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.
*
* An explicit `minZoom`/`maxZoom` on the constraints takes precedence over the
* zoom the box would otherwise enforce: `minZoom` replaces the fit zoom (so the
* viewport may zoom out past the box) and `maxZoom` replaces the global
* `MAX_ZOOM` cap.
*/
export const constrainScrollState = (
state: Pick<
@@ -110,14 +115,20 @@ export const constrainScrollState = (
tolerance = Math.max(tolerance, 0);
// 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,
});
// zoom bounds: an explicit min/maxZoom on the constraints takes precedence
// over the box-enforced fit zoom (lower) and the global MAX_ZOOM (upper).
// Without a `minZoom`, relax the fit zoom by `tolerance` so the user can
// briefly zoom out past it (rubberbanding), letting each viewport edge cross
// the box by up to `tolerance` screen px.
const minZoom =
scrollConstraints.minZoom ??
getMinZoomForConstraints(scrollConstraints, {
width: width - 2 * tolerance,
height: height - 2 * tolerance,
});
const maxZoom = scrollConstraints.maxZoom ?? MAX_ZOOM;
const zoomValue = getNormalizedZoom(
clamp(state.zoom.value, relaxedMinZoom, MAX_ZOOM),
clamp(state.zoom.value, minZoom, maxZoom),
);
// `tolerance` screen px expressed in scene coords at the current zoom
@@ -219,6 +219,51 @@ describe("setScrollConstraints (integration)", () => {
});
});
describe("explicit minZoom / maxZoom (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("lets minZoom override the box-enforced fit zoom (zoom out past the box)", () => {
// without minZoom the box forces a fit zoom of 0.2; minZoom 0.1 takes over
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: { ...box, minZoom: 0.1 },
}),
);
expect(zoom.value).toBeCloseTo(0.1);
});
it("still clamps zoom out at minZoom", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: { ...box, minZoom: 0.5 },
}),
);
expect(zoom.value).toBeCloseTo(0.5);
});
it("lets maxZoom cap zoom-in below the global MAX_ZOOM", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(MAX_ZOOM) },
scrollConstraints: { ...box, maxZoom: 3 },
}),
);
expect(zoom.value).toBeCloseTo(3);
});
it("leaves an in-range zoom untouched", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(1.5) },
scrollConstraints: { ...box, minZoom: 0.5, maxZoom: 3 },
}),
);
expect(zoom.value).toBeCloseTo(1.5);
});
});
describe("rubberband tolerance (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
+14
View File
@@ -279,7 +279,21 @@ export type ScrollConstraints = {
y: number;
width: number;
height: number;
/**
* Pixel amount to allow to overscroll.
*/
tolerance?: number;
/**
* Lower zoom bound. When set, it takes precedence over the zoom the box would
* otherwise enforce (the fit zoom that keeps the box covering the viewport),
* letting the viewport zoom out past the box.
*/
minZoom?: number;
/**
* Upper zoom bound. When set, it takes precedence over the global `MAX_ZOOM`,
* capping how far the viewport can zoom in.
*/
maxZoom?: number;
};
export interface AppState {