Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: make viewer stay inside SVG boundaries #228

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions src/features/interactions.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import {MODE_PANNING, MODE_ZOOMING, TOOL_AUTO, TOOL_NONE, TOOL_PAN, TOOL_ZOOM_IN, TOOL_ZOOM_OUT,} from '../constants';
import {ALIGN_COVER, MODE_PANNING, MODE_ZOOMING, TOOL_AUTO, TOOL_NONE, TOOL_PAN, TOOL_ZOOM_IN, TOOL_ZOOM_OUT,} from '../constants';
import {getSVGPoint, setFocus} from './common';
import {autoPanIfNeeded, startPanning, stopPanning, updatePanning} from './pan';
import {startZooming, stopZooming, updateZooming, zoom} from './zoom';
import {fitToViewer, startZooming, stopZooming, updateZooming, zoom} from './zoom';
import mapRange from '../utils/mapRange';
import {fromObject, transform, applyToPoints} from 'transformation-matrix';

export function isViewerInsideSVG(value) {
let matrix = transform(fromObject(value));

let [{x: x1, y: y1}, {x: x2, y: y2}] = applyToPoints(matrix, [
{x: value.SVGMinX, y: value.SVGMinY},
{x: value.SVGMinX + value.SVGWidth, y: value.SVGMinY + value.SVGHeight}
]);

return x1 <= 0 && y1 <= 0 && x2 >= value.viewerWidth && y2 >= value.viewerHeight;
}

export function getMousePosition(event, ViewerDOM) {
let {left, top} = ViewerDOM.getBoundingClientRect();
Expand All @@ -20,6 +32,8 @@ export function onMouseDown(event, ViewerDOM, tool, value, props, coords = null)
case TOOL_ZOOM_OUT:
let SVGPoint = getSVGPoint(value, x, y);
nextValue = zoom(value, SVGPoint.x, SVGPoint.y, 1 / props.scaleFactor, props);
if (props.constrainToSVGBounds && !isViewerInsideSVG(nextValue))
return value;
break;

case TOOL_ZOOM_IN:
Expand All @@ -29,6 +43,8 @@ export function onMouseDown(event, ViewerDOM, tool, value, props, coords = null)
case TOOL_AUTO:
case TOOL_PAN:
nextValue = startPanning(value, x, y);
if (props.constrainToSVGBounds && !isViewerInsideSVG(nextValue))
return fitToViewer(value, ALIGN_COVER, ALIGN_COVER);
break;

default:
Expand All @@ -44,7 +60,6 @@ export function onMouseMove(event, ViewerDOM, tool, value, props, coords = null)

let forceExit = (event.buttons === 0); //the mouse exited and reentered into svg
let nextValue = value;

switch (tool) {
case TOOL_ZOOM_IN:
if (value.mode === MODE_ZOOMING)
Expand All @@ -54,7 +69,7 @@ export function onMouseMove(event, ViewerDOM, tool, value, props, coords = null)
case TOOL_AUTO:
case TOOL_PAN:
if (value.mode === MODE_PANNING)
nextValue = forceExit ? stopPanning(value) : updatePanning(value, x, y, props.preventPanOutside ? 20 : undefined);
nextValue = forceExit ? stopPanning(value) : updatePanning(value, x, y, props);
break;

default:
Expand Down Expand Up @@ -107,6 +122,9 @@ export function onDoubleClick(event, ViewerDOM, tool, value, props, coords = nul
let modifierKeyActive = modifierKeys.reduce(modifierKeysReducer, false);
let scaleFactor = modifierKeyActive ? 1 / props.scaleFactor : props.scaleFactor;
nextValue = zoom(value, SVGPoint.x, SVGPoint.y, scaleFactor, props);

if (props.constrainToSVGBounds && !isViewerInsideSVG(nextValue))
return fitToViewer(value, ALIGN_COVER, ALIGN_COVER);
}

event.preventDefault();
Expand All @@ -123,6 +141,8 @@ export function onWheel(event, ViewerDOM, tool, value, props, coords = null) {

let SVGPoint = getSVGPoint(value, x, y);
let nextValue = zoom(value, SVGPoint.x, SVGPoint.y, scaleFactor, props);
if (props.constrainToSVGBounds && !isViewerInsideSVG(nextValue))
return fitToViewer(value, ALIGN_COVER, ALIGN_COVER);

event.preventDefault();
return nextValue;
Expand All @@ -138,9 +158,11 @@ export function onMouseEnterOrLeave(event, ViewerDOM, tool, value, props, coords
export function onInterval(event, ViewerDOM, tool, value, props, coords = null) {
let {x, y} = coords;

let nextValue = autoPanIfNeeded(value, x, y);
if (!([TOOL_NONE, TOOL_AUTO].indexOf(tool) >= 0)) return value;
if (!props.detectAutoPan) return value;
if (!value.focus) return value;
if (props.constrainToSVGBounds && !isViewerInsideSVG(nextValue)) return value;

return autoPanIfNeeded(value, x, y);
return nextValue;
}
83 changes: 53 additions & 30 deletions src/features/pan.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,72 @@
import {ACTION_PAN, MODE_IDLE, MODE_PANNING} from '../constants';
import {ACTION_PAN, ALIGN_COVER, MODE_IDLE, MODE_PANNING} from '../constants';
import {set, getSVGPoint} from './common';
import {fromObject, translate, transform, applyToPoints} from 'transformation-matrix';

/**
* Atomic pan operation
* Recalculate position if restrictions applied (preventPanOutside / constrainToSVGBounds)
* @param matrix
* @param value
* @param SVGDeltaX drag movement
* @param SVGDeltaY drag movement
* @param panLimit forces the image to keep at least x pixel inside the viewer
* @returns {Object}
* @param props
* @return {Object}
*/
export function pan(value, SVGDeltaX, SVGDeltaY, panLimit = undefined) {
export function applyPanLimits(matrix, value, props) {
const { preventPanOutside, constrainToSVGBounds } = props || {};
const panLimit = preventPanOutside && !constrainToSVGBounds ? 20 : 0;

let matrix = transform(
fromObject(value), //2
translate(SVGDeltaX, SVGDeltaY) //1
);
let [{x: x1, y: y1}, {x: x2, y: y2}] = applyToPoints(matrix, [
{x: value.SVGMinX + panLimit, y: value.SVGMinY + panLimit},
{x: value.SVGMinX + value.SVGWidth - panLimit, y: value.SVGMinY + value.SVGHeight - panLimit}
]);

// apply pan limits
if (panLimit) {
let [{x: x1, y: y1}, {x: x2, y: y2}] = applyToPoints(matrix, [
{x: value.SVGMinX + panLimit, y: value.SVGMinY + panLimit},
{x: value.SVGMinX + value.SVGWidth - panLimit, y: value.SVGMinY + value.SVGHeight - panLimit}
]);
let moveX = 0;
let moveY = 0;

//x limit
let moveX = 0;
if (value.viewerWidth - x1 < 0)
if (preventPanOutside) {
if (x1 > value.viewerWidth)
moveX = value.viewerWidth - x1;
else if (x2 < 0) moveX = -x2;


//y limit
let moveY = 0;
if (value.viewerHeight - y1 < 0)
moveY = value.viewerHeight - y1;
else if (y2 < 0) moveY = -y2;
}

//apply limits
matrix = transform(
translate(moveX, moveY),
matrix
)
if (constrainToSVGBounds) {
if (x1 > 0) {
moveX = -x1;
} else if (x2 < value.viewerWidth) {
moveX = value.viewerWidth - x2;
}

if (y1 > 0) {
moveY = -y1;
} else if (y2 < value.viewerHeight) {
moveY = value.viewerHeight - y2;
}
}

return transform(
translate(moveX, moveY),
matrix
)
}

/**
* Atomic pan operation
* @param value
* @param SVGDeltaX drag movement
* @param SVGDeltaY drag movement
* @param props
* @returns {Object}
*/
export function pan(value, SVGDeltaX, SVGDeltaY, props) {
let matrix = transform(
fromObject(value), //2
translate(SVGDeltaX, SVGDeltaY) //1
);

matrix = applyPanLimits(matrix, value, props);

return set(value, {
mode: MODE_IDLE,
...matrix,
Expand Down Expand Up @@ -75,7 +98,7 @@ export function startPanning(value, viewerX, viewerY) {
* @param panLimit
* @return {ReadonlyArray<unknown>}
*/
export function updatePanning(value, viewerX, viewerY, panLimit) {
export function updatePanning(value, viewerX, viewerY, panLimit, constrainToSVGBounds) {
if (value.mode !== MODE_PANNING) throw new Error('update pan not allowed in this mode ' + value.mode);

let {endX, endY} = value;
Expand All @@ -86,7 +109,7 @@ export function updatePanning(value, viewerX, viewerY, panLimit) {
let deltaX = end.x - start.x;
let deltaY = end.y - start.y;

let nextValue = pan(value, deltaX, deltaY, panLimit);
let nextValue = pan(value, deltaX, deltaY, panLimit, constrainToSVGBounds);
return set(nextValue, {
mode: MODE_PANNING,
endX: viewerX,
Expand Down
12 changes: 8 additions & 4 deletions src/features/zoom.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {fromObject, scale, transform, translate} from 'transformation-matrix';
import {fromObject, scale, transform, translate, applyToPoints} from 'transformation-matrix';

import {
ACTION_ZOOM, MODE_IDLE, MODE_ZOOMING,
Expand Down Expand Up @@ -34,14 +34,18 @@ export function limitZoomLevel(value, matrix) {
});
}

export function zoom(value, SVGPointX, SVGPointY, scaleFactor) {
export function zoom(value, SVGPointX, SVGPointY, scaleFactor, props) {
let matrix = transform(
fromObject(value)
);

if (isZoomLevelGoingOutOfBounds(value, scaleFactor)) {
// Do not change translation and scale of value
return value;
}

const matrix = transform(
fromObject(value),
matrix = transform(
matrix,
translate(SVGPointX, SVGPointY),
scale(scaleFactor, scaleFactor),
translate(-SVGPointX, -SVGPointY)
Expand Down
6 changes: 6 additions & 0 deletions src/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,11 @@ ReactSVGPanZoom.propTypes = {
*/
preventPanOutside: PropTypes.bool,

/**
* if enabled, restricts the viewer so it cannot be moved or zoomed outside the SVG boundaries
*/
constrainToSVGBounds: PropTypes.bool,

/**
* how much scale in or out
*/
Expand Down Expand Up @@ -700,6 +705,7 @@ ReactSVGPanZoom.defaultProps = {
detectPinchGesture: true,
modifierKeys: ["Alt", "Shift", "Control"],
preventPanOutside: true,
constrainToSVGBounds: false,
scaleFactor: 1.1,
scaleFactorOnWheel: 1.06,
disableZoomWithToolAuto: false,
Expand Down
20 changes: 17 additions & 3 deletions test/features/pan.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,35 @@ describe("atomic pan", () => {
expect(testSVGBBox(value1)).toEqual([50, 70, 120, 210])
})

test("pan limit", () => {
test("pan with preventPanOutside", () => {
const value = getDefaultValue(
200, 200, //viewer 200x200
0, 0, 400, 400, //svg 400x400
)

//move to bottom right limit
const value1 = pan(value, 500, 700, 20)
const value1 = pan(value, 500, 700, { preventPanOutside: true })
expect(testSVGBBox(value1)).toEqual([180, 180, expect.any(Number), expect.any(Number)])

//move to top left limit
const value2 = pan(value, -500, -700, 20)
const value2 = pan(value, -500, -700, { preventPanOutside: true })
expect(testSVGBBox(value2)).toEqual([expect.any(Number), expect.any(Number), 20, 20])
})

test("pan with constrainToSVGBounds", () => {
const value = getDefaultValue(
200, 200, // viewer 200x200
0, 0, 400, 400 // SVG 400x400
);

// Move to bottom right limit with constrainToSVGBounds
const value1 = pan(value, 500, 700, { constrainToSVGBounds: true });
expect(testSVGBBox(value1)).toEqual([0, 0, expect.any(Number), expect.any(Number)]);

// Move to top left limit with constrainToSVGBounds
const value2 = pan(value, -500, -700, { constrainToSVGBounds: true });
expect(testSVGBBox(value2)).toEqual([expect.any(Number), expect.any(Number), 200, 200]);
});
})

test("pan lifecycle", () => {
Expand Down