diff --git a/src/features/interactions.js b/src/features/interactions.js index c235a4ce..24ee49fe 100644 --- a/src/features/interactions.js +++ b/src/features/interactions.js @@ -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(); @@ -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: @@ -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: @@ -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) @@ -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: @@ -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(); @@ -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; @@ -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; } diff --git a/src/features/pan.js b/src/features/pan.js index cc9780a3..ae96cdbe 100644 --- a/src/features/pan.js +++ b/src/features/pan.js @@ -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, @@ -75,7 +98,7 @@ export function startPanning(value, viewerX, viewerY) { * @param panLimit * @return {ReadonlyArray} */ -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; @@ -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, diff --git a/src/features/zoom.js b/src/features/zoom.js index 7b828426..52edfacd 100644 --- a/src/features/zoom.js +++ b/src/features/zoom.js @@ -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, @@ -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) diff --git a/src/viewer.js b/src/viewer.js index 921f39b6..0be4d65e 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -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 */ @@ -700,6 +705,7 @@ ReactSVGPanZoom.defaultProps = { detectPinchGesture: true, modifierKeys: ["Alt", "Shift", "Control"], preventPanOutside: true, + constrainToSVGBounds: false, scaleFactor: 1.1, scaleFactorOnWheel: 1.06, disableZoomWithToolAuto: false, diff --git a/test/features/pan.spec.js b/test/features/pan.spec.js index 0fda6796..c7771044 100644 --- a/test/features/pan.spec.js +++ b/test/features/pan.spec.js @@ -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", () => {