feat: Padding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-26 17:05:29 +00:00
parent 2fb12fdd5f
commit dba56d3edc
3 changed files with 91 additions and 9 deletions
+22 -9
View File
@@ -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 },
};
@@ -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 };
+7
View File
@@ -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 {