@@ -67,19 +67,21 @@ export const getMinZoomForConstraints = (
|
|||||||
/**
|
/**
|
||||||
* Clamps a single scroll axis so the visible scene span stays inside the box.
|
* 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
|
* The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within
|
||||||
* `[boxStart, boxStart + boxSize]`, expanded by `overscroll` on each side (for
|
* `[boxStart, boxStart + boxSize]`, expanded by `startExpand` at the low edge
|
||||||
* rubberbanding). When the box can't cover the viewport on this axis (only at
|
* and `endExpand` at the high edge (rubberband overscroll plus any padding).
|
||||||
* the MAX_ZOOM cap for a tiny box) we center the box instead.
|
* 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 = (
|
const constrainScrollAxis = (
|
||||||
scroll: number,
|
scroll: number,
|
||||||
boxStart: number,
|
boxStart: number,
|
||||||
boxSize: number,
|
boxSize: number,
|
||||||
visibleSize: number,
|
visibleSize: number,
|
||||||
overscroll: number,
|
startExpand: number,
|
||||||
|
endExpand: number,
|
||||||
): number => {
|
): number => {
|
||||||
const max = -boxStart + overscroll;
|
const max = -boxStart + startExpand;
|
||||||
const min = visibleSize - (boxStart + boxSize) - overscroll;
|
const min = visibleSize - (boxStart + boxSize) - endExpand;
|
||||||
return min > max ? (min + max) / 2 : clamp(scroll, min, max);
|
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
|
* 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
|
* viewport may zoom out past the box) and `maxZoom` replaces the global
|
||||||
* `MAX_ZOOM` cap.
|
* `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 = (
|
export const constrainScrollState = (
|
||||||
state: Pick<
|
state: Pick<
|
||||||
@@ -131,8 +138,12 @@ export const constrainScrollState = (
|
|||||||
clamp(state.zoom.value, minZoom, maxZoom),
|
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 overscroll = tolerance / zoomValue;
|
||||||
|
const [padTop, padRight, padBottom, padLeft] = (
|
||||||
|
scrollConstraints.padding ?? [0, 0, 0, 0]
|
||||||
|
).map((px) => px / zoomValue);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollX: constrainScrollAxis(
|
scrollX: constrainScrollAxis(
|
||||||
@@ -140,14 +151,16 @@ export const constrainScrollState = (
|
|||||||
scrollConstraints.x,
|
scrollConstraints.x,
|
||||||
scrollConstraints.width,
|
scrollConstraints.width,
|
||||||
width / zoomValue,
|
width / zoomValue,
|
||||||
overscroll,
|
overscroll + padLeft,
|
||||||
|
overscroll + padRight,
|
||||||
),
|
),
|
||||||
scrollY: constrainScrollAxis(
|
scrollY: constrainScrollAxis(
|
||||||
state.scrollY,
|
state.scrollY,
|
||||||
scrollConstraints.y,
|
scrollConstraints.y,
|
||||||
scrollConstraints.height,
|
scrollConstraints.height,
|
||||||
height / zoomValue,
|
height / zoomValue,
|
||||||
overscroll,
|
overscroll + padTop,
|
||||||
|
overscroll + padBottom,
|
||||||
),
|
),
|
||||||
zoom: { value: zoomValue },
|
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)", () => {
|
describe("rubberband tolerance (pure)", () => {
|
||||||
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
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.
|
* capping how far the viewport can zoom in.
|
||||||
*/
|
*/
|
||||||
maxZoom?: number;
|
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 {
|
export interface AppState {
|
||||||
|
|||||||
Reference in New Issue
Block a user