feat: minZoom and maxZoom
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user