@@ -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 };
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user