diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d3362687fe..8cc29942f5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4458,6 +4458,10 @@ class App extends React.Component { * 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 diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index 71308b5f5f..fb11b359da 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -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 diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx index 5af951834c..8a2eb644c6 100644 --- a/packages/excalidraw/tests/scrollConstraints.test.tsx +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -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 }; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 7a130257ef..af4bf22ff3 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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 {