From dba56d3edc72badff6a27b29ad93e4c8872a69d6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 26 Jun 2026 17:05:29 +0000 Subject: [PATCH] feat: Padding Signed-off-by: Mark Tolmacs --- packages/excalidraw/scroll.ts | 31 +++++++--- .../tests/scrollConstraints.test.tsx | 62 +++++++++++++++++++ packages/excalidraw/types.ts | 7 +++ 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index fb11b359da..207fe534b2 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -67,19 +67,21 @@ export const getMinZoomForConstraints = ( /** * Clamps a single scroll axis so the visible scene span stays inside the box. * The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within - * `[boxStart, boxStart + boxSize]`, expanded by `overscroll` on each side (for - * rubberbanding). When the box can't cover the viewport on this axis (only at - * the MAX_ZOOM cap for a tiny box) we center the box instead. + * `[boxStart, boxStart + boxSize]`, expanded by `startExpand` at the low edge + * and `endExpand` at the high edge (rubberband overscroll plus any padding). + * When the box can't cover the viewport on this axis (only at the MAX_ZOOM cap + * for a tiny box) we center the box instead. */ const constrainScrollAxis = ( scroll: number, boxStart: number, boxSize: number, visibleSize: number, - overscroll: number, + startExpand: number, + endExpand: number, ): number => { - const max = -boxStart + overscroll; - const min = visibleSize - (boxStart + boxSize) - overscroll; + const max = -boxStart + startExpand; + const min = visibleSize - (boxStart + boxSize) - endExpand; return min > max ? (min + max) / 2 : clamp(scroll, min, max); }; @@ -99,6 +101,11 @@ const constrainScrollAxis = ( * 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. + * + * `padding` ([top, right, bottom, left], screen px) permanently extends the + * scrollable area past each box edge by that many on-screen pixels, so the + * viewport can reveal that much empty space beyond the box. Unlike `tolerance` + * it does not snap back. */ export const constrainScrollState = ( state: Pick< @@ -131,8 +138,12 @@ export const constrainScrollState = ( clamp(state.zoom.value, minZoom, maxZoom), ); - // `tolerance` screen px expressed in scene coords at the current zoom + // `tolerance`/`padding` are screen px; express them in scene coords at the + // current zoom so the on-screen amount stays the same regardless of zoom. const overscroll = tolerance / zoomValue; + const [padTop, padRight, padBottom, padLeft] = ( + scrollConstraints.padding ?? [0, 0, 0, 0] + ).map((px) => px / zoomValue); return { scrollX: constrainScrollAxis( @@ -140,14 +151,16 @@ export const constrainScrollState = ( scrollConstraints.x, scrollConstraints.width, width / zoomValue, - overscroll, + overscroll + padLeft, + overscroll + padRight, ), scrollY: constrainScrollAxis( state.scrollY, scrollConstraints.y, scrollConstraints.height, height / zoomValue, - overscroll, + overscroll + padTop, + overscroll + padBottom, ), zoom: { value: zoomValue }, }; diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx index 8a2eb644c6..8c683762ec 100644 --- a/packages/excalidraw/tests/scrollConstraints.test.tsx +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -264,6 +264,68 @@ describe("explicit minZoom / maxZoom (pure)", () => { }); }); +describe("padding (pure)", () => { + const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 }; + + it("extends the scrollable area past each edge by `[top, right, bottom, left]`", () => { + const padding: [number, number, number, number] = [10, 20, 30, 40]; + + // pan past the top-left corner → clamp to the padded corner + const topLeft = constrainScrollState( + makeState({ + scrollX: 999, + scrollY: 999, + scrollConstraints: { ...box, padding }, + }), + ); + expect(topLeft.scrollX).toBeCloseTo(40); // left padding + expect(topLeft.scrollY).toBeCloseTo(10); // top padding + + // pan past the far edges → clamp to width/height - boxSize - far padding + const farEdge = constrainScrollState( + makeState({ + scrollX: -5000, + scrollY: -5000, + scrollConstraints: { ...box, padding }, + }), + ); + expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width - 20); // -820 (right) + expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height - 30); // -930 (bottom) + }); + + it("keeps the padding a fixed screen distance regardless of zoom", () => { + // 40 screen px of top padding at zoom 2 → 20 scene px + const result = constrainScrollState( + makeState({ + scrollY: 999, + zoom: { value: getNormalizedZoom(2) }, + scrollConstraints: { ...box, padding: [40, 0, 0, 0] }, + }), + ); + expect(result.scrollY).toBeCloseTo(20); + }); + + it("defaults to no extra room", () => { + const result = constrainScrollState( + makeState({ scrollX: 999, scrollConstraints: box }), + ); + expect(result.scrollX).toBeCloseTo(0); + }); + + it("stacks with the rubberband tolerance", () => { + const tolerance = 30; // screen px + const result = constrainScrollState( + makeState({ + scrollX: 999, + scrollConstraints: { ...box, tolerance, padding: [0, 0, 0, 40] }, + }), + tolerance, + ); + // left padding (40) + tolerance overscroll (30) at zoom 1 + expect(result.scrollX).toBeCloseTo(70); + }); +}); + 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 af4bf22ff3..20db41d923 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -294,6 +294,13 @@ export type ScrollConstraints = { * capping how far the viewport can zoom in. */ maxZoom?: number; + /** + * Extra scrollable margin around the box, as `[top, right, bottom, left]` + * (cardinal order), letting the viewport scroll past each box edge to reveal + * that much empty space beyond it. Values are screen pixels, so the margin + * stays the same on-screen regardless of zoom. Defaults to `[0, 0, 0, 0]`. + */ + padding?: [top: number, right: number, bottom: number, left: number]; }; export interface AppState {